mirror of
https://github.com/Kingsrook/qqq-frontend-material-dashboard.git
synced 2025-07-21 14:48:43 +00:00
Compare commits
52 Commits
snapshot-f
...
main
Author | SHA1 | Date | |
---|---|---|---|
185775ca4d | |||
ce91f68088 | |||
81da1a4627 | |||
b279a04b43 | |||
1f2e57d688 | |||
52bb7ba411 | |||
34c6f650b5 | |||
d792c23035 | |||
e3d30633f1 | |||
a6ee682671 | |||
c62252075f | |||
debc6f3ebf | |||
679375ba63 | |||
fb10dad803 | |||
c9a618c7f6 | |||
f654208769 | |||
3dacab8d60 | |||
13ce684d23 | |||
b67eea7d87 | |||
8ae3b95105 | |||
5a309e5628 | |||
67e1e78817 | |||
214b6b8af4 | |||
8ec0ce5455 | |||
07cb6fd323 | |||
3bb8451671 | |||
6076c4ddfd | |||
44a8810260 | |||
c69a4b8203 | |||
7db4f34ddd | |||
71dc3f3f65 | |||
ce22db2f89 | |||
aacb239164 | |||
219458ec63 | |||
59fdc72455 | |||
5c3ddb7dec | |||
d65c1fb5d8 | |||
19a63d6956 | |||
40f5b55307 | |||
7320b19fbb | |||
3f8a3e7e4d | |||
3ef2d64327 | |||
d793c23861 | |||
d0201d96e1 | |||
7b66ece466 | |||
02c163899a | |||
8fafe16a95 | |||
722c8d3bcf | |||
85acb612c9 | |||
74c634414a | |||
f8368b030c | |||
dda4ea4f4b |
1
.gitignore
vendored
1
.gitignore
vendored
@ -5,6 +5,7 @@
|
||||
.yalc*
|
||||
yalc.lock
|
||||
.env
|
||||
/certs
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
|
@ -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.113",
|
||||
"@kingsrook/qqq-frontend-core": "1.0.119",
|
||||
"@mui/icons-material": "5.4.1",
|
||||
"@mui/material": "5.11.1",
|
||||
"@mui/styles": "5.11.1",
|
||||
@ -36,6 +36,8 @@
|
||||
"html-to-text": "^9.0.5",
|
||||
"http-proxy-middleware": "2.0.6",
|
||||
"jwt-decode": "3.1.2",
|
||||
"oidc-client-ts": "2.4.1",
|
||||
"react-oidc-context": "2.3.1",
|
||||
"rapidoc": "9.3.4",
|
||||
"react": "18.0.0",
|
||||
"react-ace": "10.1.0",
|
||||
|
4
pom.xml
4
pom.xml
@ -29,7 +29,7 @@
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<properties>
|
||||
<revision>0.24.0-SNAPSHOT</revision>
|
||||
<revision>0.25.0</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.21.0</version>
|
||||
<version>0.25.0-integration-sprint-62-20250307-205536</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.slf4j</groupId>
|
||||
|
234
src/App.tsx
234
src/App.tsx
@ -19,7 +19,6 @@
|
||||
* 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";
|
||||
@ -29,16 +28,20 @@ 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 {setMiniSidenav, setOpenConfigurator, useMaterialUIController} from "qqq/context";
|
||||
import {getBannerClassName, getBannerStyles, getBanner, makeBannerContent} from "qqq/components/misc/Banners";
|
||||
import {setMiniSidenav, 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";
|
||||
@ -62,10 +65,14 @@ import {Md5} from "ts-md5/dist/md5";
|
||||
const qController = Client.getInstance();
|
||||
export const SESSION_UUID_COOKIE_NAME = "sessionUUID";
|
||||
|
||||
export default function App()
|
||||
interface Props
|
||||
{
|
||||
const [cookies, setCookie, removeCookie] = useCookies([SESSION_UUID_COOKIE_NAME]);
|
||||
const {user, getAccessTokenSilently, logout} = useAuth0();
|
||||
authenticationMetaData: QAuthenticationMetaData;
|
||||
}
|
||||
|
||||
export default function App({authenticationMetaData}: Props)
|
||||
{
|
||||
const [, , removeCookie] = useCookies([SESSION_UUID_COOKIE_NAME]);
|
||||
const [loadingToken, setLoadingToken] = useState(false);
|
||||
const [isFullyAuthenticated, setIsFullyAuthenticated] = useState(false);
|
||||
const [profileRoutes, setProfileRoutes] = useState({});
|
||||
@ -74,68 +81,20 @@ export default function App()
|
||||
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(() =>
|
||||
{
|
||||
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);
|
||||
};
|
||||
Client.setUnauthorizedCallback(() => doLogout());
|
||||
|
||||
/////////////////////////////////////////////////
|
||||
// deal with making sure user is authenticated //
|
||||
/////////////////////////////////////////////////
|
||||
useEffect(() =>
|
||||
{
|
||||
if (loadingToken)
|
||||
@ -146,65 +105,17 @@ export default function App()
|
||||
|
||||
(async () =>
|
||||
{
|
||||
const authenticationMetaData: QAuthenticationMetaData = await qController.getAuthenticationMetaData();
|
||||
|
||||
if (authenticationMetaData.type === "AUTH_0")
|
||||
{
|
||||
/////////////////////////////////////////
|
||||
// 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;
|
||||
}
|
||||
await auth0SetupSession();
|
||||
}
|
||||
else if (authenticationMetaData.type === "OAUTH2")
|
||||
{
|
||||
await oauth2SetupSession();
|
||||
}
|
||||
else if (authenticationMetaData.type === "FULLY_ANONYMOUS" || authenticationMetaData.type === "MOCK")
|
||||
{
|
||||
/////////////////////////////////////////////
|
||||
// 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;
|
||||
await anonymousSetupSession();
|
||||
}
|
||||
else
|
||||
{
|
||||
@ -220,13 +131,36 @@ export default function App()
|
||||
(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, layout, openConfigurator, sidenavColor} = controller;
|
||||
const {miniSidenav, direction, sidenavColor} = controller;
|
||||
const [onMouseEnter, setOnMouseEnter] = useState(false);
|
||||
const {pathname} = useLocation();
|
||||
const [queryParams] = useSearchParams();
|
||||
@ -519,11 +453,10 @@ export default function App()
|
||||
}
|
||||
}
|
||||
|
||||
let profileRoutes = {};
|
||||
const gravatarBase = "https://www.gravatar.com/avatar/";
|
||||
const hash = Md5.hashStr(loggedInUser?.email || "user");
|
||||
const profilePicture = `${gravatarBase}${hash}`;
|
||||
profileRoutes = {
|
||||
const profileRoutes = {
|
||||
type: "collapse",
|
||||
name: loggedInUser?.name ?? "Anonymous",
|
||||
key: "username",
|
||||
@ -592,10 +525,7 @@ export default function App()
|
||||
localStorage.removeItem("accessToken");
|
||||
removeCookie(SESSION_UUID_COOKIE_NAME, {path: "/"});
|
||||
|
||||
//////////////////////////////////////////////////////
|
||||
// todo - this is auth0 logout... make more generic //
|
||||
//////////////////////////////////////////////////////
|
||||
logout();
|
||||
doLogout();
|
||||
return;
|
||||
}
|
||||
}
|
||||
@ -603,7 +533,9 @@ export default function App()
|
||||
})();
|
||||
}, [needToLoadRoutes, isFullyAuthenticated]);
|
||||
|
||||
// Open sidenav when mouse enter on mini sidenav
|
||||
///////////////////////////////////////////////////
|
||||
// Open sidenav when mouse enter on mini sidenav //
|
||||
///////////////////////////////////////////////////
|
||||
const handleOnMouseEnter = () =>
|
||||
{
|
||||
if (miniSidenav && !onMouseEnter)
|
||||
@ -613,7 +545,9 @@ export default function App()
|
||||
}
|
||||
};
|
||||
|
||||
// Close sidenav when mouse leave mini sidenav
|
||||
/////////////////////////////////////////////////
|
||||
// Close sidenav when mouse leave mini sidenav //
|
||||
/////////////////////////////////////////////////
|
||||
const handleOnMouseLeave = () =>
|
||||
{
|
||||
if (onMouseEnter)
|
||||
@ -623,16 +557,14 @@ export default function App()
|
||||
}
|
||||
};
|
||||
|
||||
// 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;
|
||||
@ -672,14 +604,14 @@ export default function App()
|
||||
const [dotMenuOpen, setDotMenuOpen] = useState(false);
|
||||
const [keyboardHelpOpen, setKeyboardHelpOpen] = useState(false);
|
||||
const [helpHelpActive] = useState(queryParams.has("helpHelp"));
|
||||
const [userId, setUserId] = useState(user?.email);
|
||||
const [userId, setUserId] = useState(loggedInUser?.email);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
setUserId(user?.email)
|
||||
}, [user]);
|
||||
setUserId(loggedInUser?.email);
|
||||
}, [loggedInUser]);
|
||||
|
||||
|
||||
|
||||
const [googleAnalyticsUtils] = useState(new GoogleAnalyticsUtils());
|
||||
|
||||
/*******************************************************************************
|
||||
@ -687,9 +619,35 @@ export default function App()
|
||||
*******************************************************************************/
|
||||
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 (
|
||||
|
||||
@ -718,6 +676,7 @@ export default function App()
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
<CommandMenu metaData={metaData} />
|
||||
{banner()}
|
||||
<Sidenav
|
||||
color={sidenavColor}
|
||||
icon={branding.icon}
|
||||
@ -727,6 +686,7 @@ export default function App()
|
||||
routes={sideNavRoutes}
|
||||
onMouseEnter={handleOnMouseEnter}
|
||||
onMouseLeave={handleOnMouseLeave}
|
||||
logout={doLogout}
|
||||
/>
|
||||
<Routes>
|
||||
<Route path="*" element={<Navigate to={defaultRoute} />} />
|
||||
|
149
src/index.tsx
149
src/index.tsx
@ -19,116 +19,97 @@
|
||||
* 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 HandleAuthorizationError from "HandleAuthorizationError";
|
||||
import ProtectedRoute from "qqq/authorization/auth0/ProtectedRoute";
|
||||
import useAnonymousAuthenticationModule from "qqq/authorization/anonymous/useAnonymousAuthenticationModule";
|
||||
import useAuth0AuthenticationModule from "qqq/authorization/auth0/useAuth0AuthenticationModule";
|
||||
import useOAuth2AuthenticationModule from "qqq/authorization/oauth2/useOAuth2AuthenticationModule";
|
||||
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) =>
|
||||
{
|
||||
// @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>
|
||||
);
|
||||
}
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
function Auth0RouterBody()
|
||||
{
|
||||
const {renderAppWrapper} = useAuth0AuthenticationModule({});
|
||||
return (renderAppWrapper(authenticationMetaData));
|
||||
}
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
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")
|
||||
{
|
||||
// @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>
|
||||
);
|
||||
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>);
|
||||
}
|
||||
else
|
||||
{
|
||||
root.render(
|
||||
<BrowserRouter>
|
||||
<MaterialUIControllerProvider>
|
||||
<App />
|
||||
</MaterialUIControllerProvider>
|
||||
</BrowserRouter>
|
||||
);
|
||||
root.render(<div>
|
||||
Error: Unknown authenticationMetaData type: [{authenticationMetaData.type}].
|
||||
</div>);
|
||||
}
|
||||
|
||||
})
|
||||
});
|
||||
|
@ -1,6 +1,6 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2022. Kingsrook, LLC
|
||||
* 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/
|
||||
@ -19,20 +19,18 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {useAuth0} from "@auth0/auth0-react";
|
||||
import {Button} from "@mui/material";
|
||||
import React from "react";
|
||||
package com.kingsrook.qqq.frontend.materialdashboard.model.metadata;
|
||||
|
||||
function AuthenticationButton()
|
||||
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.branding.BannerSlot;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public enum MaterialDashboardBannerSlots implements BannerSlot
|
||||
{
|
||||
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>;
|
||||
QFMD_TOP_OF_SITE,
|
||||
QFMD_TOP_OF_BODY,
|
||||
QFMD_SIDE_NAV_UNDER_LOGO
|
||||
}
|
||||
|
||||
export default AuthenticationButton;
|
@ -0,0 +1,82 @@
|
||||
/*
|
||||
* 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
|
||||
};
|
||||
|
||||
}
|
252
src/qqq/authorization/auth0/useAuth0AuthenticationModule.tsx
Normal file
252
src/qqq/authorization/auth0/useAuth0AuthenticationModule.tsx
Normal file
@ -0,0 +1,252 @@
|
||||
/*
|
||||
* 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
|
||||
};
|
||||
|
||||
}
|
188
src/qqq/authorization/oauth2/useOAuth2AuthenticationModule.tsx
Normal file
188
src/qqq/authorization/oauth2/useOAuth2AuthenticationModule.tsx
Normal file
@ -0,0 +1,188 @@
|
||||
/*
|
||||
* 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
|
||||
};
|
||||
|
||||
}
|
@ -57,7 +57,7 @@ function QDynamicForm({formData, formLabel, bulkEditMode, bulkEditSwitchChangeHa
|
||||
<MDTypography variant="h5">{formLabel}</MDTypography>
|
||||
</Box>
|
||||
<Box mt={1.625}>
|
||||
<Grid container spacing={3}>
|
||||
<Grid container lg={12} display="flex" spacing={3}>
|
||||
{formFields
|
||||
&& Object.keys(formFields).length > 0
|
||||
&& Object.keys(formFields).map((fieldName: any) =>
|
||||
@ -74,13 +74,14 @@ function QDynamicForm({formData, formLabel, bulkEditMode, bulkEditSwitchChangeHa
|
||||
}
|
||||
|
||||
let formattedHelpContent = <HelpContent helpContents={field?.fieldMetaData?.helpContents} roles={helpRoles} helpContentKey={`${helpContentKeyPrefix ?? ""}field:${fieldName}`} />;
|
||||
if(formattedHelpContent)
|
||||
if (formattedHelpContent)
|
||||
{
|
||||
formattedHelpContent = <Box color="#757575" fontSize="0.875rem" mt="-0.25rem">{formattedHelpContent}</Box>
|
||||
formattedHelpContent = <Box color="#757575" fontSize="0.875rem" mt="-0.25rem">{formattedHelpContent}</Box>;
|
||||
}
|
||||
|
||||
const labelElement = <DynamicFormFieldLabel name={field.name} label={field.label} />;
|
||||
|
||||
let itemLG = (field?.fieldMetaData?.gridColumns && field?.fieldMetaData?.gridColumns > 0) ? field.fieldMetaData.gridColumns : 6;
|
||||
let itemXS = 12;
|
||||
let itemSM = 6;
|
||||
|
||||
@ -92,13 +93,14 @@ function QDynamicForm({formData, formLabel, bulkEditMode, bulkEditSwitchChangeHa
|
||||
const fileUploadAdornment = field.fieldMetaData?.getAdornment(AdornmentType.FILE_UPLOAD);
|
||||
const width = fileUploadAdornment?.values?.get("width") ?? "half";
|
||||
|
||||
if(width == "full")
|
||||
if (width == "full")
|
||||
{
|
||||
itemSM = 12;
|
||||
itemLG = 12;
|
||||
}
|
||||
|
||||
return (
|
||||
<Grid item xs={itemXS} sm={itemSM} key={fieldName}>
|
||||
<Grid item lg={itemLG} xs={itemXS} sm={itemSM} flexDirection="column" key={fieldName}>
|
||||
{labelElement}
|
||||
<FileInputField field={field} record={record} errorMessage={errors[fieldName]} />
|
||||
</Grid>
|
||||
@ -114,10 +116,10 @@ function QDynamicForm({formData, formLabel, bulkEditMode, bulkEditSwitchChangeHa
|
||||
Object.keys(values).forEach((key) =>
|
||||
{
|
||||
otherValuesMap.set(key, values[key]);
|
||||
})
|
||||
});
|
||||
|
||||
return (
|
||||
<Grid item xs={itemXS} sm={itemSM} key={fieldName}>
|
||||
<Grid item lg={itemLG} xs={itemXS} sm={itemSM} key={fieldName}>
|
||||
{labelElement}
|
||||
<DynamicSelect
|
||||
fieldPossibleValueProps={field.possibleValueProps}
|
||||
@ -138,7 +140,7 @@ function QDynamicForm({formData, formLabel, bulkEditMode, bulkEditSwitchChangeHa
|
||||
// everything else!! //
|
||||
///////////////////////
|
||||
return (
|
||||
<Grid item xs={itemXS} sm={itemSM} key={fieldName}>
|
||||
<Grid item lg={itemLG} xs={itemXS} sm={itemSM} key={fieldName}>
|
||||
{labelElement}
|
||||
<QDynamicFormField
|
||||
id={field.name}
|
||||
|
@ -141,7 +141,10 @@ function QDynamicFormField({
|
||||
newValue = newValue.toLowerCase();
|
||||
}
|
||||
setFieldValue(name, newValue);
|
||||
onChangeCallback(newValue);
|
||||
if(onChangeCallback)
|
||||
{
|
||||
onChangeCallback(newValue);
|
||||
}
|
||||
});
|
||||
|
||||
const input = document.getElementById(name) as HTMLInputElement;
|
||||
|
@ -66,7 +66,7 @@ interface Props
|
||||
defaultValues: { [key: string]: string };
|
||||
disabledFields: { [key: string]: boolean } | string[];
|
||||
isCopy?: boolean;
|
||||
onSubmitCallback?: (values: any) => void;
|
||||
onSubmitCallback?: (values: any, tableName: string) => void;
|
||||
overrideHeading?: string;
|
||||
}
|
||||
|
||||
@ -173,7 +173,7 @@ function EntityForm(props: Props): JSX.Element
|
||||
*******************************************************************************/
|
||||
function openAddChildRecord(name: string, widgetData: any)
|
||||
{
|
||||
let defaultValues = widgetData.defaultValuesForNewChildRecords;
|
||||
let defaultValues = widgetData.defaultValuesForNewChildRecords || {};
|
||||
|
||||
let disabledFields = widgetData.disabledFieldsForNewChildRecords;
|
||||
if (!disabledFields)
|
||||
@ -181,6 +181,18 @@ function EntityForm(props: Props): JSX.Element
|
||||
disabledFields = widgetData.defaultValuesForNewChildRecords;
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////
|
||||
// copy values from specified fields in the parent record down into the child record //
|
||||
///////////////////////////////////////////////////////////////////////////////////////
|
||||
if(widgetData.defaultValuesForNewChildRecordsFromParentFields)
|
||||
{
|
||||
for(let childField in widgetData.defaultValuesForNewChildRecordsFromParentFields)
|
||||
{
|
||||
const parentField = widgetData.defaultValuesForNewChildRecordsFromParentFields[childField];
|
||||
defaultValues[childField] = formValues[parentField];
|
||||
}
|
||||
}
|
||||
|
||||
doOpenEditChildForm(name, widgetData.childTableMetaData, null, defaultValues, disabledFields);
|
||||
}
|
||||
|
||||
@ -208,7 +220,7 @@ function EntityForm(props: Props): JSX.Element
|
||||
function deleteChildRecord(name: string, widgetData: any, rowIndex: number)
|
||||
{
|
||||
updateChildRecordList(name, "delete", rowIndex);
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@ -243,16 +255,16 @@ function EntityForm(props: Props): JSX.Element
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
function submitEditChildForm(values: any)
|
||||
function submitEditChildForm(values: any, tableName: string)
|
||||
{
|
||||
updateChildRecordList(showEditChildForm.widgetName, showEditChildForm.rowIndex == null ? "insert" : "edit", showEditChildForm.rowIndex, values);
|
||||
updateChildRecordList(showEditChildForm.widgetName, showEditChildForm.rowIndex == null ? "insert" : "edit", showEditChildForm.rowIndex, values, tableName);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
async function updateChildRecordList(widgetName: string, action: "insert" | "edit" | "delete", rowIndex?: number, values?: any)
|
||||
async function updateChildRecordList(widgetName: string, action: "insert" | "edit" | "delete", rowIndex?: number, values?: any, childTableName?: string)
|
||||
{
|
||||
const metaData = await qController.loadMetaData();
|
||||
const widgetMetaData = metaData.widgets.get(widgetName);
|
||||
@ -263,13 +275,38 @@ function EntityForm(props: Props): JSX.Element
|
||||
newChildListWidgetData[widgetName].queryOutput.records = [];
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// 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)
|
||||
{
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// this function internally memoizes, so, we could potentially avoid an await here, but, seems ok... //
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
const childTableMetaData = await qController.loadTableMetaData(childTableName)
|
||||
for (let key in values)
|
||||
{
|
||||
const value = values[key];
|
||||
const field = childTableMetaData.fields.get(key);
|
||||
if(field.possibleValueSourceName)
|
||||
{
|
||||
const possibleValues = await qController.possibleValues(childTableName, null, field.name, null, [value], objectToMap(values), "form")
|
||||
if(possibleValues && possibleValues.length > 0)
|
||||
{
|
||||
displayValues[key] = possibleValues[0].label;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
switch (action)
|
||||
{
|
||||
case "insert":
|
||||
newChildListWidgetData[widgetName].queryOutput.records.push({values: values});
|
||||
newChildListWidgetData[widgetName].queryOutput.records.push({values: values, displayValues: displayValues});
|
||||
break;
|
||||
case "edit":
|
||||
newChildListWidgetData[widgetName].queryOutput.records[rowIndex] = {values: values};
|
||||
newChildListWidgetData[widgetName].queryOutput.records[rowIndex] = {values: values, displayValues: displayValues};
|
||||
break;
|
||||
case "delete":
|
||||
newChildListWidgetData[widgetName].queryOutput.records.splice(rowIndex, 1);
|
||||
@ -407,6 +444,7 @@ function EntityForm(props: Props): JSX.Element
|
||||
widgetMetaData={widgetMetaData}
|
||||
widgetData={widgetData}
|
||||
recordValues={formValues}
|
||||
label={tableMetaData?.fields.get(widgetData?.filterFieldName ?? "queryFilterJson")?.label}
|
||||
onSaveCallback={setFormFieldValuesFromWidget}
|
||||
/>;
|
||||
}
|
||||
@ -478,6 +516,26 @@ function EntityForm(props: Props): JSX.Element
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
function objectToMap(object: { [key: string]: any }): Map<string, any>
|
||||
{
|
||||
if(object == null)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
|
||||
const rs = new Map<string, any>();
|
||||
for (let key in object)
|
||||
{
|
||||
rs.set(key, object[key]);
|
||||
}
|
||||
return rs
|
||||
}
|
||||
|
||||
|
||||
//////////////////
|
||||
// initial load //
|
||||
//////////////////
|
||||
@ -595,18 +653,24 @@ function EntityForm(props: Props): JSX.Element
|
||||
if (defaultValue)
|
||||
{
|
||||
initialValues[fieldName] = defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////////
|
||||
// we need to set the initialDisplayValue for possible value fields with a default value //
|
||||
// so, look them up here now if needed //
|
||||
///////////////////////////////////////////////////////////////////////////////////////////
|
||||
if (fieldMetaData.possibleValueSourceName)
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// do a second loop, this time looking up display-values for any possible-value fields with a default value //
|
||||
// do it in a second loop, to pass in all the other values (from initialValues), in case there's a PVS filter that needs them. //
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
for (let i = 0; i < fieldArray.length; i++)
|
||||
{
|
||||
const fieldMetaData = fieldArray[i];
|
||||
const fieldName = fieldMetaData.name;
|
||||
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]], objectToMap(initialValues), "form");
|
||||
if (results && results.length > 0)
|
||||
{
|
||||
const results: QPossibleValue[] = await qController.possibleValues(tableName, null, fieldName, null, [initialValues[fieldName]], undefined, "form");
|
||||
if (results && results.length > 0)
|
||||
{
|
||||
defaultDisplayValues.set(fieldName, results[0].label);
|
||||
}
|
||||
defaultDisplayValues.set(fieldName, results[0].label);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -823,7 +887,7 @@ function EntityForm(props: Props): JSX.Element
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
if (props.onSubmitCallback)
|
||||
{
|
||||
props.onSubmitCallback(values);
|
||||
props.onSubmitCallback(values, tableName);
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -270,7 +270,7 @@ function NavBar({absolute, light, isMini}: Props): JSX.Element
|
||||
{
|
||||
pageHeader &&
|
||||
<Box display="flex" justifyContent="space-between">
|
||||
<MDTypography pb="0.5rem" textTransform="capitalize" variant="h3" color={light ? "white" : "dark"} noWrap>
|
||||
<MDTypography pb="0.5rem" variant="h3" color={light ? "white" : "dark"} noWrap>
|
||||
{pageHeader}
|
||||
</MDTypography>
|
||||
</Box>
|
||||
|
@ -20,21 +20,22 @@
|
||||
*/
|
||||
|
||||
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
|
||||
@ -44,6 +45,7 @@ interface Props
|
||||
logo?: string;
|
||||
appName?: string;
|
||||
branding?: QBrandingMetaData;
|
||||
logout: () => void;
|
||||
routes: {
|
||||
[key: string]:
|
||||
| ReactNode
|
||||
@ -66,7 +68,7 @@ interface Props
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
function Sidenav({color, icon, logo, appName, branding, routes, ...rest}: Props): JSX.Element
|
||||
function Sidenav({color, icon, logo, appName, branding, routes, logout, ...rest}: Props): JSX.Element
|
||||
{
|
||||
const [openCollapse, setOpenCollapse] = useState<boolean | string>(false);
|
||||
const [openNestedCollapse, setOpenNestedCollapse] = useState<boolean | string>(false);
|
||||
@ -257,7 +259,7 @@ function Sidenav({color, icon, logo, appName, branding, routes, ...rest}: Props)
|
||||
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>
|
||||
@ -300,6 +302,30 @@ function Sidenav({color, icon, logo, appName, branding, routes, ...rest}: Props)
|
||||
}
|
||||
);
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
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}
|
||||
@ -330,12 +356,7 @@ function Sidenav({color, icon, logo, appName, branding, routes, ...rest}: Props)
|
||||
</Box>
|
||||
}
|
||||
</Box>
|
||||
{
|
||||
branding && branding.environmentBannerText &&
|
||||
<Box mt={2} bgcolor={branding.environmentBannerColor} borderRadius={2}>
|
||||
{branding.environmentBannerText}
|
||||
</Box>
|
||||
}
|
||||
<EnvironmentBanner branding={branding} />
|
||||
</Box>
|
||||
<Divider
|
||||
light={
|
||||
@ -350,7 +371,7 @@ function Sidenav({color, icon, logo, appName, branding, routes, ...rest}: Props)
|
||||
(darkMode && !transparentSidenav && whiteSidenav)
|
||||
}
|
||||
/>
|
||||
<AuthenticationButton />
|
||||
<Button onClick={logout}>Log Out</Button>
|
||||
</SidenavRoot>
|
||||
);
|
||||
}
|
||||
|
@ -97,6 +97,7 @@ export default styled(Drawer)(({theme, ownerState}: { theme?: Theme | any; owner
|
||||
margin: "0",
|
||||
borderRadius: "0",
|
||||
height: "100%",
|
||||
top: "unset",
|
||||
|
||||
...(miniSidenav ? drawerCloseStyles() : drawerOpenStyles()),
|
||||
},
|
||||
|
97
src/qqq/components/misc/Banners.tsx
Normal file
97
src/qqq/components/misc/Banners.tsx
Normal file
@ -0,0 +1,97 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {Banner} from "@kingsrook/qqq-frontend-core/lib/model/metaData/Banner";
|
||||
import {QBrandingMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QBrandingMetaData";
|
||||
import parse from "html-react-parser";
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// One may render a banner using the functions in this file as: //
|
||||
// //
|
||||
// const banner = getBanner(branding, "QFMD_SIDE_NAV_UNDER_LOGO"); //
|
||||
// return (<Box className={getBannerClassName(banner)} sx={{padding: "1rem", ...getBannerStyles(banner)}}> //
|
||||
// {makeBannerContent(banner)} //
|
||||
// </Box>); //
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
export function getBanner(branding: QBrandingMetaData, slot: string): Banner | null
|
||||
{
|
||||
if (branding?.banners?.has(slot))
|
||||
{
|
||||
return (branding.banners.get(slot));
|
||||
}
|
||||
|
||||
return (null);
|
||||
}
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
export function getBannerStyles(banner: Banner)
|
||||
{
|
||||
let bgColor = "";
|
||||
let color = "";
|
||||
|
||||
if (banner)
|
||||
{
|
||||
if (banner.backgroundColor)
|
||||
{
|
||||
bgColor = banner.backgroundColor;
|
||||
}
|
||||
|
||||
if (banner.textColor)
|
||||
{
|
||||
bgColor = banner.textColor;
|
||||
}
|
||||
}
|
||||
|
||||
const rest = banner?.additionalStyles ?? {};
|
||||
|
||||
return ({
|
||||
backgroundColor: bgColor,
|
||||
color: color,
|
||||
...rest
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
export function getBannerClassName(banner: Banner)
|
||||
{
|
||||
return `banner ${banner?.severity?.toLowerCase()}`;
|
||||
}
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
export function makeBannerContent(banner: Banner): JSX.Element
|
||||
{
|
||||
return <>{banner?.messageHTML ? parse(banner?.messageHTML) : banner?.messageText}</>;
|
||||
}
|
||||
|
@ -20,6 +20,7 @@
|
||||
*/
|
||||
|
||||
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";
|
||||
@ -35,12 +36,11 @@ 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,9 +207,13 @@ function GotoRecordDialog(props: Props): JSX.Element
|
||||
const queryStringParts: string[] = [];
|
||||
options[optionIndex].forEach((field) =>
|
||||
{
|
||||
criteria.push(new QFilterCriteria(field.name, QCriteriaOperator.EQUALS, [values[field.name]]))
|
||||
queryStringParts.push(`${field.name}=${encodeURIComponent(values[field.name])}`)
|
||||
})
|
||||
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])}`);
|
||||
});
|
||||
|
||||
const filter = new QQueryFilter(criteria, null, null, "AND", null, 10);
|
||||
|
||||
@ -223,7 +227,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 //
|
||||
|
@ -21,7 +21,7 @@
|
||||
|
||||
|
||||
import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
|
||||
import {Checkbox, FormControlLabel, Radio} from "@mui/material";
|
||||
import {Checkbox, FormControlLabel, Radio, Tooltip} from "@mui/material";
|
||||
import Autocomplete from "@mui/material/Autocomplete";
|
||||
import Box from "@mui/material/Box";
|
||||
import Button from "@mui/material/Button";
|
||||
@ -34,6 +34,7 @@ import colors from "qqq/assets/theme/base/colors";
|
||||
import QDynamicFormField from "qqq/components/forms/DynamicFormField";
|
||||
import DynamicFormUtils from "qqq/components/forms/DynamicFormUtils";
|
||||
import {BulkLoadField, FileDescription} from "qqq/models/processes/BulkLoadModels";
|
||||
import Client from "qqq/utils/qqq/Client";
|
||||
import React, {useEffect, useState} from "react";
|
||||
|
||||
interface BulkLoadMappingFieldProps
|
||||
@ -45,6 +46,29 @@ interface BulkLoadMappingFieldProps
|
||||
forceParentUpdate?: () => void,
|
||||
}
|
||||
|
||||
const xIconButtonSX =
|
||||
{
|
||||
border: `1px solid ${colors.grayLines.main} !important`,
|
||||
borderRadius: "0.5rem",
|
||||
textTransform: "none",
|
||||
fontSize: "1rem",
|
||||
fontWeight: "400",
|
||||
width: "30px",
|
||||
minWidth: "30px",
|
||||
height: "2rem",
|
||||
minHeight: "2rem",
|
||||
paddingLeft: 0,
|
||||
paddingRight: 0,
|
||||
marginRight: "0.5rem",
|
||||
marginTop: "0.5rem",
|
||||
color: colors.error.main,
|
||||
"&:hover": {color: colors.error.main},
|
||||
"&:focus": {color: colors.error.main},
|
||||
"&:focus:not(:hover)": {color: colors.error.main},
|
||||
};
|
||||
|
||||
const qController = Client.getInstance();
|
||||
|
||||
/***************************************************************************
|
||||
** row for a single field on the bulk load mapping screen.
|
||||
***************************************************************************/
|
||||
@ -54,6 +78,11 @@ export default function BulkLoadFileMappingField({bulkLoadField, isRequired, rem
|
||||
|
||||
const [valueType, setValueType] = useState(bulkLoadField.valueType);
|
||||
const [selectedColumn, setSelectedColumn] = useState({label: columnNames[bulkLoadField.columnIndex], value: bulkLoadField.columnIndex});
|
||||
const [selectedColumnInputValue, setSelectedColumnInputValue] = useState(columnNames[bulkLoadField.columnIndex]);
|
||||
|
||||
const [doingInitialLoadOfPossibleValue, setDoingInitialLoadOfPossibleValue] = useState(false);
|
||||
const [everDidInitialLoadOfPossibleValue, setEverDidInitialLoadOfPossibleValue] = useState(false);
|
||||
const [possibleValueInitialDisplayValue, setPossibleValueInitialDisplayValue] = useState(null as string);
|
||||
|
||||
const fieldMetaData = new QFieldMetaData(bulkLoadField.field);
|
||||
const dynamicField = DynamicFormUtils.getDynamicField(fieldMetaData);
|
||||
@ -61,10 +90,59 @@ export default function BulkLoadFileMappingField({bulkLoadField, isRequired, rem
|
||||
dynamicFieldInObject[fieldMetaData["name"]] = dynamicField;
|
||||
DynamicFormUtils.addPossibleValueProps(dynamicFieldInObject, [fieldMetaData], bulkLoadField.tableStructure.tableName, null, null);
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////////
|
||||
// deal with dynamically loading the initial default value for a possible value... //
|
||||
/////////////////////////////////////////////////////////////////////////////////////
|
||||
let actuallyDoingInitialLoadOfPossibleValue = doingInitialLoadOfPossibleValue;
|
||||
if(dynamicField.possibleValueProps && bulkLoadField.defaultValue && !doingInitialLoadOfPossibleValue && !everDidInitialLoadOfPossibleValue)
|
||||
{
|
||||
actuallyDoingInitialLoadOfPossibleValue = true;
|
||||
setDoingInitialLoadOfPossibleValue(true);
|
||||
setEverDidInitialLoadOfPossibleValue(true);
|
||||
|
||||
(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
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);
|
||||
}
|
||||
else
|
||||
{
|
||||
setPossibleValueInitialDisplayValue(null);
|
||||
}
|
||||
}
|
||||
catch(e)
|
||||
{
|
||||
console.log(`Error loading possible value: ${e}`)
|
||||
}
|
||||
|
||||
actuallyDoingInitialLoadOfPossibleValue = false;
|
||||
setDoingInitialLoadOfPossibleValue(false);
|
||||
})();
|
||||
}
|
||||
|
||||
if(dynamicField.possibleValueProps && possibleValueInitialDisplayValue)
|
||||
{
|
||||
dynamicField.possibleValueProps.initialDisplayValue = possibleValueInitialDisplayValue;
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////
|
||||
// build array of options for the columns drop down //
|
||||
// don't allow duplicates //
|
||||
//////////////////////////////////////////////////////
|
||||
const columnOptions: { value: number, label: string }[] = [];
|
||||
const usedLabels: {[label: string]: boolean} = {};
|
||||
for (let i = 0; i < columnNames.length; i++)
|
||||
{
|
||||
columnOptions.push({label: columnNames[i], value: i});
|
||||
const label = columnNames[i];
|
||||
if(!usedLabels[label])
|
||||
{
|
||||
columnOptions.push({label: label, value: i});
|
||||
usedLabels[label] = true;
|
||||
}
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
@ -73,6 +151,7 @@ export default function BulkLoadFileMappingField({bulkLoadField, isRequired, rem
|
||||
if(bulkLoadField.columnIndex != null && bulkLoadField.columnIndex != undefined && selectedColumn.label && columnNames[bulkLoadField.columnIndex] != selectedColumn.label)
|
||||
{
|
||||
setSelectedColumn({label: columnNames[bulkLoadField.columnIndex], value: bulkLoadField.columnIndex})
|
||||
setSelectedColumnInputValue(columnNames[bulkLoadField.columnIndex]);
|
||||
}
|
||||
|
||||
const mainFontSize = "0.875rem";
|
||||
@ -98,6 +177,8 @@ export default function BulkLoadFileMappingField({bulkLoadField, isRequired, rem
|
||||
function columnChanged(event: any, newValue: any, reason: string)
|
||||
{
|
||||
setSelectedColumn(newValue);
|
||||
setSelectedColumnInputValue(newValue == null ? "" : newValue.label);
|
||||
|
||||
bulkLoadField.columnIndex = newValue == null ? null : newValue.value;
|
||||
|
||||
if (fileDescription.hasHeaderRow)
|
||||
@ -106,6 +187,7 @@ export default function BulkLoadFileMappingField({bulkLoadField, isRequired, rem
|
||||
}
|
||||
|
||||
bulkLoadField.error = null;
|
||||
bulkLoadField.warning = null;
|
||||
forceParentUpdate && forceParentUpdate();
|
||||
}
|
||||
|
||||
@ -118,6 +200,7 @@ export default function BulkLoadFileMappingField({bulkLoadField, isRequired, rem
|
||||
setFieldValue(`${bulkLoadField.field.name}.defaultValue`, newValue);
|
||||
bulkLoadField.defaultValue = newValue;
|
||||
bulkLoadField.error = null;
|
||||
bulkLoadField.warning = null;
|
||||
forceParentUpdate && forceParentUpdate();
|
||||
}
|
||||
|
||||
@ -131,6 +214,7 @@ export default function BulkLoadFileMappingField({bulkLoadField, isRequired, rem
|
||||
bulkLoadField.valueType = newValueType;
|
||||
setValueType(newValueType);
|
||||
bulkLoadField.error = null;
|
||||
bulkLoadField.warning = null;
|
||||
forceParentUpdate && forceParentUpdate();
|
||||
}
|
||||
|
||||
@ -144,7 +228,15 @@ export default function BulkLoadFileMappingField({bulkLoadField, isRequired, rem
|
||||
forceParentUpdate && forceParentUpdate();
|
||||
}
|
||||
|
||||
return (<Box py="0.5rem" sx={{borderBottom: "1px solid lightgray", width: "100%", overflow: "auto"}}>
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
function changeSelectedColumnInputValue(e: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>)
|
||||
{
|
||||
setSelectedColumnInputValue(e.target.value);
|
||||
}
|
||||
|
||||
return (<Box py="0.5rem" sx={{borderBottom: "1px solid lightgray", width: "100%", overflow: "auto"}} id={`blfmf-${bulkLoadField.field.name}`}>
|
||||
<Box display="grid" gridTemplateColumns="200px 400px auto" fontSize="1rem" gap="0.5rem" sx={
|
||||
{
|
||||
"& .MuiFormControlLabel-label": {ml: "0 !important", fontWeight: "normal !important", fontSize: mainFontSize}
|
||||
@ -152,7 +244,9 @@ export default function BulkLoadFileMappingField({bulkLoadField, isRequired, rem
|
||||
|
||||
<Box display="flex" alignItems="flex-start">
|
||||
{
|
||||
(!isRequired) && <IconButton onClick={() => removeFieldCallback()} sx={{pt: "0.75rem"}}><Icon fontSize="small">remove_circle</Icon></IconButton>
|
||||
(!isRequired) && <Tooltip placement="bottom" title="Remove this field from your mapping.">
|
||||
<Button sx={xIconButtonSX} onClick={() => removeFieldCallback()}><Icon>clear</Icon></Button>
|
||||
</Tooltip>
|
||||
}
|
||||
<Box pt="0.625rem">
|
||||
{bulkLoadField.getQualifiedLabel()}
|
||||
@ -167,13 +261,13 @@ export default function BulkLoadFileMappingField({bulkLoadField, isRequired, rem
|
||||
valueType == "column" && <Box width="100%">
|
||||
<Autocomplete
|
||||
id={bulkLoadField.field.name}
|
||||
renderInput={(params) => (<TextField {...params} label={""} value={selectedColumn?.label} fullWidth variant="outlined" autoComplete="off" type="search" InputProps={{...params.InputProps}} sx={{"& .MuiOutlinedInput-root": {borderRadius: "0.75rem"}}} />)}
|
||||
renderInput={(params) => (<TextField {...params} label={""} value={selectedColumnInputValue} onChange={e => changeSelectedColumnInputValue(e)} fullWidth variant="outlined" autoComplete="off" type="search" InputProps={{...params.InputProps}} sx={{"& .MuiOutlinedInput-root": {borderRadius: "0.75rem"}}} />)}
|
||||
fullWidth
|
||||
options={columnOptions}
|
||||
multiple={false}
|
||||
defaultValue={selectedColumn}
|
||||
value={selectedColumn}
|
||||
inputValue={selectedColumn?.label}
|
||||
inputValue={selectedColumnInputValue}
|
||||
onChange={columnChanged}
|
||||
getOptionLabel={(option) => typeof (option) == "string" ? option : (option?.label ?? "")}
|
||||
isOptionEqualToValue={(option, value) => option == null && value == null || option.value == value.value}
|
||||
@ -186,7 +280,10 @@ export default function BulkLoadFileMappingField({bulkLoadField, isRequired, rem
|
||||
<Box display="flex" alignItems="center" sx={{height: "45px"}}>
|
||||
<FormControlLabel value="defaultValue" control={<Radio size="small" onChange={(event, checked) => valueTypeChanged(!checked)} />} label={"Default value"} sx={{minWidth: "140px", whiteSpace: "nowrap"}} />
|
||||
{
|
||||
valueType == "defaultValue" && <Box width="100%">
|
||||
valueType == "defaultValue" && actuallyDoingInitialLoadOfPossibleValue && <Box width="100%">Loading...</Box>
|
||||
}
|
||||
{
|
||||
valueType == "defaultValue" && !actuallyDoingInitialLoadOfPossibleValue && <Box width="100%">
|
||||
<QDynamicFormField
|
||||
name={`${bulkLoadField.field.name}.defaultValue`}
|
||||
displayFormat={""}
|
||||
@ -200,9 +297,15 @@ export default function BulkLoadFileMappingField({bulkLoadField, isRequired, rem
|
||||
}
|
||||
</Box>
|
||||
</Box>
|
||||
{
|
||||
bulkLoadField.warning &&
|
||||
<Box fontSize={smallerFontSize} color={colors.warning.main} ml="145px" className="bulkLoadFieldError">
|
||||
{bulkLoadField.warning}
|
||||
</Box>
|
||||
}
|
||||
{
|
||||
bulkLoadField.error &&
|
||||
<Box fontSize={smallerFontSize} color={colors.error.main} ml="145px">
|
||||
<Box fontSize={smallerFontSize} color={colors.error.main} ml="145px" className="bulkLoadFieldError">
|
||||
{bulkLoadField.error}
|
||||
</Box>
|
||||
}
|
||||
|
@ -47,7 +47,7 @@ export default function BulkLoadFileMappingFields({bulkLoadMapping, fileDescript
|
||||
{
|
||||
const [, forceUpdate] = useReducer((x) => x + 1, 0);
|
||||
|
||||
const [forceRerender, setForceRerender] = useState(0);
|
||||
const [forceHierarchyAutoCompleteRerender, setForceHierarchyAutoCompleteRerender] = useState(0);
|
||||
|
||||
////////////////////////////////////////////
|
||||
// build list of fields that can be added //
|
||||
@ -98,8 +98,9 @@ export default function BulkLoadFileMappingFields({bulkLoadMapping, fileDescript
|
||||
|
||||
setAddFieldsDisableStates(newDisableStates);
|
||||
setTooltips(newTooltips);
|
||||
setForceHierarchyAutoCompleteRerender(forceHierarchyAutoCompleteRerender + 1);
|
||||
|
||||
}, [bulkLoadMapping]);
|
||||
}, [bulkLoadMapping, bulkLoadMapping.layout]);
|
||||
|
||||
|
||||
///////////////////////////////////////////////
|
||||
@ -140,9 +141,6 @@ export default function BulkLoadFileMappingFields({bulkLoadMapping, fileDescript
|
||||
***************************************************************************/
|
||||
function removeField(bulkLoadField: BulkLoadField)
|
||||
{
|
||||
// addFieldsToggleStates[bulkLoadField.getQualifiedName()] = false;
|
||||
// setAddFieldsToggleStates(Object.assign({}, addFieldsToggleStates));
|
||||
|
||||
addFieldsDisableStates[bulkLoadField.getQualifiedName()] = false;
|
||||
setAddFieldsDisableStates(Object.assign({}, addFieldsDisableStates));
|
||||
|
||||
@ -160,7 +158,7 @@ export default function BulkLoadFileMappingFields({bulkLoadMapping, fileDescript
|
||||
bulkLoadMapping.removeField(bulkLoadField);
|
||||
forceUpdate();
|
||||
forceParentUpdate();
|
||||
setForceRerender(forceRerender + 1);
|
||||
setForceHierarchyAutoCompleteRerender(forceHierarchyAutoCompleteRerender + 1);
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
@ -297,7 +295,7 @@ export default function BulkLoadFileMappingFields({bulkLoadMapping, fileDescript
|
||||
isModeSelectOne
|
||||
keepOpenAfterSelectOne
|
||||
handleSelectedOption={handleAddField}
|
||||
forceRerender={forceRerender}
|
||||
forceRerender={forceHierarchyAutoCompleteRerender}
|
||||
disabledStates={addFieldsDisableStates}
|
||||
tooltips={tooltips}
|
||||
/>
|
||||
|
@ -20,45 +20,56 @@
|
||||
*/
|
||||
|
||||
import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
|
||||
import {QFieldType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldType";
|
||||
import {QFrontendStepMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFrontendStepMetaData";
|
||||
import {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstance";
|
||||
import {QProcessMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QProcessMetaData";
|
||||
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
|
||||
import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord";
|
||||
import {Badge, Icon} from "@mui/material";
|
||||
import Autocomplete from "@mui/material/Autocomplete";
|
||||
import Box from "@mui/material/Box";
|
||||
import Grid from "@mui/material/Grid";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import Tooltip from "@mui/material/Tooltip/Tooltip";
|
||||
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 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 React, {forwardRef, useEffect, useImperativeHandle, useReducer, useState} from "react";
|
||||
import React, {forwardRef, useImperativeHandle, useReducer, useState} from "react";
|
||||
import ProcessViewForm from "./ProcessViewForm";
|
||||
|
||||
|
||||
interface BulkLoadMappingFormProps
|
||||
{
|
||||
processValues: any;
|
||||
tableMetaData: QTableMetaData;
|
||||
metaData: QInstance;
|
||||
setActiveStepLabel: (label: string) => void;
|
||||
processValues: any,
|
||||
tableMetaData: QTableMetaData,
|
||||
metaData: QInstance,
|
||||
setActiveStepLabel: (label: string) => void,
|
||||
frontendStep: QFrontendStepMetaData,
|
||||
processMetaData: QProcessMetaData,
|
||||
}
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** process component - screen where user does a bulk-load file mapping.
|
||||
***************************************************************************/
|
||||
const BulkLoadFileMappingForm = forwardRef(({processValues, tableMetaData, metaData, setActiveStepLabel}: BulkLoadMappingFormProps, ref) =>
|
||||
const BulkLoadFileMappingForm = forwardRef(({processValues, tableMetaData, metaData, setActiveStepLabel, frontendStep, processMetaData}: BulkLoadMappingFormProps, ref) =>
|
||||
{
|
||||
const {setFieldValue} = useFormikContext();
|
||||
|
||||
const [currentSavedBulkLoadProfile, setCurrentSavedBulkLoadProfile] = useState(null as QRecord);
|
||||
const savedBulkLoadProfileRecordProcessValue = processValues.savedBulkLoadProfileRecord;
|
||||
const [currentSavedBulkLoadProfile, setCurrentSavedBulkLoadProfile] = useState(savedBulkLoadProfileRecordProcessValue == null ? null : new QRecord(savedBulkLoadProfileRecordProcessValue));
|
||||
const [wrappedCurrentSavedBulkLoadProfile] = useState(new Wrapper<QRecord>(currentSavedBulkLoadProfile));
|
||||
|
||||
const [fieldErrors, setFieldErrors] = useState({} as { [fieldName: string]: string });
|
||||
const [noMappedFieldsError, setNoMappedFieldsError] = useState(null as string);
|
||||
|
||||
const [suggestedBulkLoadProfile] = useState(processValues.suggestedBulkLoadProfile as BulkLoadProfile);
|
||||
const [tableStructure] = useState(processValues.tableStructure as BulkLoadTableStructure);
|
||||
@ -119,18 +130,31 @@ const BulkLoadFileMappingForm = forwardRef(({processValues, tableMetaData, metaD
|
||||
}
|
||||
setFieldErrors(fieldErrors);
|
||||
|
||||
if(wrappedBulkLoadMapping.get().requiredFields.length == 0 && wrappedBulkLoadMapping.get().additionalFields.length == 0)
|
||||
{
|
||||
setNoMappedFieldsError("You must have at least 1 field.");
|
||||
haveLocalErrors = true;
|
||||
setTimeout(() => setNoMappedFieldsError(null), 2500);
|
||||
}
|
||||
else
|
||||
{
|
||||
setNoMappedFieldsError(null);
|
||||
}
|
||||
|
||||
if(haveProfileErrors)
|
||||
{
|
||||
setTimeout(() =>
|
||||
{
|
||||
document.querySelector(".bulkLoadFieldError")?.scrollIntoView({behavior: "smooth", block: "center", inline: "center"});
|
||||
}, 250);
|
||||
}
|
||||
|
||||
return {maySubmit: !haveProfileErrors && !haveLocalErrors, values};
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
console.log("@dk has header row changed!");
|
||||
}, [bulkLoadMapping.hasHeaderRow]);
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
@ -214,6 +238,8 @@ const BulkLoadFileMappingForm = forwardRef(({processValues, tableMetaData, metaD
|
||||
tableStructure={tableStructure}
|
||||
fileName={processValues.fileBaseName}
|
||||
fieldErrors={fieldErrors}
|
||||
frontendStep={frontendStep}
|
||||
processMetaData={processMetaData}
|
||||
forceParentUpdate={() => forceUpdate()}
|
||||
/>
|
||||
|
||||
@ -221,8 +247,15 @@ const BulkLoadFileMappingForm = forwardRef(({processValues, tableMetaData, metaD
|
||||
<BulkLoadFileMappingFields
|
||||
bulkLoadMapping={bulkLoadMapping}
|
||||
fileDescription={fileDescription}
|
||||
forceParentUpdate={() => forceUpdate()}
|
||||
forceParentUpdate={() =>
|
||||
{
|
||||
setRerenderHeader(rerenderHeader + 1);
|
||||
forceUpdate();
|
||||
}}
|
||||
/>
|
||||
{
|
||||
noMappedFieldsError && <Box color={colors.error.main} textAlign="right">{noMappedFieldsError}</Box>
|
||||
}
|
||||
</Box>
|
||||
|
||||
</Box>);
|
||||
@ -232,8 +265,6 @@ const BulkLoadFileMappingForm = forwardRef(({processValues, tableMetaData, metaD
|
||||
export default BulkLoadFileMappingForm;
|
||||
|
||||
|
||||
|
||||
|
||||
interface BulkLoadMappingHeaderProps
|
||||
{
|
||||
fileDescription: FileDescription,
|
||||
@ -241,13 +272,15 @@ interface BulkLoadMappingHeaderProps
|
||||
bulkLoadMapping?: BulkLoadMapping,
|
||||
fieldErrors: { [fieldName: string]: string },
|
||||
tableStructure: BulkLoadTableStructure,
|
||||
forceParentUpdate?: () => void
|
||||
forceParentUpdate?: () => void,
|
||||
frontendStep: QFrontendStepMetaData,
|
||||
processMetaData: QProcessMetaData,
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** private subcomponent - the header section of the bulk load file mapping screen.
|
||||
***************************************************************************/
|
||||
function BulkLoadMappingHeader({fileDescription, fileName, bulkLoadMapping, fieldErrors, tableStructure, forceParentUpdate}: BulkLoadMappingHeaderProps): JSX.Element
|
||||
function BulkLoadMappingHeader({fileDescription, fileName, bulkLoadMapping, fieldErrors, tableStructure, forceParentUpdate, frontendStep, processMetaData}: BulkLoadMappingHeaderProps): JSX.Element
|
||||
{
|
||||
const viewFields = [
|
||||
new QFieldMetaData({name: "fileName", label: "File Name", type: "STRING"}),
|
||||
@ -261,8 +294,6 @@ function BulkLoadMappingHeader({fileDescription, fileName, bulkLoadMapping, fiel
|
||||
|
||||
const hasHeaderRowFormField = {name: "hasHeaderRow", label: "Does the file have a header row?", type: "checkbox", isRequired: true, isEditable: true};
|
||||
|
||||
let helpRoles = ["PROCESS_SCREEN", "ALL_SCREENS"];
|
||||
|
||||
const layoutOptions = [
|
||||
{label: "Flat", id: "FLAT"},
|
||||
{label: "Tall", id: "TALL"},
|
||||
@ -276,27 +307,55 @@ function BulkLoadMappingHeader({fileDescription, fileName, bulkLoadMapping, fiel
|
||||
|
||||
const selectedLayout = layoutOptions.filter(o => o.id == bulkLoadMapping.layout)[0] ?? null;
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
function hasHeaderRowChanged(newValue: any)
|
||||
{
|
||||
bulkLoadMapping.hasHeaderRow = newValue;
|
||||
fileDescription.hasHeaderRow = newValue;
|
||||
|
||||
bulkLoadMapping.handleChangeToHasHeaderRow(newValue, fileDescription);
|
||||
|
||||
fieldErrors.hasHeaderRow = null;
|
||||
forceParentUpdate();
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
function layoutChanged(event: any, newValue: any)
|
||||
{
|
||||
bulkLoadMapping.layout = newValue ? newValue.id : null;
|
||||
bulkLoadMapping.switchLayout(newValue ? newValue.id : null);
|
||||
fieldErrors.layout = null;
|
||||
forceParentUpdate();
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
function getFormattedHelpContent(fieldName: string): JSX.Element
|
||||
{
|
||||
const field = frontendStep?.formFields?.find(f => f.name == fieldName);
|
||||
let helpRoles = ["PROCESS_SCREEN", "ALL_SCREENS"];
|
||||
|
||||
let formattedHelpContent = <HelpContent helpContents={field?.helpContents} roles={helpRoles} helpContentKey={`process:${processMetaData?.name};field:${fieldName}`} />;
|
||||
if (formattedHelpContent)
|
||||
{
|
||||
const mt = field && field.type == QFieldType.BOOLEAN ? "-0.5rem" : "0.5rem";
|
||||
|
||||
return <Box color="#757575" fontSize="0.875rem" mt={mt}>{formattedHelpContent}</Box>;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<h5>File Details</h5>
|
||||
<Box ml="1rem">
|
||||
<ProcessViewForm fields={viewFields} values={viewValues} columns={2} />
|
||||
<BulkLoadMappingFilePreview fileDescription={fileDescription} />
|
||||
<BulkLoadMappingFilePreview fileDescription={fileDescription} bulkLoadMapping={bulkLoadMapping} />
|
||||
<Grid container pt="1rem">
|
||||
<Grid item xs={12} md={6}>
|
||||
<DynamicFormFieldLabel name={hasHeaderRowFormField.name} label={`${hasHeaderRowFormField.label} *`} />
|
||||
@ -307,6 +366,7 @@ function BulkLoadMappingHeader({fileDescription, fileName, bulkLoadMapping, fiel
|
||||
{<div className="fieldErrorMessage">{fieldErrors.hasHeaderRow}</div>}
|
||||
</MDTypography>
|
||||
}
|
||||
{getFormattedHelpContent("hasHeaderRow")}
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<DynamicFormFieldLabel name={"layout"} label={"File Layout *"} />
|
||||
@ -320,6 +380,7 @@ function BulkLoadMappingHeader({fileDescription, fileName, bulkLoadMapping, fiel
|
||||
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"}}}
|
||||
/>
|
||||
{
|
||||
@ -328,6 +389,7 @@ function BulkLoadMappingHeader({fileDescription, fileName, bulkLoadMapping, fiel
|
||||
{<div className="fieldErrorMessage">{fieldErrors.layout}</div>}
|
||||
</MDTypography>
|
||||
}
|
||||
{getFormattedHelpContent("layout")}
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
@ -336,16 +398,16 @@ function BulkLoadMappingHeader({fileDescription, fileName, bulkLoadMapping, fiel
|
||||
}
|
||||
|
||||
|
||||
|
||||
interface BulkLoadMappingFilePreviewProps
|
||||
{
|
||||
fileDescription: FileDescription;
|
||||
fileDescription: FileDescription,
|
||||
bulkLoadMapping?: BulkLoadMapping
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** private subcomponent - the file-preview section of the bulk load file mapping screen.
|
||||
***************************************************************************/
|
||||
function BulkLoadMappingFilePreview({fileDescription}: BulkLoadMappingFilePreviewProps): JSX.Element
|
||||
function BulkLoadMappingFilePreview({fileDescription, bulkLoadMapping}: BulkLoadMappingFilePreviewProps): JSX.Element
|
||||
{
|
||||
const rows: number[] = [];
|
||||
for (let i = 0; i < fileDescription.bodyValuesPreview[0].length; i++)
|
||||
@ -353,25 +415,145 @@ function BulkLoadMappingFilePreview({fileDescription}: BulkLoadMappingFilePrevie
|
||||
rows.push(i);
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
function getValue(i: number, j: number)
|
||||
{
|
||||
const value = fileDescription.bodyValuesPreview[j][i];
|
||||
if (value == null)
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// this was useful at one point in time when we had an object coming back for xlsx files with many different data types //
|
||||
// we'd see a .string attribute, which would have the value we'd want to show. not using it now, but keep in case //
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// @ts-ignore
|
||||
if (value && value.string)
|
||||
{
|
||||
// @ts-ignore
|
||||
return (value.string);
|
||||
}
|
||||
return `${value}`;
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
function getHeaderColor(count: number): string
|
||||
{
|
||||
if (count > 0)
|
||||
{
|
||||
return "blue";
|
||||
}
|
||||
|
||||
return "black";
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
function getCursor(count: number): string
|
||||
{
|
||||
if (count > 0)
|
||||
{
|
||||
return "pointer";
|
||||
}
|
||||
|
||||
return "default";
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
function getColumnTooltip(fields: BulkLoadField[])
|
||||
{
|
||||
return (<Box>
|
||||
This column is mapped to the field{fields.length == 1 ? "" : "s"}:
|
||||
<ul style={{marginLeft: "1rem"}}>
|
||||
{fields.map((field, i) => <li key={i}>{field.getQualifiedLabel()}</li>)}
|
||||
</ul>
|
||||
</Box>);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{"& table, & td": {border: "1px solid black", borderCollapse: "collapse", padding: "0 0.25rem", fontSize: "0.875rem", whiteSpace: "nowrap"}}}>
|
||||
<Box sx={{width: "100%", overflow: "auto"}}>
|
||||
<table cellSpacing="0" width="100%">
|
||||
<thead>
|
||||
<tr style={{backgroundColor: "#d3d3d3"}}>
|
||||
<tr style={{backgroundColor: "#d3d3d3", height: "1.75rem"}}>
|
||||
<td></td>
|
||||
{fileDescription.headerLetters.map((letter) => <td key={letter} style={{textAlign: "center"}}>{letter}</td>)}
|
||||
{fileDescription.headerLetters.map((letter, index) =>
|
||||
{
|
||||
const fields = bulkLoadMapping.getFieldsForColumnIndex(index);
|
||||
const count = fields.length;
|
||||
|
||||
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>
|
||||
}
|
||||
|
||||
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>
|
||||
}
|
||||
{
|
||||
count == 0 && <Box>{dupeWarning}{letter}</Box>
|
||||
}
|
||||
</>
|
||||
</td>);
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style={{backgroundColor: "#d3d3d3", textAlign: "center"}}>1</td>
|
||||
{fileDescription.headerValues.map((value) => <td key={value} style={{backgroundColor: fileDescription.hasHeaderRow ? "#ebebeb" : ""}}>{value}</td>)}
|
||||
|
||||
{fileDescription.headerValues.map((value, index) =>
|
||||
{
|
||||
const fields = bulkLoadMapping.getFieldsForColumnIndex(index);
|
||||
const count = fields.length;
|
||||
const tdStyle = {color: getHeaderColor(count), cursor: getCursor(count), backgroundColor: ""};
|
||||
|
||||
if(fileDescription.hasHeaderRow)
|
||||
{
|
||||
tdStyle.backgroundColor = "#ebebeb";
|
||||
|
||||
if(count > 0)
|
||||
{
|
||||
return <td key={value} style={tdStyle}>
|
||||
<Tooltip title={getColumnTooltip(fields)} placement="top" enterDelay={500}><Box>{value}</Box></Tooltip>
|
||||
</td>
|
||||
}
|
||||
else
|
||||
{
|
||||
return <td key={value} style={tdStyle}>{value}</td>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
return <td key={value} style={tdStyle}>{value}</td>
|
||||
}
|
||||
}
|
||||
)}
|
||||
</tr>
|
||||
{rows.map((i) => (
|
||||
<tr key={i}>
|
||||
<td style={{backgroundColor: "#d3d3d3", textAlign: "center"}}>{i + 2}</td>
|
||||
{fileDescription.headerLetters.map((letter, j) => <td key={j}>{fileDescription.bodyValuesPreview[j][i]}</td>)}
|
||||
{fileDescription.headerLetters.map((letter, j) => <td key={j}>{getValue(i, j)}</td>)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
@ -266,76 +266,6 @@ function ValidationReview({
|
||||
</List>
|
||||
);
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
function previewRecordUsingTableLayout(record: QRecord)
|
||||
{
|
||||
if (!previewTableMetaData)
|
||||
{
|
||||
return (<Box>Loading...</Box>);
|
||||
}
|
||||
|
||||
const renderedSections: JSX.Element[] = [];
|
||||
const tableSections = TableUtils.getSectionsForRecordSidebar(previewTableMetaData);
|
||||
const previewRecord = previewRecords[previewRecordIndex];
|
||||
|
||||
for (let i = 0; i < tableSections.length; i++)
|
||||
{
|
||||
const section = tableSections[i];
|
||||
if (section.isHidden)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (section.fieldNames)
|
||||
{
|
||||
renderedSections.push(<Box mb="1rem">
|
||||
<Box><h4>{section.label}</h4></Box>
|
||||
<Box ml="1rem">
|
||||
{renderSectionOfFields(section.name, section.fieldNames, previewTableMetaData, false, previewRecord, undefined, {label: {fontWeight: "500"}})}
|
||||
</Box>
|
||||
</Box>);
|
||||
}
|
||||
else if (section.widgetName)
|
||||
{
|
||||
const widget = qInstance.widgets.get(section.widgetName);
|
||||
if (widget)
|
||||
{
|
||||
let data: ChildRecordListData = null;
|
||||
if (associationPreviewsByWidgetName[section.widgetName])
|
||||
{
|
||||
const associationPreview = associationPreviewsByWidgetName[section.widgetName];
|
||||
const associationRecords = previewRecord.associatedRecords.get(associationPreview.associationName) ?? [];
|
||||
data = {
|
||||
canAddChildRecord: false,
|
||||
childTableMetaData: childTableMetaData[associationPreview.tableName],
|
||||
defaultValuesForNewChildRecords: {},
|
||||
disabledFieldsForNewChildRecords: {},
|
||||
queryOutput: {records: associationRecords},
|
||||
totalRows: associationRecords.length,
|
||||
tablePath: "",
|
||||
title: "",
|
||||
viewAllLink: "",
|
||||
};
|
||||
|
||||
renderedSections.push(<Box mb="1rem">
|
||||
{
|
||||
data && <Box>
|
||||
<Box mb="0.5rem"><h4>{section.label}</h4></Box>
|
||||
<Box pl="1rem">
|
||||
<RecordGridWidget data={data} widgetMetaData={widget} disableRowClick gridOnly={true} gridDensity={"compact"} />
|
||||
</Box>
|
||||
</Box>
|
||||
}
|
||||
</Box>);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return renderedSections;
|
||||
}
|
||||
|
||||
const recordPreviewWidget = step.recordListFields && (
|
||||
<Box border="1px solid rgb(70%, 70%, 70%)" borderRadius="10px" p={2} mt={2}>
|
||||
@ -370,11 +300,11 @@ function ValidationReview({
|
||||
{
|
||||
processValues.validationSummary ? (
|
||||
<>
|
||||
It appears as though this process does not contain any valid records.
|
||||
It appears as though this process does not contain any valid records.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
If you choose to Perform Validation, and there are any valid records, then you will see a preview here.
|
||||
If you choose to Perform Validation, and there are any valid records, then you will see a preview here.
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -405,7 +335,15 @@ function ValidationReview({
|
||||
))
|
||||
}
|
||||
{
|
||||
previewRecords && processValues.formatPreviewRecordUsingTableLayout && previewRecords[previewRecordIndex] && previewRecordUsingTableLayout(previewRecords[previewRecordIndex])
|
||||
previewRecords && processValues.formatPreviewRecordUsingTableLayout && previewRecords[previewRecordIndex] &&
|
||||
<PreviewRecordUsingTableLayout
|
||||
index={previewRecordIndex}
|
||||
record={previewRecords[previewRecordIndex]}
|
||||
tableMetaData={previewTableMetaData}
|
||||
qInstance={qInstance}
|
||||
associationPreviewsByWidgetName={associationPreviewsByWidgetName}
|
||||
childTableMetaData={childTableMetaData}
|
||||
/>
|
||||
}
|
||||
</Box>
|
||||
</Box>
|
||||
@ -441,4 +379,84 @@ function ValidationReview({
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
interface PreviewRecordUsingTableLayoutProps
|
||||
{
|
||||
index: number
|
||||
record: QRecord,
|
||||
tableMetaData: QTableMetaData,
|
||||
qInstance: QInstance,
|
||||
associationPreviewsByWidgetName: { [widgetName: string]: AssociationPreview },
|
||||
childTableMetaData: { [name: string]: QTableMetaData },
|
||||
}
|
||||
|
||||
function PreviewRecordUsingTableLayout({record, tableMetaData, qInstance, associationPreviewsByWidgetName, childTableMetaData, index}: PreviewRecordUsingTableLayoutProps): JSX.Element
|
||||
{
|
||||
if (!tableMetaData)
|
||||
{
|
||||
return (<i>Loading...</i>);
|
||||
}
|
||||
|
||||
const renderedSections: JSX.Element[] = [];
|
||||
const tableSections = TableUtils.getSectionsForRecordSidebar(tableMetaData);
|
||||
|
||||
for (let i = 0; i < tableSections.length; i++)
|
||||
{
|
||||
const section = tableSections[i];
|
||||
if (section.isHidden)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (section.fieldNames)
|
||||
{
|
||||
renderedSections.push(<Box mb="1rem">
|
||||
<Box><h4>{section.label}</h4></Box>
|
||||
<Box ml="1rem">
|
||||
{renderSectionOfFields(section.name, section.fieldNames, tableMetaData, false, record, undefined, {label: {fontWeight: "500"}})}
|
||||
</Box>
|
||||
</Box>);
|
||||
}
|
||||
else if (section.widgetName)
|
||||
{
|
||||
const widget = qInstance.widgets.get(section.widgetName);
|
||||
if (widget)
|
||||
{
|
||||
let data: ChildRecordListData = null;
|
||||
if (associationPreviewsByWidgetName[section.widgetName])
|
||||
{
|
||||
const associationPreview = associationPreviewsByWidgetName[section.widgetName];
|
||||
const associationRecords = record.associatedRecords?.get(associationPreview.associationName) ?? [];
|
||||
data = {
|
||||
canAddChildRecord: false,
|
||||
childTableMetaData: childTableMetaData[associationPreview.tableName],
|
||||
defaultValuesForNewChildRecords: {},
|
||||
disabledFieldsForNewChildRecords: {},
|
||||
queryOutput: {records: associationRecords},
|
||||
totalRows: associationRecords.length,
|
||||
tablePath: "",
|
||||
title: "",
|
||||
viewAllLink: "",
|
||||
};
|
||||
|
||||
renderedSections.push(<Box mb="1rem">
|
||||
{
|
||||
data && <Box>
|
||||
<Box mb="0.5rem"><h4>{section.label}</h4></Box>
|
||||
<Box pl="1rem">
|
||||
<RecordGridWidget key={index} data={data} widgetMetaData={widget} disableRowClick gridOnly={true} gridDensity={"compact"} />
|
||||
</Box>
|
||||
</Box>
|
||||
}
|
||||
</Box>);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return <>{renderedSections}</>;
|
||||
}
|
||||
|
||||
|
||||
export default ValidationReview;
|
||||
|
@ -109,6 +109,7 @@ 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});
|
||||
|
@ -118,7 +118,7 @@ const doesOperatorOptionEqualCriteria = (operatorOption: OperatorOption, criteri
|
||||
** autocomplete), given an array of options, the query's active criteria in this
|
||||
** field, and the default operator to use for this field
|
||||
*******************************************************************************/
|
||||
const getOperatorSelectedValue = (operatorOptions: OperatorOption[], criteria: QFilterCriteriaWithId, defaultOperator: QCriteriaOperator): OperatorOption =>
|
||||
const getOperatorSelectedValue = (operatorOptions: OperatorOption[], criteria: QFilterCriteriaWithId, defaultOperator: QCriteriaOperator, return0thOptionInsteadOfNull: boolean = false): OperatorOption =>
|
||||
{
|
||||
if (criteria)
|
||||
{
|
||||
@ -135,6 +135,23 @@ const getOperatorSelectedValue = (operatorOptions: OperatorOption[], criteria: Q
|
||||
return (filteredOptions[0]);
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
console.log("Operator options: " + JSON.stringify(operatorOptions));
|
||||
console.log("Criteria: " + JSON.stringify(criteria));
|
||||
console.log("Default Operator: " + JSON.stringify(defaultOperator));
|
||||
}
|
||||
catch(e)
|
||||
{
|
||||
console.log(`Error in debug output: ${e}`);
|
||||
}
|
||||
|
||||
return operatorOptions[0];
|
||||
}
|
||||
|
||||
return (null);
|
||||
};
|
||||
|
||||
@ -157,7 +174,7 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData
|
||||
const [criteria, setCriteria] = useState(criteriaParamIsCriteria(criteriaParam) ? Object.assign({}, criteriaParam) as QFilterCriteriaWithId : null);
|
||||
const [id, setId] = useState(criteriaParamIsCriteria(criteriaParam) ? (criteriaParam as QFilterCriteriaWithId).id : ++seedId);
|
||||
|
||||
const [operatorSelectedValue, setOperatorSelectedValue] = useState(getOperatorSelectedValue(operatorOptions, criteria, defaultOperator));
|
||||
const [operatorSelectedValue, setOperatorSelectedValue] = useState(getOperatorSelectedValue(operatorOptions, criteria, defaultOperator, true));
|
||||
const [operatorInputValue, setOperatorInputValue] = useState(operatorSelectedValue?.label);
|
||||
|
||||
const {criteriaIsValid, criteriaStatusTooltip} = validateCriteria(criteria, operatorSelectedValue);
|
||||
|
@ -49,7 +49,7 @@ function ScriptDocsForm({helpText, exampleCode, aceEditorHeight}: Props): JSX.El
|
||||
<Card sx={{width: "100%", height: "100%"}}>
|
||||
<Typography variant="h6" p={2} pb={1}>{heading}</Typography>
|
||||
<Box className="devDocumentation" height="100%">
|
||||
<Typography variant="body2" sx={{maxWidth: "1200px", margin: "auto", height: "100%"}}>
|
||||
<Typography variant="body2" sx={{maxWidth: "1200px", margin: "auto", height: "calc(100% - 0.5rem)"}}>
|
||||
<AceEditor
|
||||
mode={mode}
|
||||
theme="github"
|
||||
@ -62,7 +62,7 @@ function ScriptDocsForm({helpText, exampleCode, aceEditorHeight}: Props): JSX.El
|
||||
width="100%"
|
||||
showPrintMargin={false}
|
||||
height="100%"
|
||||
style={{borderBottomRightRadius: "1rem", borderBottomLeftRadius: "1rem"}}
|
||||
style={{borderBottomRightRadius: "0.75rem", borderBottomLeftRadius: "0.75rem"}}
|
||||
/>
|
||||
</Typography>
|
||||
</Box>
|
||||
|
@ -313,6 +313,7 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, reco
|
||||
function deleteChildRecord(name: string, widgetIndex: number, rowIndex: number)
|
||||
{
|
||||
updateChildRecordList(name, "delete", rowIndex);
|
||||
forceUpdate();
|
||||
actionCallback(widgetData[widgetIndex]);
|
||||
};
|
||||
|
||||
@ -368,7 +369,7 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, reco
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
function submitEditChildForm(values: any)
|
||||
function submitEditChildForm(values: any, tableName: string)
|
||||
{
|
||||
updateChildRecordList(showEditChildForm.widgetName, showEditChildForm.rowIndex == null ? "insert" : "edit", showEditChildForm.rowIndex, values);
|
||||
let widgetIndex = determineChildRecordListIndex(showEditChildForm.widgetName);
|
||||
@ -718,6 +719,7 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, reco
|
||||
addNewRecordCallback={widgetData[i]?.isInProcess ? () => openAddChildRecord(widgetMetaData.name, widgetData[i]) : null}
|
||||
widgetMetaData={widgetMetaData}
|
||||
data={widgetData[i]}
|
||||
parentRecord={record}
|
||||
/>
|
||||
)
|
||||
|
||||
|
@ -46,11 +46,12 @@ import React, {useContext, useEffect, useRef, useState} from "react";
|
||||
|
||||
interface FilterAndColumnsSetupWidgetProps
|
||||
{
|
||||
isEditable: boolean;
|
||||
widgetMetaData: QWidgetMetaData;
|
||||
widgetData: any;
|
||||
recordValues: { [name: string]: any };
|
||||
onSaveCallback?: (values: { [name: string]: any }) => void;
|
||||
isEditable: boolean,
|
||||
widgetMetaData: QWidgetMetaData,
|
||||
widgetData: any,
|
||||
recordValues: { [name: string]: any },
|
||||
onSaveCallback?: (values: { [name: string]: any }) => void,
|
||||
label?: string
|
||||
}
|
||||
|
||||
FilterAndColumnsSetupWidget.defaultProps = {
|
||||
@ -83,13 +84,16 @@ const qController = Client.getInstance();
|
||||
/*******************************************************************************
|
||||
** Component for editing the main setup of a report - that is: filter & columns
|
||||
*******************************************************************************/
|
||||
export default function FilterAndColumnsSetupWidget({isEditable, widgetMetaData, widgetData, recordValues, onSaveCallback}: FilterAndColumnsSetupWidgetProps): JSX.Element
|
||||
export default function FilterAndColumnsSetupWidget({isEditable, widgetMetaData, widgetData, recordValues, onSaveCallback, label}: FilterAndColumnsSetupWidgetProps): JSX.Element
|
||||
{
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [hideColumns, setHideColumns] = useState(widgetData?.hideColumns);
|
||||
const [hidePreview, setHidePreview] = useState(widgetData?.hidePreview);
|
||||
const [hideColumns] = useState(widgetData?.hideColumns);
|
||||
const [hidePreview] = useState(widgetData?.hidePreview);
|
||||
const [tableMetaData, setTableMetaData] = useState(null as QTableMetaData);
|
||||
|
||||
const [filterFieldName] = useState(widgetData?.filterFieldName ?? "queryFilterJson");
|
||||
const [columnsFieldName] = useState(widgetData?.columnsFieldName ?? "columnsJson");
|
||||
|
||||
const [alertContent, setAlertContent] = useState(null as string);
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
@ -108,7 +112,7 @@ export default function FilterAndColumnsSetupWidget({isEditable, widgetMetaData,
|
||||
/////////////////////////////
|
||||
let columns: QQueryColumns = null;
|
||||
let usingDefaultEmptyFilter = false;
|
||||
let queryFilter = recordValues["queryFilterJson"] && JSON.parse(recordValues["queryFilterJson"]) as QQueryFilter;
|
||||
let queryFilter = recordValues[filterFieldName] && JSON.parse(recordValues[filterFieldName]) as QQueryFilter;
|
||||
const defaultFilterFields = widgetData?.filterDefaultFieldNames;
|
||||
if (!queryFilter)
|
||||
{
|
||||
@ -142,9 +146,9 @@ export default function FilterAndColumnsSetupWidget({isEditable, widgetMetaData,
|
||||
});
|
||||
}
|
||||
|
||||
if (recordValues["columnsJson"])
|
||||
if (recordValues[columnsFieldName])
|
||||
{
|
||||
columns = QQueryColumns.buildFromJSON(recordValues["columnsJson"]);
|
||||
columns = QQueryColumns.buildFromJSON(recordValues[columnsFieldName]);
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
@ -230,7 +234,10 @@ export default function FilterAndColumnsSetupWidget({isEditable, widgetMetaData,
|
||||
setFrontendQueryFilter(view.queryFilter);
|
||||
const filter = FilterUtils.prepQueryFilterForBackend(tableMetaData, view.queryFilter);
|
||||
|
||||
onSaveCallback({queryFilterJson: JSON.stringify(filter), columnsJson: JSON.stringify(view.queryColumns)});
|
||||
const rs: { [key: string]: any } = {};
|
||||
rs[filterFieldName] = JSON.stringify(filter);
|
||||
rs[columnsFieldName] = JSON.stringify(view.queryColumns);
|
||||
onSaveCallback(rs);
|
||||
|
||||
closeEditor();
|
||||
}
|
||||
@ -356,7 +363,7 @@ export default function FilterAndColumnsSetupWidget({isEditable, widgetMetaData,
|
||||
</Collapse>
|
||||
<Box pt="0.5rem">
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center">
|
||||
<h5>Query Filter</h5>
|
||||
<h5>{label ?? "Query Filter"}</h5>
|
||||
<Box fontSize="0.75rem" fontWeight="700">{mayShowQuery() && getCurrentSortIndicator(frontendQueryFilter, tableMetaData, null)}</Box>
|
||||
</Box>
|
||||
{
|
||||
|
@ -40,7 +40,7 @@ import {Link, useNavigate} from "react-router-dom";
|
||||
export interface ChildRecordListData extends WidgetData
|
||||
{
|
||||
title?: string;
|
||||
queryOutput?: { records: { values: any }[] };
|
||||
queryOutput?: { records: { values: any, displayValues?: any } [] };
|
||||
childTableMetaData?: QTableMetaData;
|
||||
tablePath?: string;
|
||||
viewAllLink?: string;
|
||||
@ -48,20 +48,22 @@ export interface ChildRecordListData extends WidgetData
|
||||
canAddChildRecord?: boolean;
|
||||
defaultValuesForNewChildRecords?: { [fieldName: string]: any };
|
||||
disabledFieldsForNewChildRecords?: { [fieldName: string]: any };
|
||||
defaultValuesForNewChildRecordsFromParentFields?: { [fieldName: string]: string };
|
||||
}
|
||||
|
||||
interface Props
|
||||
{
|
||||
widgetMetaData: QWidgetMetaData;
|
||||
data: ChildRecordListData;
|
||||
addNewRecordCallback?: () => void;
|
||||
disableRowClick: boolean;
|
||||
allowRecordEdit: boolean;
|
||||
editRecordCallback?: (rowIndex: number) => void;
|
||||
allowRecordDelete: boolean;
|
||||
deleteRecordCallback?: (rowIndex: number) => void;
|
||||
gridOnly?: boolean;
|
||||
gridDensity?: GridDensity;
|
||||
widgetMetaData: QWidgetMetaData,
|
||||
data: ChildRecordListData,
|
||||
addNewRecordCallback?: () => void,
|
||||
disableRowClick: boolean,
|
||||
allowRecordEdit: boolean,
|
||||
editRecordCallback?: (rowIndex: number) => void,
|
||||
allowRecordDelete: boolean,
|
||||
deleteRecordCallback?: (rowIndex: number) => void,
|
||||
gridOnly?: boolean,
|
||||
gridDensity?: GridDensity,
|
||||
parentRecord?: QRecord
|
||||
}
|
||||
|
||||
RecordGridWidget.defaultProps =
|
||||
@ -74,7 +76,7 @@ RecordGridWidget.defaultProps =
|
||||
|
||||
const qController = Client.getInstance();
|
||||
|
||||
function RecordGridWidget({widgetMetaData, data, addNewRecordCallback, disableRowClick, allowRecordEdit, editRecordCallback, allowRecordDelete, deleteRecordCallback, gridOnly, gridDensity}: Props): JSX.Element
|
||||
function RecordGridWidget({widgetMetaData, data, addNewRecordCallback, disableRowClick, allowRecordEdit, editRecordCallback, allowRecordDelete, deleteRecordCallback, gridOnly, gridDensity, parentRecord}: Props): JSX.Element
|
||||
{
|
||||
const instance = useRef({timer: null});
|
||||
const [rows, setRows] = useState([]);
|
||||
@ -97,7 +99,7 @@ function RecordGridWidget({widgetMetaData, data, addNewRecordCallback, disableRo
|
||||
{
|
||||
for (let i = 0; i < queryOutputRecords.length; i++)
|
||||
{
|
||||
if(queryOutputRecords[i] instanceof QRecord)
|
||||
if (queryOutputRecords[i] instanceof QRecord)
|
||||
{
|
||||
records.push(queryOutputRecords[i] as QRecord);
|
||||
}
|
||||
@ -109,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, true);
|
||||
const rows = DataGridUtils.makeRows(records, tableMetaData, undefined, true);
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////
|
||||
// note - tablePath may be null, if the user doesn't have access to the table. //
|
||||
@ -252,7 +254,22 @@ function RecordGridWidget({widgetMetaData, data, addNewRecordCallback, disableRo
|
||||
{
|
||||
disabledFields = data.defaultValuesForNewChildRecords;
|
||||
}
|
||||
labelAdditionalComponentsRight.push(new AddNewRecordButton(data.childTableMetaData, data.defaultValuesForNewChildRecords, "Add new", disabledFields, addNewRecordCallback));
|
||||
|
||||
const defaultValuesForNewChildRecords = data.defaultValuesForNewChildRecords || {};
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////
|
||||
// copy values from specified fields in the parent record down into the child record //
|
||||
///////////////////////////////////////////////////////////////////////////////////////
|
||||
if (data.defaultValuesForNewChildRecordsFromParentFields)
|
||||
{
|
||||
for (let childField in data.defaultValuesForNewChildRecordsFromParentFields)
|
||||
{
|
||||
const parentField = data.defaultValuesForNewChildRecordsFromParentFields[childField];
|
||||
defaultValuesForNewChildRecords[childField] = parentRecord?.values?.get(parentField);
|
||||
}
|
||||
}
|
||||
|
||||
labelAdditionalComponentsRight.push(new AddNewRecordButton(data.childTableMetaData, defaultValuesForNewChildRecords, "Add new", disabledFields, addNewRecordCallback));
|
||||
}
|
||||
|
||||
|
||||
@ -357,7 +374,7 @@ function RecordGridWidget({widgetMetaData, data, addNewRecordCallback, disableRo
|
||||
/>
|
||||
);
|
||||
|
||||
if(gridOnly)
|
||||
if (gridOnly)
|
||||
{
|
||||
return (grid);
|
||||
}
|
||||
|
@ -393,7 +393,7 @@ export default function ScriptViewer({scriptId, associatedScriptTableName, assoc
|
||||
}
|
||||
|
||||
return (
|
||||
<Grid container className="scriptViewer" m={-2} pt={4} width={"calc(100% + 2rem)"}>
|
||||
<Grid container className="scriptViewer" my={-3} mx={-3} pt={4} width={"calc(100% + 3rem)"}>
|
||||
<Grid item xs={12}>
|
||||
<Box>
|
||||
{
|
||||
@ -530,7 +530,7 @@ export default function ScriptViewer({scriptId, associatedScriptTableName, assoc
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel index={2} value={selectedTab}>
|
||||
<Box sx={{height: "455px"}} px={2} pb={1}>
|
||||
<Box sx={{height: "455px"}} px={2} pt={1}>
|
||||
<ScriptTestForm scriptId={scriptId}
|
||||
scriptType={scriptTypeRecord}
|
||||
tableName={associatedScriptTableName}
|
||||
@ -543,7 +543,7 @@ export default function ScriptViewer({scriptId, associatedScriptTableName, assoc
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel index={3} value={selectedTab}>
|
||||
<Box sx={{height: "455px"}} px={2} pb={1}>
|
||||
<Box sx={{height: "455px"}} px={2} pt={1}>
|
||||
<ScriptDocsForm helpText={scriptTypeRecord?.values.get("helpText")} exampleCode={scriptTypeRecord?.values.get("sampleCode")} />
|
||||
</Box>
|
||||
</TabPanel>
|
||||
|
@ -35,7 +35,7 @@ export interface ModalEditFormData
|
||||
defaultValues?: { [key: string]: string };
|
||||
disabledFields?: { [key: string]: boolean } | string[];
|
||||
overrideHeading?: string;
|
||||
onSubmitCallback?: (values: any) => void;
|
||||
onSubmitCallback?: (values: any, tableName: String) => void;
|
||||
initialShowModalValue?: boolean;
|
||||
}
|
||||
|
||||
|
@ -21,11 +21,12 @@
|
||||
|
||||
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
|
||||
{
|
||||
@ -80,12 +81,34 @@ 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>
|
||||
<NavBar />
|
||||
<Box>{children}</Box>
|
||||
<Footer company={{href: metaData?.branding?.companyUrl, name: metaData?.branding?.companyName}} />
|
||||
</DashboardLayout>
|
||||
<>
|
||||
<DashboardLayout>
|
||||
{banner()}
|
||||
<NavBar />
|
||||
<Box>{children}</Box>
|
||||
<Footer company={{href: metaData?.branding?.companyUrl, name: metaData?.branding?.companyName}} />
|
||||
</DashboardLayout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -21,6 +21,7 @@
|
||||
|
||||
|
||||
import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
|
||||
import {QFieldType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldType";
|
||||
import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord";
|
||||
|
||||
export type ValueType = "defaultValue" | "column";
|
||||
@ -42,6 +43,7 @@ export class BulkLoadField
|
||||
wideLayoutIndexPath: number[] = [];
|
||||
|
||||
error: string = null;
|
||||
warning: string = null;
|
||||
|
||||
key: string;
|
||||
|
||||
@ -49,7 +51,7 @@ export class BulkLoadField
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
constructor(field: QFieldMetaData, tableStructure: BulkLoadTableStructure, valueType: ValueType = "column", columnIndex?: number, headerName?: string, defaultValue?: any, doValueMapping?: boolean, wideLayoutIndexPath: number[] = [])
|
||||
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;
|
||||
@ -59,6 +61,8 @@ export class BulkLoadField
|
||||
this.defaultValue = defaultValue;
|
||||
this.doValueMapping = doValueMapping;
|
||||
this.wideLayoutIndexPath = wideLayoutIndexPath;
|
||||
this.error = error;
|
||||
this.warning = warning;
|
||||
this.key = new Date().getTime().toString();
|
||||
}
|
||||
|
||||
@ -68,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));
|
||||
return (new BulkLoadField(source.field, source.tableStructure, source.valueType, source.columnIndex, source.headerName, source.defaultValue, source.doValueMapping, source.wideLayoutIndexPath, source.error, source.warning));
|
||||
}
|
||||
|
||||
|
||||
@ -422,17 +426,22 @@ export class BulkLoadMapping
|
||||
}
|
||||
else
|
||||
{
|
||||
index = 0;
|
||||
///////////////////////////////////////////////////////////
|
||||
// count how many copies of this field there are already //
|
||||
///////////////////////////////////////////////////////////
|
||||
///////////////////////////////////////////////
|
||||
// find the max index for this field already //
|
||||
///////////////////////////////////////////////
|
||||
let maxIndex = -1;
|
||||
for (let existingField of [...this.requiredFields, ...this.additionalFields])
|
||||
{
|
||||
if (existingField.getQualifiedName() == bulkLoadField.getQualifiedName())
|
||||
{
|
||||
index++;
|
||||
const thisIndex = existingField.wideLayoutIndexPath[0];
|
||||
if (thisIndex != null && thisIndex != undefined && thisIndex > maxIndex)
|
||||
{
|
||||
maxIndex = thisIndex;
|
||||
}
|
||||
}
|
||||
}
|
||||
index = maxIndex + 1;
|
||||
}
|
||||
|
||||
const cloneField = BulkLoadField.clone(bulkLoadField);
|
||||
@ -455,7 +464,7 @@ export class BulkLoadMapping
|
||||
const newAdditionalFields: BulkLoadField[] = [];
|
||||
for (let bulkLoadField of this.additionalFields)
|
||||
{
|
||||
if (bulkLoadField.getQualifiedName() != toRemove.getQualifiedName())
|
||||
if (bulkLoadField.getQualifiedNameWithWideSuffix() != toRemove.getQualifiedNameWithWideSuffix())
|
||||
{
|
||||
newAdditionalFields.push(bulkLoadField);
|
||||
}
|
||||
@ -463,6 +472,171 @@ export class BulkLoadMapping
|
||||
|
||||
this.additionalFields = newAdditionalFields;
|
||||
}
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
public switchLayout(newLayout: string): void
|
||||
{
|
||||
const newAdditionalFields: BulkLoadField[] = [];
|
||||
let anyChanges = false;
|
||||
|
||||
if ("WIDE" != newLayout)
|
||||
{
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// if going to a layout other than WIDE, make sure there aren't any fields with a wideLayoutIndexPath //
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
const namesWhereOneWideLayoutIndexHasBeenFound: { [name: string]: boolean } = {};
|
||||
for (let existingField of this.additionalFields)
|
||||
{
|
||||
if (existingField.wideLayoutIndexPath.length > 0)
|
||||
{
|
||||
const name = existingField.getQualifiedName();
|
||||
if (namesWhereOneWideLayoutIndexHasBeenFound[name])
|
||||
{
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// in this case, we're on like the 2nd or 3rd instance of, say, Line Item: SKU - so - just discard it. //
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
anyChanges = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// else, this is the 1st instance of, say, Line Item: SKU - so mark that we've found it - and keep this field //
|
||||
// (that is, put it in the new array), but with no index path //
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
namesWhereOneWideLayoutIndexHasBeenFound[name] = true;
|
||||
const newField = BulkLoadField.clone(existingField);
|
||||
newField.wideLayoutIndexPath = [];
|
||||
newAdditionalFields.push(newField);
|
||||
anyChanges = true;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
//////////////////////////////////////////////////////
|
||||
// else, non-wide-path fields, just get added as-is //
|
||||
//////////////////////////////////////////////////////
|
||||
newAdditionalFields.push(existingField);
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// if going to WIDE layout, then any field from a child table needs a wide-layout-index-path //
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////
|
||||
for (let existingField of this.additionalFields)
|
||||
{
|
||||
if (existingField.tableStructure.isMain)
|
||||
{
|
||||
////////////////////////////////////////////
|
||||
// fields from main table come over as-is //
|
||||
////////////////////////////////////////////
|
||||
newAdditionalFields.push(existingField);
|
||||
}
|
||||
else
|
||||
{
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// fields from child tables get a wideLayoutIndexPath (and we're assuming just 1 for each) //
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////
|
||||
const newField = BulkLoadField.clone(existingField);
|
||||
newField.wideLayoutIndexPath = [0];
|
||||
newAdditionalFields.push(newField);
|
||||
anyChanges = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (anyChanges)
|
||||
{
|
||||
this.additionalFields = newAdditionalFields;
|
||||
}
|
||||
|
||||
this.layout = newLayout;
|
||||
}
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
public getFieldsForColumnIndex(i: number): BulkLoadField[]
|
||||
{
|
||||
const rs: BulkLoadField[] = [];
|
||||
|
||||
for (let field of [...this.requiredFields, ...this.additionalFields])
|
||||
{
|
||||
if (field.valueType == "column" && field.columnIndex == i)
|
||||
{
|
||||
rs.push(field);
|
||||
}
|
||||
}
|
||||
|
||||
return (rs);
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
public handleChangeToHasHeaderRow(newValue: any, fileDescription: FileDescription)
|
||||
{
|
||||
const newRequiredFields: BulkLoadField[] = [];
|
||||
let anyChangesToRequiredFields = false;
|
||||
|
||||
const newAdditionalFields: BulkLoadField[] = [];
|
||||
let anyChangesToAdditionalFields = false;
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// if we're switching to have header-rows enabled, then make sure that no columns w/ duplicated headers are selected //
|
||||
// strategy to do this: build new lists of both required & additional fields - and track if we had to change any //
|
||||
// column indexes (set to null) - add a warning to them, and only replace the arrays if there were changes. //
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
if (newValue)
|
||||
{
|
||||
for (let field of this.requiredFields)
|
||||
{
|
||||
if (field.valueType == "column" && fileDescription.duplicateHeaderIndexes[field.columnIndex])
|
||||
{
|
||||
const newField = BulkLoadField.clone(field);
|
||||
newField.columnIndex = null;
|
||||
newField.warning = "This field was assigned to a column with a duplicated header"
|
||||
newRequiredFields.push(newField);
|
||||
anyChangesToRequiredFields = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
newRequiredFields.push(field);
|
||||
}
|
||||
}
|
||||
|
||||
for (let field of this.additionalFields)
|
||||
{
|
||||
if (field.valueType == "column" && fileDescription.duplicateHeaderIndexes[field.columnIndex])
|
||||
{
|
||||
const newField = BulkLoadField.clone(field);
|
||||
newField.columnIndex = null;
|
||||
newField.warning = "This field was assigned to a column with a duplicated header"
|
||||
newAdditionalFields.push(newField);
|
||||
anyChangesToAdditionalFields = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
newAdditionalFields.push(field);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (anyChangesToRequiredFields)
|
||||
{
|
||||
this.requiredFields = newRequiredFields;
|
||||
}
|
||||
|
||||
if (anyChangesToAdditionalFields)
|
||||
{
|
||||
this.additionalFields = newAdditionalFields;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -475,6 +649,8 @@ export class FileDescription
|
||||
headerLetters: string[];
|
||||
bodyValuesPreview: string[][];
|
||||
|
||||
duplicateHeaderIndexes: boolean[];
|
||||
|
||||
// todo - just get this from the profile always - it's not part of the file per-se
|
||||
hasHeaderRow: boolean = true;
|
||||
|
||||
@ -486,6 +662,18 @@ export class FileDescription
|
||||
this.headerValues = headerValues;
|
||||
this.headerLetters = headerLetters;
|
||||
this.bodyValuesPreview = bodyValuesPreview;
|
||||
|
||||
this.duplicateHeaderIndexes = [];
|
||||
const usedLabels: { [label: string]: boolean } = {};
|
||||
for (let i = 0; i < headerValues.length; i++)
|
||||
{
|
||||
const label = headerValues[i];
|
||||
if (usedLabels[label])
|
||||
{
|
||||
this.duplicateHeaderIndexes[i] = true;
|
||||
}
|
||||
usedLabels[label] = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -517,21 +705,85 @@ export class FileDescription
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
public getPreviewValues(columnIndex: number): string[]
|
||||
public getPreviewValues(columnIndex: number, fieldType?: QFieldType): string[]
|
||||
{
|
||||
if (columnIndex == undefined)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
if (this.hasHeaderRow)
|
||||
function getTypedValue(value: any): string
|
||||
{
|
||||
return (this.bodyValuesPreview[columnIndex]);
|
||||
if (value == null)
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// this was useful at one point in time when we had an object coming back for xlsx files with many different data types //
|
||||
// we'd see a .string attribute, which would have the value we'd want to show. not using it now, but keep in case //
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
if (value && value.string)
|
||||
{
|
||||
switch (fieldType)
|
||||
{
|
||||
case QFieldType.BOOLEAN:
|
||||
{
|
||||
return value.bool;
|
||||
}
|
||||
|
||||
case QFieldType.STRING:
|
||||
case QFieldType.TEXT:
|
||||
case QFieldType.HTML:
|
||||
case QFieldType.PASSWORD:
|
||||
{
|
||||
return value.string;
|
||||
}
|
||||
|
||||
case QFieldType.INTEGER:
|
||||
case QFieldType.LONG:
|
||||
{
|
||||
return value.integer;
|
||||
}
|
||||
case QFieldType.DECIMAL:
|
||||
{
|
||||
return value.decimal;
|
||||
}
|
||||
case QFieldType.DATE:
|
||||
{
|
||||
return value.date;
|
||||
}
|
||||
case QFieldType.TIME:
|
||||
{
|
||||
return value.time;
|
||||
}
|
||||
case QFieldType.DATE_TIME:
|
||||
{
|
||||
return value.dateTime;
|
||||
}
|
||||
case QFieldType.BLOB:
|
||||
return ""; // !!
|
||||
}
|
||||
}
|
||||
|
||||
return (`${value}`);
|
||||
}
|
||||
else
|
||||
|
||||
const valueArray: string[] = [];
|
||||
|
||||
if (!this.hasHeaderRow)
|
||||
{
|
||||
return ([this.headerValues[columnIndex], ...this.bodyValuesPreview[columnIndex]]);
|
||||
const typedValue = getTypedValue(this.headerValues[columnIndex]);
|
||||
valueArray.push(typedValue == null ? "" : `${typedValue}`);
|
||||
}
|
||||
|
||||
for (let value of this.bodyValuesPreview[columnIndex])
|
||||
{
|
||||
const typedValue = getTypedValue(value);
|
||||
valueArray.push(typedValue == null ? "" : `${typedValue}`);
|
||||
}
|
||||
|
||||
return (valueArray);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1032,9 +1032,11 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
|
||||
<BulkLoadFileMappingForm
|
||||
processValues={processValues}
|
||||
tableMetaData={tableMetaData}
|
||||
processMetaData={processMetaData}
|
||||
metaData={qInstance}
|
||||
ref={bulkLoadFileMappingFormRef}
|
||||
setActiveStepLabel={setActiveStepLabel}
|
||||
frontendStep={activeStep}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@ -1836,6 +1838,20 @@ 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)
|
||||
@ -2220,7 +2236,7 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
|
||||
if (isModal)
|
||||
{
|
||||
return (
|
||||
<Box sx={{position: "absolute", overflowY: "auto", maxHeight: "100%", width: "100%"}}>
|
||||
<Box sx={{position: "absolute", overflowY: "auto", maxHeight: "100%", width: "100%"}} id="modalProcessScrollContainer">
|
||||
{body}
|
||||
</Box>
|
||||
);
|
||||
|
@ -1103,7 +1103,7 @@ const RecordQuery = forwardRef(({table, usage, isModal, isPreview, allowVariable
|
||||
////////////////////////////////
|
||||
// make the rows for the grid //
|
||||
////////////////////////////////
|
||||
const rows = DataGridUtils.makeRows(results, tableMetaData);
|
||||
const rows = DataGridUtils.makeRows(results, tableMetaData, tableVariant);
|
||||
setRows(rows);
|
||||
|
||||
setLoading(false);
|
||||
@ -1612,6 +1612,22 @@ 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);
|
||||
|
@ -191,7 +191,7 @@ function RecordDeveloperView({table}: Props): JSX.Element
|
||||
<Card sx={{mb: 3}}>
|
||||
<Typography variant="h6" p={2} pl={3} pb={3}>{field?.label}</Typography>
|
||||
|
||||
<Box display="flex" alignItems="center" justifyContent="space-between" gap={2}>
|
||||
<Box display="flex" alignItems="center" justifyContent="space-between" gap={2} mx={3} mb={3} mt={0}>
|
||||
{scriptId ?
|
||||
<ScriptViewer
|
||||
scriptId={scriptId}
|
||||
|
@ -92,9 +92,9 @@ 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})
|
||||
export function renderSectionOfFields(key: string, fieldNames: string[], tableMetaData: QTableMetaData, helpHelpActive: boolean, record: QRecord, fieldMap?: { [name: string]: QFieldMetaData }, styleOverrides?: {label?: SxProps, value?: SxProps}, tableVariant?: QTableVariant)
|
||||
{
|
||||
return <Box key={key} display="flex" flexDirection="column" py={1} pr={2}>
|
||||
return <Grid container lg={12} key={key} display="flex" py={1} pr={2}>
|
||||
{
|
||||
fieldNames.map((fieldName: string) =>
|
||||
{
|
||||
@ -103,6 +103,7 @@ export function renderSectionOfFields(key: string, fieldNames: string[], tableMe
|
||||
if (field != null)
|
||||
{
|
||||
let label = field.label;
|
||||
let gridColumns = (field.gridColumns && field.gridColumns > 0) ? field.gridColumns : 12;
|
||||
|
||||
const helpRoles = ["VIEW_SCREEN", "READ_SCREENS", "ALL_SCREENS"];
|
||||
const showHelp = helpHelpActive || hasHelpContent(field.helpContents, helpRoles);
|
||||
@ -111,22 +112,22 @@ export function renderSectionOfFields(key: string, fieldNames: string[], tableMe
|
||||
const labelElement = <Typography variant="button" textTransform="none" fontWeight="bold" pr={1} color="rgb(52, 71, 103)" sx={{cursor: "default", ...(styleOverrides?.label ?? {})}}>{label}:</Typography>;
|
||||
|
||||
return (
|
||||
<Box key={fieldName} flexDirection="row" pr={2}>
|
||||
<Grid item key={fieldName} lg={gridColumns} flexDirection="column" pr={2}>
|
||||
<>
|
||||
{
|
||||
showHelp && formattedHelpContent ? <Tooltip title={formattedHelpContent}>{labelElement}</Tooltip> : labelElement
|
||||
}
|
||||
<div style={{display: "inline-block", width: 0}}> </div>
|
||||
<Typography variant="button" textTransform="none" fontWeight="regular" color="rgb(123, 128, 154)" sx={{...(styleOverrides?.value ?? {})}}>
|
||||
{ValueUtils.getDisplayValue(field, record, "view", fieldName)}
|
||||
{ValueUtils.getDisplayValue(field, record, "view", fieldName, tableVariant)}
|
||||
</Typography>
|
||||
</>
|
||||
</Box>
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
})
|
||||
}
|
||||
</Box>;
|
||||
</Grid>;
|
||||
}
|
||||
|
||||
|
||||
@ -597,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);
|
||||
const fields = renderSectionOfFields(section.name, section.fieldNames, tableMetaData, helpHelpActive, record, undefined, undefined, tableVariant);
|
||||
|
||||
if (section.tier === "T1")
|
||||
{
|
||||
|
@ -748,35 +748,54 @@ input[type="search"]::-webkit-search-results-decoration
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.helpContentAlert.success
|
||||
.helpContentAlert.info,
|
||||
.banner.info
|
||||
{
|
||||
background-color: rgb(234, 242, 255);
|
||||
color: rgb(20, 51, 102);
|
||||
}
|
||||
|
||||
.helpContentAlert.info .MuiAlert-icon .material-icons-round,
|
||||
.banner.info .MuiAlert-icon .material-icons-round
|
||||
{
|
||||
color: #0062FF;
|
||||
}
|
||||
|
||||
.helpContentAlert.success,
|
||||
.banner.success
|
||||
{
|
||||
background-color: rgb(240, 248, 241);
|
||||
color: rgb(44, 76, 46);
|
||||
}
|
||||
|
||||
.helpContentAlert.success .MuiAlert-icon .material-icons-round
|
||||
.helpContentAlert.success .MuiAlert-icon .material-icons-round,
|
||||
.banner.success .MuiAlert-icon .material-icons-round
|
||||
{
|
||||
color: #4CAF50;
|
||||
}
|
||||
|
||||
.helpContentAlert.warning
|
||||
.helpContentAlert.warning,
|
||||
.banner.warning
|
||||
{
|
||||
background-color: rgb(254, 245, 234);
|
||||
color: rgb(100, 65, 20);
|
||||
}
|
||||
|
||||
.helpContentAlert.warning .MuiAlert-icon .material-icons-round
|
||||
.helpContentAlert.warning .MuiAlert-icon .material-icons-round,
|
||||
.banner.warning .MuiAlert-icon .material-icons-round
|
||||
{
|
||||
color: #fb8c00;
|
||||
}
|
||||
|
||||
.helpContentAlert.error
|
||||
.helpContentAlert.error,
|
||||
.banner.error
|
||||
{
|
||||
background-color: rgb(254, 239, 238);
|
||||
color: rgb(98, 41, 37);
|
||||
}
|
||||
|
||||
.helpContentAlert.error .MuiAlert-icon .material-icons-round
|
||||
.helpContentAlert.error .MuiAlert-icon .material-icons-round,
|
||||
.banner.error .MuiAlert-icon .material-icons-round
|
||||
{
|
||||
color: #F44335;
|
||||
}
|
||||
|
@ -24,6 +24,7 @@ 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";
|
||||
@ -70,7 +71,7 @@ export default class DataGridUtils
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public static makeRows = (results: QRecord[], tableMetaData: QTableMetaData, allowEmptyId = false): GridRowsProp[] =>
|
||||
public static makeRows = (results: QRecord[], tableMetaData: QTableMetaData, tableVariant?: QTableVariant, allowEmptyId = false): GridRowsProp[] =>
|
||||
{
|
||||
const fields = [...tableMetaData.fields.values()];
|
||||
const rows = [] as any[];
|
||||
@ -82,7 +83,7 @@ export default class DataGridUtils
|
||||
|
||||
fields.forEach((field) =>
|
||||
{
|
||||
row[field.name] = ValueUtils.getDisplayValue(field, record, "query");
|
||||
row[field.name] = ValueUtils.getDisplayValue(field, record, "query", undefined, tableVariant);
|
||||
});
|
||||
|
||||
if (tableMetaData.exposedJoins)
|
||||
@ -97,7 +98,7 @@ export default class DataGridUtils
|
||||
fields.forEach((field) =>
|
||||
{
|
||||
let fieldName = join.joinTable.name + "." + field.name;
|
||||
row[fieldName] = ValueUtils.getDisplayValue(field, record, "query", fieldName);
|
||||
row[fieldName] = ValueUtils.getDisplayValue(field, record, "query", fieldName, tableVariant);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
|
||||
|
@ -37,7 +37,7 @@ export class SavedBulkLoadProfileUtils
|
||||
|
||||
for (let bulkLoadField of orderedFieldArray)
|
||||
{
|
||||
const fieldName = bulkLoadField.field.name;
|
||||
const fieldName = bulkLoadField.getQualifiedName()
|
||||
const compareField = compareFieldsMap[fieldName];
|
||||
const baseField = baseFieldsMap[fieldName];
|
||||
if(!compareField)
|
||||
@ -55,12 +55,13 @@ export class SavedBulkLoadProfileUtils
|
||||
if (compareField.valueType == "column")
|
||||
{
|
||||
const column = fileDescription.getColumnNames()[compareField.columnIndex];
|
||||
rs.push(`Changed ${compareField.getQualifiedLabel()} from using a default value (${baseField.defaultValue}) to using a file column (${column})`);
|
||||
rs.push(`Changed ${compareField.getQualifiedLabel()} from using a default value (${baseField.defaultValue}) to using a file column ${column ? `(${column})` : ""}`);
|
||||
}
|
||||
else if (compareField.valueType == "defaultValue")
|
||||
{
|
||||
const column = fileDescription.getColumnNames()[baseField.columnIndex];
|
||||
rs.push(`Changed ${compareField.getQualifiedLabel()} from using a file column (${column}) to using a default value (${compareField.defaultValue})`);
|
||||
const value = compareField.defaultValue;
|
||||
rs.push(`Changed ${compareField.getQualifiedLabel()} from using a file column (${column}) to using a default value ${value === undefined ? "" : `(${value})`}`);
|
||||
}
|
||||
}
|
||||
else if (baseField.valueType == compareField.valueType && baseField.valueType == "defaultValue")
|
||||
@ -70,7 +71,8 @@ export class SavedBulkLoadProfileUtils
|
||||
//////////////////////////////////////////////////
|
||||
if (baseField.defaultValue != compareField.defaultValue)
|
||||
{
|
||||
rs.push(`Changed ${compareField.getQualifiedLabel()} default value from (${baseField.defaultValue}) to (${compareField.defaultValue})`);
|
||||
const value = compareField.defaultValue;
|
||||
rs.push(`Changed ${compareField.getQualifiedLabel()} default value from (${baseField.defaultValue}) to ${value === undefined ? "" : `(${value})`}`);
|
||||
}
|
||||
}
|
||||
else if (baseField.valueType == compareField.valueType && baseField.valueType == "column")
|
||||
@ -78,25 +80,29 @@ export class SavedBulkLoadProfileUtils
|
||||
///////////////////////////////////////////
|
||||
// if we changed the column, report that //
|
||||
///////////////////////////////////////////
|
||||
let isDiff = false;
|
||||
if (fileDescription.hasHeaderRow)
|
||||
{
|
||||
if (baseField.headerName != compareField.headerName)
|
||||
{
|
||||
const baseColumn = fileDescription.getColumnNames()[baseField.columnIndex];
|
||||
const compareColumn = fileDescription.getColumnNames()[compareField.columnIndex];
|
||||
rs.push(`Changed ${compareField.getQualifiedLabel()} file column from (${baseColumn}) to (${compareColumn})`);
|
||||
isDiff = true;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (baseField.columnIndex != compareField.columnIndex)
|
||||
{
|
||||
const baseColumn = fileDescription.getColumnNames()[baseField.columnIndex];
|
||||
const compareColumn = fileDescription.getColumnNames()[compareField.columnIndex];
|
||||
rs.push(`Changed ${compareField.getQualifiedLabel()} file column from (${baseColumn}) to (${compareColumn})`);
|
||||
isDiff = true;
|
||||
}
|
||||
}
|
||||
|
||||
if(isDiff)
|
||||
{
|
||||
const baseColumn = fileDescription.getColumnNames()[baseField.columnIndex];
|
||||
const compareColumn = fileDescription.getColumnNames()[compareField.columnIndex];
|
||||
rs.push(`Changed ${compareField.getQualifiedLabel()} file column from ${baseColumn ? `(${baseColumn})` : "--"} to ${compareColumn ? `(${compareColumn})` : "--"}`);
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// if the do-value-mapping field changed, report that (note, only if was and still is column-type) //
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
@ -120,7 +126,7 @@ export class SavedBulkLoadProfileUtils
|
||||
|
||||
for (let bulkLoadField of orderedFieldArray)
|
||||
{
|
||||
const fieldName = bulkLoadField.field.name;
|
||||
const fieldName = bulkLoadField.getQualifiedName()
|
||||
const compareField = compareFieldsMap[fieldName];
|
||||
if(!compareField)
|
||||
{
|
||||
@ -292,7 +298,7 @@ export class SavedBulkLoadProfileUtils
|
||||
{
|
||||
try
|
||||
{
|
||||
const fieldName = bulkLoadField.field.name;
|
||||
const fieldName = bulkLoadField.getQualifiedName() // todo - does this (and the others calls to this) need suffix?
|
||||
|
||||
const valueMappingDiff = this.diffFieldValueMappings(bulkLoadField, baseMapping.valueMappings[fieldName] ?? {}, activeMapping.valueMappings[fieldName] ?? {});
|
||||
if(valueMappingDiff)
|
||||
|
@ -23,6 +23,7 @@ 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";
|
||||
@ -76,14 +77,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): string | JSX.Element | JSX.Element[]
|
||||
public static getDisplayValue(field: QFieldMetaData, record: QRecord, usage: "view" | "query" = "view", overrideFieldName?: string, tableVariant?: QTableVariant): 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);
|
||||
return ValueUtils.getValueForDisplay(field, rawValue, displayValue, usage, tableVariant, record, fieldName);
|
||||
}
|
||||
|
||||
|
||||
@ -91,14 +92,35 @@ 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"): string | JSX.Element | JSX.Element[]
|
||||
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[]
|
||||
{
|
||||
if (field.hasAdornment(AdornmentType.LINK))
|
||||
{
|
||||
const adornment = field.getAdornment(AdornmentType.LINK);
|
||||
let href = rawValue;
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
const toRecordFromTable = adornment.getValue("toRecordFromTable");
|
||||
if (toRecordFromTable)
|
||||
{
|
||||
if (ValueUtils.getQInstance())
|
||||
@ -107,7 +129,7 @@ class ValueUtils
|
||||
if (!tablePath)
|
||||
{
|
||||
console.log("Couldn't find path for table: " + toRecordFromTable);
|
||||
return (displayValue ?? rawValue);
|
||||
return (ValueUtils.getUnadornedValueForDisplay(field, rawValue, displayValue));
|
||||
}
|
||||
|
||||
if (!tablePath.endsWith("/"))
|
||||
@ -199,12 +221,44 @@ class ValueUtils
|
||||
|
||||
if (field.type == QFieldType.BLOB || field.hasAdornment(AdornmentType.FILE_DOWNLOAD))
|
||||
{
|
||||
return (<BlobComponent field={field} url={rawValue} filename={displayValue} usage={usage} />);
|
||||
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 (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.
|
||||
@ -213,7 +267,13 @@ class ValueUtils
|
||||
{
|
||||
if (!displayValue && field.defaultValue)
|
||||
{
|
||||
displayValue = field.defaultValue;
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// note, at one point in time, we used a field's default value here if no displayValue... but that feels 100% wrong, //
|
||||
// e.g., a null field would show up (on a query or view screen) has having some value! //
|
||||
// not sure if this was maybe supposed to be displayValue = rawValue, but, keep that in mind, and keep this block here //
|
||||
// in case we run into issues and need to revisit/rethink //
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// displayValue = field.defaultValue;
|
||||
}
|
||||
|
||||
if (field.type === QFieldType.DATE_TIME)
|
||||
|
Reference in New Issue
Block a user