Compare commits

...

29 Commits

Author SHA1 Message Date
7c7b9a3dbf initial checkin of support of bulk load with file 2025-07-17 12:37:54 -05:00
1dce760934 Merge pull request #91 from Kingsrook/feature/criteria-paster-tests
added tests around filter criteria paster tool
2025-07-08 14:34:29 -05:00
ff4683af1f added tests around filter criteria paster tool 2025-07-08 13:53:02 -05:00
ab4be1d5af fix to hotfix, observe chips as well to handle paste 2025-06-30 23:55:42 -05:00
0d7e76df6c hotfix on number chip validity, text fix 2025-06-27 12:17:52 -05:00
d41f5f8339 added clarifying comment 2025-06-20 13:22:29 -05:00
4d30eb3060 Merge pull request #90 from Kingsrook/feature/search-possible-values-by-label
Feature/search possible values by label
2025-06-18 10:23:13 -05:00
d4a675e952 updated to include the unique count of valid values 2025-06-06 19:17:10 -05:00
633c97b710 fix when no helpContent avaliable 2025-06-03 17:27:11 -05:00
c70ef3dae8 feedback from review session 2025-06-02 16:39:27 -05:00
5c69ae666c added ability to search for possible value data using the PVS labels, rather than just the ids, updated the values paster widget thingy to use this change to make pvs requests in a paginated manner 2025-05-27 15:17:57 -05:00
2e5aba6c16 Merge tag 'version-0.25.0' into dev
Tag release
2025-05-20 07:52:28 -05:00
185775ca4d Merge branch 'release/0.25.0' 2025-05-20 07:52:28 -05:00
cbcb3b505e Update for next development version 2025-05-20 07:06:37 -05:00
ce91f68088 Update versions for release 2025-05-20 07:06:35 -05:00
81da1a4627 Merged feature/oauth2-authentication-module into dev 2025-05-19 20:33:35 -05:00
b279a04b43 quick bug fix for goto fields 2025-04-16 16:45:46 -05:00
1f2e57d688 Merged feature/better-goto-behavior into dev 2025-04-09 11:14:14 -05:00
52bb7ba411 Merged feature/disable-show-default-vs-display-value into dev 2025-04-09 11:14:02 -05:00
34c6f650b5 updated to handle (ignore) fields with empty strings when using goto dialog 2025-04-07 16:50:01 -05:00
d792c23035 Cleanup from code review 2025-04-05 19:58:35 -05:00
e3d30633f1 Refactor authentication handling to pass authentication metadata into App.
eliminates warnings from oauth2 hook by conditionally using its useAuth hook.
2025-04-05 19:37:02 -05:00
a6ee682671 Merged feature/dk-misc-20250318 into dev 2025-04-03 14:28:34 -05:00
c62252075f Merged feature/banners into dev 2025-04-03 14:26:13 -05:00
debc6f3ebf turn off replacing of displayValue with defaultValue 2025-04-02 12:10:39 -05:00
679375ba63 update processClicked to set alert if min/max input records isn't satisfied 2025-03-18 11:44:32 -05:00
fb10dad803 Add support for query-param defaultProcessValues (as a json object) 2025-03-18 11:40:22 -05:00
c9a618c7f6 Fix full-width file upload adornment for lg-size (regressed with field-level grid columns addition) 2025-03-10 12:12:37 -05:00
13ce684d23 Initial checkin of Banners under QBrandingMetaData
- includes migration from (now deprecated) MetaDataFilterInterface to MetaDataActionCustomizerInterface (stored on the QInstance and used by MetaDataAction)
- includes migration from (now deprecated) environmentBannerText and environmentBannerColor in QBrandingMetaData to now be implemented as a banner
2025-03-07 14:58:51 -06:00
37 changed files with 5488 additions and 19927 deletions

1
.gitignore vendored
View File

@ -5,6 +5,7 @@
.yalc* .yalc*
yalc.lock yalc.lock
.env .env
/certs
# dependencies # dependencies
/node_modules /node_modules

23616
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -6,7 +6,7 @@
"@auth0/auth0-react": "1.10.2", "@auth0/auth0-react": "1.10.2",
"@emotion/react": "11.7.1", "@emotion/react": "11.7.1",
"@emotion/styled": "11.6.0", "@emotion/styled": "11.6.0",
"@kingsrook/qqq-frontend-core": "1.0.118", "@kingsrook/qqq-frontend-core": "1.0.122",
"@mui/icons-material": "5.4.1", "@mui/icons-material": "5.4.1",
"@mui/material": "5.11.1", "@mui/material": "5.11.1",
"@mui/styles": "5.11.1", "@mui/styles": "5.11.1",
@ -35,9 +35,9 @@
"html-react-parser": "1.4.8", "html-react-parser": "1.4.8",
"html-to-text": "^9.0.5", "html-to-text": "^9.0.5",
"http-proxy-middleware": "2.0.6", "http-proxy-middleware": "2.0.6",
"lodash": "4.17.21",
"jwt-decode": "3.1.2", "jwt-decode": "3.1.2",
"oidc-client-ts": "2.4.1", "oidc-client-ts": "2.4.1",
"react-oidc-context": "2.3.1",
"rapidoc": "9.3.4", "rapidoc": "9.3.4",
"react": "18.0.0", "react": "18.0.0",
"react-ace": "10.1.0", "react-ace": "10.1.0",
@ -51,6 +51,7 @@
"react-github-btn": "1.2.1", "react-github-btn": "1.2.1",
"react-google-drive-picker": "^1.2.0", "react-google-drive-picker": "^1.2.0",
"react-markdown": "9.0.1", "react-markdown": "9.0.1",
"react-oidc-context": "2.3.1",
"react-router-dom": "6.2.1", "react-router-dom": "6.2.1",
"react-router-hash-link": "2.4.3", "react-router-hash-link": "2.4.3",
"react-table": "7.7.0", "react-table": "7.7.0",

View File

@ -29,7 +29,7 @@
<packaging>jar</packaging> <packaging>jar</packaging>
<properties> <properties>
<revision>0.25.0-SNAPSHOT</revision> <revision>0.26.0-SNAPSHOT</revision>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding> <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
@ -66,7 +66,7 @@
<dependency> <dependency>
<groupId>com.kingsrook.qqq</groupId> <groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-backend-core</artifactId> <artifactId>qqq-backend-core</artifactId>
<version>0.21.0</version> <version>0.25.0-integration-sprint-62-20250307-205536</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.slf4j</groupId> <groupId>org.slf4j</groupId>

View File

@ -28,6 +28,7 @@ import {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstan
import {QProcessMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QProcessMetaData"; import {QProcessMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QProcessMetaData";
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData"; import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
import Avatar from "@mui/material/Avatar"; import Avatar from "@mui/material/Avatar";
import Box from "@mui/material/Box";
import CssBaseline from "@mui/material/CssBaseline"; import CssBaseline from "@mui/material/CssBaseline";
import Icon from "@mui/material/Icon"; import Icon from "@mui/material/Icon";
import {ThemeProvider} from "@mui/material/styles"; import {ThemeProvider} from "@mui/material/styles";
@ -39,6 +40,7 @@ import useAuth0AuthenticationModule from "qqq/authorization/auth0/useAuth0Authen
import useOAuth2AuthenticationModule from "qqq/authorization/oauth2/useOAuth2AuthenticationModule"; import useOAuth2AuthenticationModule from "qqq/authorization/oauth2/useOAuth2AuthenticationModule";
import Sidenav from "qqq/components/horseshoe/sidenav/SideNav"; import Sidenav from "qqq/components/horseshoe/sidenav/SideNav";
import theme from "qqq/components/legacy/Theme"; import theme from "qqq/components/legacy/Theme";
import {getBannerClassName, getBannerStyles, getBanner, makeBannerContent} from "qqq/components/misc/Banners";
import {setMiniSidenav, useMaterialUIController} from "qqq/context"; import {setMiniSidenav, useMaterialUIController} from "qqq/context";
import AppHome from "qqq/pages/apps/Home"; import AppHome from "qqq/pages/apps/Home";
import NoApps from "qqq/pages/apps/NoApps"; import NoApps from "qqq/pages/apps/NoApps";
@ -63,9 +65,14 @@ import {Md5} from "ts-md5/dist/md5";
const qController = Client.getInstance(); const qController = Client.getInstance();
export const SESSION_UUID_COOKIE_NAME = "sessionUUID"; export const SESSION_UUID_COOKIE_NAME = "sessionUUID";
export default function App() interface Props
{ {
const [cookies, setCookie, removeCookie] = useCookies([SESSION_UUID_COOKIE_NAME]); authenticationMetaData: QAuthenticationMetaData;
}
export default function App({authenticationMetaData}: Props)
{
const [, , removeCookie] = useCookies([SESSION_UUID_COOKIE_NAME]);
const [loadingToken, setLoadingToken] = useState(false); const [loadingToken, setLoadingToken] = useState(false);
const [isFullyAuthenticated, setIsFullyAuthenticated] = useState(false); const [isFullyAuthenticated, setIsFullyAuthenticated] = useState(false);
const [profileRoutes, setProfileRoutes] = useState({}); const [profileRoutes, setProfileRoutes] = useState({});
@ -74,11 +81,10 @@ export default function App()
const [needLicenseKey, setNeedLicenseKey] = useState(true); const [needLicenseKey, setNeedLicenseKey] = useState(true);
const [loggedInUser, setLoggedInUser] = useState({} as { name?: string, email?: string }); const [loggedInUser, setLoggedInUser] = useState({} as { name?: string, email?: string });
const [defaultRoute, setDefaultRoute] = useState("/no-apps"); const [defaultRoute, setDefaultRoute] = useState("/no-apps");
const [authenticationMetaData, setAuthenticationMetaData] = useState(null as QAuthenticationMetaData | null);
const [earlyReturnForAuth, setEarlyReturnForAuth] = useState(null as JSX.Element); const [earlyReturnForAuth, setEarlyReturnForAuth] = useState(null as JSX.Element);
const {setupSession: auth0SetupSession, logout: auth0Logout} = useAuth0AuthenticationModule({setIsFullyAuthenticated, setLoggedInUser, setEarlyReturnForAuth}); const {setupSession: auth0SetupSession, logout: auth0Logout} = useAuth0AuthenticationModule({setIsFullyAuthenticated, setLoggedInUser, setEarlyReturnForAuth});
const {setupSession: oauth2SetupSession, logout: oauth2Logout} = useOAuth2AuthenticationModule({setIsFullyAuthenticated, setLoggedInUser, setEarlyReturnForAuth}); const {setupSession: oauth2SetupSession, logout: oauth2Logout} = useOAuth2AuthenticationModule({setIsFullyAuthenticated, setLoggedInUser, setEarlyReturnForAuth, inOAuthContext: authenticationMetaData.type === "OAUTH2"});
const {setupSession: anonymousSetupSession, logout: anonymousLogout} = useAnonymousAuthenticationModule({setIsFullyAuthenticated, setLoggedInUser, setEarlyReturnForAuth}); const {setupSession: anonymousSetupSession, logout: anonymousLogout} = useAnonymousAuthenticationModule({setIsFullyAuthenticated, setLoggedInUser, setEarlyReturnForAuth});
///////////////////////////////////////////////////////// /////////////////////////////////////////////////////////
@ -99,9 +105,6 @@ export default function App()
(async () => (async () =>
{ {
const authenticationMetaData: QAuthenticationMetaData = await qController.getAuthenticationMetaData();
setAuthenticationMetaData(authenticationMetaData);
if (authenticationMetaData.type === "AUTH_0") if (authenticationMetaData.type === "AUTH_0")
{ {
await auth0SetupSession(); await auth0SetupSession();
@ -134,7 +137,7 @@ export default function App()
} }
/*************************************************************************** /***************************************************************************
** call approprite logout function based on authentication meta data type ** call appropriate logout function based on authentication meta data type
***************************************************************************/ ***************************************************************************/
function doLogout() function doLogout()
{ {
@ -157,7 +160,7 @@ export default function App()
} }
const [controller, dispatch] = useMaterialUIController(); const [controller, dispatch] = useMaterialUIController();
const {miniSidenav, direction, layout, openConfigurator, sidenavColor} = controller; const {miniSidenav, direction, sidenavColor} = controller;
const [onMouseEnter, setOnMouseEnter] = useState(false); const [onMouseEnter, setOnMouseEnter] = useState(false);
const {pathname} = useLocation(); const {pathname} = useLocation();
const [queryParams] = useSearchParams(); const [queryParams] = useSearchParams();
@ -450,11 +453,10 @@ export default function App()
} }
} }
let profileRoutes = {};
const gravatarBase = "https://www.gravatar.com/avatar/"; const gravatarBase = "https://www.gravatar.com/avatar/";
const hash = Md5.hashStr(loggedInUser?.email || "user"); const hash = Md5.hashStr(loggedInUser?.email || "user");
const profilePicture = `${gravatarBase}${hash}`; const profilePicture = `${gravatarBase}${hash}`;
profileRoutes = { const profileRoutes = {
type: "collapse", type: "collapse",
name: loggedInUser?.name ?? "Anonymous", name: loggedInUser?.name ?? "Anonymous",
key: "username", key: "username",
@ -630,6 +632,23 @@ export default function App()
} }
/***************************************************************************
**
***************************************************************************/
function banner(): JSX.Element | null
{
const banner = getBanner(metaData?.branding, "QFMD_TOP_OF_SITE");
if (!banner)
{
return (null);
}
return (<Box className={getBannerClassName(banner)} sx={{display: "flex", justifyContent: "center", padding: "0.5rem", position: "sticky", top: "0", zIndex: 1, ...getBannerStyles(banner)}}>
{makeBannerContent(banner)}
</Box>);
}
return ( return (
appRoutes && ( appRoutes && (
@ -657,6 +676,7 @@ export default function App()
<ThemeProvider theme={theme}> <ThemeProvider theme={theme}>
<CssBaseline /> <CssBaseline />
<CommandMenu metaData={metaData} /> <CommandMenu metaData={metaData} />
{banner()}
<Sidenav <Sidenav
color={sidenavColor} color={sidenavColor}
icon={branding.icon} icon={branding.icon}

View File

@ -52,7 +52,7 @@ authenticationMetaDataPromise.then((authenticationMetaData) =>
function Auth0RouterBody() function Auth0RouterBody()
{ {
const {renderAppWrapper} = useAuth0AuthenticationModule({}); const {renderAppWrapper} = useAuth0AuthenticationModule({});
return (renderAppWrapper(authenticationMetaData, null)); return (renderAppWrapper(authenticationMetaData));
} }
@ -61,10 +61,10 @@ authenticationMetaDataPromise.then((authenticationMetaData) =>
***************************************************************************/ ***************************************************************************/
function OAuth2RouterBody() function OAuth2RouterBody()
{ {
const {renderAppWrapper} = useOAuth2AuthenticationModule({}); const {renderAppWrapper} = useOAuth2AuthenticationModule({inOAuthContext: false});
return (renderAppWrapper(authenticationMetaData, ( return (renderAppWrapper(authenticationMetaData, (
<MaterialUIControllerProvider> <MaterialUIControllerProvider>
<App /> <App authenticationMetaData={authenticationMetaData} />
</MaterialUIControllerProvider> </MaterialUIControllerProvider>
))); )));
} }
@ -78,7 +78,7 @@ authenticationMetaDataPromise.then((authenticationMetaData) =>
const {renderAppWrapper} = useAnonymousAuthenticationModule({}); const {renderAppWrapper} = useAnonymousAuthenticationModule({});
return (renderAppWrapper(authenticationMetaData, ( return (renderAppWrapper(authenticationMetaData, (
<MaterialUIControllerProvider> <MaterialUIControllerProvider>
<App /> <App authenticationMetaData={authenticationMetaData} />
</MaterialUIControllerProvider> </MaterialUIControllerProvider>
))); )));
} }

View File

@ -0,0 +1,36 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.kingsrook.qqq.frontend.materialdashboard.model.metadata;
import com.kingsrook.qqq.backend.core.model.metadata.branding.BannerSlot;
/*******************************************************************************
**
*******************************************************************************/
public enum MaterialDashboardBannerSlots implements BannerSlot
{
QFMD_TOP_OF_SITE,
QFMD_TOP_OF_BODY,
QFMD_SIDE_NAV_UNDER_LOGO
}

View File

@ -41,11 +41,11 @@ interface Props
/*************************************************************************** /***************************************************************************
** hook for working with the Auth0 authentication module ** hook for working with the Auth0 authentication module
***************************************************************************/ ***************************************************************************/
export default function useAuth0AuthenticationModule({setIsFullyAuthenticated, setLoggedInUser, setEarlyReturnForAuth}: Props) export default function useAuth0AuthenticationModule({setIsFullyAuthenticated, setLoggedInUser}: Props)
{ {
const {user: auth0User, getAccessTokenSilently: auth0GetAccessTokenSilently, logout: useAuth0Logout} = useAuth0(); const {user: auth0User, getAccessTokenSilently: auth0GetAccessTokenSilently, logout: useAuth0Logout} = useAuth0();
const [cookies, setCookie, removeCookie] = useCookies([SESSION_UUID_COOKIE_NAME]); const [cookies, removeCookie] = useCookies([SESSION_UUID_COOKIE_NAME]);
/*************************************************************************** /***************************************************************************
@ -119,12 +119,7 @@ export default function useAuth0AuthenticationModule({setIsFullyAuthenticated, s
if (shouldStoreNewToken(accessToken, lsAccessToken)) if (shouldStoreNewToken(accessToken, lsAccessToken))
{ {
console.log("Sending accessToken to backend, requesting a sessionUUID..."); console.log("Sending accessToken to backend, requesting a sessionUUID...");
const {uuid: newSessionUuid, values} = await qController.manageSession(accessToken, null); const {uuid: values} = await qController.manageSession(accessToken, null);
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
// the request to the backend should send a header to set the cookie, so we don't need to do it ourselves. //
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
// setCookie(SESSION_UUID_COOKIE_NAME, newSessionUuid, {path: "/"});
localStorage.setItem("accessToken", accessToken); localStorage.setItem("accessToken", accessToken);
localStorage.setItem("sessionValues", JSON.stringify(values)); localStorage.setItem("sessionValues", JSON.stringify(values));
@ -199,7 +194,7 @@ export default function useAuth0AuthenticationModule({setIsFullyAuthenticated, s
/*************************************************************************** /***************************************************************************
** **
***************************************************************************/ ***************************************************************************/
const renderAppWrapper = (authenticationMetaData: QAuthenticationMetaData, children: JSX.Element): JSX.Element => const renderAppWrapper = (authenticationMetaData: QAuthenticationMetaData): JSX.Element =>
{ {
// @ts-ignore // @ts-ignore
let domain: string = authenticationMetaData.data.baseUrl; let domain: string = authenticationMetaData.data.baseUrl;
@ -225,6 +220,15 @@ export default function useAuth0AuthenticationModule({setIsFullyAuthenticated, s
domain = domain.replace(/\/$/, ""); domain = domain.replace(/\/$/, "");
} }
/***************************************************************************
** simple Functional Component to wrap the <App> and pass the authentication-
** MetaData prop in, so a simple Component can be passed into ProtectedRoute
***************************************************************************/
function WrappedApp()
{
return <App authenticationMetaData={authenticationMetaData} />
}
return ( return (
<Auth0ProviderWithRedirectCallback <Auth0ProviderWithRedirectCallback
domain={domain} domain={domain}
@ -232,7 +236,7 @@ export default function useAuth0AuthenticationModule({setIsFullyAuthenticated, s
audience={audience} audience={audience}
redirectUri={`${window.location.origin}/`}> redirectUri={`${window.location.origin}/`}>
<MaterialUIControllerProvider> <MaterialUIControllerProvider>
<ProtectedRoute component={App} /> <ProtectedRoute component={WrappedApp} />
</MaterialUIControllerProvider> </MaterialUIControllerProvider>
</Auth0ProviderWithRedirectCallback> </Auth0ProviderWithRedirectCallback>
); );

View File

@ -23,7 +23,7 @@ import {QAuthenticationMetaData} from "@kingsrook/qqq-frontend-core/lib/model/me
import {SESSION_UUID_COOKIE_NAME} from "App"; import {SESSION_UUID_COOKIE_NAME} from "App";
import Client from "qqq/utils/qqq/Client"; import Client from "qqq/utils/qqq/Client";
import {useCookies} from "react-cookie"; import {useCookies} from "react-cookie";
import {AuthProvider, useAuth} from "react-oidc-context"; import {AuthContextProps, AuthProvider, useAuth} from "react-oidc-context";
import {useNavigate, useSearchParams} from "react-router-dom"; import {useNavigate, useSearchParams} from "react-router-dom";
const qController = Client.getInstance(); const qController = Client.getInstance();
@ -33,16 +33,22 @@ interface Props
setIsFullyAuthenticated?: (is: boolean) => void; setIsFullyAuthenticated?: (is: boolean) => void;
setLoggedInUser?: (user: any) => void; setLoggedInUser?: (user: any) => void;
setEarlyReturnForAuth?: (element: JSX.Element | null) => void; setEarlyReturnForAuth?: (element: JSX.Element | null) => void;
inOAuthContext: boolean;
} }
/*************************************************************************** /***************************************************************************
** hook for working with the OAuth2 authentication module ** hook for working with the OAuth2 authentication module
***************************************************************************/ ***************************************************************************/
export default function useOAuth2AuthenticationModule({setIsFullyAuthenticated, setLoggedInUser, setEarlyReturnForAuth}: Props) export default function useOAuth2AuthenticationModule({setIsFullyAuthenticated, setLoggedInUser, setEarlyReturnForAuth, inOAuthContext}: Props)
{ {
const authOidc = useAuth(); ///////////////////////////////////////////////////////////////////////////////////////
// the useAuth hook should only be called if we're inside the <AuthProvider> element //
// so on the page that uses this hook to call renderAppWrapper, we aren't in that //
// element/context, thus, don't call that hook. //
///////////////////////////////////////////////////////////////////////////////////////
const authOidc: AuthContextProps | null = inOAuthContext ? useAuth() : null;
const [cookies, setCookie, removeCookie] = useCookies([SESSION_UUID_COOKIE_NAME]); const [cookies, removeCookie] = useCookies([SESSION_UUID_COOKIE_NAME]);
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const navigate = useNavigate(); const navigate = useNavigate();
@ -56,6 +62,12 @@ export default function useOAuth2AuthenticationModule({setIsFullyAuthenticated,
const preSigninRedirectPathnameKey = "oauth2.preSigninRedirect.pathname"; const preSigninRedirectPathnameKey = "oauth2.preSigninRedirect.pathname";
if (window.location.pathname == "/token") if (window.location.pathname == "/token")
{ {
///////////////////////////////////////////////////////////////////////////
// if we're at a path of /token, get code & state params, look up values //
// from that state in local storage, and make a post to the backend to //
// with these values - which will itself talk to the identity provider //
// to get an access token, and ultimately a session. //
///////////////////////////////////////////////////////////////////////////
const code = searchParams.get("code"); const code = searchParams.get("code");
const state = searchParams.get("state"); const state = searchParams.get("state");
const oidcString = localStorage.getItem(`oidc.${state}`); const oidcString = localStorage.getItem(`oidc.${state}`);
@ -77,14 +89,24 @@ export default function useOAuth2AuthenticationModule({setIsFullyAuthenticated,
localStorage.removeItem(preSigninRedirectPathname); localStorage.removeItem(preSigninRedirectPathname);
navigate(preSigninRedirectPathname ?? "/", {replace: true}); navigate(preSigninRedirectPathname ?? "/", {replace: true});
} }
else
{
////////////////////////////////////////////
// if unrecognized state, render an error //
////////////////////////////////////////////
setEarlyReturnForAuth(<div>Login error: Unrecognized state. Refresh to try again.</div>);
}
} }
else else
{ {
//////////////////////////////////////////////////////////////////////////
// if we have a sessionUUID cookie, try to validate it with the backend //
//////////////////////////////////////////////////////////////////////////
const sessionUuid = cookies[SESSION_UUID_COOKIE_NAME]; const sessionUuid = cookies[SESSION_UUID_COOKIE_NAME];
if (sessionUuid) if (sessionUuid)
{ {
console.log(`we have session UUID: ${sessionUuid} - validating it...`); console.log(`we have session UUID: ${sessionUuid} - validating it...`);
const {uuid: newSessionUuid, values} = await qController.manageSession(null, sessionUuid, null); const {values} = await qController.manageSession(null, sessionUuid, null);
setIsFullyAuthenticated(true); setIsFullyAuthenticated(true);
qController.setGotAuthentication(); qController.setGotAuthentication();
@ -94,45 +116,16 @@ export default function useOAuth2AuthenticationModule({setIsFullyAuthenticated,
} }
else else
{ {
/////////////////////////////////////////////////////////////////////////////////////////////////
// else no cookie, and not a token url, we need to redirect to the provider's login page //
// capture the path the user was trying to access in local storage, to redirect back to later. //
/////////////////////////////////////////////////////////////////////////////////////////////////
console.log("Loading token from OAuth2 provider..."); console.log("Loading token from OAuth2 provider...");
console.log(authOidc); console.log(authOidc);
localStorage.setItem(preSigninRedirectPathnameKey, window.location.pathname); localStorage.setItem(preSigninRedirectPathnameKey, window.location.pathname);
setEarlyReturnForAuth(<div>Signing in...</div>); setEarlyReturnForAuth(<div>Signing in...</div>);
authOidc.signinRedirect(); authOidc?.signinRedirect();
} }
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// this is what's in the docs, but, it sure doesn't seem to ever hit any case other than the signinRedirect block //
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
/*
if (authOidc.isLoading)
{
setLoadingToken(false); //? so we can come back in? but i'm missing something here.
setEarlyReturnForAuth(<div>
<div>Loading...</div>
<button onClick={() => incrementCheckLoadingCounter()}>check again?</button>
</div>);
}
else if (authOidc.error)
{
setEarlyReturnForAuth(<div>Error: {authOidc.error.message}</div>);
}
else if (authOidc.isAuthenticated)
{
setEarlyReturnForAuth(
<div>
Welcome, {authOidc.user?.profile.name}!
<button onClick={() => authOidc.signoutRedirect()}>Log out</button>
</div>
);
}
else
{
localStorage.setItem(preSigninRedirectPathnameKey, window.location.pathname);
setEarlyReturnForAuth(<div>Signing in...</div>);
authOidc.signinRedirect();
}
*/
} }
} }
catch (e) catch (e)
@ -151,7 +144,7 @@ export default function useOAuth2AuthenticationModule({setIsFullyAuthenticated,
{ {
qController.clearAuthenticationMetaDataLocalStorage(); qController.clearAuthenticationMetaDataLocalStorage();
removeCookie(SESSION_UUID_COOKIE_NAME, {path: "/"}); removeCookie(SESSION_UUID_COOKIE_NAME, {path: "/"});
authOidc.signoutRedirect(); authOidc?.signoutRedirect();
}; };

View File

@ -23,8 +23,10 @@ import {Chip} from "@mui/material";
import TextField from "@mui/material/TextField"; import TextField from "@mui/material/TextField";
import {makeStyles} from "@mui/styles"; import {makeStyles} from "@mui/styles";
import Downshift from "downshift"; import Downshift from "downshift";
import {debounce} from "lodash";
import {arrayOf, func, string} from "prop-types"; import {arrayOf, func, string} from "prop-types";
import React, {useEffect, useState} from "react"; import Client from "qqq/utils/qqq/Client";
import React, {useEffect, useRef, useState} from "react";
const useStyles = makeStyles((theme: any) => ({ const useStyles = makeStyles((theme: any) => ({
chip: { chip: {
@ -34,21 +36,107 @@ const useStyles = makeStyles((theme: any) => ({
function ChipTextField({...props}) function ChipTextField({...props})
{ {
const qController = Client.getInstance();
const classes = useStyles(); const classes = useStyles();
const {handleChipChange, label, chipType, disabled, placeholder, chipData, multiline, rows, ...other} = props; const {table, field, handleChipChange, label, chipType, disabled, placeholder, chipData, multiline, rows, ...other} = props;
const [inputValue, setInputValue] = useState(""); const [inputValue, setInputValue] = useState("");
const [chips, setChips] = useState([]); const [chips, setChips] = useState([]);
const [chipColors, setChipColors] = useState([]);
const [chipValidity, setChipValidity] = useState([] as boolean[]);
const [chipPVSIds, setChipPVSIds] = useState([] as any[]);
const [isMakingRequest, setIsMakingRequest] = useState(false);
////////////////////////////////////////////////////////////////////
// these refs are used for the async api call for possible values //
////////////////////////////////////////////////////////////////////
const chipsRef = useRef<string[]>([]);
/////////////////////////////////////////////////////////////////////////////////////////////
// use debounce library to not flood server as user types, wait a second before requesting //
/////////////////////////////////////////////////////////////////////////////////////////////
async function fetchPVSLabelsAndColorChips()
{
//////////////////////////////////////////////////////////
// make a request for the possible value labels (chips) //
//////////////////////////////////////////////////////////
setIsMakingRequest(true);
const currentChips = chipsRef.current;
setChipColors([]);
///////////////////////////////////////////////////////////////////////////////
// Determine chip colors based on whether each chip value appears in results //
///////////////////////////////////////////////////////////////////////////////
const newChipColors = [] as string[];
const chipValidity = [] as boolean[];
const chipPVSIds = [] as any[];
////////////////////////////////////////////////////////////////////////////
// make the request for all 'chips' with pagination to handle large sizes //
////////////////////////////////////////////////////////////////////////////
const BATCH_SIZE = 250;
for (let i = 0; i < currentChips.length; i += BATCH_SIZE)
{
const batch = currentChips.slice(i, i + BATCH_SIZE);
const page = await qController.possibleValues(
table.name,
null,
field.name,
"",
null,
batch
);
for (let j = 0; j < batch.length; j++)
{
let found = false;
for (let k = 0; k < page.length; k++)
{
const result = page[k];
if (result.label.toLowerCase() === batch[j].toLowerCase())
{
chipPVSIds.push(result.id);
newChipColors.push("info");
chipValidity.push(true);
found = true;
break;
}
}
if (!found)
{
chipPVSIds.push(null);
chipValidity.push(false);
newChipColors.push("error");
}
}
}
setChipPVSIds(chipPVSIds);
setChipColors(newChipColors);
setChipValidity(chipValidity);
setIsMakingRequest(false);
}
const debouncedApiCall = useRef(debounce(fetchPVSLabelsAndColorChips, 500)).current;
useEffect(() => useEffect(() =>
{ {
setChips(chipData); setChips(chipData);
}, [chipData]); chipsRef.current = chipData;
determineChipColors();
if (chipType !== "pvs")
{
const currentChipValidity = chips.map((chip, i) =>
(chipType !== "number" || !Number.isNaN(Number(chips[i])))
);
setChipValidity(currentChipValidity);
}
}, [JSON.stringify(chipData), chips]);
useEffect(() => useEffect(() =>
{ {
handleChipChange(chips); handleChipChange(isMakingRequest, chipValidity, chipPVSIds);
}, [chips, handleChipChange]); }, [chipValidity, chipPVSIds, isMakingRequest]);
function handleKeyDown(event: any) function handleKeyDown(event: any)
{ {
@ -64,11 +152,14 @@ function ChipTextField({...props})
setInputValue(""); setInputValue("");
return; return;
} }
if (!event.target.value.replace(/\s/g, "").length) return; if (!event.target.value.replace(/\s/g, "").length)
{
return;
}
setInputValue("");
newChipList.push(event.target.value.trim()); newChipList.push(event.target.value.trim());
setChips(newChipList); setChips(newChipList);
setInputValue("");
} }
else if (chips.length && !inputValue.length && event.key === "Backspace") else if (chips.length && !inputValue.length && event.key === "Backspace")
{ {
@ -87,18 +178,26 @@ function ChipTextField({...props})
setChips(newChipList); setChips(newChipList);
} }
const handleDelete = (item: any) => () =>
{
const newChipList = [...chips];
newChipList.splice(newChipList.indexOf(item), 1);
setChips(newChipList);
};
function handleInputChange(event: { target: { value: React.SetStateAction<string>; }; }) function handleInputChange(event: { target: { value: React.SetStateAction<string>; }; })
{ {
setInputValue(event.target.value); setInputValue(event.target.value);
} }
function determineChipColors(): any
{
if (chipType === "pvs")
{
debouncedApiCall();
}
else
{
const newChipColors = chips.map((chip, i) =>
(chipType !== "number" || !Number.isNaN(Number(chips[i]))) ? "info" : "error"
);
setChipColors(newChipColors);
}
}
return ( return (
<React.Fragment> <React.Fragment>
@ -125,16 +224,16 @@ function ChipTextField({...props})
startAdornment: startAdornment:
<div style={{overflowY: "auto", overflowX: "hidden", margin: "-10px", width: "calc(100% + 20px)", padding: "10px", marginBottom: "-20px", height: "calc(100% + 10px)"}}> <div style={{overflowY: "auto", overflowX: "hidden", margin: "-10px", width: "calc(100% + 20px)", padding: "10px", marginBottom: "-20px", height: "calc(100% + 10px)"}}>
{ {
chips.map((item, i) => ( chips.map((item, index) => (
<Chip <Chip
color={(chipType !== "number" || ! Number.isNaN(Number(item))) ? "info" : "error"} onChange={determineChipColors}
key={`${item}-${i}`} color={chipColors[index]}
key={`${item}-${index}`}
variant="outlined" variant="outlined"
tabIndex={-1} tabIndex={-1}
label={item} label={item}
className={classes.chip} className={classes.chip}
/> />
)) ))
} }
</div>, </div>,
@ -158,6 +257,7 @@ function ChipTextField({...props})
</React.Fragment> </React.Fragment>
); );
} }
ChipTextField.defaultProps = { ChipTextField.defaultProps = {
chipData: [] chipData: []
}; };
@ -166,4 +266,4 @@ ChipTextField.propTypes = {
chipData: arrayOf(string) chipData: arrayOf(string)
}; };
export default ChipTextField export default ChipTextField;

View File

@ -96,6 +96,7 @@ function QDynamicForm({formData, formLabel, bulkEditMode, bulkEditSwitchChangeHa
if (width == "full") if (width == "full")
{ {
itemSM = 12; itemSM = 12;
itemLG = 12;
} }
return ( return (

View File

@ -20,17 +20,18 @@
*/ */
import {QPossibleValue} from "@kingsrook/qqq-frontend-core/lib/model/QPossibleValue"; import {QPossibleValue} from "@kingsrook/qqq-frontend-core/lib/model/QPossibleValue";
import {Box, InputAdornment, InputLabel} from "@mui/material"; import {InputAdornment, InputLabel} from "@mui/material";
import Box from "@mui/material/Box";
import Switch from "@mui/material/Switch"; import Switch from "@mui/material/Switch";
import {ErrorMessage, Field, useFormikContext} from "formik"; import {ErrorMessage, Field, useFormikContext} from "formik";
import DynamicFormUtils from "qqq/components/forms/DynamicFormUtils";
import DynamicSelect from "qqq/components/forms/DynamicSelect";
import React, {useMemo, useState} from "react";
import AceEditor from "react-ace";
import colors from "qqq/assets/theme/base/colors"; import colors from "qqq/assets/theme/base/colors";
import BooleanFieldSwitch from "qqq/components/forms/BooleanFieldSwitch"; import BooleanFieldSwitch from "qqq/components/forms/BooleanFieldSwitch";
import DynamicFormUtils from "qqq/components/forms/DynamicFormUtils";
import DynamicSelect from "qqq/components/forms/DynamicSelect";
import MDInput from "qqq/components/legacy/MDInput"; import MDInput from "qqq/components/legacy/MDInput";
import MDTypography from "qqq/components/legacy/MDTypography"; import MDTypography from "qqq/components/legacy/MDTypography";
import React, {useMemo, useState} from "react";
import AceEditor from "react-ace";
import {flushSync} from "react-dom"; import {flushSync} from "react-dom";
// Declaring props types for FormField // Declaring props types for FormField
@ -83,7 +84,7 @@ function QDynamicFormField({
if (placeholder) if (placeholder)
{ {
inputProps.placeholder = placeholder inputProps.placeholder = placeholder;
} }
if (backgroundColor) if (backgroundColor)
@ -167,7 +168,7 @@ function QDynamicFormField({
{ {
if (onChangeCallback) if (onChangeCallback)
{ {
onChangeCallback(newValue == null ? null : newValue.id) onChangeCallback(newValue == null ? null : newValue.id);
} }
} }
@ -186,7 +187,7 @@ function QDynamicFormField({
onChange={dynamicSelectOnChange} onChange={dynamicSelectOnChange}
// otherValues={otherValuesMap} // otherValues={otherValuesMap}
useCase="form" useCase="form"
/>) />);
} }
else if (type === "checkbox") else if (type === "checkbox")
{ {

View File

@ -174,7 +174,7 @@ function DynamicSelect({fieldPossibleValueProps, overrideId, name, fieldLabel, i
const filterInlinePossibleValues = (searchTerm: string, possibleValues: QPossibleValue[]): QPossibleValue[] => const filterInlinePossibleValues = (searchTerm: string, possibleValues: QPossibleValue[]): QPossibleValue[] =>
{ {
return possibleValues.filter(pv => pv.label?.toLowerCase().startsWith(searchTerm)); return possibleValues.filter(pv => pv.label?.toLowerCase().startsWith(searchTerm));
} };
/*************************************************************************** /***************************************************************************
@ -184,13 +184,13 @@ function DynamicSelect({fieldPossibleValueProps, overrideId, name, fieldLabel, i
{ {
if (possibleValues) if (possibleValues)
{ {
return filterInlinePossibleValues(searchTerm, possibleValues) return filterInlinePossibleValues(searchTerm, possibleValues);
} }
else else
{ {
return await qController.possibleValues(tableName, processName, possibleValueSourceName ?? fieldName, searchTerm ?? "", null, otherValues, useCase); return await qController.possibleValues(tableName, processName, possibleValueSourceName ?? fieldName, searchTerm ?? "", null, null, otherValues, useCase);
}
} }
};
/*************************************************************************** /***************************************************************************

View File

@ -284,14 +284,14 @@ function EntityForm(props: Props): JSX.Element
/////////////////////////////////////////////////////////////////////////////////////////////////////// ///////////////////////////////////////////////////////////////////////////////////////////////////////
// this function internally memoizes, so, we could potentially avoid an await here, but, seems ok... // // this function internally memoizes, so, we could potentially avoid an await here, but, seems ok... //
/////////////////////////////////////////////////////////////////////////////////////////////////////// ///////////////////////////////////////////////////////////////////////////////////////////////////////
const childTableMetaData = await qController.loadTableMetaData(childTableName) const childTableMetaData = await qController.loadTableMetaData(childTableName);
for (let key in values) for (let key in values)
{ {
const value = values[key]; const value = values[key];
const field = childTableMetaData.fields.get(key); const field = childTableMetaData.fields.get(key);
if (field.possibleValueSourceName) if (field.possibleValueSourceName)
{ {
const possibleValues = await qController.possibleValues(childTableName, null, field.name, null, [value], objectToMap(values), "form") const possibleValues = await qController.possibleValues(childTableName, null, field.name, null, [value], null, objectToMap(values), "form");
if (possibleValues && possibleValues.length > 0) if (possibleValues && possibleValues.length > 0)
{ {
displayValues[key] = possibleValues[0].label; displayValues[key] = possibleValues[0].label;
@ -516,7 +516,6 @@ function EntityForm(props: Props): JSX.Element
} }
/*************************************************************************** /***************************************************************************
** **
***************************************************************************/ ***************************************************************************/
@ -532,7 +531,7 @@ function EntityForm(props: Props): JSX.Element
{ {
rs.set(key, object[key]); rs.set(key, object[key]);
} }
return rs return rs;
} }
@ -667,7 +666,7 @@ function EntityForm(props: Props): JSX.Element
const defaultValue = (defaultValues && defaultValues[fieldName]) ? defaultValues[fieldName] : fieldMetaData.defaultValue; const defaultValue = (defaultValues && defaultValues[fieldName]) ? defaultValues[fieldName] : fieldMetaData.defaultValue;
if (defaultValue && fieldMetaData.possibleValueSourceName) if (defaultValue && fieldMetaData.possibleValueSourceName)
{ {
const results: QPossibleValue[] = await qController.possibleValues(tableName, null, fieldName, null, [initialValues[fieldName]], objectToMap(initialValues), "form"); const results: QPossibleValue[] = await qController.possibleValues(tableName, null, fieldName, null, [initialValues[fieldName]], null, objectToMap(initialValues), "form");
if (results && results.length > 0) if (results && results.length > 0)
{ {
defaultDisplayValues.set(fieldName, results[0].label); defaultDisplayValues.set(fieldName, results[0].label);

View File

@ -32,6 +32,7 @@ import SideNavList from "qqq/components/horseshoe/sidenav/SideNavList";
import SidenavRoot from "qqq/components/horseshoe/sidenav/SideNavRoot"; import SidenavRoot from "qqq/components/horseshoe/sidenav/SideNavRoot";
import sidenavLogoLabel from "qqq/components/horseshoe/sidenav/styles/SideNav"; import sidenavLogoLabel from "qqq/components/horseshoe/sidenav/styles/SideNav";
import MDTypography from "qqq/components/legacy/MDTypography"; import MDTypography from "qqq/components/legacy/MDTypography";
import {getBannerClassName, getBannerStyles, getBanner, makeBannerContent} from "qqq/components/misc/Banners";
import {setMiniSidenav, setTransparentSidenav, setWhiteSidenav, useMaterialUIController,} from "qqq/context"; import {setMiniSidenav, setTransparentSidenav, setWhiteSidenav, useMaterialUIController,} from "qqq/context";
import {ReactNode, useEffect, useReducer, useState} from "react"; import {ReactNode, useEffect, useReducer, useState} from "react";
import {NavLink, useLocation} from "react-router-dom"; import {NavLink, useLocation} from "react-router-dom";
@ -301,6 +302,30 @@ function Sidenav({color, icon, logo, appName, branding, routes, logout, ...rest}
} }
); );
/***************************************************************************
**
***************************************************************************/
function EnvironmentBanner({branding}: { branding: QBrandingMetaData }): JSX.Element | null
{
// deprecated!
if (branding && branding.environmentBannerText)
{
return <Box mt={2} bgcolor={branding.environmentBannerColor} borderRadius={2}>
{branding.environmentBannerText}
</Box>;
}
const banner = getBanner(branding, "QFMD_SIDE_NAV_UNDER_LOGO");
if (banner)
{
return <Box className={getBannerClassName(banner)} mt={2} borderRadius={2} sx={getBannerStyles(banner)}>
{makeBannerContent(banner)}
</Box>;
}
return (null);
}
return ( return (
<SidenavRoot <SidenavRoot
{...rest} {...rest}
@ -331,12 +356,7 @@ function Sidenav({color, icon, logo, appName, branding, routes, logout, ...rest}
</Box> </Box>
} }
</Box> </Box>
{ <EnvironmentBanner branding={branding} />
branding && branding.environmentBannerText &&
<Box mt={2} bgcolor={branding.environmentBannerColor} borderRadius={2}>
{branding.environmentBannerText}
</Box>
}
</Box> </Box>
<Divider <Divider
light={ light={

View File

@ -97,6 +97,7 @@ export default styled(Drawer)(({theme, ownerState}: { theme?: Theme | any; owner
margin: "0", margin: "0",
borderRadius: "0", borderRadius: "0",
height: "100%", height: "100%",
top: "unset",
...(miniSidenav ? drawerCloseStyles() : drawerOpenStyles()), ...(miniSidenav ? drawerCloseStyles() : drawerOpenStyles()),
}, },

View File

@ -0,0 +1,97 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {Banner} from "@kingsrook/qqq-frontend-core/lib/model/metaData/Banner";
import {QBrandingMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QBrandingMetaData";
import parse from "html-react-parser";
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
// One may render a banner using the functions in this file as: //
// //
// const banner = getBanner(branding, "QFMD_SIDE_NAV_UNDER_LOGO"); //
// return (<Box className={getBannerClassName(banner)} sx={{padding: "1rem", ...getBannerStyles(banner)}}> //
// {makeBannerContent(banner)} //
// </Box>); //
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
/***************************************************************************
**
***************************************************************************/
export function getBanner(branding: QBrandingMetaData, slot: string): Banner | null
{
if (branding?.banners?.has(slot))
{
return (branding.banners.get(slot));
}
return (null);
}
/***************************************************************************
**
***************************************************************************/
export function getBannerStyles(banner: Banner)
{
let bgColor = "";
let color = "";
if (banner)
{
if (banner.backgroundColor)
{
bgColor = banner.backgroundColor;
}
if (banner.textColor)
{
bgColor = banner.textColor;
}
}
const rest = banner?.additionalStyles ?? {};
return ({
backgroundColor: bgColor,
color: color,
...rest
});
}
/***************************************************************************
**
***************************************************************************/
export function getBannerClassName(banner: Banner)
{
return `banner ${banner?.severity?.toLowerCase()}`;
}
/***************************************************************************
**
***************************************************************************/
export function makeBannerContent(banner: Banner): JSX.Element
{
return <>{banner?.messageHTML ? parse(banner?.messageHTML) : banner?.messageText}</>;
}

View File

@ -20,6 +20,7 @@
*/ */
import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData"; import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
import {QFieldType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldType";
import {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstance"; import {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstance";
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData"; import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
import {QTableVariant} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableVariant"; import {QTableVariant} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableVariant";
@ -35,12 +36,11 @@ import DialogTitle from "@mui/material/DialogTitle";
import Grid from "@mui/material/Grid"; import Grid from "@mui/material/Grid";
import Icon from "@mui/material/Icon"; import Icon from "@mui/material/Icon";
import TextField from "@mui/material/TextField"; import TextField from "@mui/material/TextField";
import {any} from "prop-types";
import React, {useState} from "react";
import {useNavigate} from "react-router-dom";
import {QCancelButton} from "qqq/components/buttons/DefaultButtons"; import {QCancelButton} from "qqq/components/buttons/DefaultButtons";
import MDButton from "qqq/components/legacy/MDButton"; import MDButton from "qqq/components/legacy/MDButton";
import Client from "qqq/utils/qqq/Client"; import Client from "qqq/utils/qqq/Client";
import React, {useState} from "react";
import {useNavigate} from "react-router-dom";
interface Props interface Props
{ {
@ -186,14 +186,14 @@ function GotoRecordDialog(props: Props): JSX.Element
{ {
anyFieldsInThisOptionHaveAValue = true; anyFieldsInThisOptionHaveAValue = true;
} }
}) });
if (!anyFieldsInThisOptionHaveAValue) if (!anyFieldsInThisOptionHaveAValue)
{ {
return (true); return (true);
} }
return (false); return (false);
} };
/*************************************************************************** /***************************************************************************
@ -207,9 +207,13 @@ function GotoRecordDialog(props: Props): JSX.Element
const queryStringParts: string[] = []; const queryStringParts: string[] = [];
options[optionIndex].forEach((field) => options[optionIndex].forEach((field) =>
{ {
criteria.push(new QFilterCriteria(field.name, QCriteriaOperator.EQUALS, [values[field.name]])) if (field.type == QFieldType.STRING && !values[field.name])
queryStringParts.push(`${field.name}=${encodeURIComponent(values[field.name])}`) {
}) return;
}
criteria.push(new QFilterCriteria(field.name, QCriteriaOperator.EQUALS, [values[field.name]]));
queryStringParts.push(`${field.name}=${encodeURIComponent(values[field.name])}`);
});
const filter = new QQueryFilter(criteria, null, null, "AND", null, 10); const filter = new QQueryFilter(criteria, null, null, "AND", null, 10);

View File

@ -59,7 +59,8 @@ interface Props
bulkLoadProfileOnChangeCallback?: (record: QRecord | null) => void, bulkLoadProfileOnChangeCallback?: (record: QRecord | null) => void,
allowSelectingProfile?: boolean, allowSelectingProfile?: boolean,
fileDescription?: FileDescription, fileDescription?: FileDescription,
bulkLoadProfileResetToSuggestedMappingCallback?: () => void bulkLoadProfileResetToSuggestedMappingCallback?: () => void,
isBulkEdit?: boolean;
} }
SavedBulkLoadProfiles.defaultProps = { SavedBulkLoadProfiles.defaultProps = {
@ -72,7 +73,7 @@ const qController = Client.getInstance();
** menu-button, text elements, and modal(s) that let you work with saved ** menu-button, text elements, and modal(s) that let you work with saved
** bulk-load profiles. ** bulk-load profiles.
***************************************************************************/ ***************************************************************************/
function SavedBulkLoadProfiles({metaData, tableMetaData, tableStructure, currentSavedBulkLoadProfileRecord, bulkLoadProfileOnChangeCallback, currentMapping, allowSelectingProfile, fileDescription, bulkLoadProfileResetToSuggestedMappingCallback}: Props): JSX.Element function SavedBulkLoadProfiles({metaData, tableMetaData, tableStructure, currentSavedBulkLoadProfileRecord, bulkLoadProfileOnChangeCallback, currentMapping, allowSelectingProfile, fileDescription, bulkLoadProfileResetToSuggestedMappingCallback, isBulkEdit}: Props): JSX.Element
{ {
const [yourSavedBulkLoadProfiles, setYourSavedBulkLoadProfiles] = useState([] as QRecord[]); const [yourSavedBulkLoadProfiles, setYourSavedBulkLoadProfiles] = useState([] as QRecord[]);
const [bulkLoadProfilesSharedWithYou, setBulkLoadProfilesSharedWithYou] = useState([] as QRecord[]); const [bulkLoadProfilesSharedWithYou, setBulkLoadProfilesSharedWithYou] = useState([] as QRecord[]);
@ -142,6 +143,7 @@ function SavedBulkLoadProfiles({metaData, tableMetaData, tableStructure, current
const formData = new FormData(); const formData = new FormData();
formData.append("tableName", tableMetaData.name); formData.append("tableName", tableMetaData.name);
formData.append("isBulkEdit", isBulkEdit.toString());
const savedBulkLoadProfiles = await makeSavedBulkLoadProfileRequest("querySavedBulkLoadProfile", formData); const savedBulkLoadProfiles = await makeSavedBulkLoadProfileRequest("querySavedBulkLoadProfile", formData);
const yourSavedBulkLoadProfiles: QRecord[] = []; const yourSavedBulkLoadProfiles: QRecord[] = [];
@ -265,6 +267,7 @@ function SavedBulkLoadProfiles({metaData, tableMetaData, tableStructure, current
const bulkLoadProfile = currentMapping.toProfile(); const bulkLoadProfile = currentMapping.toProfile();
const mappingJson = JSON.stringify(bulkLoadProfile.profile); const mappingJson = JSON.stringify(bulkLoadProfile.profile);
formData.append("mappingJson", mappingJson); formData.append("mappingJson", mappingJson);
formData.append("isBulkEdit", isBulkEdit.toString());
if (isSaveAsAction || isRenameAction || currentSavedBulkLoadProfileRecord == null) if (isSaveAsAction || isRenameAction || currentSavedBulkLoadProfileRecord == null)
{ {
@ -389,6 +392,7 @@ function SavedBulkLoadProfiles({metaData, tableMetaData, tableStructure, current
return (savedBulkLoadProfiles); return (savedBulkLoadProfiles);
} }
const bulkAction = isBulkEdit ? "Edit" : "Load";
const hasStorePermission = metaData?.processes.has("storeSavedBulkLoadProfile"); const hasStorePermission = metaData?.processes.has("storeSavedBulkLoadProfile");
const hasDeletePermission = metaData?.processes.has("deleteSavedBulkLoadProfile"); const hasDeletePermission = metaData?.processes.has("deleteSavedBulkLoadProfile");
const hasQueryPermission = metaData?.processes.has("querySavedBulkLoadProfile"); const hasQueryPermission = metaData?.processes.has("querySavedBulkLoadProfile");
@ -428,15 +432,15 @@ function SavedBulkLoadProfiles({metaData, tableMetaData, tableStructure, current
PaperProps={{style: {maxHeight: "calc(100vh - 200px)", minWidth: menuWidth}}} PaperProps={{style: {maxHeight: "calc(100vh - 200px)", minWidth: menuWidth}}}
> >
{ {
<MenuItem sx={{width: menuWidth}} disabled style={{opacity: "initial"}}><b>Bulk Load Profile Actions</b></MenuItem> <MenuItem sx={{width: menuWidth}} disabled style={{opacity: "initial"}}><b>Bulk {bulkAction} Profile Actions</b></MenuItem>
} }
{ {
!allowSelectingProfile && !allowSelectingProfile &&
<MenuItem sx={{width: menuWidth}} disabled style={{opacity: "initial", whiteSpace: "wrap", display: "block"}}> <MenuItem sx={{width: menuWidth}} disabled style={{opacity: "initial", whiteSpace: "wrap", display: "block"}}>
{ {
currentSavedBulkLoadProfileRecord ? currentSavedBulkLoadProfileRecord ?
<span>You are using the bulk load profile:<br /><b style={{paddingLeft: "1rem"}}>{currentSavedBulkLoadProfileRecord.values.get("label")}</b><br /><br />You can manage this profile on this screen.</span> <span>You are using the bulk {bulkAction.toLowerCase()} profile:<br /><b style={{paddingLeft: "1rem"}}>{currentSavedBulkLoadProfileRecord.values.get("label")}</b><br /><br />You can manage this profile on this screen.</span>
: <span>You are not using a saved bulk load profile.<br /><br />You can save your profile on this screen.</span> : <span>You are not using a saved bulk {bulkAction.toLowerCase()} profile.<br /><br />You can save your profile on this screen.</span>
} }
</MenuItem> </MenuItem>
} }
@ -456,7 +460,7 @@ function SavedBulkLoadProfiles({metaData, tableMetaData, tableStructure, current
} }
{ {
hasStorePermission && currentSavedBulkLoadProfileRecord != null && hasStorePermission && currentSavedBulkLoadProfileRecord != null &&
<Tooltip {...menuTooltipAttribs} title={notOwnerTooltipText ?? "Change the name for this saved bulk load profile."}> <Tooltip {...menuTooltipAttribs} title={notOwnerTooltipText ?? "Change the name for this saved bulk {bulkAction.toLowerCase()} profile."}>
<span> <span>
<MenuItem disabled={currentSavedBulkLoadProfileRecord === null || disabledBecauseNotOwner} onClick={() => handleDropdownOptionClick(RENAME_OPTION)}> <MenuItem disabled={currentSavedBulkLoadProfileRecord === null || disabledBecauseNotOwner} onClick={() => handleDropdownOptionClick(RENAME_OPTION)}>
<ListItemIcon><Icon>edit</Icon></ListItemIcon> <ListItemIcon><Icon>edit</Icon></ListItemIcon>
@ -467,7 +471,7 @@ function SavedBulkLoadProfiles({metaData, tableMetaData, tableStructure, current
} }
{ {
hasStorePermission && currentSavedBulkLoadProfileRecord != null && hasStorePermission && currentSavedBulkLoadProfileRecord != null &&
<Tooltip {...menuTooltipAttribs} title="Save a new copy this bulk load profile, with a different name, separate from the original."> <Tooltip {...menuTooltipAttribs} title="Save a new copy this bulk {bulkAction.toLowerCase()} profile, with a different name, separate from the original.">
<span> <span>
<MenuItem disabled={currentSavedBulkLoadProfileRecord === null} onClick={() => handleDropdownOptionClick(DUPLICATE_OPTION)}> <MenuItem disabled={currentSavedBulkLoadProfileRecord === null} onClick={() => handleDropdownOptionClick(DUPLICATE_OPTION)}>
<ListItemIcon><Icon>content_copy</Icon></ListItemIcon> <ListItemIcon><Icon>content_copy</Icon></ListItemIcon>
@ -478,7 +482,7 @@ function SavedBulkLoadProfiles({metaData, tableMetaData, tableStructure, current
} }
{ {
hasDeletePermission && currentSavedBulkLoadProfileRecord != null && hasDeletePermission && currentSavedBulkLoadProfileRecord != null &&
<Tooltip {...menuTooltipAttribs} title={notOwnerTooltipText ?? "Delete this saved bulk load profile."}> <Tooltip {...menuTooltipAttribs} title={notOwnerTooltipText ?? "Delete this saved bulk {bulkAction.toLowerCase()} profile."}>
<span> <span>
<MenuItem disabled={currentSavedBulkLoadProfileRecord === null || disabledBecauseNotOwner} onClick={() => handleDropdownOptionClick(DELETE_OPTION)}> <MenuItem disabled={currentSavedBulkLoadProfileRecord === null || disabledBecauseNotOwner} onClick={() => handleDropdownOptionClick(DELETE_OPTION)}>
<ListItemIcon><Icon>delete</Icon></ListItemIcon> <ListItemIcon><Icon>delete</Icon></ListItemIcon>
@ -489,11 +493,11 @@ function SavedBulkLoadProfiles({metaData, tableMetaData, tableStructure, current
} }
{ {
allowSelectingProfile && allowSelectingProfile &&
<Tooltip {...menuTooltipAttribs} title="Create a new blank bulk load profile for this table, removing all mappings."> <Tooltip {...menuTooltipAttribs} title="Create a new blank bulk {bulkAction.toLowerCase()} profile for this table, removing all mappings.">
<span> <span>
<MenuItem onClick={() => handleDropdownOptionClick(CLEAR_OPTION)}> <MenuItem onClick={() => handleDropdownOptionClick(CLEAR_OPTION)}>
<ListItemIcon><Icon>monitor</Icon></ListItemIcon> <ListItemIcon><Icon>monitor</Icon></ListItemIcon>
New Bulk Load Profile New Bulk {bulkAction} Profile
</MenuItem> </MenuItem>
</span> </span>
</Tooltip> </Tooltip>
@ -504,7 +508,7 @@ function SavedBulkLoadProfiles({metaData, tableMetaData, tableStructure, current
{ {
<Divider /> <Divider />
} }
<MenuItem disabled style={{"opacity": "initial"}}><b>Your Saved Bulk Load Profiles</b></MenuItem> <MenuItem disabled style={{"opacity": "initial"}}><b>Your Saved Bulk {bulkAction} Profiles</b></MenuItem>
{ {
yourSavedBulkLoadProfiles && yourSavedBulkLoadProfiles.length > 0 ? ( yourSavedBulkLoadProfiles && yourSavedBulkLoadProfiles.length > 0 ? (
yourSavedBulkLoadProfiles.map((record: QRecord, index: number) => yourSavedBulkLoadProfiles.map((record: QRecord, index: number) =>
@ -514,11 +518,11 @@ function SavedBulkLoadProfiles({metaData, tableMetaData, tableStructure, current
) )
) : ( ) : (
<MenuItem disabled sx={{opacity: "1 !important"}}> <MenuItem disabled sx={{opacity: "1 !important"}}>
<i>You do not have any saved bulk load profiles for this table.</i> <i>You do not have any saved bulk {bulkAction.toLowerCase()} profiles for this table.</i>
</MenuItem> </MenuItem>
) )
} }
<MenuItem disabled style={{"opacity": "initial"}}><b>Bulk Load Profiles Shared with you</b></MenuItem> <MenuItem disabled style={{"opacity": "initial"}}><b>Bulk {bulkAction} Profiles Shared with you</b></MenuItem>
{ {
bulkLoadProfilesSharedWithYou && bulkLoadProfilesSharedWithYou.length > 0 ? ( bulkLoadProfilesSharedWithYou && bulkLoadProfilesSharedWithYou.length > 0 ? (
bulkLoadProfilesSharedWithYou.map((record: QRecord, index: number) => bulkLoadProfilesSharedWithYou.map((record: QRecord, index: number) =>
@ -528,7 +532,7 @@ function SavedBulkLoadProfiles({metaData, tableMetaData, tableStructure, current
) )
) : ( ) : (
<MenuItem disabled sx={{opacity: "1 !important"}}> <MenuItem disabled sx={{opacity: "1 !important"}}>
<i>You do not have any bulk load profiles shared with you for this table.</i> <i>You do not have any bulk {bulkAction.toLowerCase()} profiles shared with you for this table.</i>
</MenuItem> </MenuItem>
) )
} }
@ -537,7 +541,7 @@ function SavedBulkLoadProfiles({metaData, tableMetaData, tableStructure, current
</Menu> </Menu>
); );
let buttonText = "Saved Bulk Load Profiles"; let buttonText = `Saved Bulk ${bulkAction} Profiles`;
let buttonBackground = "none"; let buttonBackground = "none";
let buttonBorder = colors.grayLines.main; let buttonBorder = colors.grayLines.main;
let buttonColor = colors.gray.main; let buttonColor = colors.gray.main;
@ -639,13 +643,13 @@ function SavedBulkLoadProfiles({metaData, tableMetaData, tableStructure, current
<Tooltip {...tooltipMaxWidth("24rem")} sx={{cursor: "pointer"}} title={<> <Tooltip {...tooltipMaxWidth("24rem")} sx={{cursor: "pointer"}} title={<>
<b>Unsaved Mapping</b> <b>Unsaved Mapping</b>
<ul style={{padding: "0.5rem 1rem"}}> <ul style={{padding: "0.5rem 1rem"}}>
<li>You are not using a saved bulk load profile.</li> <li>You are not using a saved bulk {bulkAction.toLowerCase()} profile.</li>
{ {
/*bulkLoadProfileDiffs.map((s: string, i: number) => <li key={i}>{s}</li>)*/ /*bulkLoadProfileDiffs.map((s: string, i: number) => <li key={i}>{s}</li>)*/
} }
</ul> </ul>
</>}> </>}>
<Button disableRipple={true} sx={linkButtonStyle} onClick={() => handleDropdownOptionClick(SAVE_OPTION)}>Save Bulk Load Profile As&hellip;</Button> <Button disableRipple={true} sx={linkButtonStyle} onClick={() => handleDropdownOptionClick(SAVE_OPTION)}>Save Bulk {bulkAction} Profile As&hellip;</Button>
</Tooltip> </Tooltip>
{/* vertical rule */} {/* vertical rule */}
@ -716,20 +720,20 @@ function SavedBulkLoadProfiles({metaData, tableMetaData, tableStructure, current
{ {
currentSavedBulkLoadProfileRecord ? ( currentSavedBulkLoadProfileRecord ? (
isDeleteAction ? ( isDeleteAction ? (
<DialogTitle id="alert-dialog-title">Delete Bulk Load Profile</DialogTitle> <DialogTitle id="alert-dialog-title">Delete Bulk {bulkAction} Profile</DialogTitle>
) : ( ) : (
isSaveAsAction ? ( isSaveAsAction ? (
<DialogTitle id="alert-dialog-title">Save Bulk Load Profile As</DialogTitle> <DialogTitle id="alert-dialog-title">Save Bulk {bulkAction} Profile As</DialogTitle>
) : ( ) : (
isRenameAction ? ( isRenameAction ? (
<DialogTitle id="alert-dialog-title">Rename Bulk Load Profile</DialogTitle> <DialogTitle id="alert-dialog-title">Rename Bulk {bulkAction} Profile</DialogTitle>
) : ( ) : (
<DialogTitle id="alert-dialog-title">Update Existing Bulk Load Profile</DialogTitle> <DialogTitle id="alert-dialog-title">Update Existing Bulk {bulkAction} Profile</DialogTitle>
) )
) )
) )
) : ( ) : (
<DialogTitle id="alert-dialog-title">Save New Bulk Load Profile</DialogTitle> <DialogTitle id="alert-dialog-title">Save New Bulk {bulkAction} Profile</DialogTitle>
) )
} }
<DialogContent sx={{width: "500px"}}> <DialogContent sx={{width: "500px"}}>
@ -743,15 +747,15 @@ function SavedBulkLoadProfiles({metaData, tableMetaData, tableStructure, current
<Box> <Box>
{ {
isSaveAsAction ? ( isSaveAsAction ? (
<Box mb={3}>Enter a name for this new saved bulk load profile.</Box> <Box mb={3}>Enter a name for this new saved bulk {bulkAction.toLowerCase()} profile.</Box>
) : ( ) : (
<Box mb={3}>Enter a new name for this saved bulk load profile.</Box> <Box mb={3}>Enter a new name for this saved bulk {bulkAction.toLowerCase()} profile.</Box>
) )
} }
<TextField <TextField
autoFocus autoFocus
name="custom-delimiter-value" name="custom-delimiter-value"
placeholder="Bulk Load Profile Name" placeholder={`Bulk ${bulkAction} Profile Name`}
inputProps={{width: "100%", maxLength: 100}} inputProps={{width: "100%", maxLength: 100}}
value={savedBulkLoadProfileNameInputValue} value={savedBulkLoadProfileNameInputValue}
sx={{width: "100%"}} sx={{width: "100%"}}
@ -764,9 +768,9 @@ function SavedBulkLoadProfiles({metaData, tableMetaData, tableStructure, current
</Box> </Box>
) : ( ) : (
isDeleteAction ? ( isDeleteAction ? (
<Box>Are you sure you want to delete the bulk load profile {`'${currentSavedBulkLoadProfileRecord?.values.get("label")}'`}?</Box> <Box>Are you sure you want to delete the bulk {bulkAction.toLowerCase()} profile {`'${currentSavedBulkLoadProfileRecord?.values.get("label")}'`}?</Box>
) : ( ) : (
<Box>Are you sure you want to update the bulk load profile {`'${currentSavedBulkLoadProfileRecord?.values.get("label")}'`}?</Box> <Box>Are you sure you want to update the bulk {bulkAction.toLowerCase()} profile {`'${currentSavedBulkLoadProfileRecord?.values.get("label")}'`}?</Box>
) )
) )
} }

View File

@ -26,7 +26,6 @@ import Autocomplete from "@mui/material/Autocomplete";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import Button from "@mui/material/Button"; import Button from "@mui/material/Button";
import Icon from "@mui/material/Icon"; import Icon from "@mui/material/Icon";
import IconButton from "@mui/material/IconButton";
import RadioGroup from "@mui/material/RadioGroup"; import RadioGroup from "@mui/material/RadioGroup";
import TextField from "@mui/material/TextField"; import TextField from "@mui/material/TextField";
import {useFormikContext} from "formik"; import {useFormikContext} from "formik";
@ -44,6 +43,7 @@ interface BulkLoadMappingFieldProps
removeFieldCallback?: () => void, removeFieldCallback?: () => void,
fileDescription: FileDescription, fileDescription: FileDescription,
forceParentUpdate?: () => void, forceParentUpdate?: () => void,
isBulkEdit?: boolean
} }
const xIconButtonSX = const xIconButtonSX =
@ -72,7 +72,7 @@ const qController = Client.getInstance();
/*************************************************************************** /***************************************************************************
** row for a single field on the bulk load mapping screen. ** row for a single field on the bulk load mapping screen.
***************************************************************************/ ***************************************************************************/
export default function BulkLoadFileMappingField({bulkLoadField, isRequired, removeFieldCallback, fileDescription, forceParentUpdate}: BulkLoadMappingFieldProps): JSX.Element export default function BulkLoadFileMappingField({bulkLoadField, isRequired, removeFieldCallback, fileDescription, forceParentUpdate, isBulkEdit}: BulkLoadMappingFieldProps): JSX.Element
{ {
const columnNames = fileDescription.getColumnNames(); const columnNames = fileDescription.getColumnNames();
@ -104,7 +104,7 @@ export default function BulkLoadFileMappingField({bulkLoadField, isRequired, rem
{ {
try try
{ {
const possibleValues = await qController.possibleValues(bulkLoadField.tableStructure.tableName, null, fieldMetaData.name, null, [bulkLoadField.defaultValue], undefined, "filter"); const possibleValues = await qController.possibleValues(bulkLoadField.tableStructure.tableName, null, fieldMetaData.name, null, [bulkLoadField.defaultValue], undefined, null, "filter");
if (possibleValues && possibleValues.length > 0) if (possibleValues && possibleValues.length > 0)
{ {
setPossibleValueInitialDisplayValue(possibleValues[0].label); setPossibleValueInitialDisplayValue(possibleValues[0].label);
@ -116,7 +116,7 @@ export default function BulkLoadFileMappingField({bulkLoadField, isRequired, rem
} }
catch (e) catch (e)
{ {
console.log(`Error loading possible value: ${e}`) console.log(`Error loading possible value: ${e}`);
} }
actuallyDoingInitialLoadOfPossibleValue = false; actuallyDoingInitialLoadOfPossibleValue = false;
@ -150,7 +150,7 @@ export default function BulkLoadFileMappingField({bulkLoadField, isRequired, rem
////////////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////////
if (bulkLoadField.columnIndex != null && bulkLoadField.columnIndex != undefined && selectedColumn.label && columnNames[bulkLoadField.columnIndex] != selectedColumn.label) if (bulkLoadField.columnIndex != null && bulkLoadField.columnIndex != undefined && selectedColumn.label && columnNames[bulkLoadField.columnIndex] != selectedColumn.label)
{ {
setSelectedColumn({label: columnNames[bulkLoadField.columnIndex], value: bulkLoadField.columnIndex}) setSelectedColumn({label: columnNames[bulkLoadField.columnIndex], value: bulkLoadField.columnIndex});
setSelectedColumnInputValue(columnNames[bulkLoadField.columnIndex]); setSelectedColumnInputValue(columnNames[bulkLoadField.columnIndex]);
} }
@ -228,6 +228,17 @@ export default function BulkLoadFileMappingField({bulkLoadField, isRequired, rem
forceParentUpdate && forceParentUpdate(); forceParentUpdate && forceParentUpdate();
} }
/***************************************************************************
**
***************************************************************************/
function clearIfEmptyChanged(value: boolean)
{
bulkLoadField.clearIfEmpty = value;
forceParentUpdate && forceParentUpdate();
}
/*************************************************************************** /***************************************************************************
** **
***************************************************************************/ ***************************************************************************/
@ -314,8 +325,11 @@ export default function BulkLoadFileMappingField({bulkLoadField, isRequired, rem
<Box ml="1rem"> <Box ml="1rem">
{ {
valueType == "column" && <> valueType == "column" && <>
<Box> <Box display="flex" alignItems="center" sx={{height: "45px"}}>
<FormControlLabel value="mapValues" control={<Checkbox size="small" defaultChecked={bulkLoadField.doValueMapping} onChange={(event, checked) => mapValuesChanged(checked)} />} label={"Map values"} sx={{minWidth: "140px", whiteSpace: "nowrap"}} /> <FormControlLabel value="mapValues" control={<Checkbox size="small" defaultChecked={bulkLoadField.doValueMapping} onChange={(event, checked) => mapValuesChanged(checked)} />} label={"Map values"} sx={{minWidth: "140px", whiteSpace: "nowrap"}} />
{
isBulkEdit && !isRequired && <FormControlLabel value="clearIfEmpty" control={<Checkbox size="small" defaultChecked={bulkLoadField.clearIfEmpty} onChange={(event, checked) => clearIfEmptyChanged(checked)} />} label={"Clear if empty"} sx={{minWidth: "140px", whiteSpace: "nowrap"}} />
}
</Box> </Box>
<Box fontSize={mainFontSize} mt="0.5rem"> <Box fontSize={mainFontSize} mt="0.5rem">
Preview Values: <span style={{color: "gray"}}>{(fileDescription.getPreviewValues(selectedColumn?.value) ?? [""]).join(", ")}</span> Preview Values: <span style={{color: "gray"}}>{(fileDescription.getPreviewValues(selectedColumn?.value) ?? [""]).join(", ")}</span>

View File

@ -33,6 +33,7 @@ interface BulkLoadMappingFieldsProps
bulkLoadMapping: BulkLoadMapping, bulkLoadMapping: BulkLoadMapping,
fileDescription: FileDescription, fileDescription: FileDescription,
forceParentUpdate?: () => void, forceParentUpdate?: () => void,
isBulkEdit?: boolean
} }
@ -43,7 +44,7 @@ const ALREADY_ADDED_FIELD_TOOLTIP = "This field has already been added to your m
/*************************************************************************** /***************************************************************************
** The section of the bulk load mapping screen with all the fields. ** The section of the bulk load mapping screen with all the fields.
***************************************************************************/ ***************************************************************************/
export default function BulkLoadFileMappingFields({bulkLoadMapping, fileDescription, forceParentUpdate}: BulkLoadMappingFieldsProps): JSX.Element export default function BulkLoadFileMappingFields({bulkLoadMapping, fileDescription, forceParentUpdate, isBulkEdit}: BulkLoadMappingFieldsProps): JSX.Element
{ {
const [, forceUpdate] = useReducer((x) => x + 1, 0); const [, forceUpdate] = useReducer((x) => x + 1, 0);
@ -254,11 +255,16 @@ export default function BulkLoadFileMappingFields({bulkLoadMapping, fileDescript
return ( return (
<> <>
<h5>Required Fields</h5> {isBulkEdit ? <h5>Key Fields</h5> : <h5>Required Fields</h5>}
<Box pl={"1rem"}> <Box pl={"1rem"}>
{ {
bulkLoadMapping.requiredFields.length == 0 && bulkLoadMapping.requiredFields.length == 0 &&
(
isBulkEdit ?
<i style={{fontSize: "0.875rem"}}>Select table key fields to continue.</i>
:
<i style={{fontSize: "0.875rem"}}>There are no required fields in this table.</i> <i style={{fontSize: "0.875rem"}}>There are no required fields in this table.</i>
)
} }
{bulkLoadMapping.requiredFields.map((bulkLoadField) => ( {bulkLoadMapping.requiredFields.map((bulkLoadField) => (
<BulkLoadFileMappingField <BulkLoadFileMappingField
@ -267,12 +273,13 @@ export default function BulkLoadFileMappingFields({bulkLoadMapping, fileDescript
bulkLoadField={bulkLoadField} bulkLoadField={bulkLoadField}
isRequired={true} isRequired={true}
forceParentUpdate={forceParentUpdate} forceParentUpdate={forceParentUpdate}
isBulkEdit={isBulkEdit}
/> />
))} ))}
</Box> </Box>
<Box mt="1rem"> <Box mt="1rem">
<h5>Additional Fields</h5> {isBulkEdit ? <h5>Fields To Update</h5> : <h5>Additional Fields</h5>}
<Box pl={"1rem"}> <Box pl={"1rem"}>
{bulkLoadMapping.additionalFields.map((bulkLoadField) => ( {bulkLoadMapping.additionalFields.map((bulkLoadField) => (
<BulkLoadFileMappingField <BulkLoadFileMappingField
@ -282,6 +289,7 @@ export default function BulkLoadFileMappingFields({bulkLoadMapping, fileDescript
isRequired={false} isRequired={false}
removeFieldCallback={() => removeField(bulkLoadField)} removeFieldCallback={() => removeField(bulkLoadField)}
forceParentUpdate={forceParentUpdate} forceParentUpdate={forceParentUpdate}
isBulkEdit={isBulkEdit}
/> />
))} ))}

View File

@ -36,15 +36,18 @@ import {useFormikContext} from "formik";
import colors from "qqq/assets/theme/base/colors"; import colors from "qqq/assets/theme/base/colors";
import {DynamicFormFieldLabel} from "qqq/components/forms/DynamicForm"; import {DynamicFormFieldLabel} from "qqq/components/forms/DynamicForm";
import QDynamicFormField from "qqq/components/forms/DynamicFormField"; import QDynamicFormField from "qqq/components/forms/DynamicFormField";
import DynamicFormUtils from "qqq/components/forms/DynamicFormUtils";
import MDTypography from "qqq/components/legacy/MDTypography"; import MDTypography from "qqq/components/legacy/MDTypography";
import HelpContent from "qqq/components/misc/HelpContent"; import HelpContent from "qqq/components/misc/HelpContent";
import SavedBulkLoadProfiles from "qqq/components/misc/SavedBulkLoadProfiles"; import SavedBulkLoadProfiles from "qqq/components/misc/SavedBulkLoadProfiles";
import BulkLoadFileMappingFields from "qqq/components/processes/BulkLoadFileMappingFields"; import BulkLoadFileMappingFields from "qqq/components/processes/BulkLoadFileMappingFields";
import {BulkLoadField, BulkLoadMapping, BulkLoadProfile, BulkLoadTableStructure, FileDescription, Wrapper} from "qqq/models/processes/BulkLoadModels"; import {BulkLoadField, BulkLoadMapping, BulkLoadProfile, BulkLoadTableStructure, FileDescription, Wrapper} from "qqq/models/processes/BulkLoadModels";
import {SubFormPreSubmitCallbackResultType} from "qqq/pages/processes/ProcessRun"; import {SubFormPreSubmitCallbackResultType} from "qqq/pages/processes/ProcessRun";
import React, {forwardRef, useImperativeHandle, useReducer, useState} from "react"; import Client from "qqq/utils/qqq/Client";
import React, {forwardRef, useEffect, useImperativeHandle, useReducer, useState} from "react";
import ProcessViewForm from "./ProcessViewForm"; import ProcessViewForm from "./ProcessViewForm";
const qController = Client.getInstance();
interface BulkLoadMappingFormProps interface BulkLoadMappingFormProps
{ {
@ -73,13 +76,12 @@ const BulkLoadFileMappingForm = forwardRef(({processValues, tableMetaData, metaD
const [suggestedBulkLoadProfile] = useState(processValues.suggestedBulkLoadProfile as BulkLoadProfile); const [suggestedBulkLoadProfile] = useState(processValues.suggestedBulkLoadProfile as BulkLoadProfile);
const [tableStructure] = useState(processValues.tableStructure as BulkLoadTableStructure); const [tableStructure] = useState(processValues.tableStructure as BulkLoadTableStructure);
const [bulkLoadMapping, setBulkLoadMapping] = useState(BulkLoadMapping.fromBulkLoadProfile(tableStructure, processValues.bulkLoadProfile)); const [bulkLoadMapping, setBulkLoadMapping] = useState(BulkLoadMapping.fromBulkLoadProfile(tableStructure, processValues.bulkLoadProfile, processMetaData.name));
const [wrappedBulkLoadMapping] = useState(new Wrapper<BulkLoadMapping>(bulkLoadMapping)); const [wrappedBulkLoadMapping] = useState(new Wrapper<BulkLoadMapping>(bulkLoadMapping));
const [fileDescription] = useState(new FileDescription(processValues.headerValues, processValues.headerLetters, processValues.bodyValuesPreview)); const [fileDescription] = useState(new FileDescription(processValues.headerValues, processValues.headerLetters, processValues.bodyValuesPreview));
fileDescription.setHasHeaderRow(bulkLoadMapping.hasHeaderRow); fileDescription.setHasHeaderRow(bulkLoadMapping.hasHeaderRow);
const [, forceUpdate] = useReducer((x) => x + 1, 0); const [, forceUpdate] = useReducer((x) => x + 1, 0);
///////////////////////////////////////////////////////////////////////////////////////////////// /////////////////////////////////////////////////////////////////////////////////////////////////
@ -114,6 +116,8 @@ const BulkLoadFileMappingForm = forwardRef(({processValues, tableMetaData, metaD
values["savedBulkLoadProfileId"] = wrappedCurrentSavedBulkLoadProfile.get()?.values?.get("id"); values["savedBulkLoadProfileId"] = wrappedCurrentSavedBulkLoadProfile.get()?.values?.get("id");
values["layout"] = wrappedBulkLoadMapping.get().layout; values["layout"] = wrappedBulkLoadMapping.get().layout;
values["hasHeaderRow"] = wrappedBulkLoadMapping.get().hasHeaderRow; values["hasHeaderRow"] = wrappedBulkLoadMapping.get().hasHeaderRow;
values["isBulkEdit"] = wrappedBulkLoadMapping.get().isBulkEdit;
values["keyFields"] = wrappedBulkLoadMapping.get().keyFields;
let haveLocalErrors = false; let haveLocalErrors = false;
const fieldErrors: { [fieldName: string]: string } = {}; const fieldErrors: { [fieldName: string]: string } = {};
@ -130,6 +134,13 @@ const BulkLoadFileMappingForm = forwardRef(({processValues, tableMetaData, metaD
} }
setFieldErrors(fieldErrors); setFieldErrors(fieldErrors);
if (values["isBulkEdit"] && (values["keyFields"] == null || values["keyFields"] == undefined))
{
haveLocalErrors = true;
fieldErrors["keyFields"] = "This field is required.";
}
setFieldErrors(fieldErrors);
if (wrappedBulkLoadMapping.get().requiredFields.length == 0 && wrappedBulkLoadMapping.get().additionalFields.length == 0) if (wrappedBulkLoadMapping.get().requiredFields.length == 0 && wrappedBulkLoadMapping.get().additionalFields.length == 0)
{ {
setNoMappedFieldsError("You must have at least 1 field."); setNoMappedFieldsError("You must have at least 1 field.");
@ -182,7 +193,7 @@ const BulkLoadFileMappingForm = forwardRef(({processValues, tableMetaData, metaD
***************************************************************************/ ***************************************************************************/
function bulkLoadProfileResetToSuggestedMappingCallback() function bulkLoadProfileResetToSuggestedMappingCallback()
{ {
handleNewBulkLoadMapping(BulkLoadMapping.fromBulkLoadProfile(processValues.tableStructure, suggestedBulkLoadProfile)); handleNewBulkLoadMapping(BulkLoadMapping.fromBulkLoadProfile(processValues.tableStructure, suggestedBulkLoadProfile, processValues.name));
} }
@ -201,6 +212,8 @@ const BulkLoadFileMappingForm = forwardRef(({processValues, tableMetaData, metaD
setBulkLoadMapping(newBulkLoadMapping); setBulkLoadMapping(newBulkLoadMapping);
wrappedBulkLoadMapping.set(newBulkLoadMapping); wrappedBulkLoadMapping.set(newBulkLoadMapping);
setFieldValue("isBulkEdit", newBulkLoadMapping.isBulkEdit);
setFieldValue("keyFields", newBulkLoadMapping.keyFields);
setFieldValue("hasHeaderRow", newBulkLoadMapping.hasHeaderRow); setFieldValue("hasHeaderRow", newBulkLoadMapping.hasHeaderRow);
setFieldValue("layout", newBulkLoadMapping.layout); setFieldValue("layout", newBulkLoadMapping.layout);
@ -228,10 +241,13 @@ const BulkLoadFileMappingForm = forwardRef(({processValues, tableMetaData, metaD
bulkLoadProfileOnChangeCallback={bulkLoadProfileOnChangeCallback} bulkLoadProfileOnChangeCallback={bulkLoadProfileOnChangeCallback}
bulkLoadProfileResetToSuggestedMappingCallback={bulkLoadProfileResetToSuggestedMappingCallback} bulkLoadProfileResetToSuggestedMappingCallback={bulkLoadProfileResetToSuggestedMappingCallback}
fileDescription={fileDescription} fileDescription={fileDescription}
isBulkEdit={processValues.isBulkEdit}
/> />
</Box> </Box>
<BulkLoadMappingHeader <BulkLoadMappingHeader
tableMetaData={tableMetaData}
isBulkEdit={processValues.isBulkEdit}
key={rerenderHeader} key={rerenderHeader}
bulkLoadMapping={bulkLoadMapping} bulkLoadMapping={bulkLoadMapping}
fileDescription={fileDescription} fileDescription={fileDescription}
@ -245,6 +261,7 @@ const BulkLoadFileMappingForm = forwardRef(({processValues, tableMetaData, metaD
<Box mt="2rem"> <Box mt="2rem">
<BulkLoadFileMappingFields <BulkLoadFileMappingFields
isBulkEdit={processValues.isBulkEdit}
bulkLoadMapping={bulkLoadMapping} bulkLoadMapping={bulkLoadMapping}
fileDescription={fileDescription} fileDescription={fileDescription}
forceParentUpdate={() => forceParentUpdate={() =>
@ -267,6 +284,7 @@ export default BulkLoadFileMappingForm;
interface BulkLoadMappingHeaderProps interface BulkLoadMappingHeaderProps
{ {
isBulkEdit?: boolean,
fileDescription: FileDescription, fileDescription: FileDescription,
fileName: string, fileName: string,
bulkLoadMapping?: BulkLoadMapping, bulkLoadMapping?: BulkLoadMapping,
@ -275,13 +293,16 @@ interface BulkLoadMappingHeaderProps
forceParentUpdate?: () => void, forceParentUpdate?: () => void,
frontendStep: QFrontendStepMetaData, frontendStep: QFrontendStepMetaData,
processMetaData: QProcessMetaData, processMetaData: QProcessMetaData,
tableMetaData: QTableMetaData,
} }
/*************************************************************************** /***************************************************************************
** private subcomponent - the header section of the bulk load file mapping screen. ** private subcomponent - the header section of the bulk load file mapping screen.
***************************************************************************/ ***************************************************************************/
function BulkLoadMappingHeader({fileDescription, fileName, bulkLoadMapping, fieldErrors, tableStructure, forceParentUpdate, frontendStep, processMetaData}: BulkLoadMappingHeaderProps): JSX.Element function BulkLoadMappingHeader({isBulkEdit, fileDescription, fileName, bulkLoadMapping, fieldErrors, tableStructure, forceParentUpdate, frontendStep, processMetaData, tableMetaData}: BulkLoadMappingHeaderProps): JSX.Element
{ {
const [dynamicField, setDynamicField] = useState(null);
const viewFields = [ const viewFields = [
new QFieldMetaData({name: "fileName", label: "File Name", type: "STRING"}), new QFieldMetaData({name: "fileName", label: "File Name", type: "STRING"}),
new QFieldMetaData({name: "fileDetails", label: "File Details", type: "STRING"}), new QFieldMetaData({name: "fileDetails", label: "File Details", type: "STRING"}),
@ -307,6 +328,36 @@ function BulkLoadMappingHeader({fileDescription, fileName, bulkLoadMapping, fiel
const selectedLayout = layoutOptions.filter(o => o.id == bulkLoadMapping.layout)[0] ?? null; const selectedLayout = layoutOptions.filter(o => o.id == bulkLoadMapping.layout)[0] ?? null;
useEffect(() =>
{
(async () =>
{
if (isBulkEdit)
{
/////////////////////////////////////////////////////////////////////////
// if doing a bulk edit, the selected keyFields and set as the display //
/////////////////////////////////////////////////////////////////////////
const displayValues = new Map<string, string>;
if (bulkLoadMapping.keyFields)
{
const possibleValues = await qController.possibleValues(null, processMetaData.name, "tableKeyFields", bulkLoadMapping.keyFields, null);
console.log("Received possible values of: " + JSON.stringify(possibleValues));
displayValues.set("tableKeyFields", possibleValues[0].label);
}
const tableKeyFieldsField = processMetaData.frontendSteps.find(s => s.name == "fileMapping")?.formFields.find(f => f.name == "tableKeyFields");
const newDynamicField = DynamicFormUtils.getDynamicField(tableKeyFieldsField);
const dynamicFieldInObject: any = {};
dynamicFieldInObject[tableKeyFieldsField["name"]] = newDynamicField;
DynamicFormUtils.addPossibleValueProps(dynamicFieldInObject, [tableKeyFieldsField], null, processMetaData.name, displayValues);
keyFieldsChanged(bulkLoadMapping.keyFields);
setDynamicField(newDynamicField);
forceParentUpdate();
}
})();
}, [JSON.stringify(bulkLoadMapping)]);
/*************************************************************************** /***************************************************************************
** **
***************************************************************************/ ***************************************************************************/
@ -331,6 +382,61 @@ function BulkLoadMappingHeader({fileDescription, fileName, bulkLoadMapping, fiel
forceParentUpdate(); forceParentUpdate();
} }
/***************************************************************************
**
***************************************************************************/
async function keyFieldsChanged(newValue: any)
{
fieldErrors.keyFields = null;
if (newValue && newValue.length > 0)
{
//////////////////////////////////////////////////////////
// validate that the fields in the key have been mapped //
//////////////////////////////////////////////////////////
console.log("Received key fields of: " + newValue);
const keyFields = newValue.split("|");
const unmappedKeyFields: string[] = [];
const requiredFields: BulkLoadField[] = [];
const additionalFields: BulkLoadField[] = [];
////////////////////////////////////////////////////////////////////////////////////////////////
// iterate over all fields in the table, when there are key fields found, make them required, //
// otherwise add them to addition fields //
////////////////////////////////////////////////////////////////////////////////////////////////
for (let bulkLoadField of [...bulkLoadMapping.requiredFields, ...bulkLoadMapping.additionalFields])
{
const qualifiedName = bulkLoadField.getQualifiedName();
const keyField = keyFields.find((k: string) => k == qualifiedName);
if (keyField)
{
requiredFields.push(bulkLoadField);
var fieldsByTablePrefix = bulkLoadMapping.fieldsByTablePrefix[""][keyField];
if (!fieldsByTablePrefix || fieldsByTablePrefix.columnIndex == null)
{
unmappedKeyFields.push(tableMetaData.fields.get(keyField).label);
}
}
else
{
additionalFields.push(bulkLoadField);
}
}
bulkLoadMapping.requiredFields = requiredFields;
bulkLoadMapping.additionalFields = additionalFields;
if (unmappedKeyFields.length > 0)
{
fieldErrors.keyFields = "The following key fields are not mapped: " + unmappedKeyFields.join(", ");
}
bulkLoadMapping.handleChangeToKeyFields(newValue);
}
forceParentUpdate();
}
/*************************************************************************** /***************************************************************************
** **
***************************************************************************/ ***************************************************************************/
@ -369,6 +475,9 @@ function BulkLoadMappingHeader({fileDescription, fileName, bulkLoadMapping, fiel
{getFormattedHelpContent("hasHeaderRow")} {getFormattedHelpContent("hasHeaderRow")}
</Grid> </Grid>
<Grid item xs={12} md={6}> <Grid item xs={12} md={6}>
{
!isBulkEdit ? (
<>
<DynamicFormFieldLabel name={"layout"} label={"File Layout *"} /> <DynamicFormFieldLabel name={"layout"} label={"File Layout *"} />
<Autocomplete <Autocomplete
id={"layout"} id={"layout"}
@ -390,6 +499,26 @@ function BulkLoadMappingHeader({fileDescription, fileName, bulkLoadMapping, fiel
</MDTypography> </MDTypography>
} }
{getFormattedHelpContent("layout")} {getFormattedHelpContent("layout")}
</>
) : (
<>
{
dynamicField &&
<>
<DynamicFormFieldLabel name={dynamicField.name} label={`${dynamicField.label} *`} />
<QDynamicFormField name={dynamicField.name} displayFormat={""} label={""} formFieldObject={dynamicField} type={"pvs"} value={bulkLoadMapping.keyFields} onChangeCallback={keyFieldsChanged} />
{
fieldErrors.keyFields &&
<MDTypography component="div" variant="caption" color="error" fontWeight="regular" mt="0.25rem">
{<div className="fieldErrorMessage">{fieldErrors.keyFields}</div>}
</MDTypography>
}
{getFormattedHelpContent("tableKeyFields")}
</>
}
</>
)
}
</Grid> </Grid>
</Grid> </Grid>
</Box> </Box>
@ -490,12 +619,12 @@ function BulkLoadMappingFilePreview({fileDescription, bulkLoadMapping}: BulkLoad
const fields = bulkLoadMapping.getFieldsForColumnIndex(index); const fields = bulkLoadMapping.getFieldsForColumnIndex(index);
const count = fields.length; const count = fields.length;
let dupeWarning = <></> let dupeWarning = <></>;
if (fileDescription.hasHeaderRow && fileDescription.duplicateHeaderIndexes[index]) if (fileDescription.hasHeaderRow && fileDescription.duplicateHeaderIndexes[index])
{ {
dupeWarning = <Tooltip title="This column header is a duplicate. Only the first occurrance of it will be used." placement="top" enterDelay={500}> dupeWarning = <Tooltip title="This column header is a duplicate. Only the first occurrance of it will be used." placement="top" enterDelay={500}>
<Icon color="warning" sx={{p: "0.125rem", mr: "0.25rem"}}>warning</Icon> <Icon color="warning" sx={{p: "0.125rem", mr: "0.25rem"}}>warning</Icon>
</Tooltip> </Tooltip>;
} }
return (<td key={letter} style={{textAlign: "center", color: getHeaderColor(count), cursor: getCursor(count)}}> return (<td key={letter} style={{textAlign: "center", color: getHeaderColor(count), cursor: getCursor(count)}}>
@ -536,16 +665,16 @@ function BulkLoadMappingFilePreview({fileDescription, bulkLoadMapping}: BulkLoad
{ {
return <td key={value} style={tdStyle}> return <td key={value} style={tdStyle}>
<Tooltip title={getColumnTooltip(fields)} placement="top" enterDelay={500}><Box>{value}</Box></Tooltip> <Tooltip title={getColumnTooltip(fields)} placement="top" enterDelay={500}><Box>{value}</Box></Tooltip>
</td> </td>;
} }
else else
{ {
return <td key={value} style={tdStyle}>{value}</td> return <td key={value} style={tdStyle}>{value}</td>;
} }
} }
else else
{ {
return <td key={value} style={tdStyle}>{value}</td> return <td key={value} style={tdStyle}>{value}</td>;
} }
} }
)} )}

View File

@ -43,12 +43,12 @@ interface BulkLoadValueMappingFormProps
const BulkLoadProfileForm = forwardRef(({processValues, tableMetaData, metaData}: BulkLoadValueMappingFormProps, ref) => const BulkLoadProfileForm = forwardRef(({processValues, tableMetaData, metaData}: BulkLoadValueMappingFormProps, ref) =>
{ {
const savedBulkLoadProfileRecordProcessValue = processValues.savedBulkLoadProfileRecord; const savedBulkLoadProfileRecordProcessValue = processValues.savedBulkLoadProfileRecord;
const [savedBulkLoadProfileRecord, setSavedBulkLoadProfileRecord] = useState(savedBulkLoadProfileRecordProcessValue == null ? null : new QRecord(savedBulkLoadProfileRecordProcessValue)) const [savedBulkLoadProfileRecord, setSavedBulkLoadProfileRecord] = useState(savedBulkLoadProfileRecordProcessValue == null ? null : new QRecord(savedBulkLoadProfileRecordProcessValue));
const [tableStructure] = useState(processValues.tableStructure as BulkLoadTableStructure); const [tableStructure] = useState(processValues.tableStructure as BulkLoadTableStructure);
const [bulkLoadProfile, setBulkLoadProfile] = useState(processValues.bulkLoadProfile as BulkLoadProfile); const [bulkLoadProfile, setBulkLoadProfile] = useState(processValues.bulkLoadProfile as BulkLoadProfile);
const [currentMapping, setCurrentMapping] = useState(BulkLoadMapping.fromBulkLoadProfile(tableStructure, bulkLoadProfile)) const [currentMapping, setCurrentMapping] = useState(BulkLoadMapping.fromBulkLoadProfile(tableStructure, bulkLoadProfile));
const [wrappedCurrentSavedBulkLoadProfile] = useState(new Wrapper<QRecord>(savedBulkLoadProfileRecord)); const [wrappedCurrentSavedBulkLoadProfile] = useState(new Wrapper<QRecord>(savedBulkLoadProfileRecord));
const [fileDescription] = useState(new FileDescription(processValues.headerValues, processValues.headerLetters, processValues.bodyValuesPreview)); const [fileDescription] = useState(new FileDescription(processValues.headerValues, processValues.headerLetters, processValues.bodyValuesPreview));
@ -93,6 +93,7 @@ const BulkLoadProfileForm = forwardRef(({processValues, tableMetaData, metaData}
allowSelectingProfile={false} allowSelectingProfile={false}
fileDescription={fileDescription} fileDescription={fileDescription}
bulkLoadProfileOnChangeCallback={bulkLoadProfileOnChangeCallback} bulkLoadProfileOnChangeCallback={bulkLoadProfileOnChangeCallback}
isBulkEdit={processValues.isBulkEdit}
/> />
</Box> </Box>

View File

@ -75,7 +75,7 @@ const BulkLoadValueMappingForm = forwardRef(({processValues, setActiveStepLabel,
*******************************************************************************/ *******************************************************************************/
function initializeCurrentBulkLoadMapping(): BulkLoadMapping function initializeCurrentBulkLoadMapping(): BulkLoadMapping
{ {
const bulkLoadMapping = BulkLoadMapping.fromBulkLoadProfile(tableStructure, bulkLoadProfile); const bulkLoadMapping = BulkLoadMapping.fromBulkLoadProfile(tableStructure, bulkLoadProfile, processValues.name);
if (!bulkLoadMapping.valueMappings[fieldFullName]) if (!bulkLoadMapping.valueMappings[fieldFullName])
{ {
@ -195,6 +195,7 @@ const BulkLoadValueMappingForm = forwardRef(({processValues, setActiveStepLabel,
allowSelectingProfile={false} allowSelectingProfile={false}
bulkLoadProfileOnChangeCallback={bulkLoadProfileOnChangeCallback} bulkLoadProfileOnChangeCallback={bulkLoadProfileOnChangeCallback}
fileDescription={fileDescription} fileDescription={fileDescription}
isBulkEdit={processValues.isBulkEdit}
/> />
</Box> </Box>

View File

@ -19,6 +19,10 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
import {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstance";
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
import {QPossibleValue} from "@kingsrook/qqq-frontend-core/lib/model/QPossibleValue";
import {FormControl, InputLabel, Select, SelectChangeEvent} from "@mui/material"; import {FormControl, InputLabel, Select, SelectChangeEvent} from "@mui/material";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import Card from "@mui/material/Card"; import Card from "@mui/material/Card";
@ -28,20 +32,26 @@ import Modal from "@mui/material/Modal";
import TextField from "@mui/material/TextField"; import TextField from "@mui/material/TextField";
import Tooltip from "@mui/material/Tooltip"; import Tooltip from "@mui/material/Tooltip";
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
import {GridFilterItem} from "@mui/x-data-grid-pro";
import React, {useEffect, useState} from "react";
import {QCancelButton, QSaveButton} from "qqq/components/buttons/DefaultButtons"; import {QCancelButton, QSaveButton} from "qqq/components/buttons/DefaultButtons";
import ChipTextField from "qqq/components/forms/ChipTextField"; import ChipTextField from "qqq/components/forms/ChipTextField";
import HelpContent from "qqq/components/misc/HelpContent";
import {LoadingState} from "qqq/models/LoadingState";
import Client from "qqq/utils/qqq/Client";
import React, {useEffect, useReducer, useState} from "react";
interface Props interface Props
{ {
type: string; type: string;
onSave: (newValues: any[]) => void; onSave: (newValues: any[]) => void;
table?: QTableMetaData;
field?: QFieldMetaData;
} }
FilterCriteriaPaster.defaultProps = {}; FilterCriteriaPaster.defaultProps = {};
const qController = Client.getInstance();
function FilterCriteriaPaster({type, onSave}: Props): JSX.Element function FilterCriteriaPaster({table, field, type, onSave}: Props): JSX.Element
{ {
enum Delimiter enum Delimiter
{ {
@ -68,6 +78,12 @@ function FilterCriteriaPaster({type, onSave}: Props): JSX.Element
mainCardStyles.width = "60%"; mainCardStyles.width = "60%";
mainCardStyles.minWidth = "500px"; mainCardStyles.minWidth = "500px";
///////////////////////////////////////////////////////////////////////////////////////////
// add a LoadingState object, in case the initial loads (of meta data and view) are slow //
///////////////////////////////////////////////////////////////////////////////////////////
const [, forceUpdate] = useReducer((x) => x + 1, 0);
const [pageLoadingState, _] = useState(new LoadingState(forceUpdate));
//x const [gridFilterItem, setGridFilterItem] = useState(props.item); //x const [gridFilterItem, setGridFilterItem] = useState(props.item);
const [pasteModalIsOpen, setPasteModalIsOpen] = useState(false); const [pasteModalIsOpen, setPasteModalIsOpen] = useState(false);
const [inputText, setInputText] = useState(""); const [inputText, setInputText] = useState("");
@ -75,8 +91,13 @@ function FilterCriteriaPaster({type, onSave}: Props): JSX.Element
const [delimiterCharacter, setDelimiterCharacter] = useState(""); const [delimiterCharacter, setDelimiterCharacter] = useState("");
const [customDelimiterValue, setCustomDelimiterValue] = useState(""); const [customDelimiterValue, setCustomDelimiterValue] = useState("");
const [chipData, setChipData] = useState(undefined); const [chipData, setChipData] = useState(undefined);
const [uniqueCount, setUniqueCount] = useState(undefined);
const [chipValidity, setChipValidity] = useState([] as boolean[]);
const [chipPVSIds, setChipPVSIds] = useState([] as any[]);
const [detectedText, setDetectedText] = useState(""); const [detectedText, setDetectedText] = useState("");
const [errorText, setErrorText] = useState(""); const [errorText, setErrorText] = useState("");
const [saveDisabled, setSaveDisabled] = useState(true);
const [metaData, setMetaData] = useState(null as QInstance);
////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////
// handler for when paste icon is clicked in 'any' operator // // handler for when paste icon is clicked in 'any' operator //
@ -92,6 +113,7 @@ function FilterCriteriaPaster({type, onSave}: Props): JSX.Element
setDelimiter(""); setDelimiter("");
setDelimiterCharacter(""); setDelimiterCharacter("");
setChipData([]); setChipData([]);
setChipValidity([]);
setInputText(""); setInputText("");
setDetectedText(""); setDetectedText("");
setCustomDelimiterValue(""); setCustomDelimiterValue("");
@ -106,17 +128,42 @@ function FilterCriteriaPaster({type, onSave}: Props): JSX.Element
const handleSaveClicked = () => const handleSaveClicked = () =>
{ {
//////////////////////////////////////// ///////////////////////////////////////////////////////////////
// if numeric remove any non-numerics // // if numeric remove any non-numerics, or invalid pvs values //
//////////////////////////////////////// ///////////////////////////////////////////////////////////////
let saveData = []; let saveData = [];
let usedLabels = new Map<any, boolean>();
for (let i = 0; i < chipData.length; i++) for (let i = 0; i < chipData.length; i++)
{ {
if (type !== "number" || !Number.isNaN(Number(chipData[i]))) if (chipValidity[i] === true)
{
if (type === "pvs")
{
/////////////////////////////////////////////
// if already used this PVS label, skip it //
/////////////////////////////////////////////
if (usedLabels.get(chipData[i]) != null)
{
continue;
}
saveData.push(new QPossibleValue({id: chipPVSIds[i], label: chipData[i]}));
usedLabels.set(chipData[i], true);
}
else
{ {
saveData.push(chipData[i]); saveData.push(chipData[i]);
} }
} }
}
//////////////////////////////////////////
// for pvs, sort by label before saving //
//////////////////////////////////////////
if (type === "pvs")
{
saveData.sort((a: QPossibleValue, b: QPossibleValue) => b.label.localeCompare(a.label));
}
onSave(saveData); onSave(saveData);
@ -214,6 +261,12 @@ function FilterCriteriaPaster({type, onSave}: Props): JSX.Element
useEffect(() => useEffect(() =>
{ {
(async () =>
{
const metaData = await qController.loadMetaData();
setMetaData(metaData);
})();
let currentDelimiter = delimiter; let currentDelimiter = delimiter;
let currentDelimiterCharacter = delimiterCharacter; let currentDelimiterCharacter = delimiterCharacter;
@ -246,10 +299,16 @@ function FilterCriteriaPaster({type, onSave}: Props): JSX.Element
let parts = inputText.split(regex); let parts = inputText.split(regex);
let chipData = [] as string[]; let chipData = [] as string[];
/////////////////////////////////////////////////////////////////
// use a map to keep track of the counts for each unique value //
/////////////////////////////////////////////////////////////////
const uniqueValuesMap: { [key: string]: number } = {};
/////////////////////////////////////////////////////// ///////////////////////////////////////////////////////
// if delimiter is empty string, dont split anything // // if delimiter is empty string, dont split anything //
/////////////////////////////////////////////////////// ///////////////////////////////////////////////////////
setErrorText(""); setErrorText("");
let invalidCount = 0;
if (currentDelimiterCharacter !== "") if (currentDelimiterCharacter !== "")
{ {
for (let i = 0; i < parts.length; i++) for (let i = 0; i < parts.length; i++)
@ -259,30 +318,58 @@ function FilterCriteriaPaster({type, onSave}: Props): JSX.Element
{ {
chipData.push(part); chipData.push(part);
/////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////
// if numeric, check that first before pushing as a chip // // if numeric or pvs, check validity and add to invalid count //
/////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////
if (type === "number" && Number.isNaN(Number(part))) if (chipValidity[i] != null && chipValidity[i] !== true)
{ {
setErrorText("Some values are not numbers"); if ((type === "number" && Number.isNaN(Number(part))) || type === "pvs")
{
invalidCount++;
}
}
else
{
let count = uniqueValuesMap[part] == null ? 0 : uniqueValuesMap[part];
uniqueValuesMap[part] = count + 1;
} }
} }
} }
} }
if (invalidCount > 0)
{
if (type === "number")
{
let suffix = invalidCount === 1 ? " value is not a number" : " values are not numbers";
setErrorText(invalidCount + suffix + " and will not be added to the filter");
}
else if (type === "pvs")
{
let suffix = invalidCount === 1 ? " value was" : " values were";
setErrorText(invalidCount + suffix + " not found and will not be added to the filter");
}
}
setUniqueCount(Object.keys(uniqueValuesMap).length);
setChipData(chipData); setChipData(chipData);
}, [inputText, delimiterCharacter, customDelimiterValue, detectedText]); }, [inputText, delimiterCharacter, customDelimiterValue, detectedText, chipValidity]);
const slotName = type === "pvs" ? "bulkAddFilterValuesPossibleValueSource" : "bulkAddFilterValues";
const helpRoles = ["QUERY_SCREEN"];
const formattedHelpContent = <HelpContent helpContents={metaData?.helpContent?.get(slotName)} roles={helpRoles} heading={null} helpContentKey={`instanceLevel:true;slot:${slotName}`} />;
return ( return (
<Box> <Box>
<Tooltip title="Quickly add many values to your filter by pasting them from a spreadsheet or any other data source."> <Tooltip title="Quickly add many values to your filter by pasting them from a spreadsheet or any other data source.">
<Icon onClick={handlePasteClick} fontSize="small" color="info" sx={{mx: 0.25, cursor: "pointer"}}>paste_content</Icon> <Icon className="criteriaPasterButton" onClick={handlePasteClick} fontSize="small" color="info" sx={{mx: 0.25, cursor: "pointer"}}>paste_content</Icon>
</Tooltip> </Tooltip>
{ {
pasteModalIsOpen && pasteModalIsOpen &&
( (
<Modal open={pasteModalIsOpen}> <Modal open={pasteModalIsOpen}>
<Box>
<Box sx={{position: "absolute", overflowY: "auto", width: "100%"}}> <Box sx={{position: "absolute", overflowY: "auto", width: "100%"}}>
<Box py={3} justifyContent="center" sx={{display: "flex", mt: 8}}> <Box py={3} justifyContent="center" sx={{display: "flex", mt: 8}}>
<Card sx={mainCardStyles}> <Card sx={mainCardStyles}>
@ -290,11 +377,13 @@ function FilterCriteriaPaster({type, onSave}: Props): JSX.Element
<Grid container> <Grid container>
<Grid item pr={3} xs={12} lg={12}> <Grid item pr={3} xs={12} lg={12}>
<Typography variant="h5">Bulk Add Filter Values</Typography> <Typography variant="h5">Bulk Add Filter Values</Typography>
{
formattedHelpContent && <Box sx={{display: "flex", lineHeight: "1.7", textTransform: "none"}}>
<Typography sx={{display: "flex", lineHeight: "1.7", textTransform: "revert"}} variant="button"> <Typography sx={{display: "flex", lineHeight: "1.7", textTransform: "revert"}} variant="button">
Paste into the box on the left. {formattedHelpContent}
Review the filter values in the box on the right.
If the filter values are not what are expected, try changing the separator using the dropdown below.
</Typography> </Typography>
</Box>
}
</Grid> </Grid>
</Grid> </Grid>
</Box> </Box>
@ -302,6 +391,7 @@ function FilterCriteriaPaster({type, onSave}: Props): JSX.Element
<Grid item pr={3} xs={6} lg={6} sx={{width: "100%", display: "flex", flexDirection: "column", flexGrow: 1}}> <Grid item pr={3} xs={6} lg={6} sx={{width: "100%", display: "flex", flexDirection: "column", flexGrow: 1}}>
<FormControl sx={{m: 1, width: "100%"}}> <FormControl sx={{m: 1, width: "100%"}}>
<TextField <TextField
className="criteriaPasterTextArea"
id="outlined-multiline-static" id="outlined-multiline-static"
label="PASTE TEXT" label="PASTE TEXT"
multiline multiline
@ -314,10 +404,25 @@ function FilterCriteriaPaster({type, onSave}: Props): JSX.Element
<Grid item xs={6} lg={6} sx={{display: "flex", flexGrow: 1}}> <Grid item xs={6} lg={6} sx={{display: "flex", flexGrow: 1}}>
<FormControl sx={{m: 1, width: "100%"}}> <FormControl sx={{m: 1, width: "100%"}}>
<ChipTextField <ChipTextField
handleChipChange={() => handleChipChange={(isMakingRequest: boolean, chipValidity: boolean[], chipPVSIds: any[]) =>
{ {
setErrorText("");
if (isMakingRequest)
{
pageLoadingState.setLoading();
}
else
{
pageLoadingState.setNotLoading();
}
setSaveDisabled(isMakingRequest);
setChipPVSIds(chipPVSIds);
setChipValidity(chipValidity);
}} }}
table={table}
field={field}
chipData={chipData} chipData={chipData}
chipValidity={chipValidity}
chipType={type} chipType={type}
multiline multiline
fullWidth fullWidth
@ -377,7 +482,7 @@ function FilterCriteriaPaster({type, onSave}: Props): JSX.Element
)} )}
</Box> </Box>
</Grid> </Grid>
<Grid sx={{display: "flex", justifyContent: "flex-start", alignItems: "flex-start"}} item pl={1} xs={3} lg={3}> <Grid sx={{display: "flex", justifyContent: "flex-start", alignItems: "flex-start"}} item pl={1} xs={4} lg={4}>
{ {
errorText && chipData.length > 0 && ( errorText && chipData.length > 0 && (
<Box sx={{display: "flex", justifyContent: "flex-start", alignItems: "flex-start"}}> <Box sx={{display: "flex", justifyContent: "flex-start", alignItems: "flex-start"}}>
@ -386,11 +491,19 @@ function FilterCriteriaPaster({type, onSave}: Props): JSX.Element
</Box> </Box>
) )
} }
{
pageLoadingState.isLoadingSlow() && (
<Box sx={{display: "flex", justifyContent: "flex-start", alignItems: "flex-start"}}>
<Icon color="warning">warning</Icon>
<Typography sx={{paddingLeft: "4px", textTransform: "revert"}} variant="button">Loading...</Typography>
</Box>
)
}
</Grid> </Grid>
<Grid sx={{display: "flex", justifyContent: "flex-end", alignItems: "flex-start"}} item pr={1} xs={3} lg={3}> <Grid sx={{display: "flex", justifyContent: "flex-end", alignItems: "flex-start"}} item pr={1} xs={2} lg={2}>
{ {
chipData && chipData.length > 0 && ( chipData && chipData.length > 0 && (
<Typography sx={{textTransform: "revert"}} variant="button">{chipData.length.toLocaleString()} {chipData.length === 1 ? "value" : "values"}</Typography> <Typography sx={{textTransform: "revert"}} variant="button">{chipData.length.toLocaleString()} {chipData.length === 1 ? "value" : "values"} {uniqueCount && `(${uniqueCount} unique)`}</Typography>
) )
} }
</Grid> </Grid>
@ -401,12 +514,13 @@ function FilterCriteriaPaster({type, onSave}: Props): JSX.Element
onClickHandler={handleCancelClicked} onClickHandler={handleCancelClicked}
iconName="cancel" iconName="cancel"
disabled={false} /> disabled={false} />
<QSaveButton onClickHandler={handleSaveClicked} label="Add Filters" disabled={false} /> <QSaveButton onClickHandler={handleSaveClicked} label="Add Values" disabled={saveDisabled} />
</Grid> </Grid>
</Box> </Box>
</Card> </Card>
</Box> </Box>
</Box> </Box>
</Box>
</Modal> </Modal>
) )

View File

@ -398,11 +398,12 @@ function FilterCriteriaRowValues({operatorOption, criteria, field, table, valueC
initialValues = criteria.values; initialValues = criteria.values;
} }
} }
return <Box> return <Box display="flex" alignItems="flex-end" className="multiValue">
<Box width={"100%"}>
<DynamicSelect <DynamicSelect
fieldPossibleValueProps={{tableName: table.name, fieldName: field.name, initialDisplayValue: null}} fieldPossibleValueProps={{tableName: table.name, fieldName: field.name, initialDisplayValue: null}}
overrideId={field.name + "-multi-" + criteria.id} overrideId={field.name + "-multi-" + criteria.id}
key={field.name + "-multi-" + criteria.id} key={field.name + "-multi-" + criteria.id + "-" + criteria.values.length}
isMultiple isMultiple
fieldLabel="Values" fieldLabel="Values"
initialValues={initialValues} initialValues={initialValues}
@ -412,6 +413,10 @@ function FilterCriteriaRowValues({operatorOption, criteria, field, table, valueC
variant="standard" variant="standard"
useCase="filter" useCase="filter"
/> />
</Box>
<Box>
<FilterCriteriaPaster table={table} field={field} type="pvs" onSave={(newValues: any[]) => saveNewPasterValues(newValues)} />
</Box>
</Box>; </Box>;
} }

View File

@ -29,9 +29,9 @@ import Icon from "@mui/material/Icon";
import ListItemIcon from "@mui/material/ListItemIcon"; import ListItemIcon from "@mui/material/ListItemIcon";
import Menu from "@mui/material/Menu"; import Menu from "@mui/material/Menu";
import MenuItem from "@mui/material/MenuItem"; import MenuItem from "@mui/material/MenuItem";
import {QActionsMenuButton} from "qqq/components/buttons/DefaultButtons";
import React, {useState} from "react"; import React, {useState} from "react";
import {useNavigate} from "react-router-dom"; import {useNavigate} from "react-router-dom";
import {QActionsMenuButton} from "qqq/components/buttons/DefaultButtons";
interface QueryScreenActionMenuProps interface QueryScreenActionMenuProps
{ {
@ -40,28 +40,28 @@ interface QueryScreenActionMenuProps
tableProcesses: QProcessMetaData[]; tableProcesses: QProcessMetaData[];
bulkLoadClicked: () => void; bulkLoadClicked: () => void;
bulkEditClicked: () => void; bulkEditClicked: () => void;
bulkEditWithFileClicked: () => void;
bulkDeleteClicked: () => void; bulkDeleteClicked: () => void;
processClicked: (process: QProcessMetaData) => void; processClicked: (process: QProcessMetaData) => void;
} }
QueryScreenActionMenu.defaultProps = { QueryScreenActionMenu.defaultProps = {};
};
export default function QueryScreenActionMenu({metaData, tableMetaData, tableProcesses, bulkLoadClicked, bulkEditClicked, bulkDeleteClicked, processClicked}: QueryScreenActionMenuProps): JSX.Element export default function QueryScreenActionMenu({metaData, tableMetaData, tableProcesses, bulkLoadClicked, bulkEditClicked, bulkEditWithFileClicked, bulkDeleteClicked, processClicked}: QueryScreenActionMenuProps): JSX.Element
{ {
const [anchorElement, setAnchorElement] = useState(null) const [anchorElement, setAnchorElement] = useState(null);
const navigate = useNavigate(); const navigate = useNavigate();
const openActionsMenu = (event: any) => const openActionsMenu = (event: any) =>
{ {
setAnchorElement(event.currentTarget); setAnchorElement(event.currentTarget);
} };
const closeActionsMenu = () => const closeActionsMenu = () =>
{ {
setAnchorElement(null); setAnchorElement(null);
} };
const pushDividerIfNeeded = (menuItems: JSX.Element[]) => const pushDividerIfNeeded = (menuItems: JSX.Element[]) =>
{ {
@ -75,7 +75,7 @@ export default function QueryScreenActionMenu({metaData, tableMetaData, tablePro
{ {
closeActionsMenu(); closeActionsMenu();
handler(); handler();
} };
const menuItems: JSX.Element[] = []; const menuItems: JSX.Element[] = [];
if (tableMetaData.capabilities.has(Capability.TABLE_INSERT) && tableMetaData.insertPermission) if (tableMetaData.capabilities.has(Capability.TABLE_INSERT) && tableMetaData.insertPermission)
@ -85,6 +85,7 @@ export default function QueryScreenActionMenu({metaData, tableMetaData, tablePro
if (tableMetaData.capabilities.has(Capability.TABLE_UPDATE) && tableMetaData.editPermission) if (tableMetaData.capabilities.has(Capability.TABLE_UPDATE) && tableMetaData.editPermission)
{ {
menuItems.push(<MenuItem key="bulkEdit" onClick={() => runSomething(bulkEditClicked)}><ListItemIcon><Icon>edit</Icon></ListItemIcon>Bulk Edit</MenuItem>); menuItems.push(<MenuItem key="bulkEdit" onClick={() => runSomething(bulkEditClicked)}><ListItemIcon><Icon>edit</Icon></ListItemIcon>Bulk Edit</MenuItem>);
menuItems.push(<MenuItem key="bulkEditWithFile" onClick={() => runSomething(bulkEditWithFileClicked)}><ListItemIcon><Icon>edit_note</Icon></ListItemIcon>Bulk Edit With File</MenuItem>);
} }
if (tableMetaData.capabilities.has(Capability.TABLE_DELETE) && tableMetaData.deletePermission) if (tableMetaData.capabilities.has(Capability.TABLE_DELETE) && tableMetaData.deletePermission)
{ {
@ -130,5 +131,5 @@ export default function QueryScreenActionMenu({metaData, tableMetaData, tablePro
{menuItems} {menuItems}
</Menu> </Menu>
</> </>
) );
} }

View File

@ -38,7 +38,7 @@ import XIcon from "qqq/components/query/XIcon";
import {QueryScreenUsage} from "qqq/pages/records/query/RecordQuery"; import {QueryScreenUsage} from "qqq/pages/records/query/RecordQuery";
import FilterUtils from "qqq/utils/qqq/FilterUtils"; import FilterUtils from "qqq/utils/qqq/FilterUtils";
import TableUtils from "qqq/utils/qqq/TableUtils"; import TableUtils from "qqq/utils/qqq/TableUtils";
import React, {SyntheticEvent, useContext, useReducer, useState} from "react"; import React, {SyntheticEvent, useContext, useEffect, useReducer, useState} from "react";
export type CriteriaParamType = QFilterCriteriaWithId | null | "tooComplex"; export type CriteriaParamType = QFilterCriteriaWithId | null | "tooComplex";
@ -186,6 +186,13 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData
////////////////////// //////////////////////
const [, forceUpdate] = useReducer((x) => x + 1, 0); const [, forceUpdate] = useReducer((x) => x + 1, 0);
useEffect(() =>
{
//////////////////////////////////////////////////////////////////////////////
// was not seeing criteria changes take place until watching it stringified //
//////////////////////////////////////////////////////////////////////////////
setCriteria(criteria);
}, [JSON.stringify(criteria)]);
/******************************************************************************* /*******************************************************************************
** **

View File

@ -21,11 +21,12 @@
import {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstance"; import {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstance";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import {ReactNode, useEffect, useState} from "react";
import Footer from "qqq/components/horseshoe/Footer"; import Footer from "qqq/components/horseshoe/Footer";
import NavBar from "qqq/components/horseshoe/NavBar"; import NavBar from "qqq/components/horseshoe/NavBar";
import {getBannerClassName, getBannerStyles, getBanner, makeBannerContent} from "qqq/components/misc/Banners";
import DashboardLayout from "qqq/layouts/DashboardLayout"; import DashboardLayout from "qqq/layouts/DashboardLayout";
import Client from "qqq/utils/qqq/Client"; import Client from "qqq/utils/qqq/Client";
import {ReactNode, useEffect, useState} from "react";
interface Props interface Props
{ {
@ -80,12 +81,34 @@ function BaseLayout({stickyNavbar, children}: Props): JSX.Element
return () => window.removeEventListener("resize", handleTabsOrientation); return () => window.removeEventListener("resize", handleTabsOrientation);
}, [tabsOrientation]); }, [tabsOrientation]);
/***************************************************************************
**
***************************************************************************/
function banner(): JSX.Element | null
{
const banner = getBanner(metaData?.branding, "QFMD_TOP_OF_BODY");
if (!banner)
{
return (null);
}
return (<Box className={getBannerClassName(banner)} sx={{display: "flex", justifyContent: "center", padding: "0.5rem", margin: "-20px", marginBottom: "20px", ...getBannerStyles(banner)}}>
{makeBannerContent(banner)}
</Box>);
}
return ( return (
<>
<DashboardLayout> <DashboardLayout>
{banner()}
<NavBar /> <NavBar />
<Box>{children}</Box> <Box>{children}</Box>
<Footer company={{href: metaData?.branding?.companyUrl, name: metaData?.branding?.companyName}} /> <Footer company={{href: metaData?.branding?.companyUrl, name: metaData?.branding?.companyName}} />
</DashboardLayout> </DashboardLayout>
</>
); );
} }

View File

@ -39,6 +39,7 @@ export class BulkLoadField
headerName?: string = null; headerName?: string = null;
defaultValue?: any = null; defaultValue?: any = null;
doValueMapping: boolean = false; doValueMapping: boolean = false;
clearIfEmpty?: boolean = false;
wideLayoutIndexPath: number[] = []; wideLayoutIndexPath: number[] = [];
@ -51,7 +52,7 @@ export class BulkLoadField
/*************************************************************************** /***************************************************************************
** **
***************************************************************************/ ***************************************************************************/
constructor(field: QFieldMetaData, tableStructure: BulkLoadTableStructure, valueType: ValueType = "column", columnIndex?: number, headerName?: string, defaultValue?: any, doValueMapping?: boolean, wideLayoutIndexPath: number[] = [], error: string = null, warning: string = null) constructor(field: QFieldMetaData, tableStructure: BulkLoadTableStructure, valueType: ValueType = "column", columnIndex?: number, headerName?: string, defaultValue?: any, doValueMapping?: boolean, wideLayoutIndexPath: number[] = [], error: string = null, warning: string = null, clearIfEmpty?: boolean)
{ {
this.field = field; this.field = field;
this.tableStructure = tableStructure; this.tableStructure = tableStructure;
@ -64,6 +65,7 @@ export class BulkLoadField
this.error = error; this.error = error;
this.warning = warning; this.warning = warning;
this.key = new Date().getTime().toString(); this.key = new Date().getTime().toString();
this.clearIfEmpty = clearIfEmpty ?? false;
} }
@ -72,7 +74,7 @@ export class BulkLoadField
***************************************************************************/ ***************************************************************************/
public static clone(source: BulkLoadField): BulkLoadField public static clone(source: BulkLoadField): BulkLoadField
{ {
return (new BulkLoadField(source.field, source.tableStructure, source.valueType, source.columnIndex, source.headerName, source.defaultValue, source.doValueMapping, source.wideLayoutIndexPath, source.error, source.warning)); return (new BulkLoadField(source.field, source.tableStructure, source.valueType, source.columnIndex, source.headerName, source.defaultValue, source.doValueMapping, source.wideLayoutIndexPath, source.error, source.warning, source.clearIfEmpty));
} }
@ -173,6 +175,9 @@ export interface BulkLoadTableStructure
associationPath: string; associationPath: string;
fields: QFieldMetaData[]; fields: QFieldMetaData[];
associations: BulkLoadTableStructure[]; associations: BulkLoadTableStructure[];
isBulkEdit: boolean;
possibleKeyFields: string[];
keyFields?: string;
} }
@ -193,6 +198,8 @@ export class BulkLoadMapping
valueMappings: { [fieldName: string]: { [fileValue: string]: any } } = {}; valueMappings: { [fieldName: string]: { [fileValue: string]: any } } = {};
isBulkEdit: boolean;
keyFields: string;
hasHeaderRow: boolean; hasHeaderRow: boolean;
layout: string; layout: string;
@ -211,6 +218,8 @@ export class BulkLoadMapping
} }
} }
this.isBulkEdit = tableStructure.isBulkEdit;
this.keyFields = tableStructure.keyFields;
this.hasHeaderRow = true; this.hasHeaderRow = true;
} }
@ -218,11 +227,13 @@ export class BulkLoadMapping
/*************************************************************************** /***************************************************************************
** **
***************************************************************************/ ***************************************************************************/
private processTableStructure(tableStructure: BulkLoadTableStructure) public processTableStructure(tableStructure: BulkLoadTableStructure)
{ {
const prefix = tableStructure.isMain ? "" : tableStructure.associationPath; const prefix = tableStructure.isMain ? "" : tableStructure.associationPath;
this.fieldsByTablePrefix[prefix] = {}; this.fieldsByTablePrefix[prefix] = {};
this.tablesByPath[prefix] = tableStructure; this.tablesByPath[prefix] = tableStructure;
this.isBulkEdit = tableStructure.isBulkEdit;
this.keyFields = tableStructure.keyFields;
for (let field of tableStructure.fields) for (let field of tableStructure.fields)
{ {
@ -233,6 +244,27 @@ export class BulkLoadMapping
this.fields[qualifiedName] = bulkLoadField; this.fields[qualifiedName] = bulkLoadField;
this.fieldsByTablePrefix[prefix][qualifiedName] = bulkLoadField; this.fieldsByTablePrefix[prefix][qualifiedName] = bulkLoadField;
if (this.isBulkEdit)
{
if (this.keyFields == null)
{
this.unusedFields.push(bulkLoadField);
}
else
{
const keyFields = this.keyFields.split("|");
if (keyFields.includes(qualifiedName))
{
this.requiredFields.push(bulkLoadField);
}
else
{
this.unusedFields.push(bulkLoadField);
}
}
}
else
{
if (tableStructure.isMain && field.isRequired) if (tableStructure.isMain && field.isRequired)
{ {
this.requiredFields.push(bulkLoadField); this.requiredFields.push(bulkLoadField);
@ -243,6 +275,7 @@ export class BulkLoadMapping
} }
} }
} }
}
for (let associatedTableStructure of tableStructure.associations ?? []) for (let associatedTableStructure of tableStructure.associations ?? [])
{ {
@ -266,14 +299,16 @@ export class BulkLoadMapping
** take a saved bulk load profile - and convert it into a working bulkLoadMapping ** take a saved bulk load profile - and convert it into a working bulkLoadMapping
** for the frontend to use! ** for the frontend to use!
***************************************************************************/ ***************************************************************************/
public static fromBulkLoadProfile(tableStructure: BulkLoadTableStructure, bulkLoadProfile: BulkLoadProfile): BulkLoadMapping public static fromBulkLoadProfile(tableStructure: BulkLoadTableStructure, bulkLoadProfile: BulkLoadProfile, processName?: string): BulkLoadMapping
{ {
const bulkLoadMapping = new BulkLoadMapping(tableStructure); const bulkLoadMapping = new BulkLoadMapping(tableStructure);
if (bulkLoadProfile.version == "v1") if (bulkLoadProfile.version == "v1")
{ {
bulkLoadMapping.isBulkEdit = bulkLoadProfile.isBulkEdit;
bulkLoadMapping.hasHeaderRow = bulkLoadProfile.hasHeaderRow; bulkLoadMapping.hasHeaderRow = bulkLoadProfile.hasHeaderRow;
bulkLoadMapping.layout = bulkLoadProfile.layout; bulkLoadMapping.layout = bulkLoadProfile.layout;
bulkLoadMapping.keyFields = bulkLoadProfile.keyFields;
//////////////////////////////////////////////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// function to get a bulkLoadMapping field by its (full) name - whether that's in the required fields list, // // function to get a bulkLoadMapping field by its (full) name - whether that's in the required fields list, //
@ -322,6 +357,7 @@ export class BulkLoadMapping
{ {
bulkLoadField.valueType = "column"; bulkLoadField.valueType = "column";
bulkLoadField.doValueMapping = bulkLoadProfileField.doValueMapping; bulkLoadField.doValueMapping = bulkLoadProfileField.doValueMapping;
bulkLoadField.clearIfEmpty = bulkLoadProfileField.clearIfEmpty;
bulkLoadField.headerName = bulkLoadProfileField.headerName; bulkLoadField.headerName = bulkLoadProfileField.headerName;
bulkLoadField.columnIndex = bulkLoadProfileField.columnIndex; bulkLoadField.columnIndex = bulkLoadProfileField.columnIndex;
@ -344,6 +380,29 @@ export class BulkLoadMapping
} }
} }
if (!bulkLoadMapping.keyFields && tableStructure.possibleKeyFields?.length > 0)
{
////////////////////////////////////////////////////////////////////////////////////////////////
// look at each of the possible key fields, compare with the fields in the bulk load profile, //
// on the first one that matches, use that as the default bulk load mapping key field //
////////////////////////////////////////////////////////////////////////////////////////////////
for (let keyField of tableStructure.possibleKeyFields)
{
const parts = keyField.split("|");
const allPartsMatch = parts.every(part =>
(bulkLoadProfile.fieldList ?? []).some((field: BulkLoadProfileField) =>
field.fieldName === part
)
);
if (allPartsMatch)
{
bulkLoadMapping.keyFields = keyField;
break; // stop after the first valid match
}
}
}
return (bulkLoadMapping); return (bulkLoadMapping);
} }
else else
@ -365,6 +424,8 @@ export class BulkLoadMapping
profile.version = "v1"; profile.version = "v1";
profile.hasHeaderRow = this.hasHeaderRow; profile.hasHeaderRow = this.hasHeaderRow;
profile.layout = this.layout; profile.layout = this.layout;
profile.isBulkEdit = this.isBulkEdit;
profile.keyFields = this.keyFields;
for (let bulkLoadField of [...this.requiredFields, ...this.additionalFields]) for (let bulkLoadField of [...this.requiredFields, ...this.additionalFields])
{ {
@ -384,7 +445,7 @@ export class BulkLoadMapping
} }
else else
{ {
const field: BulkLoadProfileField = {fieldName: fullFieldName, columnIndex: bulkLoadField.columnIndex, headerName: bulkLoadField.headerName, doValueMapping: bulkLoadField.doValueMapping}; const field: BulkLoadProfileField = {fieldName: fullFieldName, columnIndex: bulkLoadField.columnIndex, headerName: bulkLoadField.headerName, doValueMapping: bulkLoadField.doValueMapping, clearIfEmpty: bulkLoadField.clearIfEmpty};
if (this.valueMappings[fullFieldName]) if (this.valueMappings[fullFieldName])
{ {
@ -576,6 +637,16 @@ export class BulkLoadMapping
return (rs); return (rs);
} }
/***************************************************************************
**
***************************************************************************/
public handleChangeToKeyFields(newKeyFields: any)
{
this.keyFields = newKeyFields;
}
/*************************************************************************** /***************************************************************************
** **
***************************************************************************/ ***************************************************************************/
@ -600,7 +671,7 @@ export class BulkLoadMapping
{ {
const newField = BulkLoadField.clone(field); const newField = BulkLoadField.clone(field);
newField.columnIndex = null; newField.columnIndex = null;
newField.warning = "This field was assigned to a column with a duplicated header" newField.warning = "This field was assigned to a column with a duplicated header";
newRequiredFields.push(newField); newRequiredFields.push(newField);
anyChangesToRequiredFields = true; anyChangesToRequiredFields = true;
} }
@ -616,7 +687,7 @@ export class BulkLoadMapping
{ {
const newField = BulkLoadField.clone(field); const newField = BulkLoadField.clone(field);
newField.columnIndex = null; newField.columnIndex = null;
newField.warning = "This field was assigned to a column with a duplicated header" newField.warning = "This field was assigned to a column with a duplicated header";
newAdditionalFields.push(newField); newAdditionalFields.push(newField);
anyChangesToAdditionalFields = true; anyChangesToAdditionalFields = true;
} }
@ -798,6 +869,8 @@ export class BulkLoadProfile
fieldList: BulkLoadProfileField[] = []; fieldList: BulkLoadProfileField[] = [];
hasHeaderRow: boolean; hasHeaderRow: boolean;
layout: string; layout: string;
isBulkEdit: boolean;
keyFields: string;
} }
type BulkLoadProfileField = type BulkLoadProfileField =
@ -807,6 +880,7 @@ type BulkLoadProfileField =
headerName?: string, headerName?: string,
defaultValue?: any, defaultValue?: any,
doValueMapping?: boolean, doValueMapping?: boolean,
clearIfEmpty?: boolean,
valueMappings?: { [fileValue: string]: any } valueMappings?: { [fileValue: string]: any }
}; };

View File

@ -1838,6 +1838,20 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
return; return;
} }
if(urlSearchParams.get("defaultProcessValues"))
{
if(!defaultProcessValues)
{
defaultProcessValues = {}
}
const values = JSON.parse(urlSearchParams.get("defaultProcessValues"));
for (let key in values)
{
defaultProcessValues[key] = values[key]
}
}
if (defaultProcessValues) if (defaultProcessValues)
{ {
for (let key in defaultProcessValues) for (let key in defaultProcessValues)

View File

@ -1557,7 +1557,7 @@ const RecordQuery = forwardRef(({table, usage, isModal, isPreview, allowVariable
/******************************************************************************* /*******************************************************************************
** function to open one of the bulk (insert/edit/delete) processes. ** function to open one of the bulk (insert/edit/delete) processes.
*******************************************************************************/ *******************************************************************************/
const openBulkProcess = (processNamePart: "Insert" | "Edit" | "Delete", processLabelPart: "Load" | "Edit" | "Delete") => const openBulkProcess = (processNamePart: "Insert" | "Edit" | "Delete" | "EditWithFile", processLabelPart: "Load" | "Edit" | "Delete" | "Edit With File") =>
{ {
const processList = allTableProcesses.filter(p => p.name.endsWith(`.bulk${processNamePart}`)); const processList = allTableProcesses.filter(p => p.name.endsWith(`.bulk${processNamePart}`));
if (processList.length > 0) if (processList.length > 0)
@ -1593,6 +1593,15 @@ const RecordQuery = forwardRef(({table, usage, isModal, isPreview, allowVariable
}; };
/*******************************************************************************
** Event handler for the bulk-edit-with-file process being selected
*******************************************************************************/
const bulkEditWithFileClicked = () =>
{
openBulkProcess("EditWithFile", "Edit With File");
};
/******************************************************************************* /*******************************************************************************
** Event handler for the bulk-delete process being selected ** Event handler for the bulk-delete process being selected
*******************************************************************************/ *******************************************************************************/
@ -1612,6 +1621,22 @@ const RecordQuery = forwardRef(({table, usage, isModal, isPreview, allowVariable
*******************************************************************************/ *******************************************************************************/
const processClicked = (process: QProcessMetaData) => const processClicked = (process: QProcessMetaData) =>
{ {
if (process.minInputRecords != null && process.minInputRecords > 0 && getNoOfSelectedRecords() === 0)
{
setAlertContent(`No records were selected for the process: ${process.label}`);
return;
}
else if (process.minInputRecords != null && getNoOfSelectedRecords() < process.minInputRecords)
{
setAlertContent(`Too few records were selected for the process: ${process.label}. A minimum of ${process.minInputRecords} is required.`);
return;
}
else if (process.maxInputRecords != null && getNoOfSelectedRecords() > process.maxInputRecords)
{
setAlertContent(`Too many records were selected for the process: ${process.label}. A maximum of ${process.maxInputRecords} is allowed.`);
return;
}
// todo - let the process specify that it needs initial rows - err if none selected. // todo - let the process specify that it needs initial rows - err if none selected.
// alternatively, let a process itself have an initial screen to select rows... // alternatively, let a process itself have an initial screen to select rows...
openModalProcess(process); openModalProcess(process);
@ -2845,6 +2870,7 @@ const RecordQuery = forwardRef(({table, usage, isModal, isPreview, allowVariable
tableProcesses={tableProcesses} tableProcesses={tableProcesses}
bulkLoadClicked={bulkLoadClicked} bulkLoadClicked={bulkLoadClicked}
bulkEditClicked={bulkEditClicked} bulkEditClicked={bulkEditClicked}
bulkEditWithFileClicked={bulkEditWithFileClicked}
bulkDeleteClicked={bulkDeleteClicked} bulkDeleteClicked={bulkDeleteClicked}
processClicked={processClicked} processClicked={processClicked}
/> />

View File

@ -748,35 +748,54 @@ input[type="search"]::-webkit-search-results-decoration
padding: 8px 0; padding: 8px 0;
} }
.helpContentAlert.success .helpContentAlert.info,
.banner.info
{
background-color: rgb(234, 242, 255);
color: rgb(20, 51, 102);
}
.helpContentAlert.info .MuiAlert-icon .material-icons-round,
.banner.info .MuiAlert-icon .material-icons-round
{
color: #0062FF;
}
.helpContentAlert.success,
.banner.success
{ {
background-color: rgb(240, 248, 241); background-color: rgb(240, 248, 241);
color: rgb(44, 76, 46); color: rgb(44, 76, 46);
} }
.helpContentAlert.success .MuiAlert-icon .material-icons-round .helpContentAlert.success .MuiAlert-icon .material-icons-round,
.banner.success .MuiAlert-icon .material-icons-round
{ {
color: #4CAF50; color: #4CAF50;
} }
.helpContentAlert.warning .helpContentAlert.warning,
.banner.warning
{ {
background-color: rgb(254, 245, 234); background-color: rgb(254, 245, 234);
color: rgb(100, 65, 20); color: rgb(100, 65, 20);
} }
.helpContentAlert.warning .MuiAlert-icon .material-icons-round .helpContentAlert.warning .MuiAlert-icon .material-icons-round,
.banner.warning .MuiAlert-icon .material-icons-round
{ {
color: #fb8c00; color: #fb8c00;
} }
.helpContentAlert.error .helpContentAlert.error,
.banner.error
{ {
background-color: rgb(254, 239, 238); background-color: rgb(254, 239, 238);
color: rgb(98, 41, 37); color: rgb(98, 41, 37);
} }
.helpContentAlert.error .MuiAlert-icon .material-icons-round .helpContentAlert.error .MuiAlert-icon .material-icons-round,
.banner.error .MuiAlert-icon .material-icons-round
{ {
color: #F44335; color: #F44335;
} }

View File

@ -133,7 +133,7 @@ class FilterUtils
} }
else else
{ {
values = await qController.possibleValues(fieldTable.name, null, field.name, "", values, undefined, "filter"); values = await qController.possibleValues(fieldTable.name, null, field.name, "", values, undefined, undefined, "filter");
} }
} }

View File

@ -267,7 +267,13 @@ class ValueUtils
{ {
if (!displayValue && field.defaultValue) if (!displayValue && field.defaultValue)
{ {
displayValue = field.defaultValue; /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// note, at one point in time, we used a field's default value here if no displayValue... but that feels 100% wrong, //
// e.g., a null field would show up (on a query or view screen) has having some value! //
// not sure if this was maybe supposed to be displayValue = rawValue, but, keep that in mind, and keep this block here //
// in case we run into issues and need to revisit/rethink //
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// displayValue = field.defaultValue;
} }
if (field.type === QFieldType.DATE_TIME) if (field.type === QFieldType.DATE_TIME)

View File

@ -103,6 +103,30 @@ public class QueryScreenLib
/*******************************************************************************
**
*******************************************************************************/
public void openCriteriaPasterAndPasteValues(String fieldName, List<String> values)
{
/////////////////////////////////////////////////////////////////////////////
// open the is any of criteria for given field and click the paster button //
/////////////////////////////////////////////////////////////////////////////
qSeleniumLib.waitForSelectorContaining("BUTTON", fieldName).click();
qSeleniumLib.waitForSelector("#criteriaOperator").click();
qSeleniumLib.waitForSelectorContaining("LI", "is any of").click();
qSeleniumLib.waitForMillis(250);
qSeleniumLib.waitForSelector(".criteriaPasterButton").click();
////////////////////////////////////////
// paste the values into the textarea //
////////////////////////////////////////
qSeleniumLib
.waitForSelector(".criteriaPasterTextArea textarea#outlined-multiline-static")
.sendKeys(String.join("\n", values));
}
/******************************************************************************* /*******************************************************************************
** **
*******************************************************************************/ *******************************************************************************/

View File

@ -22,13 +22,16 @@
package com.kingsrook.qqq.frontend.materialdashboard.selenium.tests.query; package com.kingsrook.qqq.frontend.materialdashboard.selenium.tests.query;
import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.stream.Collectors;
import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.QBaseSeleniumTest; import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.QBaseSeleniumTest;
import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.QQQMaterialDashboardSelectors; import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.QQQMaterialDashboardSelectors;
import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.QueryScreenLib; import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.QueryScreenLib;
import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.javalin.CapturedContext; import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.javalin.CapturedContext;
import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.javalin.QSeleniumJavalin; import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.javalin.QSeleniumJavalin;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.openqa.selenium.WebElement;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
@ -200,6 +203,204 @@ public class QueryScreenTest extends QBaseSeleniumTest
/*******************************************************************************
**
*******************************************************************************/
@Test
void testCriteriaPasterHappyPath()
{
QueryScreenLib queryScreenLib = new QueryScreenLib(qSeleniumLib);
////////////////////////////
// go to the person page //
////////////////////////////
qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person", "Person");
queryScreenLib.waitForQueryToHaveRan();
//////////////////////////////////////
// open the paste values dialog UI //
//////////////////////////////////////
queryScreenLib.openCriteriaPasterAndPasteValues("id", List.of("1", "2", "3"));
///////////////////////////////////////////////////////////////
// wait for chips to appear in the filter values review box //
///////////////////////////////////////////////////////////////
assertFilterPasterChipCounts(3, 0);
///////////////////////////////////////////////
// confirm each chip has the blue color class //
///////////////////////////////////////////////
qSeleniumLib.waitForSelectorAll(".MuiChip-root", 3).forEach(chip ->
{
String classAttr = chip.getAttribute("class");
assertThat(classAttr).contains("MuiChip-colorInfo");
});
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void testCriteriaPasterInvalidValueValidation()
{
QueryScreenLib queryScreenLib = new QueryScreenLib(qSeleniumLib);
////////////////////////////
// go to the person page //
////////////////////////////
qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person", "Person");
queryScreenLib.waitForQueryToHaveRan();
//////////////////////////////////////
// open the paste values dialog UI //
//////////////////////////////////////
queryScreenLib.openCriteriaPasterAndPasteValues("id", List.of("1", "a", "3"));
//////////////////////////////////////////////////////
// check that chips match values and are classified //
//////////////////////////////////////////////////////
assertFilterPasterChipCounts(2, 1);
////////////////////////////////////////////////////////////////////
// confirm that an appropriate validation error message is shown //
////////////////////////////////////////////////////////////////////
WebElement errorMessage = qSeleniumLib.waitForSelectorContaining("span", "value is not a number");
assertThat(errorMessage.getText()).contains("value is not a number");
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void testCriteriaPasterDuplicateValueValidation()
{
QueryScreenLib queryScreenLib = new QueryScreenLib(qSeleniumLib);
////////////////////////////
// go to the person page //
////////////////////////////
qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person", "Person");
queryScreenLib.waitForQueryToHaveRan();
//////////////////////////////////////
// open the paste values dialog UI //
//////////////////////////////////////
List<String> pastedValues = List.of("1", "1", "1", "2", "2");
queryScreenLib.openCriteriaPasterAndPasteValues("id", pastedValues);
///////////////////////////////////////////////
// expected chip & uniqueness calculations //
///////////////////////////////////////////////
int totalCount = pastedValues.size(); // 5
int uniqueCount = new HashSet<>(pastedValues).size(); // 2
/////////////////////////////
// chips should show dupes //
/////////////////////////////
assertFilterPasterChipCounts(pastedValues.size(), 0);
////////////////////////////////////////////////////////////////
// counter text should match “5 values (2 unique)” (or alike) //
////////////////////////////////////////////////////////////////
String expectedCounter = totalCount + " values (" + uniqueCount + " unique)";
WebElement counterLabel = qSeleniumLib.waitForSelectorContaining("span", "unique");
assertThat(counterLabel.getText()).contains(expectedCounter);
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void testCriteriaPasterWithPVSHappyPath()
{
QueryScreenLib queryScreenLib = new QueryScreenLib(qSeleniumLib);
////////////////////////////
// go to the person page //
////////////////////////////
qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person", "Person");
queryScreenLib.waitForQueryToHaveRan();
//////////////////////////////////////
// open the paste values dialog UI //
//////////////////////////////////////
queryScreenLib.addBasicFilter("home city");
queryScreenLib.openCriteriaPasterAndPasteValues("home city", List.of("St. Louis", "chesterfield"));
qSeleniumLib.waitForSeconds(1);
///////////////////////////////////////////////////////////////
// wait for chips to appear in the filter values review box //
///////////////////////////////////////////////////////////////
assertFilterPasterChipCounts(2, 0);
///////////////////////////////////////////////
// confirm each chip has the blue color class //
///////////////////////////////////////////////
qSeleniumLib.waitForSelectorAll(".MuiChip-root", 2).forEach(chip ->
{
String classAttr = chip.getAttribute("class");
assertThat(classAttr).contains("MuiChip-colorInfo");
});
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void testCriteriaPasterWithPVSTwoGoodOneBadAndDupes()
{
QueryScreenLib queryScreenLib = new QueryScreenLib(qSeleniumLib);
////////////////////////////
// go to the person page //
////////////////////////////
qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person", "Person");
queryScreenLib.waitForQueryToHaveRan();
//////////////////////////////////////
// open the paste values dialog UI //
//////////////////////////////////////
List<String> cities = List.of("St. Louis", "chesterfield", "Maryville", "st. louis", "st. louis", "chesterfield");
queryScreenLib.addBasicFilter("home city");
queryScreenLib.openCriteriaPasterAndPasteValues("home city", cities);
qSeleniumLib.waitForSeconds(1);
///////////////////////////////////////////////
// expected chip & uniqueness calculations //
///////////////////////////////////////////////
int totalCount = cities.size();
int uniqueCount = cities.stream().map(String::toLowerCase).collect(Collectors.toSet()).size();
///////////////////////////////////////////
// chips should show dupes and bad chips //
///////////////////////////////////////////
assertFilterPasterChipCounts(5, 1);
////////////////////////////////////////////////////////////////
// counter text should match “5 values (2 unique)” (or alike) //
////////////////////////////////////////////////////////////////
String expectedCounter = totalCount + " values (" + uniqueCount + " unique)";
WebElement counterLabel = qSeleniumLib.waitForSelectorContaining("span", "unique");
assertThat(counterLabel.getText()).contains(expectedCounter);
//////////////////////////////////////////
// assert the "value not found" warning //
//////////////////////////////////////////
WebElement warning = qSeleniumLib.waitForSelectorContaining("span", "was not found");
assertThat(warning.getText()).contains("1 value was not found and will not be added to the filter");
}
/******************************************************************************* /*******************************************************************************
** **
*******************************************************************************/ *******************************************************************************/
@ -273,4 +474,18 @@ public class QueryScreenTest extends QBaseSeleniumTest
queryScreenLib.clickAdvancedFilterClearIcon(); queryScreenLib.clickAdvancedFilterClearIcon();
} }
/*******************************************************************************
**
*******************************************************************************/
private void assertFilterPasterChipCounts(int expectedValid, int expectedInvalid)
{
List<WebElement> chips = qSeleniumLib.waitForSelectorAll(".MuiChip-root", expectedValid + expectedInvalid);
long validCount = chips.stream().filter(c -> c.getAttribute("class").contains("MuiChip-colorInfo")).count();
long errorCount = chips.stream().filter(c -> c.getAttribute("class").contains("MuiChip-colorError")).count();
assertThat(validCount).isEqualTo(expectedValid);
assertThat(errorCount).isEqualTo(expectedInvalid);
}
} }