Compare commits

..

16 Commits

Author SHA1 Message Date
185775ca4d Merge branch 'release/0.25.0' 2025-05-20 07:52:28 -05:00
ce91f68088 Update versions for release 2025-05-20 07:06:35 -05:00
81da1a4627 Merged feature/oauth2-authentication-module into dev 2025-05-19 20:33:35 -05:00
b279a04b43 quick bug fix for goto fields 2025-04-16 16:45:46 -05:00
1f2e57d688 Merged feature/better-goto-behavior into dev 2025-04-09 11:14:14 -05:00
52bb7ba411 Merged feature/disable-show-default-vs-display-value into dev 2025-04-09 11:14:02 -05:00
34c6f650b5 updated to handle (ignore) fields with empty strings when using goto dialog 2025-04-07 16:50:01 -05:00
d792c23035 Cleanup from code review 2025-04-05 19:58:35 -05:00
e3d30633f1 Refactor authentication handling to pass authentication metadata into App.
eliminates warnings from oauth2 hook by conditionally using its useAuth hook.
2025-04-05 19:37:02 -05:00
a6ee682671 Merged feature/dk-misc-20250318 into dev 2025-04-03 14:28:34 -05:00
c62252075f Merged feature/banners into dev 2025-04-03 14:26:13 -05:00
debc6f3ebf turn off replacing of displayValue with defaultValue 2025-04-02 12:10:39 -05:00
679375ba63 update processClicked to set alert if min/max input records isn't satisfied 2025-03-18 11:44:32 -05:00
fb10dad803 Add support for query-param defaultProcessValues (as a json object) 2025-03-18 11:40:22 -05:00
c9a618c7f6 Fix full-width file upload adornment for lg-size (regressed with field-level grid columns addition) 2025-03-10 12:12:37 -05:00
13ce684d23 Initial checkin of Banners under QBrandingMetaData
- includes migration from (now deprecated) MetaDataFilterInterface to MetaDataActionCustomizerInterface (stored on the QInstance and used by MetaDataAction)
- includes migration from (now deprecated) environmentBannerText and environmentBannerColor in QBrandingMetaData to now be implemented as a banner
2025-03-07 14:58:51 -06:00
18 changed files with 357 additions and 102 deletions

1
.gitignore vendored
View File

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

View File

@ -6,7 +6,7 @@
"@auth0/auth0-react": "1.10.2",
"@emotion/react": "11.7.1",
"@emotion/styled": "11.6.0",
"@kingsrook/qqq-frontend-core": "1.0.118",
"@kingsrook/qqq-frontend-core": "1.0.119",
"@mui/icons-material": "5.4.1",
"@mui/material": "5.11.1",
"@mui/styles": "5.11.1",

View File

@ -29,7 +29,7 @@
<packaging>jar</packaging>
<properties>
<revision>0.25.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>

View File

@ -28,6 +28,7 @@ import {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstan
import {QProcessMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QProcessMetaData";
import {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";
@ -39,6 +40,7 @@ import useAuth0AuthenticationModule from "qqq/authorization/auth0/useAuth0Authen
import useOAuth2AuthenticationModule from "qqq/authorization/oauth2/useOAuth2AuthenticationModule";
import Sidenav from "qqq/components/horseshoe/sidenav/SideNav";
import theme from "qqq/components/legacy/Theme";
import {getBannerClassName, getBannerStyles, getBanner, makeBannerContent} from "qqq/components/misc/Banners";
import {setMiniSidenav, useMaterialUIController} from "qqq/context";
import AppHome from "qqq/pages/apps/Home";
import NoApps from "qqq/pages/apps/NoApps";
@ -63,9 +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]);
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,11 +81,10 @@ 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 [authenticationMetaData, setAuthenticationMetaData] = useState(null as QAuthenticationMetaData | null);
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});
const {setupSession: oauth2SetupSession, logout: oauth2Logout} = useOAuth2AuthenticationModule({setIsFullyAuthenticated, setLoggedInUser, setEarlyReturnForAuth, inOAuthContext: authenticationMetaData.type === "OAUTH2"});
const {setupSession: anonymousSetupSession, logout: anonymousLogout} = useAnonymousAuthenticationModule({setIsFullyAuthenticated, setLoggedInUser, setEarlyReturnForAuth});
/////////////////////////////////////////////////////////
@ -99,9 +105,6 @@ export default function App()
(async () =>
{
const authenticationMetaData: QAuthenticationMetaData = await qController.getAuthenticationMetaData();
setAuthenticationMetaData(authenticationMetaData);
if (authenticationMetaData.type === "AUTH_0")
{
await auth0SetupSession();
@ -134,7 +137,7 @@ export default function App()
}
/***************************************************************************
** call approprite logout function based on authentication meta data type
** call appropriate logout function based on authentication meta data type
***************************************************************************/
function doLogout()
{
@ -157,7 +160,7 @@ export default function App()
}
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();
@ -450,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",
@ -630,6 +632,23 @@ export default function App()
}
/***************************************************************************
**
***************************************************************************/
function banner(): JSX.Element | null
{
const banner = getBanner(metaData?.branding, "QFMD_TOP_OF_SITE");
if (!banner)
{
return (null);
}
return (<Box className={getBannerClassName(banner)} sx={{display: "flex", justifyContent: "center", padding: "0.5rem", position: "sticky", top: "0", zIndex: 1, ...getBannerStyles(banner)}}>
{makeBannerContent(banner)}
</Box>);
}
return (
appRoutes && (
@ -657,6 +676,7 @@ export default function App()
<ThemeProvider theme={theme}>
<CssBaseline />
<CommandMenu metaData={metaData} />
{banner()}
<Sidenav
color={sidenavColor}
icon={branding.icon}

View File

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

View File

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

View File

@ -41,11 +41,11 @@ interface Props
/***************************************************************************
** hook for working with the Auth0 authentication module
***************************************************************************/
export default function useAuth0AuthenticationModule({setIsFullyAuthenticated, setLoggedInUser, setEarlyReturnForAuth}: Props)
export default function useAuth0AuthenticationModule({setIsFullyAuthenticated, setLoggedInUser}: Props)
{
const {user: auth0User, getAccessTokenSilently: auth0GetAccessTokenSilently, logout: useAuth0Logout} = useAuth0();
const [cookies, setCookie, removeCookie] = useCookies([SESSION_UUID_COOKIE_NAME]);
const [cookies, removeCookie] = useCookies([SESSION_UUID_COOKIE_NAME]);
/***************************************************************************
@ -119,12 +119,7 @@ export default function useAuth0AuthenticationModule({setIsFullyAuthenticated, s
if (shouldStoreNewToken(accessToken, lsAccessToken))
{
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: "/"});
const {uuid: values} = await qController.manageSession(accessToken, null);
localStorage.setItem("accessToken", accessToken);
localStorage.setItem("sessionValues", JSON.stringify(values));
@ -199,7 +194,7 @@ export default function useAuth0AuthenticationModule({setIsFullyAuthenticated, s
/***************************************************************************
**
***************************************************************************/
const renderAppWrapper = (authenticationMetaData: QAuthenticationMetaData, children: JSX.Element): JSX.Element =>
const renderAppWrapper = (authenticationMetaData: QAuthenticationMetaData): JSX.Element =>
{
// @ts-ignore
let domain: string = authenticationMetaData.data.baseUrl;
@ -225,6 +220,15 @@ export default function useAuth0AuthenticationModule({setIsFullyAuthenticated, s
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}
@ -232,7 +236,7 @@ export default function useAuth0AuthenticationModule({setIsFullyAuthenticated, s
audience={audience}
redirectUri={`${window.location.origin}/`}>
<MaterialUIControllerProvider>
<ProtectedRoute component={App} />
<ProtectedRoute component={WrappedApp} />
</MaterialUIControllerProvider>
</Auth0ProviderWithRedirectCallback>
);

View File

@ -23,7 +23,7 @@ import {QAuthenticationMetaData} from "@kingsrook/qqq-frontend-core/lib/model/me
import {SESSION_UUID_COOKIE_NAME} from "App";
import Client from "qqq/utils/qqq/Client";
import {useCookies} from "react-cookie";
import {AuthProvider, useAuth} from "react-oidc-context";
import {AuthContextProps, AuthProvider, useAuth} from "react-oidc-context";
import {useNavigate, useSearchParams} from "react-router-dom";
const qController = Client.getInstance();
@ -33,16 +33,22 @@ 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}: Props)
export default function useOAuth2AuthenticationModule({setIsFullyAuthenticated, setLoggedInUser, setEarlyReturnForAuth, inOAuthContext}: Props)
{
const authOidc = useAuth();
///////////////////////////////////////////////////////////////////////////////////////
// the useAuth hook should only be called if we're inside the <AuthProvider> element //
// so on the page that uses this hook to call renderAppWrapper, we aren't in that //
// element/context, thus, don't call that hook. //
///////////////////////////////////////////////////////////////////////////////////////
const authOidc: AuthContextProps | null = inOAuthContext ? useAuth() : null;
const [cookies, setCookie, removeCookie] = useCookies([SESSION_UUID_COOKIE_NAME]);
const [cookies, removeCookie] = useCookies([SESSION_UUID_COOKIE_NAME]);
const [searchParams] = useSearchParams();
const navigate = useNavigate();
@ -56,6 +62,12 @@ export default function useOAuth2AuthenticationModule({setIsFullyAuthenticated,
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}`);
@ -77,14 +89,24 @@ export default function useOAuth2AuthenticationModule({setIsFullyAuthenticated,
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 {uuid: newSessionUuid, values} = await qController.manageSession(null, sessionUuid, null);
const {values} = await qController.manageSession(null, sessionUuid, null);
setIsFullyAuthenticated(true);
qController.setGotAuthentication();
@ -94,45 +116,16 @@ export default function useOAuth2AuthenticationModule({setIsFullyAuthenticated,
}
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();
authOidc?.signinRedirect();
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// this is what's in the docs, but, it sure doesn't seem to ever hit any case other than the signinRedirect block //
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
/*
if (authOidc.isLoading)
{
setLoadingToken(false); //? so we can come back in? but i'm missing something here.
setEarlyReturnForAuth(<div>
<div>Loading...</div>
<button onClick={() => incrementCheckLoadingCounter()}>check again?</button>
</div>);
}
else if (authOidc.error)
{
setEarlyReturnForAuth(<div>Error: {authOidc.error.message}</div>);
}
else if (authOidc.isAuthenticated)
{
setEarlyReturnForAuth(
<div>
Welcome, {authOidc.user?.profile.name}!
<button onClick={() => authOidc.signoutRedirect()}>Log out</button>
</div>
);
}
else
{
localStorage.setItem(preSigninRedirectPathnameKey, window.location.pathname);
setEarlyReturnForAuth(<div>Signing in...</div>);
authOidc.signinRedirect();
}
*/
}
}
catch (e)
@ -151,7 +144,7 @@ export default function useOAuth2AuthenticationModule({setIsFullyAuthenticated,
{
qController.clearAuthenticationMetaDataLocalStorage();
removeCookie(SESSION_UUID_COOKIE_NAME, {path: "/"});
authOidc.signoutRedirect();
authOidc?.signoutRedirect();
};

View File

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

View File

@ -32,6 +32,7 @@ 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";
@ -301,6 +302,30 @@ function Sidenav({color, icon, logo, appName, branding, routes, logout, ...rest}
}
);
/***************************************************************************
**
***************************************************************************/
function EnvironmentBanner({branding}: { branding: QBrandingMetaData }): JSX.Element | null
{
// deprecated!
if (branding && branding.environmentBannerText)
{
return <Box mt={2} bgcolor={branding.environmentBannerColor} borderRadius={2}>
{branding.environmentBannerText}
</Box>;
}
const banner = getBanner(branding, "QFMD_SIDE_NAV_UNDER_LOGO");
if (banner)
{
return <Box className={getBannerClassName(banner)} mt={2} borderRadius={2} sx={getBannerStyles(banner)}>
{makeBannerContent(banner)}
</Box>;
}
return (null);
}
return (
<SidenavRoot
{...rest}
@ -331,12 +356,7 @@ function Sidenav({color, icon, logo, appName, branding, routes, logout, ...rest}
</Box>
}
</Box>
{
branding && branding.environmentBannerText &&
<Box mt={2} bgcolor={branding.environmentBannerColor} borderRadius={2}>
{branding.environmentBannerText}
</Box>
}
<EnvironmentBanner branding={branding} />
</Box>
<Divider
light={

View File

@ -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()),
},

View File

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

View File

@ -20,6 +20,7 @@
*/
import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
import {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 //

View File

@ -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>
</>
);
}

View File

@ -1838,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)

View File

@ -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);

View File

@ -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;
}

View File

@ -267,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)