Compare commits

..

2 Commits

44 changed files with 20141 additions and 6169 deletions

1
.gitignore vendored
View File

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

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

View File

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

View File

@ -19,6 +19,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {useAuth0} from "@auth0/auth0-react";
import {QException} from "@kingsrook/qqq-frontend-core/lib/exceptions/QException";
import {QAppNodeType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QAppNodeType";
import {QAppTreeNode} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QAppTreeNode";
@ -28,20 +29,16 @@ import {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstan
import {QProcessMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QProcessMetaData";
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
import Avatar from "@mui/material/Avatar";
import Box from "@mui/material/Box";
import CssBaseline from "@mui/material/CssBaseline";
import Icon from "@mui/material/Icon";
import {ThemeProvider} from "@mui/material/styles";
import {LicenseInfo} from "@mui/x-license-pro";
import CommandMenu from "CommandMenu";
import jwt_decode from "jwt-decode";
import QContext from "QContext";
import useAnonymousAuthenticationModule from "qqq/authorization/anonymous/useAnonymousAuthenticationModule";
import useAuth0AuthenticationModule from "qqq/authorization/auth0/useAuth0AuthenticationModule";
import useOAuth2AuthenticationModule from "qqq/authorization/oauth2/useOAuth2AuthenticationModule";
import Sidenav from "qqq/components/horseshoe/sidenav/SideNav";
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, setOpenConfigurator, useMaterialUIController} from "qqq/context";
import AppHome from "qqq/pages/apps/Home";
import NoApps from "qqq/pages/apps/NoApps";
import ProcessRun from "qqq/pages/processes/ProcessRun";
@ -65,14 +62,10 @@ import {Md5} from "ts-md5/dist/md5";
const qController = Client.getInstance();
export const SESSION_UUID_COOKIE_NAME = "sessionUUID";
interface Props
export default function App()
{
authenticationMetaData: QAuthenticationMetaData;
}
export default function App({authenticationMetaData}: Props)
{
const [, , removeCookie] = useCookies([SESSION_UUID_COOKIE_NAME]);
const [cookies, setCookie, removeCookie] = useCookies([SESSION_UUID_COOKIE_NAME]);
const {user, getAccessTokenSilently, logout} = useAuth0();
const [loadingToken, setLoadingToken] = useState(false);
const [isFullyAuthenticated, setIsFullyAuthenticated] = useState(false);
const [profileRoutes, setProfileRoutes] = useState({});
@ -81,20 +74,68 @@ export default function App({authenticationMetaData}: Props)
const [needLicenseKey, setNeedLicenseKey] = useState(true);
const [loggedInUser, setLoggedInUser] = useState({} as { name?: string, email?: string });
const [defaultRoute, setDefaultRoute] = useState("/no-apps");
const [earlyReturnForAuth, setEarlyReturnForAuth] = useState(null as JSX.Element);
const {setupSession: auth0SetupSession, logout: auth0Logout} = useAuth0AuthenticationModule({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});
/////////////////////////////////////////////////////////
// tell the client how to do a logout if it sees a 401 //
/////////////////////////////////////////////////////////
Client.setUnauthorizedCallback(() => doLogout());
Client.setUnauthorizedCallback(() =>
{
logout();
});
const shouldStoreNewToken = (newToken: string, oldToken: string): boolean =>
{
if (!cookies[SESSION_UUID_COOKIE_NAME])
{
console.log("No session uuid cookie - so we should store a new one.");
return (true);
}
if (!oldToken)
{
console.log("No accessToken in localStorage - so we should store a new one.");
return (true);
}
try
{
const oldJSON: any = jwt_decode(oldToken);
const newJSON: any = jwt_decode(newToken);
////////////////////////////////////////////////////////////////////////////////////
// if the old (local storage) token is expired, then we need to store the new one //
////////////////////////////////////////////////////////////////////////////////////
const oldExp = oldJSON["exp"];
if (oldExp * 1000 < (new Date().getTime()))
{
console.log("Access token in local storage was expired - so we should store a new one.");
return (true);
}
////////////////////////////////////////////////////////////////////////////////////////////////
// remove the exp & iat values from what we compare - as they are always different from auth0 //
// note, this is only deleting them from what we compare, not from what we'd store. //
////////////////////////////////////////////////////////////////////////////////////////////////
delete newJSON["exp"];
delete newJSON["iat"];
delete oldJSON["exp"];
delete oldJSON["iat"];
const different = JSON.stringify(newJSON) !== JSON.stringify(oldJSON);
if (different)
{
console.log("Latest access token from auth0 has changed vs localStorage - so we should store a new one.");
}
return (different);
}
catch (e)
{
console.log("Caught in shouldStoreNewToken: " + e);
}
return (true);
};
/////////////////////////////////////////////////
// deal with making sure user is authenticated //
/////////////////////////////////////////////////
useEffect(() =>
{
if (loadingToken)
@ -105,17 +146,65 @@ export default function App({authenticationMetaData}: Props)
(async () =>
{
const authenticationMetaData: QAuthenticationMetaData = await qController.getAuthenticationMetaData();
if (authenticationMetaData.type === "AUTH_0")
{
await auth0SetupSession();
}
else if (authenticationMetaData.type === "OAUTH2")
{
await oauth2SetupSession();
/////////////////////////////////////////
// use auth0 if auth type is ... auth0 //
/////////////////////////////////////////
try
{
console.log("Loading token from auth0...");
const accessToken = await getAccessTokenSilently();
const lsAccessToken = localStorage.getItem("accessToken");
if (shouldStoreNewToken(accessToken, lsAccessToken))
{
console.log("Sending accessToken to backend, requesting a sessionUUID...");
const {uuid: newSessionUuid, 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("sessionValues", JSON.stringify(values));
console.log("Got new sessionUUID from backend, and stored new accessToken");
}
else
{
console.log("Using existing sessionUUID cookie");
}
setIsFullyAuthenticated(true);
qController.setGotAuthentication();
setLoggedInUser(user);
console.log("Token load complete.");
}
catch (e)
{
console.log(`Error loading token: ${JSON.stringify(e)}`);
qController.clearAuthenticationMetaDataLocalStorage();
localStorage.removeItem("accessToken");
removeCookie(SESSION_UUID_COOKIE_NAME, {path: "/"});
logout();
return;
}
}
else if (authenticationMetaData.type === "FULLY_ANONYMOUS" || authenticationMetaData.type === "MOCK")
{
await anonymousSetupSession();
/////////////////////////////////////////////
// use a random token if anonymous or mock //
/////////////////////////////////////////////
console.log("Generating random token...");
setIsFullyAuthenticated(true);
qController.setGotAuthentication();
setCookie(SESSION_UUID_COOKIE_NAME, Md5.hashStr(`${new Date()}`), {path: "/"});
console.log("Token generation complete.");
return;
}
else
{
@ -131,36 +220,13 @@ export default function App({authenticationMetaData}: Props)
(async () =>
{
const metaData: QInstance = await qController.loadMetaData();
LicenseInfo.setLicenseKey(metaData.environmentValues?.get("MATERIAL_UI_LICENSE_KEY") || process.env.REACT_APP_MATERIAL_UI_LICENSE_KEY);
LicenseInfo.setLicenseKey(metaData.environmentValues.get("MATERIAL_UI_LICENSE_KEY") || process.env.REACT_APP_MATERIAL_UI_LICENSE_KEY);
setNeedLicenseKey(false);
})();
}
/***************************************************************************
** call appropriate logout function based on authentication meta data type
***************************************************************************/
function doLogout()
{
if (authenticationMetaData?.type === "AUTH_0")
{
auth0Logout();
}
else if (authenticationMetaData?.type === "OAUTH2")
{
oauth2Logout();
}
else if (authenticationMetaData?.type === "FULLY_ANONYMOUS" || authenticationMetaData?.type === "MOCK")
{
anonymousLogout();
}
else
{
console.log(`No logout callback for authentication type [${authenticationMetaData?.type}].`);
}
}
const [controller, dispatch] = useMaterialUIController();
const {miniSidenav, direction, sidenavColor} = controller;
const {miniSidenav, direction, layout, openConfigurator, sidenavColor} = controller;
const [onMouseEnter, setOnMouseEnter] = useState(false);
const {pathname} = useLocation();
const [queryParams] = useSearchParams();
@ -453,10 +519,11 @@ export default function App({authenticationMetaData}: Props)
}
}
let profileRoutes = {};
const gravatarBase = "https://www.gravatar.com/avatar/";
const hash = Md5.hashStr(loggedInUser?.email || "user");
const profilePicture = `${gravatarBase}${hash}`;
const profileRoutes = {
profileRoutes = {
type: "collapse",
name: loggedInUser?.name ?? "Anonymous",
key: "username",
@ -525,7 +592,10 @@ export default function App({authenticationMetaData}: Props)
localStorage.removeItem("accessToken");
removeCookie(SESSION_UUID_COOKIE_NAME, {path: "/"});
doLogout();
//////////////////////////////////////////////////////
// todo - this is auth0 logout... make more generic //
//////////////////////////////////////////////////////
logout();
return;
}
}
@ -533,9 +603,7 @@ export default function App({authenticationMetaData}: Props)
})();
}, [needToLoadRoutes, isFullyAuthenticated]);
///////////////////////////////////////////////////
// Open sidenav when mouse enter on mini sidenav //
///////////////////////////////////////////////////
// Open sidenav when mouse enter on mini sidenav
const handleOnMouseEnter = () =>
{
if (miniSidenav && !onMouseEnter)
@ -545,9 +613,7 @@ export default function App({authenticationMetaData}: Props)
}
};
/////////////////////////////////////////////////
// Close sidenav when mouse leave mini sidenav //
/////////////////////////////////////////////////
// Close sidenav when mouse leave mini sidenav
const handleOnMouseLeave = () =>
{
if (onMouseEnter)
@ -557,14 +623,16 @@ export default function App({authenticationMetaData}: Props)
}
};
// Change the openConfigurator state
const handleConfiguratorOpen = () => setOpenConfigurator(dispatch, !openConfigurator);
// Setting the dir attribute for the body element
useEffect(() =>
{
document.body.setAttribute("dir", direction);
}, [direction]);
//////////////////////////////////////////////////////
// Setting page scroll to 0 when changing the route //
//////////////////////////////////////////////////////
// Setting page scroll to 0 when changing the route
useEffect(() =>
{
document.documentElement.scrollTop = 0;
@ -604,14 +672,14 @@ export default function App({authenticationMetaData}: Props)
const [dotMenuOpen, setDotMenuOpen] = useState(false);
const [keyboardHelpOpen, setKeyboardHelpOpen] = useState(false);
const [helpHelpActive] = useState(queryParams.has("helpHelp"));
const [userId, setUserId] = useState(loggedInUser?.email);
const [userId, setUserId] = useState(user?.email);
useEffect(() =>
{
setUserId(loggedInUser?.email);
}, [loggedInUser]);
setUserId(user?.email)
}, [user]);
const [googleAnalyticsUtils] = useState(new GoogleAnalyticsUtils());
/*******************************************************************************
@ -619,35 +687,9 @@ export default function App({authenticationMetaData}: Props)
*******************************************************************************/
function recordAnalytics(model: AnalyticsModel)
{
googleAnalyticsUtils.recordAnalytics(model);
googleAnalyticsUtils.recordAnalytics(model)
}
///////////////////////////////////////////////////////////////////
// if any of the auth/session setup code determined that we need //
// to render something and return early - then do so here. //
///////////////////////////////////////////////////////////////////
if (earlyReturnForAuth)
{
return (earlyReturnForAuth);
}
/***************************************************************************
**
***************************************************************************/
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 (
@ -676,7 +718,6 @@ export default function App({authenticationMetaData}: Props)
<ThemeProvider theme={theme}>
<CssBaseline />
<CommandMenu metaData={metaData} />
{banner()}
<Sidenav
color={sidenavColor}
icon={branding.icon}
@ -686,7 +727,6 @@ export default function App({authenticationMetaData}: Props)
routes={sideNavRoutes}
onMouseEnter={handleOnMouseEnter}
onMouseLeave={handleOnMouseLeave}
logout={doLogout}
/>
<Routes>
<Route path="*" element={<Navigate to={defaultRoute} />} />

View File

@ -19,97 +19,116 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {Auth0Provider} from "@auth0/auth0-react";
import {QAuthenticationMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QAuthenticationMetaData";
import React from "react";
import {createRoot} from "react-dom/client";
import {BrowserRouter, useNavigate, useSearchParams} from "react-router-dom";
import App from "App";
import "qqq/styles/qqq-override-styles.css";
import "qqq/styles/globals.scss";
import "qqq/styles/raycast.scss";
import useAnonymousAuthenticationModule from "qqq/authorization/anonymous/useAnonymousAuthenticationModule";
import useAuth0AuthenticationModule from "qqq/authorization/auth0/useAuth0AuthenticationModule";
import useOAuth2AuthenticationModule from "qqq/authorization/oauth2/useOAuth2AuthenticationModule";
import HandleAuthorizationError from "HandleAuthorizationError";
import ProtectedRoute from "qqq/authorization/auth0/ProtectedRoute";
import {MaterialUIControllerProvider} from "qqq/context";
import Client from "qqq/utils/qqq/Client";
import React from "react";
import {createRoot} from "react-dom/client";
import {BrowserRouter} from "react-router-dom";
const qController = Client.getInstance();
if (document.location.search && document.location.search.indexOf("clearAuthenticationMetaDataLocalStorage") > -1)
if(document.location.search && document.location.search.indexOf("clearAuthenticationMetaDataLocalStorage") > -1)
{
qController.clearAuthenticationMetaDataLocalStorage();
qController.clearAuthenticationMetaDataLocalStorage()
}
const authenticationMetaDataPromise: Promise<QAuthenticationMetaData> = qController.getAuthenticationMetaData();
const authenticationMetaDataPromise: Promise<QAuthenticationMetaData> = qController.getAuthenticationMetaData()
authenticationMetaDataPromise.then((authenticationMetaData) =>
{
/***************************************************************************
**
***************************************************************************/
function Auth0RouterBody()
// @ts-ignore
function Auth0ProviderWithRedirectCallback({children, ...props})
{
const {renderAppWrapper} = useAuth0AuthenticationModule({});
return (renderAppWrapper(authenticationMetaData));
const navigate = useNavigate();
const [searchParams] = useSearchParams();
// @ts-ignore
const onRedirectCallback = (appState) =>
{
navigate((appState && appState.returnTo) || window.location.pathname);
};
if (searchParams.get("error"))
{
return (
// @ts-ignore
<Auth0Provider {...props}>
<HandleAuthorizationError errorMessage={searchParams.get("error_description")} />
</Auth0Provider>
);
}
else
{
return (
// @ts-ignore
<Auth0Provider onRedirectCallback={onRedirectCallback} {...props}>
{children}
</Auth0Provider>
);
}
}
/***************************************************************************
**
***************************************************************************/
function OAuth2RouterBody()
{
const {renderAppWrapper} = useOAuth2AuthenticationModule({inOAuthContext: false});
return (renderAppWrapper(authenticationMetaData, (
<MaterialUIControllerProvider>
<App authenticationMetaData={authenticationMetaData} />
</MaterialUIControllerProvider>
)));
}
/***************************************************************************
**
***************************************************************************/
function AnonymousRouterBody()
{
const {renderAppWrapper} = useAnonymousAuthenticationModule({});
return (renderAppWrapper(authenticationMetaData, (
<MaterialUIControllerProvider>
<App authenticationMetaData={authenticationMetaData} />
</MaterialUIControllerProvider>
)));
}
const container = document.getElementById("root");
const root = createRoot(container);
if (authenticationMetaData.type === "AUTH_0")
{
root.render(<BrowserRouter>
<Auth0RouterBody />
</BrowserRouter>);
}
else if (authenticationMetaData.type === "OAUTH2")
{
root.render(<BrowserRouter>
<OAuth2RouterBody />
</BrowserRouter>);
}
else if (authenticationMetaData.type === "FULLY_ANONYMOUS" || authenticationMetaData.type === "MOCK")
{
root.render(<BrowserRouter>
<AnonymousRouterBody />
</BrowserRouter>);
// @ts-ignore
let domain: string = authenticationMetaData.data.baseUrl;
// @ts-ignore
const clientId = authenticationMetaData.data.clientId;
// @ts-ignore
const audience = authenticationMetaData.data.audience;
if(!domain || !clientId)
{
root.render(
<div>Error: AUTH0 authenticationMetaData is missing domain [{domain}] and/or clientId [{clientId}].</div>
);
return;
}
if(domain.endsWith("/"))
{
/////////////////////////////////////////////////////////////////////////////////////
// auth0 lib fails if we have a trailing slash. be a bit more graceful than that. //
/////////////////////////////////////////////////////////////////////////////////////
domain = domain.replace(/\/$/, "");
}
root.render(
<BrowserRouter>
<Auth0ProviderWithRedirectCallback
domain={domain}
clientId={clientId}
audience={audience}
redirectUri={`${window.location.origin}/`}
>
<MaterialUIControllerProvider>
<ProtectedRoute component={App} />
</MaterialUIControllerProvider>
</Auth0ProviderWithRedirectCallback>
</BrowserRouter>
);
}
else
{
root.render(<div>
Error: Unknown authenticationMetaData type: [{authenticationMetaData.type}].
</div>);
root.render(
<BrowserRouter>
<MaterialUIControllerProvider>
<App />
</MaterialUIControllerProvider>
</BrowserRouter>
);
}
});
})

View File

@ -1,82 +0,0 @@
/*
* 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 {QAuthenticationMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QAuthenticationMetaData";
import {SESSION_UUID_COOKIE_NAME} from "App";
import Client from "qqq/utils/qqq/Client";
import {useCookies} from "react-cookie";
import {Md5} from "ts-md5/dist/md5";
const qController = Client.getInstance();
interface Props
{
setIsFullyAuthenticated?: (is: boolean) => void;
setLoggedInUser?: (user: any) => void;
setEarlyReturnForAuth?: (element: JSX.Element | null) => void;
}
/***************************************************************************
** hook for working with the anonymous authentication module
***************************************************************************/
export default function useAnonymousAuthenticationModule({setIsFullyAuthenticated, setLoggedInUser, setEarlyReturnForAuth}: Props)
{
const [cookies, setCookie, removeCookie] = useCookies([SESSION_UUID_COOKIE_NAME]);
/***************************************************************************
**
***************************************************************************/
const setupSession = async () =>
{
console.log("Generating random token...");
setIsFullyAuthenticated(true);
qController.setGotAuthentication();
setCookie(SESSION_UUID_COOKIE_NAME, Md5.hashStr(`${new Date()}`), {path: "/"});
console.log("Token generation complete.");
};
/***************************************************************************
**
***************************************************************************/
const logout = () =>
{
qController.clearAuthenticationMetaDataLocalStorage();
removeCookie(SESSION_UUID_COOKIE_NAME, {path: "/"});
};
/***************************************************************************
**
***************************************************************************/
const renderAppWrapper = (authenticationMetaData: QAuthenticationMetaData, children: JSX.Element): JSX.Element =>
{
return children;
};
return {
setupSession,
logout,
renderAppWrapper
};
}

View File

@ -1,252 +0,0 @@
/*
* 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 {Auth0Provider, useAuth0} from "@auth0/auth0-react";
import {QAuthenticationMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QAuthenticationMetaData";
import App, {SESSION_UUID_COOKIE_NAME} from "App";
import HandleAuthorizationError from "HandleAuthorizationError";
import jwt_decode from "jwt-decode";
import ProtectedRoute from "qqq/authorization/auth0/ProtectedRoute";
import {MaterialUIControllerProvider} from "qqq/context";
import Client from "qqq/utils/qqq/Client";
import {useCookies} from "react-cookie";
import {useNavigate, useSearchParams} from "react-router-dom";
const qController = Client.getInstance();
interface Props
{
setIsFullyAuthenticated?: (is: boolean) => void;
setLoggedInUser?: (user: any) => void;
setEarlyReturnForAuth?: (element: JSX.Element | null) => void;
}
/***************************************************************************
** hook for working with the Auth0 authentication module
***************************************************************************/
export default function useAuth0AuthenticationModule({setIsFullyAuthenticated, setLoggedInUser}: Props)
{
const {user: auth0User, getAccessTokenSilently: auth0GetAccessTokenSilently, logout: useAuth0Logout} = useAuth0();
const [cookies, removeCookie] = useCookies([SESSION_UUID_COOKIE_NAME]);
/***************************************************************************
**
***************************************************************************/
const shouldStoreNewToken = (newToken: string, oldToken: string): boolean =>
{
if (!cookies[SESSION_UUID_COOKIE_NAME])
{
console.log("No session uuid cookie - so we should store a new one.");
return (true);
}
if (!oldToken)
{
console.log("No accessToken in localStorage - so we should store a new one.");
return (true);
}
try
{
const oldJSON: any = jwt_decode(oldToken);
const newJSON: any = jwt_decode(newToken);
////////////////////////////////////////////////////////////////////////////////////
// if the old (local storage) token is expired, then we need to store the new one //
////////////////////////////////////////////////////////////////////////////////////
const oldExp = oldJSON["exp"];
if (oldExp * 1000 < (new Date().getTime()))
{
console.log("Access token in local storage was expired - so we should store a new one.");
return (true);
}
////////////////////////////////////////////////////////////////////////////////////////////////
// remove the exp & iat values from what we compare - as they are always different from auth0 //
// note, this is only deleting them from what we compare, not from what we'd store. //
////////////////////////////////////////////////////////////////////////////////////////////////
delete newJSON["exp"];
delete newJSON["iat"];
delete oldJSON["exp"];
delete oldJSON["iat"];
const different = JSON.stringify(newJSON) !== JSON.stringify(oldJSON);
if (different)
{
console.log("Latest access token from auth0 has changed vs localStorage - so we should store a new one.");
}
return (different);
}
catch (e)
{
console.log("Caught in shouldStoreNewToken: " + e);
}
return (true);
};
/***************************************************************************
**
***************************************************************************/
const setupSession = async () =>
{
try
{
console.log("Loading token from auth0...");
const accessToken = await auth0GetAccessTokenSilently();
const lsAccessToken = localStorage.getItem("accessToken");
if (shouldStoreNewToken(accessToken, lsAccessToken))
{
console.log("Sending accessToken to backend, requesting a sessionUUID...");
const {uuid: values} = await qController.manageSession(accessToken, null);
localStorage.setItem("accessToken", accessToken);
localStorage.setItem("sessionValues", JSON.stringify(values));
console.log("Got new sessionUUID from backend, and stored new accessToken");
}
else
{
console.log("Using existing sessionUUID cookie");
}
setIsFullyAuthenticated(true);
qController.setGotAuthentication();
setLoggedInUser(auth0User);
console.log("Token load complete.");
}
catch (e)
{
console.log(`Error loading token: ${JSON.stringify(e)}`);
qController.clearAuthenticationMetaDataLocalStorage();
localStorage.removeItem("accessToken");
removeCookie(SESSION_UUID_COOKIE_NAME, {path: "/"});
useAuth0Logout();
return;
}
};
/***************************************************************************
**
***************************************************************************/
const logout = () =>
{
useAuth0Logout({returnTo: window.location.origin});
};
/***************************************************************************
**
***************************************************************************/
// @ts-ignore
function Auth0ProviderWithRedirectCallback({children, ...props})
{
const navigate = useNavigate();
const [searchParams] = useSearchParams();
// @ts-ignore
const onRedirectCallback = (appState) =>
{
navigate((appState && appState.returnTo) || window.location.pathname);
};
if (searchParams.get("error"))
{
return (
// @ts-ignore
<Auth0Provider {...props}>
<HandleAuthorizationError errorMessage={searchParams.get("error_description")} />
</Auth0Provider>
);
}
else
{
return (
// @ts-ignore
<Auth0Provider onRedirectCallback={onRedirectCallback} {...props}>
{children}
</Auth0Provider>
);
}
}
/***************************************************************************
**
***************************************************************************/
const renderAppWrapper = (authenticationMetaData: QAuthenticationMetaData): JSX.Element =>
{
// @ts-ignore
let domain: string = authenticationMetaData.data.baseUrl;
// @ts-ignore
const clientId = authenticationMetaData.data.clientId;
// @ts-ignore
const audience = authenticationMetaData.data.audience;
if (!domain || !clientId)
{
return (
<div>Error: AUTH0 authenticationMetaData is missing baseUrl [{domain}] and/or clientId [{clientId}].</div>
);
}
if (domain.endsWith("/"))
{
/////////////////////////////////////////////////////////////////////////////////////
// auth0 lib fails if we have a trailing slash. be a bit more graceful than that. //
/////////////////////////////////////////////////////////////////////////////////////
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 (
<Auth0ProviderWithRedirectCallback
domain={domain}
clientId={clientId}
audience={audience}
redirectUri={`${window.location.origin}/`}>
<MaterialUIControllerProvider>
<ProtectedRoute component={WrappedApp} />
</MaterialUIControllerProvider>
</Auth0ProviderWithRedirectCallback>
);
};
return {
setupSession,
logout,
renderAppWrapper
};
}

View File

@ -1,188 +0,0 @@
/*
* 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 {QAuthenticationMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QAuthenticationMetaData";
import {SESSION_UUID_COOKIE_NAME} from "App";
import Client from "qqq/utils/qqq/Client";
import {useCookies} from "react-cookie";
import {AuthContextProps, AuthProvider, useAuth} from "react-oidc-context";
import {useNavigate, useSearchParams} from "react-router-dom";
const qController = Client.getInstance();
interface Props
{
setIsFullyAuthenticated?: (is: boolean) => void;
setLoggedInUser?: (user: any) => void;
setEarlyReturnForAuth?: (element: JSX.Element | null) => void;
inOAuthContext: boolean;
}
/***************************************************************************
** hook for working with the OAuth2 authentication module
***************************************************************************/
export default function useOAuth2AuthenticationModule({setIsFullyAuthenticated, setLoggedInUser, setEarlyReturnForAuth, inOAuthContext}: Props)
{
///////////////////////////////////////////////////////////////////////////////////////
// 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, removeCookie] = useCookies([SESSION_UUID_COOKIE_NAME]);
const [searchParams] = useSearchParams();
const navigate = useNavigate();
/***************************************************************************
**
***************************************************************************/
const setupSession = async () =>
{
try
{
const preSigninRedirectPathnameKey = "oauth2.preSigninRedirect.pathname";
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 state = searchParams.get("state");
const oidcString = localStorage.getItem(`oidc.${state}`);
if (oidcString)
{
const oidcObject = JSON.parse(oidcString) as { [name: string]: any };
console.log(oidcObject);
const manageSessionRequestBody = {code: code, codeVerifier: oidcObject.code_verifier, redirectUri: oidcObject.redirect_uri};
const {uuid: newSessionUuid, values} = await qController.manageSession(null, null, manageSessionRequestBody);
console.log(`we have new session UUID: ${newSessionUuid}`);
setIsFullyAuthenticated(true);
qController.setGotAuthentication();
setLoggedInUser(values?.user);
console.log("Token load complete.");
const preSigninRedirectPathname = localStorage.getItem(preSigninRedirectPathnameKey);
localStorage.removeItem(preSigninRedirectPathname);
navigate(preSigninRedirectPathname ?? "/", {replace: true});
}
else
{
////////////////////////////////////////////
// if unrecognized state, render an error //
////////////////////////////////////////////
setEarlyReturnForAuth(<div>Login error: Unrecognized state. Refresh to try again.</div>);
}
}
else
{
//////////////////////////////////////////////////////////////////////////
// if we have a sessionUUID cookie, try to validate it with the backend //
//////////////////////////////////////////////////////////////////////////
const sessionUuid = cookies[SESSION_UUID_COOKIE_NAME];
if (sessionUuid)
{
console.log(`we have session UUID: ${sessionUuid} - validating it...`);
const {values} = await qController.manageSession(null, sessionUuid, null);
setIsFullyAuthenticated(true);
qController.setGotAuthentication();
setLoggedInUser(values?.user);
console.log("Token load complete.");
}
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(authOidc);
localStorage.setItem(preSigninRedirectPathnameKey, window.location.pathname);
setEarlyReturnForAuth(<div>Signing in...</div>);
authOidc?.signinRedirect();
}
}
}
catch (e)
{
console.log(`Error loading token: ${JSON.stringify(e)}`);
logout();
return;
}
};
/***************************************************************************
**
***************************************************************************/
const logout = () =>
{
qController.clearAuthenticationMetaDataLocalStorage();
removeCookie(SESSION_UUID_COOKIE_NAME, {path: "/"});
authOidc?.signoutRedirect();
};
/***************************************************************************
**
***************************************************************************/
const renderAppWrapper = (authenticationMetaData: QAuthenticationMetaData, children: JSX.Element): JSX.Element =>
{
const authority: string = authenticationMetaData.data.baseUrl;
const clientId = authenticationMetaData.data.clientId;
if (!authority || !clientId)
{
return (
<div>Error: OAuth2 authenticationMetaData is missing baseUrl [{authority}] and/or clientId [{clientId}].</div>
);
}
const oidcConfig =
{
authority: authority,
client_id: clientId,
redirect_uri: `${window.location.origin}/token`,
response_type: "code",
scope: "openid profile email offline_access",
};
return (<AuthProvider {...oidcConfig}>
{children}
</AuthProvider>
);
};
return {
setupSession,
logout,
renderAppWrapper
};
}

View File

@ -1,6 +1,6 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* Copyright (C) 2021-2022. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
@ -19,18 +19,20 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.kingsrook.qqq.frontend.materialdashboard.model.metadata;
import {useAuth0} from "@auth0/auth0-react";
import {Button} from "@mui/material";
import React from "react";
import com.kingsrook.qqq.backend.core.model.metadata.branding.BannerSlot;
/*******************************************************************************
**
*******************************************************************************/
public enum MaterialDashboardBannerSlots implements BannerSlot
function AuthenticationButton()
{
QFMD_TOP_OF_SITE,
QFMD_TOP_OF_BODY,
QFMD_SIDE_NAV_UNDER_LOGO
const {loginWithRedirect, logout, isAuthenticated} = useAuth0();
if (isAuthenticated)
{
return <Button onClick={() => logout({returnTo: window.location.origin})}>Log Out</Button>;
}
return <Button onClick={() => loginWithRedirect()}>Log In</Button>;
}
export default AuthenticationButton;

View File

@ -23,10 +23,8 @@ import {Chip} from "@mui/material";
import TextField from "@mui/material/TextField";
import {makeStyles} from "@mui/styles";
import Downshift from "downshift";
import {debounce} from "lodash";
import {arrayOf, func, string} from "prop-types";
import Client from "qqq/utils/qqq/Client";
import React, {useEffect, useRef, useState} from "react";
import React, {useEffect, useState} from "react";
const useStyles = makeStyles((theme: any) => ({
chip: {
@ -36,107 +34,21 @@ const useStyles = makeStyles((theme: any) => ({
function ChipTextField({...props})
{
const qController = Client.getInstance();
const classes = useStyles();
const {table, field, handleChipChange, label, chipType, disabled, placeholder, chipData, multiline, rows, ...other} = props;
const {handleChipChange, label, chipType, disabled, placeholder, chipData, multiline, rows, ...other} = props;
const [inputValue, setInputValue] = 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(() =>
{
setChips(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]);
}, [chipData]);
useEffect(() =>
{
handleChipChange(isMakingRequest, chipValidity, chipPVSIds);
}, [chipValidity, chipPVSIds, isMakingRequest]);
handleChipChange(chips);
}, [chips, handleChipChange]);
function handleKeyDown(event: any)
{
@ -152,16 +64,13 @@ function ChipTextField({...props})
setInputValue("");
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());
setChips(newChipList);
setInputValue("");
}
else if (chips.length && !inputValue.length && event.key === "Backspace")
else if (chips.length && !inputValue.length && event.key === "Backspace" )
{
setChips(chips.slice(0, chips.length - 1));
}
@ -178,26 +87,18 @@ function ChipTextField({...props})
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>; }; })
{
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 (
<React.Fragment>
@ -215,7 +116,7 @@ function ChipTextField({...props})
});
// @ts-ignore
return (
<div id="chip-text-field-container" style={{flexWrap: "wrap", display: "flex"}}>
<div id="chip-text-field-container" style={{flexWrap: "wrap", display:"flex"}}>
<TextField
sx={{width: "99%"}}
disabled={disabled}
@ -224,16 +125,16 @@ function ChipTextField({...props})
startAdornment:
<div style={{overflowY: "auto", overflowX: "hidden", margin: "-10px", width: "calc(100% + 20px)", padding: "10px", marginBottom: "-20px", height: "calc(100% + 10px)"}}>
{
chips.map((item, index) => (
chips.map((item, i) => (
<Chip
onChange={determineChipColors}
color={chipColors[index]}
key={`${item}-${index}`}
color={(chipType !== "number" || ! Number.isNaN(Number(item))) ? "info" : "error"}
key={`${item}-${i}`}
variant="outlined"
tabIndex={-1}
label={item}
className={classes.chip}
/>
))
}
</div>,
@ -257,7 +158,6 @@ function ChipTextField({...props})
</React.Fragment>
);
}
ChipTextField.defaultProps = {
chipData: []
};
@ -266,4 +166,4 @@ ChipTextField.propTypes = {
chipData: arrayOf(string)
};
export default ChipTextField;
export default ChipTextField

View File

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

View File

@ -20,18 +20,17 @@
*/
import {QPossibleValue} from "@kingsrook/qqq-frontend-core/lib/model/QPossibleValue";
import {InputAdornment, InputLabel} from "@mui/material";
import Box from "@mui/material/Box";
import {Box, InputAdornment, InputLabel} from "@mui/material";
import Switch from "@mui/material/Switch";
import {ErrorMessage, Field, useFormikContext} from "formik";
import colors from "qqq/assets/theme/base/colors";
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 MDTypography from "qqq/components/legacy/MDTypography";
import React, {useMemo, useState} from "react";
import AceEditor from "react-ace";
import colors from "qqq/assets/theme/base/colors";
import BooleanFieldSwitch from "qqq/components/forms/BooleanFieldSwitch";
import MDInput from "qqq/components/legacy/MDInput";
import MDTypography from "qqq/components/legacy/MDTypography";
import {flushSync} from "react-dom";
// Declaring props types for FormField
@ -84,10 +83,10 @@ function QDynamicFormField({
if (placeholder)
{
inputProps.placeholder = placeholder;
inputProps.placeholder = placeholder
}
if (backgroundColor)
if(backgroundColor)
{
inputProps.sx = {
"&.MuiInputBase-root": {
@ -125,7 +124,7 @@ function QDynamicFormField({
{
onChange.onChange = (e: any) =>
{
if (isToUpperCase || isToLowerCase)
if(isToUpperCase || isToLowerCase)
{
const beforeStart = e.target.selectionStart;
const beforeEnd = e.target.selectionEnd;
@ -142,7 +141,7 @@ function QDynamicFormField({
newValue = newValue.toLowerCase();
}
setFieldValue(name, newValue);
if (onChangeCallback)
if(onChangeCallback)
{
onChangeCallback(newValue);
}
@ -154,7 +153,7 @@ function QDynamicFormField({
input.setSelectionRange(beforeStart, beforeEnd);
}
}
else if (onChangeCallback)
else if(onChangeCallback)
{
onChangeCallback(e.currentTarget.value);
}
@ -166,15 +165,15 @@ function QDynamicFormField({
***************************************************************************/
function dynamicSelectOnChange(newValue?: QPossibleValue)
{
if (onChangeCallback)
if(onChangeCallback)
{
onChangeCallback(newValue == null ? null : newValue.id);
onChangeCallback(newValue == null ? null : newValue.id)
}
}
let field;
let getsBulkEditHtmlLabel = true;
if (formFieldObject.possibleValueProps)
if(formFieldObject.possibleValueProps)
{
field = (<DynamicSelect
name={name}
@ -187,7 +186,7 @@ function QDynamicFormField({
onChange={dynamicSelectOnChange}
// otherValues={otherValuesMap}
useCase="form"
/>);
/>)
}
else if (type === "checkbox")
{
@ -221,7 +220,7 @@ function QDynamicFormField({
onChange={(value: string, event: any) =>
{
setFieldValue(name, value, false);
if (onChangeCallback)
if(onChangeCallback)
{
onChangeCallback(value);
}

View File

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

View File

@ -184,9 +184,9 @@ function EntityForm(props: Props): JSX.Element
///////////////////////////////////////////////////////////////////////////////////////
// copy values from specified fields in the parent record down into the child record //
///////////////////////////////////////////////////////////////////////////////////////
if (widgetData.defaultValuesForNewChildRecordsFromParentFields)
if(widgetData.defaultValuesForNewChildRecordsFromParentFields)
{
for (let childField in widgetData.defaultValuesForNewChildRecordsFromParentFields)
for(let childField in widgetData.defaultValuesForNewChildRecordsFromParentFields)
{
const parentField = widgetData.defaultValuesForNewChildRecordsFromParentFields[childField];
defaultValues[childField] = formValues[parentField];
@ -278,21 +278,21 @@ function EntityForm(props: Props): JSX.Element
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// build a map of display values for the new record, specifically, for any possible-values that need translated. //
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
const displayValues: { [fieldName: string]: string } = {};
if (childTableName && values)
const displayValues: {[fieldName: string]: string} = {};
if(childTableName && values)
{
///////////////////////////////////////////////////////////////////////////////////////////////////////
// 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)
{
const value = values[key];
const field = childTableMetaData.fields.get(key);
if (field.possibleValueSourceName)
if(field.possibleValueSourceName)
{
const possibleValues = await qController.possibleValues(childTableName, null, field.name, null, [value], null, objectToMap(values), "form");
if (possibleValues && possibleValues.length > 0)
const possibleValues = await qController.possibleValues(childTableName, null, field.name, null, [value], objectToMap(values), "form")
if(possibleValues && possibleValues.length > 0)
{
displayValues[key] = possibleValues[0].label;
}
@ -516,12 +516,13 @@ function EntityForm(props: Props): JSX.Element
}
/***************************************************************************
**
***************************************************************************/
function objectToMap(object: { [key: string]: any }): Map<string, any>
{
if (object == null)
if(object == null)
{
return (null);
}
@ -531,7 +532,7 @@ function EntityForm(props: Props): JSX.Element
{
rs.set(key, object[key]);
}
return rs;
return rs
}
@ -666,7 +667,7 @@ function EntityForm(props: Props): JSX.Element
const defaultValue = (defaultValues && defaultValues[fieldName]) ? defaultValues[fieldName] : fieldMetaData.defaultValue;
if (defaultValue && fieldMetaData.possibleValueSourceName)
{
const results: QPossibleValue[] = await qController.possibleValues(tableName, null, fieldName, null, [initialValues[fieldName]], null, objectToMap(initialValues), "form");
const results: QPossibleValue[] = await qController.possibleValues(tableName, null, fieldName, null, [initialValues[fieldName]], objectToMap(initialValues), "form");
if (results && results.length > 0)
{
defaultDisplayValues.set(fieldName, results[0].label);

View File

@ -270,7 +270,7 @@ function NavBar({absolute, light, isMini}: Props): JSX.Element
{
pageHeader &&
<Box display="flex" justifyContent="space-between">
<MDTypography pb="0.5rem" variant="h3" color={light ? "white" : "dark"} noWrap>
<MDTypography pb="0.5rem" textTransform="capitalize" variant="h3" color={light ? "white" : "dark"} noWrap>
{pageHeader}
</MDTypography>
</Box>

View File

@ -20,22 +20,21 @@
*/
import {QBrandingMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QBrandingMetaData";
import {Button} from "@mui/material";
import Box from "@mui/material/Box";
import Divider from "@mui/material/Divider";
import Icon from "@mui/material/Icon";
import Link from "@mui/material/Link";
import List from "@mui/material/List";
import {ReactNode, useEffect, useReducer, useState} from "react";
import {NavLink, useLocation} from "react-router-dom";
import AuthenticationButton from "qqq/components/buttons/AuthenticationButton";
import SideNavCollapse from "qqq/components/horseshoe/sidenav/SideNavCollapse";
import SideNavItem from "qqq/components/horseshoe/sidenav/SideNavItem";
import SideNavList from "qqq/components/horseshoe/sidenav/SideNavList";
import SidenavRoot from "qqq/components/horseshoe/sidenav/SideNavRoot";
import sidenavLogoLabel from "qqq/components/horseshoe/sidenav/styles/SideNav";
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 {ReactNode, useEffect, useReducer, useState} from "react";
import {NavLink, useLocation} from "react-router-dom";
interface Props
@ -45,7 +44,6 @@ interface Props
logo?: string;
appName?: string;
branding?: QBrandingMetaData;
logout: () => void;
routes: {
[key: string]:
| ReactNode
@ -68,7 +66,7 @@ interface Props
[key: string]: any;
}
function Sidenav({color, icon, logo, appName, branding, routes, logout, ...rest}: Props): JSX.Element
function Sidenav({color, icon, logo, appName, branding, routes, ...rest}: Props): JSX.Element
{
const [openCollapse, setOpenCollapse] = useState<boolean | string>(false);
const [openNestedCollapse, setOpenNestedCollapse] = useState<boolean | string>(false);
@ -259,7 +257,7 @@ function Sidenav({color, icon, logo, appName, branding, routes, logout, ...rest}
active={key === collapseName}
open={openCollapse === key}
noCollapse={noCollapse}
onClick={() => (!noCollapse ? (openCollapse === key ? setOpenCollapse(false) : setOpenCollapse(key)) : null)}
onClick={() => (! noCollapse ? (openCollapse === key ? setOpenCollapse(false) : setOpenCollapse(key)) : null) }
>
{collapse ? renderCollapse(collapse) : null}
</SideNavCollapse>
@ -302,30 +300,6 @@ 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 (
<SidenavRoot
{...rest}
@ -356,7 +330,12 @@ function Sidenav({color, icon, logo, appName, branding, routes, logout, ...rest}
</Box>
}
</Box>
<EnvironmentBanner branding={branding} />
{
branding && branding.environmentBannerText &&
<Box mt={2} bgcolor={branding.environmentBannerColor} borderRadius={2}>
{branding.environmentBannerText}
</Box>
}
</Box>
<Divider
light={
@ -371,7 +350,7 @@ function Sidenav({color, icon, logo, appName, branding, routes, logout, ...rest}
(darkMode && !transparentSidenav && whiteSidenav)
}
/>
<Button onClick={logout}>Log Out</Button>
<AuthenticationButton />
</SidenavRoot>
);
}

View File

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

View File

@ -1,97 +0,0 @@
/*
* 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,7 +20,6 @@
*/
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 {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
import {QTableVariant} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableVariant";
@ -36,11 +35,12 @@ import DialogTitle from "@mui/material/DialogTitle";
import Grid from "@mui/material/Grid";
import Icon from "@mui/material/Icon";
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 MDButton from "qqq/components/legacy/MDButton";
import Client from "qqq/utils/qqq/Client";
import React, {useState} from "react";
import {useNavigate} from "react-router-dom";
interface Props
{
@ -162,8 +162,8 @@ function GotoRecordDialog(props: Props): JSX.Element
/***************************************************************************
** event handler for close button
***************************************************************************/
** event handler for close button
***************************************************************************/
const closeRequested = () =>
{
if (props.mayClose)
@ -182,23 +182,23 @@ function GotoRecordDialog(props: Props): JSX.Element
options[optionIndex].forEach((field) =>
{
if (values[field.name])
if(values[field.name])
{
anyFieldsInThisOptionHaveAValue = true;
}
});
})
if (!anyFieldsInThisOptionHaveAValue)
if(!anyFieldsInThisOptionHaveAValue)
{
return (true);
}
return (false);
};
}
/***************************************************************************
** event handler for clicking an 'option's go/submit button
***************************************************************************/
** event handler for clicking an 'option's go/submit button
***************************************************************************/
const optionGoClicked = async (optionIndex: number) =>
{
setError("");
@ -207,13 +207,9 @@ function GotoRecordDialog(props: Props): JSX.Element
const queryStringParts: string[] = [];
options[optionIndex].forEach((field) =>
{
if (field.type == QFieldType.STRING && !values[field.name])
{
return;
}
criteria.push(new QFilterCriteria(field.name, QCriteriaOperator.EQUALS, [values[field.name]]));
queryStringParts.push(`${field.name}=${encodeURIComponent(values[field.name])}`);
});
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);
@ -227,7 +223,7 @@ function GotoRecordDialog(props: Props): JSX.Element
}
else if (queryResult.length == 1)
{
if (options[optionIndex].length == 1 && options[optionIndex][0].name == pkey?.name)
if(options[optionIndex].length == 1 && options[optionIndex][0].name == pkey?.name)
{
/////////////////////////////////////////////////
// navigate by pkey, if that's how we searched //

View File

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

View File

@ -26,6 +26,7 @@ import Autocomplete from "@mui/material/Autocomplete";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import Icon from "@mui/material/Icon";
import IconButton from "@mui/material/IconButton";
import RadioGroup from "@mui/material/RadioGroup";
import TextField from "@mui/material/TextField";
import {useFormikContext} from "formik";
@ -43,7 +44,6 @@ interface BulkLoadMappingFieldProps
removeFieldCallback?: () => void,
fileDescription: FileDescription,
forceParentUpdate?: () => void,
isBulkEdit?: boolean
}
const xIconButtonSX =
@ -72,7 +72,7 @@ const qController = Client.getInstance();
/***************************************************************************
** row for a single field on the bulk load mapping screen.
***************************************************************************/
export default function BulkLoadFileMappingField({bulkLoadField, isRequired, removeFieldCallback, fileDescription, forceParentUpdate, isBulkEdit}: BulkLoadMappingFieldProps): JSX.Element
export default function BulkLoadFileMappingField({bulkLoadField, isRequired, removeFieldCallback, fileDescription, forceParentUpdate}: BulkLoadMappingFieldProps): JSX.Element
{
const columnNames = fileDescription.getColumnNames();
@ -94,7 +94,7 @@ export default function BulkLoadFileMappingField({bulkLoadField, isRequired, rem
// deal with dynamically loading the initial default value for a possible value... //
/////////////////////////////////////////////////////////////////////////////////////
let actuallyDoingInitialLoadOfPossibleValue = doingInitialLoadOfPossibleValue;
if (dynamicField.possibleValueProps && bulkLoadField.defaultValue && !doingInitialLoadOfPossibleValue && !everDidInitialLoadOfPossibleValue)
if(dynamicField.possibleValueProps && bulkLoadField.defaultValue && !doingInitialLoadOfPossibleValue && !everDidInitialLoadOfPossibleValue)
{
actuallyDoingInitialLoadOfPossibleValue = true;
setDoingInitialLoadOfPossibleValue(true);
@ -104,7 +104,7 @@ export default function BulkLoadFileMappingField({bulkLoadField, isRequired, rem
{
try
{
const possibleValues = await qController.possibleValues(bulkLoadField.tableStructure.tableName, null, fieldMetaData.name, null, [bulkLoadField.defaultValue], undefined, null, "filter");
const possibleValues = await qController.possibleValues(bulkLoadField.tableStructure.tableName, null, fieldMetaData.name, null, [bulkLoadField.defaultValue], undefined, "filter");
if (possibleValues && possibleValues.length > 0)
{
setPossibleValueInitialDisplayValue(possibleValues[0].label);
@ -114,9 +114,9 @@ export default function BulkLoadFileMappingField({bulkLoadField, isRequired, rem
setPossibleValueInitialDisplayValue(null);
}
}
catch (e)
catch(e)
{
console.log(`Error loading possible value: ${e}`);
console.log(`Error loading possible value: ${e}`)
}
actuallyDoingInitialLoadOfPossibleValue = false;
@ -124,7 +124,7 @@ export default function BulkLoadFileMappingField({bulkLoadField, isRequired, rem
})();
}
if (dynamicField.possibleValueProps && possibleValueInitialDisplayValue)
if(dynamicField.possibleValueProps && possibleValueInitialDisplayValue)
{
dynamicField.possibleValueProps.initialDisplayValue = possibleValueInitialDisplayValue;
}
@ -134,11 +134,11 @@ export default function BulkLoadFileMappingField({bulkLoadField, isRequired, rem
// don't allow duplicates //
//////////////////////////////////////////////////////
const columnOptions: { value: number, label: string }[] = [];
const usedLabels: { [label: string]: boolean } = {};
const usedLabels: {[label: string]: boolean} = {};
for (let i = 0; i < columnNames.length; i++)
{
const label = columnNames[i];
if (!usedLabels[label])
if(!usedLabels[label])
{
columnOptions.push({label: label, value: i});
usedLabels[label] = true;
@ -148,9 +148,9 @@ export default function BulkLoadFileMappingField({bulkLoadField, isRequired, rem
//////////////////////////////////////////////////////////////////////
// try to pick up changes in the hasHeaderRow toggle from way above //
//////////////////////////////////////////////////////////////////////
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]);
}
@ -228,17 +228,6 @@ export default function BulkLoadFileMappingField({bulkLoadField, isRequired, rem
forceParentUpdate && forceParentUpdate();
}
/***************************************************************************
**
***************************************************************************/
function clearIfEmptyChanged(value: boolean)
{
bulkLoadField.clearIfEmpty = value;
forceParentUpdate && forceParentUpdate();
}
/***************************************************************************
**
***************************************************************************/
@ -325,11 +314,8 @@ export default function BulkLoadFileMappingField({bulkLoadField, isRequired, rem
<Box ml="1rem">
{
valueType == "column" && <>
<Box display="flex" alignItems="center" sx={{height: "45px"}}>
<Box>
<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 fontSize={mainFontSize} mt="0.5rem">
Preview Values: <span style={{color: "gray"}}>{(fileDescription.getPreviewValues(selectedColumn?.value) ?? [""]).join(", ")}</span>

View File

@ -33,7 +33,6 @@ interface BulkLoadMappingFieldsProps
bulkLoadMapping: BulkLoadMapping,
fileDescription: FileDescription,
forceParentUpdate?: () => void,
isBulkEdit?: boolean
}
@ -44,7 +43,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.
***************************************************************************/
export default function BulkLoadFileMappingFields({bulkLoadMapping, fileDescription, forceParentUpdate, isBulkEdit}: BulkLoadMappingFieldsProps): JSX.Element
export default function BulkLoadFileMappingFields({bulkLoadMapping, fileDescription, forceParentUpdate}: BulkLoadMappingFieldsProps): JSX.Element
{
const [, forceUpdate] = useReducer((x) => x + 1, 0);
@ -255,16 +254,11 @@ export default function BulkLoadFileMappingFields({bulkLoadMapping, fileDescript
return (
<>
{isBulkEdit ? <h5>Key Fields</h5> : <h5>Required Fields</h5>}
<h5>Required Fields</h5>
<Box pl={"1rem"}>
{
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) => (
<BulkLoadFileMappingField
@ -273,13 +267,12 @@ export default function BulkLoadFileMappingFields({bulkLoadMapping, fileDescript
bulkLoadField={bulkLoadField}
isRequired={true}
forceParentUpdate={forceParentUpdate}
isBulkEdit={isBulkEdit}
/>
))}
</Box>
<Box mt="1rem">
{isBulkEdit ? <h5>Fields To Update</h5> : <h5>Additional Fields</h5>}
<h5>Additional Fields</h5>
<Box pl={"1rem"}>
{bulkLoadMapping.additionalFields.map((bulkLoadField) => (
<BulkLoadFileMappingField
@ -289,7 +282,6 @@ export default function BulkLoadFileMappingFields({bulkLoadMapping, fileDescript
isRequired={false}
removeFieldCallback={() => removeField(bulkLoadField)}
forceParentUpdate={forceParentUpdate}
isBulkEdit={isBulkEdit}
/>
))}

View File

@ -36,18 +36,15 @@ import {useFormikContext} from "formik";
import colors from "qqq/assets/theme/base/colors";
import {DynamicFormFieldLabel} from "qqq/components/forms/DynamicForm";
import QDynamicFormField from "qqq/components/forms/DynamicFormField";
import DynamicFormUtils from "qqq/components/forms/DynamicFormUtils";
import MDTypography from "qqq/components/legacy/MDTypography";
import HelpContent from "qqq/components/misc/HelpContent";
import SavedBulkLoadProfiles from "qqq/components/misc/SavedBulkLoadProfiles";
import BulkLoadFileMappingFields from "qqq/components/processes/BulkLoadFileMappingFields";
import {BulkLoadField, BulkLoadMapping, BulkLoadProfile, BulkLoadTableStructure, FileDescription, Wrapper} from "qqq/models/processes/BulkLoadModels";
import {SubFormPreSubmitCallbackResultType} from "qqq/pages/processes/ProcessRun";
import Client from "qqq/utils/qqq/Client";
import React, {forwardRef, useEffect, useImperativeHandle, useReducer, useState} from "react";
import React, {forwardRef, useImperativeHandle, useReducer, useState} from "react";
import ProcessViewForm from "./ProcessViewForm";
const qController = Client.getInstance();
interface BulkLoadMappingFormProps
{
@ -76,12 +73,13 @@ const BulkLoadFileMappingForm = forwardRef(({processValues, tableMetaData, metaD
const [suggestedBulkLoadProfile] = useState(processValues.suggestedBulkLoadProfile as BulkLoadProfile);
const [tableStructure] = useState(processValues.tableStructure as BulkLoadTableStructure);
const [bulkLoadMapping, setBulkLoadMapping] = useState(BulkLoadMapping.fromBulkLoadProfile(tableStructure, processValues.bulkLoadProfile, processMetaData.name));
const [bulkLoadMapping, setBulkLoadMapping] = useState(BulkLoadMapping.fromBulkLoadProfile(tableStructure, processValues.bulkLoadProfile));
const [wrappedBulkLoadMapping] = useState(new Wrapper<BulkLoadMapping>(bulkLoadMapping));
const [fileDescription] = useState(new FileDescription(processValues.headerValues, processValues.headerLetters, processValues.bodyValuesPreview));
fileDescription.setHasHeaderRow(bulkLoadMapping.hasHeaderRow);
const [, forceUpdate] = useReducer((x) => x + 1, 0);
/////////////////////////////////////////////////////////////////////////////////////////////////
@ -116,8 +114,6 @@ const BulkLoadFileMappingForm = forwardRef(({processValues, tableMetaData, metaD
values["savedBulkLoadProfileId"] = wrappedCurrentSavedBulkLoadProfile.get()?.values?.get("id");
values["layout"] = wrappedBulkLoadMapping.get().layout;
values["hasHeaderRow"] = wrappedBulkLoadMapping.get().hasHeaderRow;
values["isBulkEdit"] = wrappedBulkLoadMapping.get().isBulkEdit;
values["keyFields"] = wrappedBulkLoadMapping.get().keyFields;
let haveLocalErrors = false;
const fieldErrors: { [fieldName: string]: string } = {};
@ -134,14 +130,7 @@ const BulkLoadFileMappingForm = forwardRef(({processValues, tableMetaData, metaD
}
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.");
haveLocalErrors = true;
@ -152,9 +141,9 @@ const BulkLoadFileMappingForm = forwardRef(({processValues, tableMetaData, metaD
setNoMappedFieldsError(null);
}
if (haveProfileErrors)
if(haveProfileErrors)
{
setTimeout(() =>
setTimeout(() =>
{
document.querySelector(".bulkLoadFieldError")?.scrollIntoView({behavior: "smooth", block: "center", inline: "center"});
}, 250);
@ -193,7 +182,7 @@ const BulkLoadFileMappingForm = forwardRef(({processValues, tableMetaData, metaD
***************************************************************************/
function bulkLoadProfileResetToSuggestedMappingCallback()
{
handleNewBulkLoadMapping(BulkLoadMapping.fromBulkLoadProfile(processValues.tableStructure, suggestedBulkLoadProfile, processValues.name));
handleNewBulkLoadMapping(BulkLoadMapping.fromBulkLoadProfile(processValues.tableStructure, suggestedBulkLoadProfile));
}
@ -212,8 +201,6 @@ const BulkLoadFileMappingForm = forwardRef(({processValues, tableMetaData, metaD
setBulkLoadMapping(newBulkLoadMapping);
wrappedBulkLoadMapping.set(newBulkLoadMapping);
setFieldValue("isBulkEdit", newBulkLoadMapping.isBulkEdit);
setFieldValue("keyFields", newBulkLoadMapping.keyFields);
setFieldValue("hasHeaderRow", newBulkLoadMapping.hasHeaderRow);
setFieldValue("layout", newBulkLoadMapping.layout);
@ -241,13 +228,10 @@ const BulkLoadFileMappingForm = forwardRef(({processValues, tableMetaData, metaD
bulkLoadProfileOnChangeCallback={bulkLoadProfileOnChangeCallback}
bulkLoadProfileResetToSuggestedMappingCallback={bulkLoadProfileResetToSuggestedMappingCallback}
fileDescription={fileDescription}
isBulkEdit={processValues.isBulkEdit}
/>
</Box>
<BulkLoadMappingHeader
tableMetaData={tableMetaData}
isBulkEdit={processValues.isBulkEdit}
key={rerenderHeader}
bulkLoadMapping={bulkLoadMapping}
fileDescription={fileDescription}
@ -261,7 +245,6 @@ const BulkLoadFileMappingForm = forwardRef(({processValues, tableMetaData, metaD
<Box mt="2rem">
<BulkLoadFileMappingFields
isBulkEdit={processValues.isBulkEdit}
bulkLoadMapping={bulkLoadMapping}
fileDescription={fileDescription}
forceParentUpdate={() =>
@ -284,7 +267,6 @@ export default BulkLoadFileMappingForm;
interface BulkLoadMappingHeaderProps
{
isBulkEdit?: boolean,
fileDescription: FileDescription,
fileName: string,
bulkLoadMapping?: BulkLoadMapping,
@ -293,16 +275,13 @@ interface BulkLoadMappingHeaderProps
forceParentUpdate?: () => void,
frontendStep: QFrontendStepMetaData,
processMetaData: QProcessMetaData,
tableMetaData: QTableMetaData,
}
/***************************************************************************
** private subcomponent - the header section of the bulk load file mapping screen.
***************************************************************************/
function BulkLoadMappingHeader({isBulkEdit, fileDescription, fileName, bulkLoadMapping, fieldErrors, tableStructure, forceParentUpdate, frontendStep, processMetaData, tableMetaData}: BulkLoadMappingHeaderProps): JSX.Element
function BulkLoadMappingHeader({fileDescription, fileName, bulkLoadMapping, fieldErrors, tableStructure, forceParentUpdate, frontendStep, processMetaData}: BulkLoadMappingHeaderProps): JSX.Element
{
const [dynamicField, setDynamicField] = useState(null);
const viewFields = [
new QFieldMetaData({name: "fileName", label: "File Name", type: "STRING"}),
new QFieldMetaData({name: "fileDetails", label: "File Details", type: "STRING"}),
@ -328,36 +307,6 @@ function BulkLoadMappingHeader({isBulkEdit, fileDescription, fileName, bulkLoadM
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)]);
/***************************************************************************
**
***************************************************************************/
@ -382,61 +331,6 @@ function BulkLoadMappingHeader({isBulkEdit, fileDescription, fileName, bulkLoadM
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();
}
/***************************************************************************
**
***************************************************************************/
@ -475,50 +369,27 @@ function BulkLoadMappingHeader({isBulkEdit, fileDescription, fileName, bulkLoadM
{getFormattedHelpContent("hasHeaderRow")}
</Grid>
<Grid item xs={12} md={6}>
<DynamicFormFieldLabel name={"layout"} label={"File Layout *"} />
<Autocomplete
id={"layout"}
renderInput={(params) => (<TextField {...params} label={""} fullWidth variant="outlined" autoComplete="off" type="search" InputProps={{...params.InputProps}} sx={{"& .MuiOutlinedInput-root": {borderRadius: "0.75rem"}}} />)}
options={layoutOptions}
multiple={false}
defaultValue={selectedLayout}
onChange={layoutChanged}
getOptionLabel={(option) => typeof (option) == "string" ? option : (option?.label ?? "")}
isOptionEqualToValue={(option, value) => option == null && value == null || option.id == value.id}
renderOption={(props, option, state) => (<li {...props}>{option?.label ?? ""}</li>)}
disableClearable
sx={{"& .MuiOutlinedInput-root": {padding: "0"}}}
/>
{
!isBulkEdit ? (
<>
<DynamicFormFieldLabel name={"layout"} label={"File Layout *"} />
<Autocomplete
id={"layout"}
renderInput={(params) => (<TextField {...params} label={""} fullWidth variant="outlined" autoComplete="off" type="search" InputProps={{...params.InputProps}} sx={{"& .MuiOutlinedInput-root": {borderRadius: "0.75rem"}}} />)}
options={layoutOptions}
multiple={false}
defaultValue={selectedLayout}
onChange={layoutChanged}
getOptionLabel={(option) => typeof (option) == "string" ? option : (option?.label ?? "")}
isOptionEqualToValue={(option, value) => option == null && value == null || option.id == value.id}
renderOption={(props, option, state) => (<li {...props}>{option?.label ?? ""}</li>)}
disableClearable
sx={{"& .MuiOutlinedInput-root": {padding: "0"}}}
/>
{
fieldErrors.layout &&
<MDTypography component="div" variant="caption" color="error" fontWeight="regular" mt="0.25rem">
{<div className="fieldErrorMessage">{fieldErrors.layout}</div>}
</MDTypography>
}
{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")}
</>
}
</>
)
fieldErrors.layout &&
<MDTypography component="div" variant="caption" color="error" fontWeight="regular" mt="0.25rem">
{<div className="fieldErrorMessage">{fieldErrors.layout}</div>}
</MDTypography>
}
{getFormattedHelpContent("layout")}
</Grid>
</Grid>
</Box>
@ -619,25 +490,25 @@ function BulkLoadMappingFilePreview({fileDescription, bulkLoadMapping}: BulkLoad
const fields = bulkLoadMapping.getFieldsForColumnIndex(index);
const count = fields.length;
let dupeWarning = <></>;
if (fileDescription.hasHeaderRow && fileDescription.duplicateHeaderIndexes[index])
let dupeWarning = <></>
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}>
<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)}}>
<>
{
count > 0 &&
<Tooltip title={getColumnTooltip(fields)} placement="top" enterDelay={500}>
<Box>
{dupeWarning}
{letter}
<Badge badgeContent={count} variant={"standard"} color="secondary" sx={{marginTop: ".75rem"}}><Icon></Icon></Badge>
</Box>
</Tooltip>
<Tooltip title={getColumnTooltip(fields)} placement="top" enterDelay={500}>
<Box>
{dupeWarning}
{letter}
<Badge badgeContent={count} variant={"standard"} color="secondary" sx={{marginTop: ".75rem"}}><Icon></Icon></Badge>
</Box>
</Tooltip>
}
{
count == 0 && <Box>{dupeWarning}{letter}</Box>
@ -657,24 +528,24 @@ function BulkLoadMappingFilePreview({fileDescription, bulkLoadMapping}: BulkLoad
const count = fields.length;
const tdStyle = {color: getHeaderColor(count), cursor: getCursor(count), backgroundColor: ""};
if (fileDescription.hasHeaderRow)
if(fileDescription.hasHeaderRow)
{
tdStyle.backgroundColor = "#ebebeb";
if (count > 0)
if(count > 0)
{
return <td key={value} style={tdStyle}>
<Tooltip title={getColumnTooltip(fields)} placement="top" enterDelay={500}><Box>{value}</Box></Tooltip>
</td>;
</td>
}
else
{
return <td key={value} style={tdStyle}>{value}</td>;
return <td key={value} style={tdStyle}>{value}</td>
}
}
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 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 [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 [fileDescription] = useState(new FileDescription(processValues.headerValues, processValues.headerLetters, processValues.bodyValuesPreview));
@ -93,11 +93,10 @@ const BulkLoadProfileForm = forwardRef(({processValues, tableMetaData, metaData}
allowSelectingProfile={false}
fileDescription={fileDescription}
bulkLoadProfileOnChangeCallback={bulkLoadProfileOnChangeCallback}
isBulkEdit={processValues.isBulkEdit}
/>
</Box>
</Box>);
});
export default BulkLoadProfileForm;
export default BulkLoadProfileForm;

View File

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

View File

@ -19,10 +19,6 @@
* 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 Box from "@mui/material/Box";
import Card from "@mui/material/Card";
@ -32,26 +28,20 @@ import Modal from "@mui/material/Modal";
import TextField from "@mui/material/TextField";
import Tooltip from "@mui/material/Tooltip";
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 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
{
type: string;
onSave: (newValues: any[]) => void;
table?: QTableMetaData;
field?: QFieldMetaData;
}
FilterCriteriaPaster.defaultProps = {};
const qController = Client.getInstance();
function FilterCriteriaPaster({table, field, type, onSave}: Props): JSX.Element
function FilterCriteriaPaster({type, onSave}: Props): JSX.Element
{
enum Delimiter
{
@ -78,12 +68,6 @@ function FilterCriteriaPaster({table, field, type, onSave}: Props): JSX.Element
mainCardStyles.width = "60%";
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);
const [pasteModalIsOpen, setPasteModalIsOpen] = useState(false);
const [inputText, setInputText] = useState("");
@ -91,13 +75,8 @@ function FilterCriteriaPaster({table, field, type, onSave}: Props): JSX.Element
const [delimiterCharacter, setDelimiterCharacter] = useState("");
const [customDelimiterValue, setCustomDelimiterValue] = useState("");
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 [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 //
@ -113,7 +92,6 @@ function FilterCriteriaPaster({table, field, type, onSave}: Props): JSX.Element
setDelimiter("");
setDelimiterCharacter("");
setChipData([]);
setChipValidity([]);
setInputText("");
setDetectedText("");
setCustomDelimiterValue("");
@ -128,43 +106,18 @@ function FilterCriteriaPaster({table, field, type, onSave}: Props): JSX.Element
const handleSaveClicked = () =>
{
///////////////////////////////////////////////////////////////
// if numeric remove any non-numerics, or invalid pvs values //
///////////////////////////////////////////////////////////////
////////////////////////////////////////
// if numeric remove any non-numerics //
////////////////////////////////////////
let saveData = [];
let usedLabels = new Map<any, boolean>();
for (let i = 0; i < chipData.length; i++)
{
if (chipValidity[i] === true)
if (type !== "number" || !Number.isNaN(Number(chipData[i])))
{
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);
clearData();
@ -261,12 +214,6 @@ function FilterCriteriaPaster({table, field, type, onSave}: Props): JSX.Element
useEffect(() =>
{
(async () =>
{
const metaData = await qController.loadMetaData();
setMetaData(metaData);
})();
let currentDelimiter = delimiter;
let currentDelimiterCharacter = delimiterCharacter;
@ -299,16 +246,10 @@ function FilterCriteriaPaster({table, field, type, onSave}: Props): JSX.Element
let parts = inputText.split(regex);
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 //
///////////////////////////////////////////////////////
setErrorText("");
let invalidCount = 0;
if (currentDelimiterCharacter !== "")
{
for (let i = 0; i < parts.length; i++)
@ -318,207 +259,152 @@ function FilterCriteriaPaster({table, field, type, onSave}: Props): JSX.Element
{
chipData.push(part);
////////////////////////////////////////////////////////////////
// if numeric or pvs, check validity and add to invalid count //
////////////////////////////////////////////////////////////////
if (chipValidity[i] != null && chipValidity[i] !== true)
///////////////////////////////////////////////////////////
// if numeric, check that first before pushing as a chip //
///////////////////////////////////////////////////////////
if (type === "number" && Number.isNaN(Number(part)))
{
if ((type === "number" && Number.isNaN(Number(part))) || type === "pvs")
{
invalidCount++;
}
}
else
{
let count = uniqueValuesMap[part] == null ? 0 : uniqueValuesMap[part];
uniqueValuesMap[part] = count + 1;
setErrorText("Some values are not numbers");
}
}
}
}
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);
}, [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}`} />;
}, [inputText, delimiterCharacter, customDelimiterValue, detectedText]);
return (
<Box>
<Tooltip title="Quickly add many values to your filter by pasting them from a spreadsheet or any other data source.">
<Icon className="criteriaPasterButton" onClick={handlePasteClick} fontSize="small" color="info" sx={{mx: 0.25, cursor: "pointer"}}>paste_content</Icon>
<Icon onClick={handlePasteClick} fontSize="small" color="info" sx={{mx: 0.25, cursor: "pointer"}}>paste_content</Icon>
</Tooltip>
{
pasteModalIsOpen &&
(
<Modal open={pasteModalIsOpen}>
<Box>
<Box sx={{position: "absolute", overflowY: "auto", width: "100%"}}>
<Box py={3} justifyContent="center" sx={{display: "flex", mt: 8}}>
<Card sx={mainCardStyles}>
<Box p={4} pb={2}>
<Grid container>
<Grid item pr={3} xs={12} lg={12}>
<Typography variant="h5">Bulk Add Filter Values</Typography>
<Box sx={{position: "absolute", overflowY: "auto", width: "100%"}}>
<Box py={3} justifyContent="center" sx={{display: "flex", mt: 8}}>
<Card sx={mainCardStyles}>
<Box p={4} pb={2}>
<Grid container>
<Grid item pr={3} xs={12} lg={12}>
<Typography variant="h5">Bulk Add Filter Values</Typography>
<Typography sx={{display: "flex", lineHeight: "1.7", textTransform: "revert"}} variant="button">
Paste into the box on the left.
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>
</Grid>
</Grid>
</Box>
<Grid container pl={3} pr={3} justifyContent="center" alignItems="stretch" sx={{display: "flex", height: "100%"}}>
<Grid item pr={3} xs={6} lg={6} sx={{width: "100%", display: "flex", flexDirection: "column", flexGrow: 1}}>
<FormControl sx={{m: 1, width: "100%"}}>
<TextField
id="outlined-multiline-static"
label="PASTE TEXT"
multiline
onChange={handleTextChange}
rows={16}
value={inputText}
/>
</FormControl>
</Grid>
<Grid item xs={6} lg={6} sx={{display: "flex", flexGrow: 1}}>
<FormControl sx={{m: 1, width: "100%"}}>
<ChipTextField
handleChipChange={() =>
{
formattedHelpContent && <Box sx={{display: "flex", lineHeight: "1.7", textTransform: "none"}}>
<Typography sx={{display: "flex", lineHeight: "1.7", textTransform: "revert"}} variant="button">
{formattedHelpContent}
</Typography>
</Box>
}
</Grid>
</Grid>
</Box>
<Grid container pl={3} pr={3} justifyContent="center" alignItems="stretch" sx={{display: "flex", height: "100%"}}>
<Grid item pr={3} xs={6} lg={6} sx={{width: "100%", display: "flex", flexDirection: "column", flexGrow: 1}}>
<FormControl sx={{m: 1, width: "100%"}}>
<TextField
className="criteriaPasterTextArea"
id="outlined-multiline-static"
label="PASTE TEXT"
}}
chipData={chipData}
chipType={type}
multiline
fullWidth
variant="outlined"
id="tags"
rows={0}
name="tags"
label="FILTER VALUES REVIEW"
/>
</FormControl>
</Grid>
</Grid>
<Grid container pl={3} pr={3} justifyContent="center" alignItems="stretch" sx={{display: "flex", height: "100%"}}>
<Grid item pl={1} pr={3} xs={6} lg={6} sx={{width: "100%", display: "flex", flexDirection: "column", flexGrow: 1}}>
<Box sx={{display: "inline-flex", alignItems: "baseline"}}>
<FormControl sx={{mt: 2, width: "50%"}}>
<InputLabel htmlFor="select-native">
SEPARATOR
</InputLabel>
<Select
multiline
onChange={handleTextChange}
rows={16}
value={inputText}
/>
</FormControl>
</Grid>
<Grid item xs={6} lg={6} sx={{display: "flex", flexGrow: 1}}>
<FormControl sx={{m: 1, width: "100%"}}>
<ChipTextField
handleChipChange={(isMakingRequest: boolean, chipValidity: boolean[], chipPVSIds: any[]) =>
{
setErrorText("");
if (isMakingRequest)
{
pageLoadingState.setLoading();
}
else
{
pageLoadingState.setNotLoading();
}
setSaveDisabled(isMakingRequest);
setChipPVSIds(chipPVSIds);
setChipValidity(chipValidity);
native
value={delimiter}
onChange={handleDelimiterChange}
label="SEPARATOR"
size="medium"
inputProps={{
id: "select-native",
}}
table={table}
field={field}
chipData={chipData}
chipValidity={chipValidity}
chipType={type}
multiline
fullWidth
variant="outlined"
id="tags"
rows={0}
name="tags"
label="FILTER VALUES REVIEW"
/>
>
{delimiterDropdownOptions.map((delimiter) => (
<option key={delimiter} value={delimiter}>
{delimiter}
</option>
))}
</Select>
</FormControl>
</Grid>
</Grid>
<Grid container pl={3} pr={3} justifyContent="center" alignItems="stretch" sx={{display: "flex", height: "100%"}}>
<Grid item pl={1} pr={3} xs={6} lg={6} sx={{width: "100%", display: "flex", flexDirection: "column", flexGrow: 1}}>
<Box sx={{display: "inline-flex", alignItems: "baseline"}}>
<FormControl sx={{mt: 2, width: "50%"}}>
<InputLabel htmlFor="select-native">
SEPARATOR
</InputLabel>
<Select
multiline
native
value={delimiter}
onChange={handleDelimiterChange}
label="SEPARATOR"
size="medium"
inputProps={{
id: "select-native",
}}
>
{delimiterDropdownOptions.map((delimiter) => (
<option key={delimiter} value={delimiter}>
{delimiter}
</option>
))}
</Select>
{delimiter === Delimiter.CUSTOM.valueOf() && (
<FormControl sx={{pl: 2, top: 5, width: "50%"}}>
<TextField
name="custom-delimiter-value"
placeholder="Custom Separator"
label="Custom Separator"
variant="standard"
value={customDelimiterValue}
onChange={handleCustomDelimiterChange}
inputProps={{maxLength: 1}}
/>
</FormControl>
{delimiter === Delimiter.CUSTOM.valueOf() && (
)}
{inputText && delimiter === Delimiter.DETECT_AUTOMATICALLY.valueOf() && (
<FormControl sx={{pl: 2, top: 5, width: "50%"}}>
<TextField
name="custom-delimiter-value"
placeholder="Custom Separator"
label="Custom Separator"
variant="standard"
value={customDelimiterValue}
onChange={handleCustomDelimiterChange}
inputProps={{maxLength: 1}}
/>
</FormControl>
)}
{inputText && delimiter === Delimiter.DETECT_AUTOMATICALLY.valueOf() && (
<Typography pl={2} variant="button" sx={{top: "1px", textTransform: "revert"}}>
<i>{detectedText}</i>
</Typography>
)}
</Box>
</Grid>
<Grid sx={{display: "flex", justifyContent: "flex-start", alignItems: "flex-start"}} item pl={1} xs={4} lg={4}>
{
errorText && chipData.length > 0 && (
<Box sx={{display: "flex", justifyContent: "flex-start", alignItems: "flex-start"}}>
<Icon color="error">error</Icon>
<Typography sx={{paddingLeft: "4px", textTransform: "revert"}} variant="button">{errorText}</Typography>
</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 sx={{display: "flex", justifyContent: "flex-end", alignItems: "flex-start"}} item pr={1} xs={2} lg={2}>
{
chipData && chipData.length > 0 && (
<Typography sx={{textTransform: "revert"}} variant="button">{chipData.length.toLocaleString()} {chipData.length === 1 ? "value" : "values"} {uniqueCount && `(${uniqueCount} unique)`}</Typography>
)
}
</Grid>
<Typography pl={2} variant="button" sx={{top: "1px", textTransform: "revert"}}>
<i>{detectedText}</i>
</Typography>
)}
</Box>
</Grid>
<Box p={3} pt={0}>
<Grid container pl={1} pr={1} justifyContent="right" alignItems="stretch" sx={{display: "flex-inline "}}>
<QCancelButton
onClickHandler={handleCancelClicked}
iconName="cancel"
disabled={false} />
<QSaveButton onClickHandler={handleSaveClicked} label="Add Values" disabled={saveDisabled} />
</Grid>
</Box>
</Card>
</Box>
<Grid sx={{display: "flex", justifyContent: "flex-start", alignItems: "flex-start"}} item pl={1} xs={3} lg={3}>
{
errorText && chipData.length > 0 && (
<Box sx={{display: "flex", justifyContent: "flex-start", alignItems: "flex-start"}}>
<Icon color="error">error</Icon>
<Typography sx={{paddingLeft: "4px", textTransform: "revert"}} variant="button">{errorText}</Typography>
</Box>
)
}
</Grid>
<Grid sx={{display: "flex", justifyContent: "flex-end", alignItems: "flex-start"}} item pr={1} xs={3} lg={3}>
{
chipData && chipData.length > 0 && (
<Typography sx={{textTransform: "revert"}} variant="button">{chipData.length.toLocaleString()} {chipData.length === 1 ? "value" : "values"}</Typography>
)
}
</Grid>
</Grid>
<Box p={3} pt={0}>
<Grid container pl={1} pr={1} justifyContent="right" alignItems="stretch" sx={{display: "flex-inline "}}>
<QCancelButton
onClickHandler={handleCancelClicked}
iconName="cancel"
disabled={false} />
<QSaveButton onClickHandler={handleSaveClicked} label="Add Filters" disabled={false} />
</Grid>
</Box>
</Card>
</Box>
</Box>
</Modal>

View File

@ -109,7 +109,6 @@ export const getOperatorOptions = (tableMetaData: QTableMetaData, fieldName: str
{
case QFieldType.DECIMAL:
case QFieldType.INTEGER:
case QFieldType.LONG:
operatorOptions.push({label: "equals", value: QCriteriaOperator.EQUALS, valueMode: ValueMode.SINGLE});
operatorOptions.push({label: "does not equal", value: QCriteriaOperator.NOT_EQUALS_OR_IS_NULL, valueMode: ValueMode.SINGLE});
operatorOptions.push({label: "greater than", value: QCriteriaOperator.GREATER_THAN, valueMode: ValueMode.SINGLE});

View File

@ -398,25 +398,20 @@ function FilterCriteriaRowValues({operatorOption, criteria, field, table, valueC
initialValues = criteria.values;
}
}
return <Box display="flex" alignItems="flex-end" className="multiValue">
<Box width={"100%"}>
<DynamicSelect
fieldPossibleValueProps={{tableName: table.name, fieldName: field.name, initialDisplayValue: null}}
overrideId={field.name + "-multi-" + criteria.id}
key={field.name + "-multi-" + criteria.id + "-" + criteria.values.length}
isMultiple
fieldLabel="Values"
initialValues={initialValues}
initiallyOpen={false /*initiallyOpenMultiValuePvs*/}
inForm={false}
onChange={(value: any) => valueChangeHandler(null, "all", value)}
variant="standard"
useCase="filter"
/>
</Box>
<Box>
<FilterCriteriaPaster table={table} field={field} type="pvs" onSave={(newValues: any[]) => saveNewPasterValues(newValues)} />
</Box>
return <Box>
<DynamicSelect
fieldPossibleValueProps={{tableName: table.name, fieldName: field.name, initialDisplayValue: null}}
overrideId={field.name + "-multi-" + criteria.id}
key={field.name + "-multi-" + criteria.id}
isMultiple
fieldLabel="Values"
initialValues={initialValues}
initiallyOpen={false /*initiallyOpenMultiValuePvs*/}
inForm={false}
onChange={(value: any) => valueChangeHandler(null, "all", value)}
variant="standard"
useCase="filter"
/>
</Box>;
}

View File

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

View File

@ -38,7 +38,7 @@ import XIcon from "qqq/components/query/XIcon";
import {QueryScreenUsage} from "qqq/pages/records/query/RecordQuery";
import FilterUtils from "qqq/utils/qqq/FilterUtils";
import TableUtils from "qqq/utils/qqq/TableUtils";
import React, {SyntheticEvent, useContext, useEffect, useReducer, useState} from "react";
import React, {SyntheticEvent, useContext, useReducer, useState} from "react";
export type CriteriaParamType = QFilterCriteriaWithId | null | "tooComplex";
@ -135,7 +135,7 @@ const getOperatorSelectedValue = (operatorOptions: OperatorOption[], criteria: Q
return (filteredOptions[0]);
}
if (return0thOptionInsteadOfNull)
if(return0thOptionInsteadOfNull)
{
console.log("Returning 0th operator instead of null - this isn't expected, but has been seen to happen - so here's some additional debugging:");
try
@ -144,7 +144,7 @@ const getOperatorSelectedValue = (operatorOptions: OperatorOption[], criteria: Q
console.log("Criteria: " + JSON.stringify(criteria));
console.log("Default Operator: " + JSON.stringify(defaultOperator));
}
catch (e)
catch(e)
{
console.log(`Error in debug output: ${e}`);
}
@ -186,13 +186,6 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData
//////////////////////
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

@ -111,7 +111,7 @@ function RecordGridWidget({widgetMetaData, data, addNewRecordCallback, disableRo
}
const tableMetaData = data.childTableMetaData instanceof QTableMetaData ? data.childTableMetaData as QTableMetaData : new QTableMetaData(data.childTableMetaData);
const rows = DataGridUtils.makeRows(records, tableMetaData, undefined, true);
const rows = DataGridUtils.makeRows(records, tableMetaData, true);
/////////////////////////////////////////////////////////////////////////////////
// note - tablePath may be null, if the user doesn't have access to the table. //
@ -255,14 +255,14 @@ function RecordGridWidget({widgetMetaData, data, addNewRecordCallback, disableRo
disabledFields = data.defaultValuesForNewChildRecords;
}
const defaultValuesForNewChildRecords = data.defaultValuesForNewChildRecords || {};
const defaultValuesForNewChildRecords = data.defaultValuesForNewChildRecords || {}
///////////////////////////////////////////////////////////////////////////////////////
// copy values from specified fields in the parent record down into the child record //
///////////////////////////////////////////////////////////////////////////////////////
if (data.defaultValuesForNewChildRecordsFromParentFields)
if(data.defaultValuesForNewChildRecordsFromParentFields)
{
for (let childField in data.defaultValuesForNewChildRecordsFromParentFields)
for(let childField in data.defaultValuesForNewChildRecordsFromParentFields)
{
const parentField = data.defaultValuesForNewChildRecordsFromParentFields[childField];
defaultValuesForNewChildRecords[childField] = parentRecord?.values?.get(parentField);

View File

@ -21,12 +21,11 @@
import {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstance";
import Box from "@mui/material/Box";
import {ReactNode, useEffect, useState} from "react";
import Footer from "qqq/components/horseshoe/Footer";
import NavBar from "qqq/components/horseshoe/NavBar";
import {getBannerClassName, getBannerStyles, getBanner, makeBannerContent} from "qqq/components/misc/Banners";
import DashboardLayout from "qqq/layouts/DashboardLayout";
import Client from "qqq/utils/qqq/Client";
import {ReactNode, useEffect, useState} from "react";
interface Props
{
@ -81,34 +80,12 @@ function BaseLayout({stickyNavbar, children}: Props): JSX.Element
return () => window.removeEventListener("resize", handleTabsOrientation);
}, [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 (
<>
<DashboardLayout>
{banner()}
<NavBar />
<Box>{children}</Box>
<Footer company={{href: metaData?.branding?.companyUrl, name: metaData?.branding?.companyName}} />
</DashboardLayout>
</>
<DashboardLayout>
<NavBar />
<Box>{children}</Box>
<Footer company={{href: metaData?.branding?.companyUrl, name: metaData?.branding?.companyName}} />
</DashboardLayout>
);
}

View File

@ -39,7 +39,6 @@ export class BulkLoadField
headerName?: string = null;
defaultValue?: any = null;
doValueMapping: boolean = false;
clearIfEmpty?: boolean = false;
wideLayoutIndexPath: number[] = [];
@ -52,7 +51,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, clearIfEmpty?: boolean)
constructor(field: QFieldMetaData, tableStructure: BulkLoadTableStructure, valueType: ValueType = "column", columnIndex?: number, headerName?: string, defaultValue?: any, doValueMapping?: boolean, wideLayoutIndexPath: number[] = [], error: string = null, warning: string = null)
{
this.field = field;
this.tableStructure = tableStructure;
@ -65,7 +64,6 @@ export class BulkLoadField
this.error = error;
this.warning = warning;
this.key = new Date().getTime().toString();
this.clearIfEmpty = clearIfEmpty ?? false;
}
@ -74,7 +72,7 @@ export class 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, source.clearIfEmpty));
return (new BulkLoadField(source.field, source.tableStructure, source.valueType, source.columnIndex, source.headerName, source.defaultValue, source.doValueMapping, source.wideLayoutIndexPath, source.error, source.warning));
}
@ -175,9 +173,6 @@ export interface BulkLoadTableStructure
associationPath: string;
fields: QFieldMetaData[];
associations: BulkLoadTableStructure[];
isBulkEdit: boolean;
possibleKeyFields: string[];
keyFields?: string;
}
@ -198,8 +193,6 @@ export class BulkLoadMapping
valueMappings: { [fieldName: string]: { [fileValue: string]: any } } = {};
isBulkEdit: boolean;
keyFields: string;
hasHeaderRow: boolean;
layout: string;
@ -218,8 +211,6 @@ export class BulkLoadMapping
}
}
this.isBulkEdit = tableStructure.isBulkEdit;
this.keyFields = tableStructure.keyFields;
this.hasHeaderRow = true;
}
@ -227,13 +218,11 @@ export class BulkLoadMapping
/***************************************************************************
**
***************************************************************************/
public processTableStructure(tableStructure: BulkLoadTableStructure)
private processTableStructure(tableStructure: BulkLoadTableStructure)
{
const prefix = tableStructure.isMain ? "" : tableStructure.associationPath;
this.fieldsByTablePrefix[prefix] = {};
this.tablesByPath[prefix] = tableStructure;
this.isBulkEdit = tableStructure.isBulkEdit;
this.keyFields = tableStructure.keyFields;
for (let field of tableStructure.fields)
{
@ -244,35 +233,13 @@ export class BulkLoadMapping
this.fields[qualifiedName] = bulkLoadField;
this.fieldsByTablePrefix[prefix][qualifiedName] = bulkLoadField;
if (this.isBulkEdit)
if (tableStructure.isMain && field.isRequired)
{
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);
}
}
this.requiredFields.push(bulkLoadField);
}
else
{
if (tableStructure.isMain && field.isRequired)
{
this.requiredFields.push(bulkLoadField);
}
else
{
this.unusedFields.push(bulkLoadField);
}
this.unusedFields.push(bulkLoadField);
}
}
}
@ -299,16 +266,14 @@ export class BulkLoadMapping
** take a saved bulk load profile - and convert it into a working bulkLoadMapping
** for the frontend to use!
***************************************************************************/
public static fromBulkLoadProfile(tableStructure: BulkLoadTableStructure, bulkLoadProfile: BulkLoadProfile, processName?: string): BulkLoadMapping
public static fromBulkLoadProfile(tableStructure: BulkLoadTableStructure, bulkLoadProfile: BulkLoadProfile): BulkLoadMapping
{
const bulkLoadMapping = new BulkLoadMapping(tableStructure);
if (bulkLoadProfile.version == "v1")
{
bulkLoadMapping.isBulkEdit = bulkLoadProfile.isBulkEdit;
bulkLoadMapping.hasHeaderRow = bulkLoadProfile.hasHeaderRow;
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, //
@ -357,7 +322,6 @@ export class BulkLoadMapping
{
bulkLoadField.valueType = "column";
bulkLoadField.doValueMapping = bulkLoadProfileField.doValueMapping;
bulkLoadField.clearIfEmpty = bulkLoadProfileField.clearIfEmpty;
bulkLoadField.headerName = bulkLoadProfileField.headerName;
bulkLoadField.columnIndex = bulkLoadProfileField.columnIndex;
@ -380,29 +344,6 @@ 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);
}
else
@ -424,8 +365,6 @@ export class BulkLoadMapping
profile.version = "v1";
profile.hasHeaderRow = this.hasHeaderRow;
profile.layout = this.layout;
profile.isBulkEdit = this.isBulkEdit;
profile.keyFields = this.keyFields;
for (let bulkLoadField of [...this.requiredFields, ...this.additionalFields])
{
@ -445,7 +384,7 @@ export class BulkLoadMapping
}
else
{
const field: BulkLoadProfileField = {fieldName: fullFieldName, columnIndex: bulkLoadField.columnIndex, headerName: bulkLoadField.headerName, doValueMapping: bulkLoadField.doValueMapping, clearIfEmpty: bulkLoadField.clearIfEmpty};
const field: BulkLoadProfileField = {fieldName: fullFieldName, columnIndex: bulkLoadField.columnIndex, headerName: bulkLoadField.headerName, doValueMapping: bulkLoadField.doValueMapping};
if (this.valueMappings[fullFieldName])
{
@ -637,16 +576,6 @@ export class BulkLoadMapping
return (rs);
}
/***************************************************************************
**
***************************************************************************/
public handleChangeToKeyFields(newKeyFields: any)
{
this.keyFields = newKeyFields;
}
/***************************************************************************
**
***************************************************************************/
@ -671,7 +600,7 @@ export class BulkLoadMapping
{
const newField = BulkLoadField.clone(field);
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);
anyChangesToRequiredFields = true;
}
@ -687,7 +616,7 @@ export class BulkLoadMapping
{
const newField = BulkLoadField.clone(field);
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);
anyChangesToAdditionalFields = true;
}
@ -869,8 +798,6 @@ export class BulkLoadProfile
fieldList: BulkLoadProfileField[] = [];
hasHeaderRow: boolean;
layout: string;
isBulkEdit: boolean;
keyFields: string;
}
type BulkLoadProfileField =
@ -880,7 +807,6 @@ type BulkLoadProfileField =
headerName?: string,
defaultValue?: any,
doValueMapping?: boolean,
clearIfEmpty?: boolean,
valueMappings?: { [fileValue: string]: any }
};

View File

@ -1838,20 +1838,6 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
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)
{
for (let key in defaultProcessValues)

View File

@ -1103,7 +1103,7 @@ const RecordQuery = forwardRef(({table, usage, isModal, isPreview, allowVariable
////////////////////////////////
// make the rows for the grid //
////////////////////////////////
const rows = DataGridUtils.makeRows(results, tableMetaData, tableVariant);
const rows = DataGridUtils.makeRows(results, tableMetaData);
setRows(rows);
setLoading(false);
@ -1557,7 +1557,7 @@ const RecordQuery = forwardRef(({table, usage, isModal, isPreview, allowVariable
/*******************************************************************************
** function to open one of the bulk (insert/edit/delete) processes.
*******************************************************************************/
const openBulkProcess = (processNamePart: "Insert" | "Edit" | "Delete" | "EditWithFile", processLabelPart: "Load" | "Edit" | "Delete" | "Edit With File") =>
const openBulkProcess = (processNamePart: "Insert" | "Edit" | "Delete", processLabelPart: "Load" | "Edit" | "Delete") =>
{
const processList = allTableProcesses.filter(p => p.name.endsWith(`.bulk${processNamePart}`));
if (processList.length > 0)
@ -1593,15 +1593,6 @@ 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
*******************************************************************************/
@ -1621,22 +1612,6 @@ const RecordQuery = forwardRef(({table, usage, isModal, isPreview, allowVariable
*******************************************************************************/
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.
// alternatively, let a process itself have an initial screen to select rows...
openModalProcess(process);
@ -2870,7 +2845,6 @@ const RecordQuery = forwardRef(({table, usage, isModal, isPreview, allowVariable
tableProcesses={tableProcesses}
bulkLoadClicked={bulkLoadClicked}
bulkEditClicked={bulkEditClicked}
bulkEditWithFileClicked={bulkEditWithFileClicked}
bulkDeleteClicked={bulkDeleteClicked}
processClicked={processClicked}
/>

View File

@ -92,7 +92,7 @@ const TABLE_VARIANT_LOCAL_STORAGE_KEY_ROOT = "qqq.tableVariant";
/*******************************************************************************
**
*******************************************************************************/
export function renderSectionOfFields(key: string, fieldNames: string[], tableMetaData: QTableMetaData, helpHelpActive: boolean, record: QRecord, fieldMap?: { [name: string]: QFieldMetaData }, styleOverrides?: {label?: SxProps, value?: SxProps}, tableVariant?: QTableVariant)
export function renderSectionOfFields(key: string, fieldNames: string[], tableMetaData: QTableMetaData, helpHelpActive: boolean, record: QRecord, fieldMap?: { [name: string]: QFieldMetaData }, styleOverrides?: { label?: SxProps, value?: SxProps })
{
return <Grid container lg={12} key={key} display="flex" py={1} pr={2}>
{
@ -119,7 +119,7 @@ export function renderSectionOfFields(key: string, fieldNames: string[], tableMe
}
<div style={{display: "inline-block", width: 0}}>&nbsp;</div>
<Typography variant="button" textTransform="none" fontWeight="regular" color="rgb(123, 128, 154)" sx={{...(styleOverrides?.value ?? {})}}>
{ValueUtils.getDisplayValue(field, record, "view", fieldName, tableVariant)}
{ValueUtils.getDisplayValue(field, record, "view", fieldName)}
</Typography>
</>
</Grid>
@ -598,7 +598,7 @@ function RecordView({table, record: overrideRecord, launchProcess}: Props): JSX.
// for a section with field names, render the field values. //
// for the T1 section, the "wrapper" will come out below - but for other sections, produce a wrapper too. //
////////////////////////////////////////////////////////////////////////////////////////////////////////////
const fields = renderSectionOfFields(section.name, section.fieldNames, tableMetaData, helpHelpActive, record, undefined, undefined, tableVariant);
const fields = renderSectionOfFields(section.name, section.fieldNames, tableMetaData, helpHelpActive, record);
if (section.tier === "T1")
{

View File

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

View File

@ -24,7 +24,6 @@ import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QF
import {QFieldType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldType";
import {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstance";
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
import {QTableVariant} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableVariant";
import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord";
import Tooltip from "@mui/material/Tooltip/Tooltip";
import {GridColDef, GridRowsProp, MuiEvent} from "@mui/x-data-grid-pro";
@ -71,7 +70,7 @@ export default class DataGridUtils
/*******************************************************************************
**
*******************************************************************************/
public static makeRows = (results: QRecord[], tableMetaData: QTableMetaData, tableVariant?: QTableVariant, allowEmptyId = false): GridRowsProp[] =>
public static makeRows = (results: QRecord[], tableMetaData: QTableMetaData, allowEmptyId = false): GridRowsProp[] =>
{
const fields = [...tableMetaData.fields.values()];
const rows = [] as any[];
@ -83,7 +82,7 @@ export default class DataGridUtils
fields.forEach((field) =>
{
row[field.name] = ValueUtils.getDisplayValue(field, record, "query", undefined, tableVariant);
row[field.name] = ValueUtils.getDisplayValue(field, record, "query");
});
if (tableMetaData.exposedJoins)
@ -98,7 +97,7 @@ export default class DataGridUtils
fields.forEach((field) =>
{
let fieldName = join.joinTable.name + "." + field.name;
row[fieldName] = ValueUtils.getDisplayValue(field, record, "query", fieldName, tableVariant);
row[fieldName] = ValueUtils.getDisplayValue(field, record, "query", fieldName);
});
}
}

View File

@ -102,7 +102,7 @@ export default class GoogleAnalyticsUtils
console.log("Error reading session values from localStorage: " + e);
}
if (this.metaData.environmentValues?.get("GOOGLE_ANALYTICS_ENABLED") == "true" && this.metaData.environmentValues?.get("GOOGLE_ANALYTICS_TRACKING_ID"))
if (this.metaData.environmentValues.get("GOOGLE_ANALYTICS_ENABLED") == "true" && this.metaData.environmentValues.get("GOOGLE_ANALYTICS_TRACKING_ID"))
{
this.active = true;

View File

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

View File

@ -23,7 +23,6 @@ import {AdornmentType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/Ado
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 {QTableVariant} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableVariant";
import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord";
import "datejs"; // https://github.com/datejs/Datejs
import {Chip, ClickAwayListener, Icon} from "@mui/material";
@ -77,14 +76,14 @@ class ValueUtils
** When you have a field, and a record - call this method to get a string or
** element back to display the field's value.
*******************************************************************************/
public static getDisplayValue(field: QFieldMetaData, record: QRecord, usage: "view" | "query" = "view", overrideFieldName?: string, tableVariant?: QTableVariant): string | JSX.Element | JSX.Element[]
public static getDisplayValue(field: QFieldMetaData, record: QRecord, usage: "view" | "query" = "view", overrideFieldName?: string): string | JSX.Element | JSX.Element[]
{
const fieldName = overrideFieldName ?? field.name;
const displayValue = record.displayValues ? record.displayValues.get(fieldName) : undefined;
const rawValue = record.values ? record.values.get(fieldName) : undefined;
return ValueUtils.getValueForDisplay(field, rawValue, displayValue, usage, tableVariant, record, fieldName);
return ValueUtils.getValueForDisplay(field, rawValue, displayValue, usage);
}
@ -92,35 +91,14 @@ class ValueUtils
** When you have a field and a value (either just a raw value, or a raw and
** display value), call this method to get a string Element to display.
*******************************************************************************/
public static getValueForDisplay(field: QFieldMetaData, rawValue: any, displayValue: any = rawValue, usage: "view" | "query" = "view", tableVariant?: QTableVariant, record?: QRecord, fieldName?: string): string | JSX.Element | JSX.Element[]
public static getValueForDisplay(field: QFieldMetaData, rawValue: any, displayValue: any = rawValue, usage: "view" | "query" = "view"): string | JSX.Element | JSX.Element[]
{
if (field.hasAdornment(AdornmentType.LINK))
{
const adornment = field.getAdornment(AdornmentType.LINK);
let href = String(rawValue);
let toRecordFromTable = adornment.getValue("toRecordFromTable");
/////////////////////////////////////////////////////////////////////////////////////
// if the link adornment has a 'toRecordFromTableDynamic', then look for a display //
// value named `fieldName`:toRecordFromTableDynamic for the table name. //
/////////////////////////////////////////////////////////////////////////////////////
if(adornment.getValue("toRecordFromTableDynamic"))
{
const toRecordFromTableDynamic = record?.displayValues?.get(fieldName + ":toRecordFromTableDynamic");
if(toRecordFromTableDynamic)
{
toRecordFromTable = toRecordFromTableDynamic;
}
else
{
///////////////////////////////////////////////////////////////////
// if the table name isn't known, then return w/o the adornment. //
///////////////////////////////////////////////////////////////////
return (ValueUtils.getUnadornedValueForDisplay(field, rawValue, displayValue));
}
}
let href = rawValue;
const toRecordFromTable = adornment.getValue("toRecordFromTable");
if (toRecordFromTable)
{
if (ValueUtils.getQInstance())
@ -129,7 +107,7 @@ class ValueUtils
if (!tablePath)
{
console.log("Couldn't find path for table: " + toRecordFromTable);
return (ValueUtils.getUnadornedValueForDisplay(field, rawValue, displayValue));
return (displayValue ?? rawValue);
}
if (!tablePath.endsWith("/"))
@ -221,44 +199,12 @@ class ValueUtils
if (field.type == QFieldType.BLOB || field.hasAdornment(AdornmentType.FILE_DOWNLOAD))
{
let url = rawValue;
if(tableVariant)
{
url += "?tableVariant=" + encodeURIComponent(JSON.stringify(tableVariant));
}
//////////////////////////////////////////////////////////////////////////////
// if the field has the download adornment with a downloadUrlDynamic value, //
// then get the url from a displayValue of `fieldName`:downloadUrlDynamic. //
//////////////////////////////////////////////////////////////////////////////
if(field.hasAdornment(AdornmentType.FILE_DOWNLOAD))
{
const adornment = field.getAdornment(AdornmentType.FILE_DOWNLOAD);
let downloadUrlDynamicAdornmentValue = adornment.getValue("downloadUrlDynamic");
if(downloadUrlDynamicAdornmentValue)
{
const downloadUrlDynamicValue = record?.displayValues?.get(fieldName + ":downloadUrlDynamic");
if (downloadUrlDynamicValue)
{
url = downloadUrlDynamicValue;
}
else
{
////////////////////////////////////////////////////////////////
// if the url isn't available, then return w/o the adornment. //
////////////////////////////////////////////////////////////////
return (ValueUtils.getUnadornedValueForDisplay(field, rawValue, displayValue));
}
}
}
return (<BlobComponent field={field} url={url} filename={displayValue} usage={usage} />);
return (<BlobComponent field={field} url={rawValue} filename={displayValue} usage={usage} />);
}
return (ValueUtils.getUnadornedValueForDisplay(field, rawValue, displayValue));
}
/*******************************************************************************
** After we know there's no element to be returned (e.g., because no adornment),
** this method does the string formatting.
@ -267,13 +213,7 @@ class ValueUtils
{
if (!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;
displayValue = field.defaultValue;
}
if (field.type === QFieldType.DATE_TIME)

View File

@ -103,30 +103,6 @@ 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,16 +22,13 @@
package com.kingsrook.qqq.frontend.materialdashboard.selenium.tests.query;
import java.util.HashSet;
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.QQQMaterialDashboardSelectors;
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.QSeleniumJavalin;
import org.junit.jupiter.api.Test;
import org.openqa.selenium.WebElement;
import static org.assertj.core.api.Assertions.assertThat;
@ -203,204 +200,6 @@ 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");
}
/*******************************************************************************
**
*******************************************************************************/
@ -474,18 +273,4 @@ public class QueryScreenTest extends QBaseSeleniumTest
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);
}
}