Compare commits

..

1 Commits

Author SHA1 Message Date
9a6fcd8bb1 CE-1482: POC of workflow library 2024-08-09 11:44:54 -05:00
97 changed files with 9087 additions and 12373 deletions

1
.gitignore vendored
View File

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

7844
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -6,7 +6,7 @@
"@auth0/auth0-react": "1.10.2",
"@emotion/react": "11.7.1",
"@emotion/styled": "11.6.0",
"@kingsrook/qqq-frontend-core": "1.0.122",
"@kingsrook/qqq-frontend-core": "1.0.104",
"@mui/icons-material": "5.4.1",
"@mui/material": "5.11.1",
"@mui/styles": "5.11.1",
@ -18,8 +18,8 @@
"@react-jvectormap/core": "1.0.1",
"@react-jvectormap/unitedstates": "1.0.1",
"@react-oauth/google": "0.2.8",
"@types/prop-types": "^15.7.5",
"@types/react": "18.0.0",
"@types/prop-types": "15.7.5",
"@types/react": "18.2.0",
"@types/react-dom": "18.0.0",
"@types/react-router-hash-link": "2.4.5",
"ace-builds": "1.12.3",
@ -33,11 +33,9 @@
"form-data": "4.0.0",
"formik": "2.2.9",
"html-react-parser": "1.4.8",
"html-to-text": "^9.0.5",
"html-to-text": "9.0.5",
"http-proxy-middleware": "2.0.6",
"lodash": "4.17.21",
"jwt-decode": "3.1.2",
"oidc-client-ts": "2.4.1",
"rapidoc": "9.3.4",
"react": "18.0.0",
"react-ace": "10.1.0",
@ -46,16 +44,18 @@
"react-dnd": "16.0.1",
"react-dnd-html5-backend": "16.0.1",
"react-dom": "18.0.0",
"react-dropzone": "14.3.5",
"react-ga4": "2.1.0",
"react-github-btn": "1.2.1",
"react-google-drive-picker": "^1.2.0",
"react-google-drive-picker": "1.2.0",
"react-markdown": "9.0.1",
"react-oidc-context": "2.3.1",
"react-router-dom": "6.2.1",
"react-router-hash-link": "2.4.3",
"react-table": "7.7.0",
"sass": "1.63.4",
"sequential-workflow-designer": "0.22.0",
"sequential-workflow-designer-react": "0.22.0",
"sequential-workflow-editor": "0.13.2",
"sequential-workflow-editor-model": "0.13.2",
"ts-md5": "1.2.11",
"yup": "0.32.11"
},

View File

@ -29,7 +29,7 @@
<packaging>jar</packaging>
<properties>
<revision>0.26.0-SNAPSHOT</revision>
<revision>0.21.0-SNAPSHOT</revision>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
@ -66,7 +66,7 @@
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-backend-core</artifactId>
<version>0.25.0-integration-sprint-62-20250307-205536</version>
<version>0.20.0-20240308.165846-65</version>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
@ -154,11 +154,11 @@
<versionTagPrefix>version-</versionTagPrefix>
</gitFlowConfig>
<skipFeatureVersion>true</skipFeatureVersion> <!-- Keep feature names out of versions -->
<postReleaseGoals>install</postReleaseGoals> <!-- Let CI run deploys -->
<commitDevelopmentVersionAtStart>true</commitDevelopmentVersionAtStart>
<versionDigitToIncrement>1</versionDigitToIncrement> <!-- In general, we update the minor -->
<versionProperty>revision</versionProperty>
<skipUpdateVersion>true</skipUpdateVersion>
<skipTestProject>true</skipTestProject> <!-- we allow CI to do the tests -->
</configuration>
</plugin>

View File

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

View File

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

View File

@ -1,82 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {QAuthenticationMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QAuthenticationMetaData";
import {SESSION_UUID_COOKIE_NAME} from "App";
import Client from "qqq/utils/qqq/Client";
import {useCookies} from "react-cookie";
import {Md5} from "ts-md5/dist/md5";
const qController = Client.getInstance();
interface Props
{
setIsFullyAuthenticated?: (is: boolean) => void;
setLoggedInUser?: (user: any) => void;
setEarlyReturnForAuth?: (element: JSX.Element | null) => void;
}
/***************************************************************************
** hook for working with the anonymous authentication module
***************************************************************************/
export default function useAnonymousAuthenticationModule({setIsFullyAuthenticated, setLoggedInUser, setEarlyReturnForAuth}: Props)
{
const [cookies, setCookie, removeCookie] = useCookies([SESSION_UUID_COOKIE_NAME]);
/***************************************************************************
**
***************************************************************************/
const setupSession = async () =>
{
console.log("Generating random token...");
setIsFullyAuthenticated(true);
qController.setGotAuthentication();
setCookie(SESSION_UUID_COOKIE_NAME, Md5.hashStr(`${new Date()}`), {path: "/"});
console.log("Token generation complete.");
};
/***************************************************************************
**
***************************************************************************/
const logout = () =>
{
qController.clearAuthenticationMetaDataLocalStorage();
removeCookie(SESSION_UUID_COOKIE_NAME, {path: "/"});
};
/***************************************************************************
**
***************************************************************************/
const renderAppWrapper = (authenticationMetaData: QAuthenticationMetaData, children: JSX.Element): JSX.Element =>
{
return children;
};
return {
setupSession,
logout,
renderAppWrapper
};
}

View File

@ -1,252 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {Auth0Provider, useAuth0} from "@auth0/auth0-react";
import {QAuthenticationMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QAuthenticationMetaData";
import App, {SESSION_UUID_COOKIE_NAME} from "App";
import HandleAuthorizationError from "HandleAuthorizationError";
import jwt_decode from "jwt-decode";
import ProtectedRoute from "qqq/authorization/auth0/ProtectedRoute";
import {MaterialUIControllerProvider} from "qqq/context";
import Client from "qqq/utils/qqq/Client";
import {useCookies} from "react-cookie";
import {useNavigate, useSearchParams} from "react-router-dom";
const qController = Client.getInstance();
interface Props
{
setIsFullyAuthenticated?: (is: boolean) => void;
setLoggedInUser?: (user: any) => void;
setEarlyReturnForAuth?: (element: JSX.Element | null) => void;
}
/***************************************************************************
** hook for working with the Auth0 authentication module
***************************************************************************/
export default function useAuth0AuthenticationModule({setIsFullyAuthenticated, setLoggedInUser}: Props)
{
const {user: auth0User, getAccessTokenSilently: auth0GetAccessTokenSilently, logout: useAuth0Logout} = useAuth0();
const [cookies, removeCookie] = useCookies([SESSION_UUID_COOKIE_NAME]);
/***************************************************************************
**
***************************************************************************/
const shouldStoreNewToken = (newToken: string, oldToken: string): boolean =>
{
if (!cookies[SESSION_UUID_COOKIE_NAME])
{
console.log("No session uuid cookie - so we should store a new one.");
return (true);
}
if (!oldToken)
{
console.log("No accessToken in localStorage - so we should store a new one.");
return (true);
}
try
{
const oldJSON: any = jwt_decode(oldToken);
const newJSON: any = jwt_decode(newToken);
////////////////////////////////////////////////////////////////////////////////////
// if the old (local storage) token is expired, then we need to store the new one //
////////////////////////////////////////////////////////////////////////////////////
const oldExp = oldJSON["exp"];
if (oldExp * 1000 < (new Date().getTime()))
{
console.log("Access token in local storage was expired - so we should store a new one.");
return (true);
}
////////////////////////////////////////////////////////////////////////////////////////////////
// remove the exp & iat values from what we compare - as they are always different from auth0 //
// note, this is only deleting them from what we compare, not from what we'd store. //
////////////////////////////////////////////////////////////////////////////////////////////////
delete newJSON["exp"];
delete newJSON["iat"];
delete oldJSON["exp"];
delete oldJSON["iat"];
const different = JSON.stringify(newJSON) !== JSON.stringify(oldJSON);
if (different)
{
console.log("Latest access token from auth0 has changed vs localStorage - so we should store a new one.");
}
return (different);
}
catch (e)
{
console.log("Caught in shouldStoreNewToken: " + e);
}
return (true);
};
/***************************************************************************
**
***************************************************************************/
const setupSession = async () =>
{
try
{
console.log("Loading token from auth0...");
const accessToken = await auth0GetAccessTokenSilently();
const lsAccessToken = localStorage.getItem("accessToken");
if (shouldStoreNewToken(accessToken, lsAccessToken))
{
console.log("Sending accessToken to backend, requesting a sessionUUID...");
const {uuid: values} = await qController.manageSession(accessToken, null);
localStorage.setItem("accessToken", accessToken);
localStorage.setItem("sessionValues", JSON.stringify(values));
console.log("Got new sessionUUID from backend, and stored new accessToken");
}
else
{
console.log("Using existing sessionUUID cookie");
}
setIsFullyAuthenticated(true);
qController.setGotAuthentication();
setLoggedInUser(auth0User);
console.log("Token load complete.");
}
catch (e)
{
console.log(`Error loading token: ${JSON.stringify(e)}`);
qController.clearAuthenticationMetaDataLocalStorage();
localStorage.removeItem("accessToken");
removeCookie(SESSION_UUID_COOKIE_NAME, {path: "/"});
useAuth0Logout();
return;
}
};
/***************************************************************************
**
***************************************************************************/
const logout = () =>
{
useAuth0Logout({returnTo: window.location.origin});
};
/***************************************************************************
**
***************************************************************************/
// @ts-ignore
function Auth0ProviderWithRedirectCallback({children, ...props})
{
const navigate = useNavigate();
const [searchParams] = useSearchParams();
// @ts-ignore
const onRedirectCallback = (appState) =>
{
navigate((appState && appState.returnTo) || window.location.pathname);
};
if (searchParams.get("error"))
{
return (
// @ts-ignore
<Auth0Provider {...props}>
<HandleAuthorizationError errorMessage={searchParams.get("error_description")} />
</Auth0Provider>
);
}
else
{
return (
// @ts-ignore
<Auth0Provider onRedirectCallback={onRedirectCallback} {...props}>
{children}
</Auth0Provider>
);
}
}
/***************************************************************************
**
***************************************************************************/
const renderAppWrapper = (authenticationMetaData: QAuthenticationMetaData): JSX.Element =>
{
// @ts-ignore
let domain: string = authenticationMetaData.data.baseUrl;
// @ts-ignore
const clientId = authenticationMetaData.data.clientId;
// @ts-ignore
const audience = authenticationMetaData.data.audience;
if (!domain || !clientId)
{
return (
<div>Error: AUTH0 authenticationMetaData is missing baseUrl [{domain}] and/or clientId [{clientId}].</div>
);
}
if (domain.endsWith("/"))
{
/////////////////////////////////////////////////////////////////////////////////////
// auth0 lib fails if we have a trailing slash. be a bit more graceful than that. //
/////////////////////////////////////////////////////////////////////////////////////
domain = domain.replace(/\/$/, "");
}
/***************************************************************************
** simple Functional Component to wrap the <App> and pass the authentication-
** MetaData prop in, so a simple Component can be passed into ProtectedRoute
***************************************************************************/
function WrappedApp()
{
return <App authenticationMetaData={authenticationMetaData} />
}
return (
<Auth0ProviderWithRedirectCallback
domain={domain}
clientId={clientId}
audience={audience}
redirectUri={`${window.location.origin}/`}>
<MaterialUIControllerProvider>
<ProtectedRoute component={WrappedApp} />
</MaterialUIControllerProvider>
</Auth0ProviderWithRedirectCallback>
);
};
return {
setupSession,
logout,
renderAppWrapper
};
}

View File

@ -1,188 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {QAuthenticationMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QAuthenticationMetaData";
import {SESSION_UUID_COOKIE_NAME} from "App";
import Client from "qqq/utils/qqq/Client";
import {useCookies} from "react-cookie";
import {AuthContextProps, AuthProvider, useAuth} from "react-oidc-context";
import {useNavigate, useSearchParams} from "react-router-dom";
const qController = Client.getInstance();
interface Props
{
setIsFullyAuthenticated?: (is: boolean) => void;
setLoggedInUser?: (user: any) => void;
setEarlyReturnForAuth?: (element: JSX.Element | null) => void;
inOAuthContext: boolean;
}
/***************************************************************************
** hook for working with the OAuth2 authentication module
***************************************************************************/
export default function useOAuth2AuthenticationModule({setIsFullyAuthenticated, setLoggedInUser, setEarlyReturnForAuth, inOAuthContext}: Props)
{
///////////////////////////////////////////////////////////////////////////////////////
// the useAuth hook should only be called if we're inside the <AuthProvider> element //
// so on the page that uses this hook to call renderAppWrapper, we aren't in that //
// element/context, thus, don't call that hook. //
///////////////////////////////////////////////////////////////////////////////////////
const authOidc: AuthContextProps | null = inOAuthContext ? useAuth() : null;
const [cookies, removeCookie] = useCookies([SESSION_UUID_COOKIE_NAME]);
const [searchParams] = useSearchParams();
const navigate = useNavigate();
/***************************************************************************
**
***************************************************************************/
const setupSession = async () =>
{
try
{
const preSigninRedirectPathnameKey = "oauth2.preSigninRedirect.pathname";
if (window.location.pathname == "/token")
{
///////////////////////////////////////////////////////////////////////////
// if we're at a path of /token, get code & state params, look up values //
// from that state in local storage, and make a post to the backend to //
// with these values - which will itself talk to the identity provider //
// to get an access token, and ultimately a session. //
///////////////////////////////////////////////////////////////////////////
const code = searchParams.get("code");
const state = searchParams.get("state");
const oidcString = localStorage.getItem(`oidc.${state}`);
if (oidcString)
{
const oidcObject = JSON.parse(oidcString) as { [name: string]: any };
console.log(oidcObject);
const manageSessionRequestBody = {code: code, codeVerifier: oidcObject.code_verifier, redirectUri: oidcObject.redirect_uri};
const {uuid: newSessionUuid, values} = await qController.manageSession(null, null, manageSessionRequestBody);
console.log(`we have new session UUID: ${newSessionUuid}`);
setIsFullyAuthenticated(true);
qController.setGotAuthentication();
setLoggedInUser(values?.user);
console.log("Token load complete.");
const preSigninRedirectPathname = localStorage.getItem(preSigninRedirectPathnameKey);
localStorage.removeItem(preSigninRedirectPathname);
navigate(preSigninRedirectPathname ?? "/", {replace: true});
}
else
{
////////////////////////////////////////////
// if unrecognized state, render an error //
////////////////////////////////////////////
setEarlyReturnForAuth(<div>Login error: Unrecognized state. Refresh to try again.</div>);
}
}
else
{
//////////////////////////////////////////////////////////////////////////
// if we have a sessionUUID cookie, try to validate it with the backend //
//////////////////////////////////////////////////////////////////////////
const sessionUuid = cookies[SESSION_UUID_COOKIE_NAME];
if (sessionUuid)
{
console.log(`we have session UUID: ${sessionUuid} - validating it...`);
const {values} = await qController.manageSession(null, sessionUuid, null);
setIsFullyAuthenticated(true);
qController.setGotAuthentication();
setLoggedInUser(values?.user);
console.log("Token load complete.");
}
else
{
/////////////////////////////////////////////////////////////////////////////////////////////////
// else no cookie, and not a token url, we need to redirect to the provider's login page //
// capture the path the user was trying to access in local storage, to redirect back to later. //
/////////////////////////////////////////////////////////////////////////////////////////////////
console.log("Loading token from OAuth2 provider...");
console.log(authOidc);
localStorage.setItem(preSigninRedirectPathnameKey, window.location.pathname);
setEarlyReturnForAuth(<div>Signing in...</div>);
authOidc?.signinRedirect();
}
}
}
catch (e)
{
console.log(`Error loading token: ${JSON.stringify(e)}`);
logout();
return;
}
};
/***************************************************************************
**
***************************************************************************/
const logout = () =>
{
qController.clearAuthenticationMetaDataLocalStorage();
removeCookie(SESSION_UUID_COOKIE_NAME, {path: "/"});
authOidc?.signoutRedirect();
};
/***************************************************************************
**
***************************************************************************/
const renderAppWrapper = (authenticationMetaData: QAuthenticationMetaData, children: JSX.Element): JSX.Element =>
{
const authority: string = authenticationMetaData.data.baseUrl;
const clientId = authenticationMetaData.data.clientId;
if (!authority || !clientId)
{
return (
<div>Error: OAuth2 authenticationMetaData is missing baseUrl [{authority}] and/or clientId [{clientId}].</div>
);
}
const oidcConfig =
{
authority: authority,
client_id: clientId,
redirect_uri: `${window.location.origin}/token`,
response_type: "code",
scope: "openid profile email offline_access",
};
return (<AuthProvider {...oidcConfig}>
{children}
</AuthProvider>
);
};
return {
setupSession,
logout,
renderAppWrapper
};
}

View File

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

View File

@ -30,17 +30,14 @@ import MDButton from "qqq/components/legacy/MDButton";
export const standardWidth = "150px";
const standardML = {xs: 1, md: 3};
interface QCreateNewButtonProps
{
tablePath: string;
}
export function QCreateNewButton({tablePath}: QCreateNewButtonProps): JSX.Element
{
return (
<Box display="inline-block" ml={standardML} mr={0} width={standardWidth}>
<Box display="inline-block" ml={3} mr={0} width={standardWidth}>
<Link to={`${tablePath}/create`}>
<MDButton variant="gradient" color="info" fullWidth startIcon={<Icon>add</Icon>}>
Create New
@ -57,7 +54,6 @@ interface QSaveButtonProps
onClickHandler?: any,
disabled: boolean
}
QSaveButton.defaultProps = {
label: "Save",
iconName: "save"
@ -66,7 +62,7 @@ QSaveButton.defaultProps = {
export function QSaveButton({label, iconName, onClickHandler, disabled}: QSaveButtonProps): JSX.Element
{
return (
<Box ml={standardML} width={standardWidth}>
<Box ml={3} width={standardWidth}>
<MDButton type="submit" variant="gradient" color="info" size="small" onClick={onClickHandler} fullWidth startIcon={<Icon>{iconName}</Icon>} disabled={disabled}>
{label}
</MDButton>
@ -76,18 +72,17 @@ export function QSaveButton({label, iconName, onClickHandler, disabled}: QSaveBu
interface QDeleteButtonProps
{
onClickHandler: any;
disabled?: boolean;
onClickHandler: any
disabled?: boolean
}
QDeleteButton.defaultProps = {
disabled: false
};
export function QDeleteButton({onClickHandler, disabled}: QDeleteButtonProps): JSX.Element
{
return (
<Box ml={standardML} width={standardWidth}>
<Box ml={3} width={standardWidth}>
<MDButton variant="gradient" color="primary" size="small" onClick={onClickHandler} fullWidth startIcon={<Icon>delete</Icon>} disabled={disabled}>
Delete
</MDButton>
@ -98,7 +93,7 @@ export function QDeleteButton({onClickHandler, disabled}: QDeleteButtonProps): J
export function QEditButton(): JSX.Element
{
return (
<Box ml={standardML} width={standardWidth}>
<Box ml={3} width={standardWidth}>
<Link to="edit">
<MDButton variant="gradient" color="dark" size="small" fullWidth startIcon={<Icon>edit</Icon>}>
Edit
@ -137,7 +132,7 @@ interface QCancelButtonProps
onClickHandler: any;
disabled: boolean;
label?: string;
iconName?: string;
iconName?: string
}
export function QCancelButton({
@ -145,7 +140,7 @@ export function QCancelButton({
}: QCancelButtonProps): JSX.Element
{
return (
<Box ml={standardML} width={standardWidth}>
<Box ml="auto" width={standardWidth}>
<MDButton type="button" variant="outlined" color="dark" size="small" fullWidth startIcon={<Icon>{iconName}</Icon>} onClick={onClickHandler} disabled={disabled}>
{label}
</MDButton>
@ -160,15 +155,15 @@ QCancelButton.defaultProps = {
interface QSubmitButtonProps
{
label?: string;
iconName?: string;
disabled: boolean;
label?: string
iconName?: string
disabled: boolean
}
export function QSubmitButton({label, iconName, disabled}: QSubmitButtonProps): JSX.Element
{
return (
<Box ml={standardML} width={standardWidth}>
<Box ml={3} width={standardWidth}>
<MDButton type="submit" variant="gradient" color="dark" size="small" fullWidth startIcon={<Icon>{iconName}</Icon>} disabled={disabled}>
{label}
</MDButton>
@ -180,24 +175,3 @@ QSubmitButton.defaultProps = {
label: "Submit",
iconName: "check",
};
interface QAlternateButtonProps
{
label: string,
iconName?: string,
disabled: boolean,
onClick?: () => void
}
export function QAlternateButton({label, iconName, disabled, onClick}: QAlternateButtonProps): JSX.Element
{
return (
<Box ml={standardML} width={standardWidth}>
<MDButton type="button" variant="gradient" color="secondary" size="small" fullWidth startIcon={iconName && <Icon>{iconName}</Icon>} onClick={onClick} disabled={disabled}>
{label}
</MDButton>
</Box>
);
}
QAlternateButton.defaultProps = {};

View File

@ -80,12 +80,11 @@ interface Props
label: string;
value: boolean;
isDisabled: boolean;
onChangeCallback?: (newValue: any) => void;
}
function BooleanFieldSwitch({name, label, value, isDisabled, onChangeCallback}: Props) : JSX.Element
function BooleanFieldSwitch({name, label, value, isDisabled}: Props) : JSX.Element
{
const {setFieldValue} = useFormikContext();
@ -94,10 +93,6 @@ function BooleanFieldSwitch({name, label, value, isDisabled, onChangeCallback}:
if(!isDisabled)
{
setFieldValue(name, newValue);
if(onChangeCallback)
{
onChangeCallback(newValue);
}
event.stopPropagation();
}
}
@ -105,10 +100,6 @@ function BooleanFieldSwitch({name, label, value, isDisabled, onChangeCallback}:
const toggleSwitch = () =>
{
setFieldValue(name, !value);
if(onChangeCallback)
{
onChangeCallback(!value);
}
}
const classNullSwitch = (value === null || value == undefined || `${value}` == "") ? "nullSwitch" : "";

View File

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

View File

@ -19,16 +19,21 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {AdornmentType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/AdornmentType";
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";
import {colors, Icon, InputLabel} from "@mui/material";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import Grid from "@mui/material/Grid";
import Tooltip from "@mui/material/Tooltip";
import {useFormikContext} from "formik";
import React, {useState} from "react";
import QDynamicFormField from "qqq/components/forms/DynamicFormField";
import DynamicSelect from "qqq/components/forms/DynamicSelect";
import FileInputField from "qqq/components/forms/FileInputField";
import MDTypography from "qqq/components/legacy/MDTypography";
import HelpContent from "qqq/components/misc/HelpContent";
import React from "react";
import ValueUtils from "qqq/utils/qqq/ValueUtils";
interface Props
{
@ -45,6 +50,28 @@ function QDynamicForm({formData, formLabel, bulkEditMode, bulkEditSwitchChangeHa
{
const {formFields, values, errors, touched} = formData;
const formikProps = useFormikContext();
const [fileName, setFileName] = useState(null as string);
const fileChanged = (event: React.FormEvent<HTMLInputElement>, field: any) =>
{
setFileName(null);
if (event.currentTarget.files && event.currentTarget.files[0])
{
setFileName(event.currentTarget.files[0].name);
}
formikProps.setFieldValue(field.name, event.currentTarget.files[0]);
};
const removeFile = (fieldName: string) =>
{
setFileName(null);
formikProps.setFieldValue(fieldName, null);
record?.values.delete(fieldName)
record?.displayValues.delete(fieldName)
};
const bulkEditSwitchChanged = (name: string, value: boolean) =>
{
bulkEditSwitchChangeHandler(name, value);
@ -55,9 +82,14 @@ function QDynamicForm({formData, formLabel, bulkEditMode, bulkEditSwitchChangeHa
<Box>
<Box lineHeight={0}>
<MDTypography variant="h5">{formLabel}</MDTypography>
{/* TODO - help text
<MDTypography variant="button" color="text">
Mandatory information
</MDTypography>
*/}
</Box>
<Box mt={1.625}>
<Grid container lg={12} display="flex" spacing={3}>
<Grid container spacing={3}>
{formFields
&& Object.keys(formFields).length > 0
&& Object.keys(formFields).map((fieldName: any) =>
@ -73,74 +105,94 @@ function QDynamicForm({formData, formLabel, bulkEditMode, bulkEditSwitchChangeHa
values[fieldName] = "";
}
let formattedHelpContent = <HelpContent helpContents={field?.fieldMetaData?.helpContents} roles={helpRoles} helpContentKey={`${helpContentKeyPrefix ?? ""}field:${fieldName}`} />;
if (formattedHelpContent)
let formattedHelpContent = <HelpContent helpContents={field.fieldMetaData.helpContents} roles={helpRoles} helpContentKey={`${helpContentKeyPrefix ?? ""}field:${fieldName}`} />;
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} />;
const labelElement = <Box fontSize="1rem" fontWeight="500" marginBottom="0.25rem">
<label htmlFor={field.name}>{field.label}</label>
</Box>
let itemLG = (field?.fieldMetaData?.gridColumns && field?.fieldMetaData?.gridColumns > 0) ? field.fieldMetaData.gridColumns : 6;
let itemXS = 12;
let itemSM = 6;
/////////////
// files!! //
/////////////
if (field.type === "file")
{
const fileUploadAdornment = field.fieldMetaData?.getAdornment(AdornmentType.FILE_UPLOAD);
const width = fileUploadAdornment?.values?.get("width") ?? "half";
if (width == "full")
{
itemSM = 12;
itemLG = 12;
}
const pseudoField = new QFieldMetaData({name: fieldName, type: QFieldType.BLOB});
return (
<Grid item lg={itemLG} xs={itemXS} sm={itemSM} flexDirection="column" key={fieldName}>
{labelElement}
<FileInputField field={field} record={record} errorMessage={errors[fieldName]} />
<Grid item xs={12} sm={6} key={fieldName}>
<Box mb={1.5}>
{labelElement}
{
record && record.values.get(fieldName) && <Box fontSize="0.875rem" pb={1}>
Current File:
<Box display="inline-flex" pl={1}>
{ValueUtils.getDisplayValue(pseudoField, record, "view")}
<Tooltip placement="bottom" title="Remove current file">
<Icon className="blobIcon" fontSize="small" onClick={(e) => removeFile(fieldName)}>delete</Icon>
</Tooltip>
</Box>
</Box>
}
<Box display="flex" alignItems="center">
<Button variant="outlined" component="label">
<span style={{color: colors.lightBlue[500]}}>Choose file to upload</span>
<input
id={fieldName}
name={fieldName}
type="file"
hidden
onChange={(event: React.FormEvent<HTMLInputElement>) => fileChanged(event, field)}
/>
</Button>
<Box ml={1} fontSize={"1rem"}>
{fileName}
</Box>
</Box>
<Box mt={0.75}>
<MDTypography component="div" variant="caption" color="error" fontWeight="regular">
{errors[fieldName] && <span>You must select a file to proceed</span>}
</MDTypography>
</Box>
</Box>
</Grid>
);
}
///////////////////////
// possible values!! //
///////////////////////
else if (field.possibleValueProps)
// possible values!!
if (field.possibleValueProps)
{
const otherValuesMap = field.possibleValueProps.otherValues ?? new Map<string, any>();
Object.keys(values).forEach((key) =>
{
otherValuesMap.set(key, values[key]);
});
})
return (
<Grid item lg={itemLG} xs={itemXS} sm={itemSM} key={fieldName}>
<Grid item xs={12} sm={6} key={fieldName}>
{labelElement}
<DynamicSelect
fieldPossibleValueProps={field.possibleValueProps}
tableName={field.possibleValueProps.tableName}
processName={field.possibleValueProps.processName}
possibleValueSourceName={field.possibleValueProps.possibleValueSourceName}
fieldName={field.possibleValueProps.fieldName}
isEditable={field.isEditable}
fieldLabel=""
initialValue={values[fieldName]}
initialDisplayValue={field.possibleValueProps.initialDisplayValue}
bulkEditMode={bulkEditMode}
bulkEditSwitchChangeHandler={bulkEditSwitchChanged}
otherValues={otherValuesMap}
useCase="form"
/>
{formattedHelpContent}
</Grid>
);
}
///////////////////////
// everything else!! //
///////////////////////
// todo? inputProps={{ autoComplete: "" }}
// todo? placeholder={password.placeholder}
return (
<Grid item lg={itemLG} xs={itemXS} sm={itemSM} key={fieldName}>
<Grid item xs={12} sm={6} key={fieldName}>
{labelElement}
<QDynamicFormField
id={field.name}
@ -175,19 +227,4 @@ QDynamicForm.defaultProps = {
},
};
interface DynamicFormFieldLabelProps
{
name: string;
label: string;
}
export function DynamicFormFieldLabel({name, label}: DynamicFormFieldLabelProps): JSX.Element
{
return (<Box fontSize="1rem" fontWeight="500" marginBottom="0.25rem">
<label htmlFor={name}>{label}</label>
</Box>);
}
export default QDynamicForm;

View File

@ -19,19 +19,16 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {QPossibleValue} from "@kingsrook/qqq-frontend-core/lib/model/QPossibleValue";
import {InputAdornment, InputLabel} from "@mui/material";
import Box from "@mui/material/Box";
import {Box, InputAdornment, InputLabel} from "@mui/material";
import Switch from "@mui/material/Switch";
import {ErrorMessage, Field, useFormikContext} from "formik";
import colors from "qqq/assets/theme/base/colors";
import BooleanFieldSwitch from "qqq/components/forms/BooleanFieldSwitch";
import DynamicFormUtils from "qqq/components/forms/DynamicFormUtils";
import DynamicSelect from "qqq/components/forms/DynamicSelect";
import MDInput from "qqq/components/legacy/MDInput";
import MDTypography from "qqq/components/legacy/MDTypography";
import React, {useMemo, useState} from "react";
import AceEditor from "react-ace";
import colors from "qqq/assets/theme/base/colors";
import BooleanFieldSwitch from "qqq/components/forms/BooleanFieldSwitch";
import MDInput from "qqq/components/legacy/MDInput";
import MDTypography from "qqq/components/legacy/MDTypography";
import {flushSync} from "react-dom";
// Declaring props types for FormField
@ -43,10 +40,6 @@ interface Props
value: any;
type: string;
isEditable?: boolean;
placeholder?: string;
backgroundColor?: string;
onChangeCallback?: (newValue: any) => void;
[key: string]: any;
@ -56,7 +49,7 @@ interface Props
}
function QDynamicFormField({
label, name, displayFormat, value, bulkEditMode, bulkEditSwitchChangeHandler, type, isEditable, placeholder, backgroundColor, formFieldObject, onChangeCallback, ...rest
label, name, displayFormat, value, bulkEditMode, bulkEditSwitchChangeHandler, type, isEditable, formFieldObject, ...rest
}: Props): JSX.Element
{
const [switchChecked, setSwitchChecked] = useState(false);
@ -72,30 +65,18 @@ function QDynamicFormField({
inputLabelProps.shrink = true;
}
const inputProps: any = {};
const inputProps = {};
if (displayFormat && displayFormat.startsWith("$"))
{
// @ts-ignore
inputProps.startAdornment = <InputAdornment position="start">$</InputAdornment>;
}
if (displayFormat && displayFormat.endsWith("%%"))
{
// @ts-ignore
inputProps.endAdornment = <InputAdornment position="end">%</InputAdornment>;
}
if (placeholder)
{
inputProps.placeholder = placeholder;
}
if (backgroundColor)
{
inputProps.sx = {
"&.MuiInputBase-root": {
backgroundColor: backgroundColor
}
};
}
// @ts-ignore
const handleOnWheel = (e) =>
{
@ -121,79 +102,42 @@ function QDynamicFormField({
// put the onChange in an object and assign it with a spread //
////////////////////////////////////////////////////////////////////////
let onChange: any = {};
if (isToUpperCase || isToLowerCase || onChangeCallback)
if (isToUpperCase || isToLowerCase)
{
onChange.onChange = (e: any) =>
{
if (isToUpperCase || isToLowerCase)
const beforeStart = e.target.selectionStart;
const beforeEnd = e.target.selectionEnd;
flushSync(() =>
{
const beforeStart = e.target.selectionStart;
const beforeEnd = e.target.selectionEnd;
flushSync(() =>
let newValue = e.currentTarget.value;
if (isToUpperCase)
{
let newValue = e.currentTarget.value;
if (isToUpperCase)
{
newValue = newValue.toUpperCase();
}
if (isToLowerCase)
{
newValue = newValue.toLowerCase();
}
setFieldValue(name, newValue);
if (onChangeCallback)
{
onChangeCallback(newValue);
}
});
const input = document.getElementById(name) as HTMLInputElement;
if (input)
{
input.setSelectionRange(beforeStart, beforeEnd);
newValue = newValue.toUpperCase();
}
}
else if (onChangeCallback)
if (isToLowerCase)
{
newValue = newValue.toLowerCase();
}
setFieldValue(name, newValue);
});
const input = document.getElementById(name) as HTMLInputElement;
if (input)
{
onChangeCallback(e.currentTarget.value);
input.setSelectionRange(beforeStart, beforeEnd);
}
};
}
/***************************************************************************
**
***************************************************************************/
function dynamicSelectOnChange(newValue?: QPossibleValue)
{
if (onChangeCallback)
{
onChangeCallback(newValue == null ? null : newValue.id);
}
}
let field;
let getsBulkEditHtmlLabel = true;
if (formFieldObject.possibleValueProps)
{
field = (<DynamicSelect
name={name}
fieldPossibleValueProps={formFieldObject.possibleValueProps}
isEditable={!isDisabled}
fieldLabel={label}
initialValue={value}
bulkEditMode={bulkEditMode}
bulkEditSwitchChangeHandler={bulkEditSwitchChangeHandler}
onChange={dynamicSelectOnChange}
// otherValues={otherValuesMap}
useCase="form"
/>);
}
else if (type === "checkbox")
if (type === "checkbox")
{
getsBulkEditHtmlLabel = false;
field = (<>
<BooleanFieldSwitch name={name} label={label} value={value} isDisabled={isDisabled} onChangeCallback={onChangeCallback} />
<BooleanFieldSwitch name={name} label={label} value={value} isDisabled={isDisabled} />
<Box mt={0.75}>
<MDTypography component="div" variant="caption" color="error" fontWeight="regular">
{!isDisabled && <div className="fieldErrorMessage"><ErrorMessage name={name} render={msg => <span data-field-error="true">{msg}</span>} /></div>}
@ -221,10 +165,6 @@ function QDynamicFormField({
onChange={(value: string, event: any) =>
{
setFieldValue(name, value, false);
if (onChangeCallback)
{
onChangeCallback(value);
}
}}
setOptions={{useWorker: false}}
width="100%"

View File

@ -22,7 +22,6 @@
import {AdornmentType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/AdornmentType";
import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
import {QFieldType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldType";
import {FieldPossibleValueProps} from "qqq/models/fields/FieldPossibleValueProps";
import * as Yup from "yup";
@ -130,11 +129,18 @@ class DynamicFormUtils
if (effectivelyIsRequired)
{
////////////////////////////////////////////////////////////////////////////////////////////
// the "nullable(true)" here doesn't mean that you're allowed to set the field to null... //
// rather, it's more like "null is how empty will be treated" or some-such... //
////////////////////////////////////////////////////////////////////////////////////////////
return (Yup.string().required(`${field.label ?? "This field"} is required.`).nullable(true));
if (field.possibleValueSourceName)
{
////////////////////////////////////////////////////////////////////////////////////////////
// the "nullable(true)" here doesn't mean that you're allowed to set the field to null... //
// rather, it's more like "null is how empty will be treated" or some-such... //
////////////////////////////////////////////////////////////////////////////////////////////
return (Yup.string().required(`${field.label} is required.`).nullable(true));
}
else
{
return (Yup.string().required(`${field.label} is required.`));
}
}
return (null);
}
@ -149,49 +155,47 @@ class DynamicFormUtils
{
const field = qFields[i];
if(!dynamicFormFields[field.name])
{
continue;
}
/////////////////////////////////////////
// add props for possible value fields //
/////////////////////////////////////////
if (field.possibleValueSourceName || field.inlinePossibleValueSource)
if (field.possibleValueSourceName && dynamicFormFields[field.name])
{
let props: FieldPossibleValueProps =
{
isPossibleValue: true,
fieldName: field.name,
initialDisplayValue: null
}
let initialDisplayValue = null;
if (displayValues)
{
props.initialDisplayValue = displayValues.get(field.name);
initialDisplayValue = displayValues.get(field.name);
}
if(field.inlinePossibleValueSource)
if (tableName)
{
//////////////////////////////////////////////////////////////////////
// handle an inline PVS - which is a list of possible value objects //
//////////////////////////////////////////////////////////////////////
props.possibleValues = field.inlinePossibleValueSource;
}
else if (tableName)
{
props.tableName = tableName;
dynamicFormFields[field.name].possibleValueProps =
{
isPossibleValue: true,
tableName: tableName,
fieldName: field.name,
initialDisplayValue: initialDisplayValue,
};
}
else if (processName)
{
props.processName = processName;
dynamicFormFields[field.name].possibleValueProps =
{
isPossibleValue: true,
processName: processName,
fieldName: field.name,
initialDisplayValue: initialDisplayValue,
};
}
else
{
props.possibleValueSourceName = field.possibleValueSourceName;
dynamicFormFields[field.name].possibleValueProps =
{
isPossibleValue: true,
initialDisplayValue: initialDisplayValue,
fieldName: field.name,
possibleValueSourceName: field.possibleValueSourceName
};
}
dynamicFormFields[field.name].possibleValueProps = props;
}
}
}

View File

@ -30,18 +30,20 @@ import TextField from "@mui/material/TextField";
import {ErrorMessage, useFormikContext} from "formik";
import colors from "qqq/assets/theme/base/colors";
import MDTypography from "qqq/components/legacy/MDTypography";
import {FieldPossibleValueProps} from "qqq/models/fields/FieldPossibleValueProps";
import Client from "qqq/utils/qqq/Client";
import React, {useEffect, useState} from "react";
interface Props
{
fieldPossibleValueProps: FieldPossibleValueProps;
tableName?: string;
processName?: string;
fieldName?: string;
possibleValueSourceName?: string;
overrideId?: string;
name?: string;
fieldLabel: string;
inForm: boolean;
initialValue?: any;
initialDisplayValue?: string;
initialValues?: QPossibleValue[];
onChange?: any;
isEditable?: boolean;
@ -51,12 +53,16 @@ interface Props
otherValues?: Map<string, any>;
variant: "standard" | "outlined";
initiallyOpen: boolean;
useCase: "form" | "filter";
}
DynamicSelect.defaultProps = {
tableName: null,
processName: null,
fieldName: null,
possibleValueSourceName: null,
inForm: true,
initialValue: null,
initialDisplayValue: null,
initialValues: undefined,
onChange: null,
isEditable: true,
@ -96,10 +102,8 @@ export const getAutocompleteOutlinedStyle = (isDisabled: boolean) =>
const qController = Client.getInstance();
function DynamicSelect({fieldPossibleValueProps, overrideId, name, fieldLabel, inForm, initialValue, initialValues, onChange, isEditable, isMultiple, bulkEditMode, bulkEditSwitchChangeHandler, otherValues, variant, initiallyOpen, useCase}: Props)
function DynamicSelect({tableName, processName, fieldName, possibleValueSourceName, overrideId, fieldLabel, inForm, initialValue, initialDisplayValue, initialValues, onChange, isEditable, isMultiple, bulkEditMode, bulkEditSwitchChangeHandler, otherValues, variant, initiallyOpen}: Props)
{
const {fieldName, initialDisplayValue, possibleValueSourceName, possibleValues, processName, tableName} = fieldPossibleValueProps;
const [open, setOpen] = useState(initiallyOpen);
const [options, setOptions] = useState<readonly QPossibleValue[]>([]);
const [searchTerm, setSearchTerm] = useState(null);
@ -167,35 +171,6 @@ function DynamicSelect({fieldPossibleValueProps, overrideId, name, fieldLabel, i
setFieldValueRef = setFieldValue;
}
/*******************************************************************************
**
*******************************************************************************/
const filterInlinePossibleValues = (searchTerm: string, possibleValues: QPossibleValue[]): QPossibleValue[] =>
{
return possibleValues.filter(pv => pv.label?.toLowerCase().startsWith(searchTerm));
};
/***************************************************************************
**
***************************************************************************/
const loadResults = async (): Promise<QPossibleValue[]> =>
{
if (possibleValues)
{
return filterInlinePossibleValues(searchTerm, possibleValues);
}
else
{
return await qController.possibleValues(tableName, processName, possibleValueSourceName ?? fieldName, searchTerm ?? "", null, null, otherValues, useCase);
}
};
/***************************************************************************
**
***************************************************************************/
useEffect(() =>
{
if (firstRender)
@ -219,7 +194,7 @@ function DynamicSelect({fieldPossibleValueProps, overrideId, name, fieldLabel, i
(async () =>
{
// console.log(`doing a search with ${searchTerm}`);
const results: QPossibleValue[] = await loadResults();
const results: QPossibleValue[] = await qController.possibleValues(tableName, processName, possibleValueSourceName ?? fieldName, searchTerm ?? "", null, otherValues);
if (tableMetaData == null && tableName)
{
@ -242,10 +217,7 @@ function DynamicSelect({fieldPossibleValueProps, overrideId, name, fieldLabel, i
};
}, [searchTerm]);
/***************************************************************************
** todo - finish... call it in onOpen?
***************************************************************************/
// todo - finish... call it in onOpen?
const reloadIfOtherValuesAreChanged = () =>
{
if (JSON.stringify(Object.fromEntries(otherValues)) != otherValuesWhenResultsWereLoaded)
@ -254,10 +226,8 @@ function DynamicSelect({fieldPossibleValueProps, overrideId, name, fieldLabel, i
{
setLoading(true);
setOptions([]);
console.log("Refreshing possible values...");
const results: QPossibleValue[] = await loadResults();
const results: QPossibleValue[] = await qController.possibleValues(tableName, processName, possibleValueSourceName ?? fieldName, searchTerm ?? "", null, otherValues);
setLoading(false);
setOptions([...results]);
setOtherValuesWhenResultsWereLoaded(JSON.stringify(Object.fromEntries(otherValues)));
@ -265,10 +235,6 @@ function DynamicSelect({fieldPossibleValueProps, overrideId, name, fieldLabel, i
}
};
/***************************************************************************
**
***************************************************************************/
const inputChanged = (event: React.SyntheticEvent, value: string, reason: string) =>
{
// console.log(`input changed. Reason: ${reason}, setting search term to ${value}`);
@ -279,19 +245,11 @@ function DynamicSelect({fieldPossibleValueProps, overrideId, name, fieldLabel, i
}
};
/***************************************************************************
**
***************************************************************************/
const handleBlur = (x: any) =>
{
setSearchTerm(null);
};
/***************************************************************************
**
***************************************************************************/
const handleChanged = (event: React.SyntheticEvent, value: any | any[], reason: string, details?: string) =>
{
// console.log("handleChanged. value is:");
@ -315,10 +273,6 @@ function DynamicSelect({fieldPossibleValueProps, overrideId, name, fieldLabel, i
}
};
/***************************************************************************
**
***************************************************************************/
const filterOptions = (options: { id: any; label: string; }[], state: FilterOptionsState<{ id: any; label: string; }>): { id: any; label: string; }[] =>
{
/////////////////////////////////////////////////////////////////////////////////
@ -328,10 +282,6 @@ function DynamicSelect({fieldPossibleValueProps, overrideId, name, fieldLabel, i
return (options);
};
/***************************************************************************
**
***************************************************************************/
// @ts-ignore
const renderOption = (props: Object, option: any, {selected}) =>
{
@ -380,10 +330,6 @@ function DynamicSelect({fieldPossibleValueProps, overrideId, name, fieldLabel, i
);
};
/***************************************************************************
**
***************************************************************************/
const bulkEditSwitchChanged = () =>
{
const newSwitchValue = !switchChecked;
@ -404,8 +350,7 @@ function DynamicSelect({fieldPossibleValueProps, overrideId, name, fieldLabel, i
const autocomplete = (
<Box>
<Autocomplete
id={overrideId ?? fieldName ?? possibleValueSourceName ?? "anonymous"}
name={name}
id={overrideId ?? fieldName ?? possibleValueSourceName}
sx={autocompleteSX}
open={open}
fullWidth
@ -485,7 +430,7 @@ function DynamicSelect({fieldPossibleValueProps, overrideId, name, fieldLabel, i
inForm &&
<Box mt={0.75}>
<MDTypography component="div" variant="caption" color="error" fontWeight="regular">
{!isDisabled && <div className="fieldErrorMessage"><ErrorMessage name={overrideId ?? fieldName ?? possibleValueSourceName ?? "anonymous"} render={msg => <span data-field-error="true">{msg}</span>} /></div>}
{!isDisabled && <div className="fieldErrorMessage"><ErrorMessage name={fieldName ?? possibleValueSourceName} render={msg => <span data-field-error="true">{msg}</span>} /></div>}
</MDTypography>
</Box>
}

View File

@ -66,7 +66,7 @@ interface Props
defaultValues: { [key: string]: string };
disabledFields: { [key: string]: boolean } | string[];
isCopy?: boolean;
onSubmitCallback?: (values: any, tableName: string) => void;
onSubmitCallback?: (values: any) => 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,18 +181,6 @@ 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);
}
@ -220,7 +208,7 @@ function EntityForm(props: Props): JSX.Element
function deleteChildRecord(name: string, widgetData: any, rowIndex: number)
{
updateChildRecordList(name, "delete", rowIndex);
}
};
/*******************************************************************************
@ -255,16 +243,16 @@ function EntityForm(props: Props): JSX.Element
/*******************************************************************************
**
*******************************************************************************/
function submitEditChildForm(values: any, tableName: string)
function submitEditChildForm(values: any)
{
updateChildRecordList(showEditChildForm.widgetName, showEditChildForm.rowIndex == null ? "insert" : "edit", showEditChildForm.rowIndex, values, tableName);
updateChildRecordList(showEditChildForm.widgetName, showEditChildForm.rowIndex == null ? "insert" : "edit", showEditChildForm.rowIndex, values);
}
/*******************************************************************************
**
*******************************************************************************/
async function updateChildRecordList(widgetName: string, action: "insert" | "edit" | "delete", rowIndex?: number, values?: any, childTableName?: string)
async function updateChildRecordList(widgetName: string, action: "insert" | "edit" | "delete", rowIndex?: number, values?: any)
{
const metaData = await qController.loadMetaData();
const widgetMetaData = metaData.widgets.get(widgetName);
@ -275,38 +263,13 @@ 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], null, objectToMap(values), "form");
if (possibleValues && possibleValues.length > 0)
{
displayValues[key] = possibleValues[0].label;
}
}
}
}
switch (action)
{
case "insert":
newChildListWidgetData[widgetName].queryOutput.records.push({values: values, displayValues: displayValues});
newChildListWidgetData[widgetName].queryOutput.records.push({values: values});
break;
case "edit":
newChildListWidgetData[widgetName].queryOutput.records[rowIndex] = {values: values, displayValues: displayValues};
newChildListWidgetData[widgetName].queryOutput.records[rowIndex] = {values: values};
break;
case "delete":
newChildListWidgetData[widgetName].queryOutput.records.splice(rowIndex, 1);
@ -444,7 +407,6 @@ function EntityForm(props: Props): JSX.Element
widgetMetaData={widgetMetaData}
widgetData={widgetData}
recordValues={formValues}
label={tableMetaData?.fields.get(widgetData?.filterFieldName ?? "queryFilterJson")?.label}
onSaveCallback={setFormFieldValuesFromWidget}
/>;
}
@ -516,25 +478,6 @@ 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 //
//////////////////
@ -559,7 +502,7 @@ function EntityForm(props: Props): JSX.Element
/////////////////////////////////////////////////
const tableSections = TableUtils.getSectionsForRecordSidebar(tableMetaData, [...tableMetaData.fields.keys()], (section: QTableSection) =>
{
const widget = metaData?.widgets?.get(section.widgetName);
const widget = metaData?.widgets.get(section.widgetName);
if (widget)
{
if (widget.type == "childRecordList" && widget.defaultValues?.has("manageAssociationName"))
@ -652,24 +595,18 @@ function EntityForm(props: Props): JSX.Element
if (defaultValue)
{
initialValues[fieldName] = defaultValue;
}
}
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// 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]], null, objectToMap(initialValues), "form");
if (results && results.length > 0)
///////////////////////////////////////////////////////////////////////////////////////////
// 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)
{
defaultDisplayValues.set(fieldName, results[0].label);
const results: QPossibleValue[] = await qController.possibleValues(tableName, null, fieldName, null, [initialValues[fieldName]]);
if (results && results.length > 0)
{
defaultDisplayValues.set(fieldName, results[0].label);
}
}
}
}
@ -881,12 +818,12 @@ function EntityForm(props: Props): JSX.Element
{
actions.setSubmitting(true);
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// if there's a callback (e.g., for a modal nested on another create/edit screen), then just pass our data back there and return. //
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// if there's a callback (e.g., for a modal nested on another create/edit screen), then just pass our data back there anre return. //
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
if (props.onSubmitCallback)
{
props.onSubmitCallback(values, tableName);
props.onSubmitCallback(values);
return;
}
@ -1215,11 +1152,11 @@ function EntityForm(props: Props): JSX.Element
<Grid container spacing={3}>
{
!props.isModal &&
<Grid item xs={12} lg={3} className="recordSidebar">
<Grid item xs={12} lg={3}>
<QRecordSidebar tableSections={tableSections} />
</Grid>
}
<Grid item xs={12} lg={props.isModal ? 12 : 9} className={props.isModal ? "" : "recordWithSidebar"}>
<Grid item xs={12} lg={props.isModal ? 12 : 9}>
<Formik
initialValues={initialValues}
@ -1353,7 +1290,7 @@ function EntityForm(props: Props): JSX.Element
table={showEditChildForm.table}
defaultValues={showEditChildForm.defaultValues}
disabledFields={showEditChildForm.disabledFields}
onSubmitCallback={props.onSubmitCallback ? props.onSubmitCallback : submitEditChildForm}
onSubmitCallback={submitEditChildForm}
overrideHeading={`${showEditChildForm.rowIndex != null ? "Editing" : "Creating New"} ${showEditChildForm.table.label}`}
/>
</div>

View File

@ -1,156 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. 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 {AdornmentType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/AdornmentType";
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";
import {Button, colors, Icon} from "@mui/material";
import Box from "@mui/material/Box";
import Tooltip from "@mui/material/Tooltip";
import {useFormikContext} from "formik";
import MDTypography from "qqq/components/legacy/MDTypography";
import ValueUtils from "qqq/utils/qqq/ValueUtils";
import React, {useCallback, useState} from "react";
import {useDropzone} from "react-dropzone";
interface FileInputFieldProps
{
field: any,
record?: QRecord,
errorMessage?: any
}
export default function FileInputField({field, record, errorMessage}: FileInputFieldProps): JSX.Element
{
const [fileName, setFileName] = useState(null as string);
const formikProps = useFormikContext();
const fileChanged = (event: React.FormEvent<HTMLInputElement>, field: any) =>
{
setFileName(null);
if (event.currentTarget.files && event.currentTarget.files[0])
{
setFileName(event.currentTarget.files[0].name);
}
formikProps.setFieldValue(field.name, event.currentTarget.files[0]);
};
const onDrop = useCallback((acceptedFiles: any) =>
{
setFileName(null);
if (acceptedFiles.length && acceptedFiles[0])
{
setFileName(acceptedFiles[0].name);
}
formikProps.setFieldValue(field.name, acceptedFiles[0]);
}, []);
const {getRootProps, getInputProps, isDragActive} = useDropzone({onDrop});
const removeFile = (fieldName: string) =>
{
setFileName(null);
formikProps.setFieldValue(fieldName, null);
record?.values.delete(fieldName);
record?.displayValues.delete(fieldName);
};
const pseudoField = new QFieldMetaData({name: field.name, type: QFieldType.BLOB});
const fileUploadAdornment = field.fieldMetaData?.getAdornment(AdornmentType.FILE_UPLOAD);
const format = fileUploadAdornment?.values?.get("format") ?? "button";
return (
<Box mb={1.5}>
{
record && record.values.get(field.name) && <Box fontSize="0.875rem" pb={1}>
Current File:
<Box display="inline-flex" pl={1}>
{ValueUtils.getDisplayValue(pseudoField, record, "view")}
<Tooltip placement="bottom" title="Remove current file">
<Icon className="blobIcon" fontSize="small" onClick={(e) => removeFile(field.name)}>delete</Icon>
</Tooltip>
</Box>
</Box>
}
{
format == "button" &&
<Box display="flex" alignItems="center">
<Button variant="outlined" component="label">
<span style={{color: colors.lightBlue[500]}}>Choose file to upload</span>
<input
id={field.name}
name={field.name}
type="file"
hidden
onChange={(event: React.FormEvent<HTMLInputElement>) => fileChanged(event, field)}
/>
</Button>
<Box ml={1} fontSize={"1rem"}>
{fileName}
</Box>
</Box>
}
{
format == "dragAndDrop" &&
<>
<Box {...getRootProps()} sx={
{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: "100%",
height: "300px",
borderRadius: "2rem",
backgroundColor: isDragActive ? colors.lightBlue[50] : "transparent",
border: `2px ${isDragActive ? "solid" : "dashed"} ${colors.lightBlue[500]}`
}}>
<input {...getInputProps()} />
<Box display="flex" alignItems="center" flexDirection="column">
<Icon sx={{fontSize: "4rem !important", color: colors.lightBlue[500]}}>upload_file</Icon>
<Box>Drag and drop a file</Box>
<Box fontSize="1rem" m="0.5rem">or</Box>
<Box border={`2px solid ${colors.lightBlue[500]}`} mt="0.25rem" padding="0.25rem 1rem" borderRadius="0.5rem" sx={{cursor: "pointer"}} fontSize="1rem">
Browse files
</Box>
</Box>
</Box>
<Box fontSize={"1rem"} mt="0.25rem">
{fileName}&nbsp;
</Box>
</>
}
<Box mt={0.75}>
<MDTypography component="div" variant="caption" color="error" fontWeight="regular">
{errorMessage && <span>{errorMessage}</span>}
</MDTypography>
</Box>
</Box>
);
}

View File

@ -64,14 +64,13 @@ function Footer({company, links}: Props): JSX.Element
<Box
width="100%"
display="flex"
flexDirection={{xs: "column", md: "row"}}
flexDirection={{xs: "column", lg: "row"}}
justifyContent="space-between"
alignItems="center"
px={1.5}
style={{
position: "fixed", bottom: "0px", zIndex: -1, marginBottom: "10px",
}}
left={{xs: "0", xl: "auto"}}
>
{
href && name &&

View File

@ -25,7 +25,6 @@ import Autocomplete from "@mui/material/Autocomplete";
import Icon from "@mui/material/Icon";
import IconButton from "@mui/material/IconButton";
import ListItemIcon from "@mui/material/ListItemIcon";
import {Theme} from "@mui/material/styles";
import TextField from "@mui/material/TextField";
import Toolbar from "@mui/material/Toolbar";
import React, {useContext, useEffect, useRef, useState} from "react";
@ -226,19 +225,6 @@ function NavBar({absolute, light, isMini}: Props): JSX.Element
const breadcrumbTitle = fullPathToLabel(fullPath, route[route.length - 1]);
///////////////////////////////////////////////////////////////////////////////////////////////
// set the right-half of the navbar up so that below the 'md' breakpoint, it just disappears //
///////////////////////////////////////////////////////////////////////////////////////////////
const navbarRowRight = (theme: Theme, {isMini}: any) =>
{
return {
[theme.breakpoints.down("md")]: {
display: "none",
},
...navbarRow(theme, isMini)
}
};
return (
<AppBar
position={absolute ? "absolute" : navbarType}
@ -255,7 +241,7 @@ function NavBar({absolute, light, isMini}: Props): JSX.Element
<QBreadcrumbs icon="home" title={breadcrumbTitle} route={route} light={light} />
</Box>
{isMini ? null : (
<Box sx={(theme) => navbarRowRight(theme, {isMini})}>
<Box sx={(theme) => navbarRow(theme, {isMini})}>
<Box mt={"-0.25rem"} pb={"0.75rem"} pr={2} mr={-2} sx={{"& *": {cursor: "pointer !important"}}}>
{renderHistory()}
</Box>
@ -270,7 +256,7 @@ function NavBar({absolute, light, isMini}: Props): JSX.Element
{
pageHeader &&
<Box display="flex" justifyContent="space-between">
<MDTypography pb="0.5rem" variant="h3" color={light ? "white" : "dark"} noWrap>
<MDTypography pb="0.5rem" textTransform="capitalize" variant="h3" color={light ? "white" : "dark"} noWrap>
{pageHeader}
</MDTypography>
</Box>

View File

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

View File

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

View File

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

View File

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

View File

@ -1,795 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. 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 Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import FormControlLabel from "@mui/material/FormControlLabel";
import Icon from "@mui/material/Icon";
import IconButton from "@mui/material/IconButton";
import List from "@mui/material/List/List";
import ListItem, {ListItemProps} from "@mui/material/ListItem/ListItem";
import Menu from "@mui/material/Menu";
import Switch from "@mui/material/Switch";
import TextField from "@mui/material/TextField";
import Tooltip from "@mui/material/Tooltip/Tooltip";
import React, {useState} from "react";
export type Option = { label: string, value: string | number, [key: string]: any }
export type Group = { label: string, value: string | number, options: Option[], subGroups?: Group[], [key: string]: any }
type StringOrNumber = string | number
interface QHierarchyAutoCompleteProps
{
idPrefix: string;
heading?: string;
placeholder?: string;
defaultGroup: Group;
showGroupHeaderEvenIfNoSubGroups: boolean;
optionValuesToHide?: StringOrNumber[];
buttonProps: any;
buttonChildren: JSX.Element | string;
menuDirection: "down" | "up";
isModeSelectOne?: boolean;
keepOpenAfterSelectOne?: boolean;
handleSelectedOption?: (option: Option, group: Group) => void;
isModeToggle?: boolean;
toggleStates?: { [optionValue: string]: boolean };
disabledStates?: { [optionValue: string]: boolean };
tooltips?: { [optionValue: string]: string };
handleToggleOption?: (option: Option, group: Group, newValue: boolean) => void;
optionEndAdornment?: JSX.Element;
handleAdornmentClick?: (option: Option, group: Group, event: React.MouseEvent<any>) => void;
forceRerender?: number
}
QHierarchyAutoComplete.defaultProps = {
menuDirection: "down",
showGroupHeaderEvenIfNoSubGroups: false,
isModeSelectOne: false,
keepOpenAfterSelectOne: false,
isModeToggle: false,
};
interface GroupWithOptions
{
group?: Group;
options: Option[];
}
/***************************************************************************
** a sort of re-implementation of Autocomplete, that can display headers
** & children, which may be collapsable (Is that only for toggle mode?)
** but which also can have adornments that trigger actions, or be in a
** single-click-do-something mode.
*
** Originally built just for fields exposed on a table query screen, but
** then factored out of that for use in bulk-load (where it wasn't based on
** exposed joins).
***************************************************************************/
export default function QHierarchyAutoComplete({idPrefix, heading, placeholder, defaultGroup, showGroupHeaderEvenIfNoSubGroups, optionValuesToHide, buttonProps, buttonChildren, isModeSelectOne, keepOpenAfterSelectOne, isModeToggle, handleSelectedOption, toggleStates, disabledStates, tooltips, handleToggleOption, optionEndAdornment, handleAdornmentClick, menuDirection, forceRerender}: QHierarchyAutoCompleteProps): JSX.Element
{
const [menuAnchorElement, setMenuAnchorElement] = useState(null);
const [searchText, setSearchText] = useState("");
const [focusedIndex, setFocusedIndex] = useState(null as number);
const [optionsByGroup, setOptionsByGroup] = useState([] as GroupWithOptions[]);
const [collapsedGroups, setCollapsedGroups] = useState({} as { [groupValue: string | number]: boolean });
const [lastMouseOverXY, setLastMouseOverXY] = useState({x: 0, y: 0});
const [timeOfLastArrow, setTimeOfLastArrow] = useState(0);
//////////////////
// check usages //
//////////////////
if(isModeSelectOne)
{
if(!handleSelectedOption)
{
throw("In QAutoComplete, if isModeSelectOne=true, then a callback for handleSelectedOption must be provided.");
}
}
if(isModeToggle)
{
if(!toggleStates)
{
throw("In QAutoComplete, if isModeToggle=true, then a model for toggleStates must be provided.");
}
if(!handleToggleOption)
{
throw("In QAutoComplete, if isModeToggle=true, then a callback for handleToggleOption must be provided.");
}
}
/////////////////////
// init some stuff //
/////////////////////
if (optionsByGroup.length == 0)
{
collapsedGroups[defaultGroup.value] = false;
if (defaultGroup.subGroups?.length > 0)
{
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// if we have exposed joins, put the table meta data with its fields, and then all of the join tables & fields too //
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
optionsByGroup.push({group: defaultGroup, options: getGroupOptionsAsAlphabeticalArray(defaultGroup)});
for (let i = 0; i < defaultGroup.subGroups?.length; i++)
{
const subGroup = defaultGroup.subGroups[i];
optionsByGroup.push({group: subGroup, options: getGroupOptionsAsAlphabeticalArray(subGroup)});
collapsedGroups[subGroup.value] = false;
}
}
else
{
///////////////////////////////////////////////////////////
// no exposed joins - just the table (w/o its meta-data) //
///////////////////////////////////////////////////////////
optionsByGroup.push({options: getGroupOptionsAsAlphabeticalArray(defaultGroup)});
}
setOptionsByGroup(optionsByGroup);
setCollapsedGroups(collapsedGroups);
}
/*******************************************************************************
**
*******************************************************************************/
function getGroupOptionsAsAlphabeticalArray(group: Group): Option[]
{
const options: Option[] = [];
group.options.forEach(option =>
{
let fullOptionValue = option.value;
if(group.value != defaultGroup.value)
{
fullOptionValue = `${defaultGroup.value}.${option.value}`;
}
if(optionValuesToHide && optionValuesToHide.indexOf(fullOptionValue) > -1)
{
return;
}
options.push(option)
});
options.sort((a, b) => a.label.localeCompare(b.label));
return (options);
}
const optionsByGroupToShow: GroupWithOptions[] = [];
let maxOptionIndex = 0;
optionsByGroup.forEach((groupWithOptions) =>
{
let optionsToShowForThisGroup = groupWithOptions.options.filter(doesOptionMatchSearchText);
if (optionsToShowForThisGroup.length > 0)
{
optionsByGroupToShow.push({group: groupWithOptions.group, options: optionsToShowForThisGroup});
maxOptionIndex += optionsToShowForThisGroup.length;
}
});
/*******************************************************************************
**
*******************************************************************************/
function doesOptionMatchSearchText(option: Option): boolean
{
if (searchText == "")
{
return (true);
}
const columnLabelMinusTable = option.label.replace(/.*: /, "");
if (columnLabelMinusTable.toLowerCase().startsWith(searchText.toLowerCase()))
{
return (true);
}
try
{
////////////////////////////////////////////////////////////
// try to match word-boundary followed by the filter text //
// e.g., "name" would match "First Name" or "Last Name" //
////////////////////////////////////////////////////////////
const re = new RegExp("\\b" + searchText.toLowerCase());
if (columnLabelMinusTable.toLowerCase().match(re))
{
return (true);
}
}
catch (e)
{
//////////////////////////////////////////////////////////////////////////////////
// in case text is an invalid regex... well, at least do a starts-with match... //
//////////////////////////////////////////////////////////////////////////////////
if (columnLabelMinusTable.toLowerCase().startsWith(searchText.toLowerCase()))
{
return (true);
}
}
const tableLabel = option.label.replace(/:.*/, "");
if (tableLabel)
{
try
{
////////////////////////////////////////////////////////////
// try to match word-boundary followed by the filter text //
// e.g., "name" would match "First Name" or "Last Name" //
////////////////////////////////////////////////////////////
const re = new RegExp("\\b" + searchText.toLowerCase());
if (tableLabel.toLowerCase().match(re))
{
return (true);
}
}
catch (e)
{
//////////////////////////////////////////////////////////////////////////////////
// in case text is an invalid regex... well, at least do a starts-with match... //
//////////////////////////////////////////////////////////////////////////////////
if (tableLabel.toLowerCase().startsWith(searchText.toLowerCase()))
{
return (true);
}
}
}
return (false);
}
/*******************************************************************************
**
*******************************************************************************/
function openMenu(event: any)
{
setFocusedIndex(null);
setMenuAnchorElement(event.currentTarget);
setTimeout(() =>
{
document.getElementById(`field-list-dropdown-${idPrefix}-textField`).focus();
doSetFocusedIndex(0, true);
});
}
/*******************************************************************************
**
*******************************************************************************/
function closeMenu()
{
setMenuAnchorElement(null);
}
/*******************************************************************************
** Event handler for toggling an option in toggle mode
*******************************************************************************/
function handleOptionToggle(event: React.ChangeEvent<HTMLInputElement>, option: Option, group: Group)
{
event.stopPropagation();
handleToggleOption(option, group, event.target.checked);
}
/*******************************************************************************
** Event handler for toggling a group in toggle mode
*******************************************************************************/
function handleGroupToggle(event: React.ChangeEvent<HTMLInputElement>, group: Group)
{
event.stopPropagation();
const optionsList = [...group.options.values()];
for (let i = 0; i < optionsList.length; i++)
{
const option = optionsList[i];
if (doesOptionMatchSearchText(option))
{
handleToggleOption(option, group, event.target.checked);
}
}
}
/*******************************************************************************
**
*******************************************************************************/
function toggleCollapsedGroup(value: string | number)
{
collapsedGroups[value] = !collapsedGroups[value];
setCollapsedGroups(Object.assign({}, collapsedGroups));
}
/*******************************************************************************
**
*******************************************************************************/
function getShownOptionAndGroupByIndex(targetIndex: number): { option: Option, group: Group }
{
let index = -1;
for (let i = 0; i < optionsByGroupToShow.length; i++)
{
const groupWithOption = optionsByGroupToShow[i];
for (let j = 0; j < groupWithOption.options.length; j++)
{
index++;
if (index == targetIndex)
{
return {option: groupWithOption.options[j], group: groupWithOption.group};
}
}
}
return (null);
}
/*******************************************************************************
** event handler for keys presses
*******************************************************************************/
function keyDown(event: any)
{
// console.log(`Event key: ${event.key}`);
setTimeout(() => document.getElementById(`field-list-dropdown-${idPrefix}-textField`).focus());
if (isModeSelectOne && event.key == "Enter" && focusedIndex != null)
{
setTimeout(() =>
{
event.stopPropagation();
const {option, group} = getShownOptionAndGroupByIndex(focusedIndex);
if (option)
{
const fullOptionValue = group && group.value != defaultGroup.value ? `${group.value}.${option.value}` : option.value;
const isDisabled = disabledStates && disabledStates[fullOptionValue]
if(isDisabled)
{
return;
}
if(!keepOpenAfterSelectOne)
{
closeMenu();
}
handleSelectedOption(option, group ?? defaultGroup);
}
});
return;
}
const keyOffsetMap: { [key: string]: number } = {
"End": 10000,
"Home": -10000,
"ArrowDown": 1,
"ArrowUp": -1,
"PageDown": 5,
"PageUp": -5,
};
const offset = keyOffsetMap[event.key];
if (offset)
{
event.stopPropagation();
setTimeOfLastArrow(new Date().getTime());
if (isModeSelectOne)
{
let startIndex = focusedIndex;
if (offset > 0)
{
/////////////////
// a down move //
/////////////////
if (startIndex == null)
{
startIndex = -1;
}
let goalIndex = startIndex + offset;
if (goalIndex > maxOptionIndex - 1)
{
goalIndex = maxOptionIndex - 1;
}
doSetFocusedIndex(goalIndex, true);
}
else
{
////////////////
// an up move //
////////////////
let goalIndex = startIndex + offset;
if (goalIndex < 0)
{
goalIndex = 0;
}
doSetFocusedIndex(goalIndex, true);
}
}
}
}
/*******************************************************************************
**
*******************************************************************************/
function doSetFocusedIndex(i: number, tryToScrollIntoView: boolean): void
{
if (isModeSelectOne)
{
setFocusedIndex(i);
console.log(`Setting index to ${i}`);
if (tryToScrollIntoView)
{
const element = document.getElementById(`field-list-dropdown-${idPrefix}-${i}`);
element?.scrollIntoView({block: "center"});
}
}
}
/*******************************************************************************
**
*******************************************************************************/
function setFocusedOption(option: Option, group: Group, tryToScrollIntoView: boolean)
{
let index = -1;
for (let i = 0; i < optionsByGroupToShow.length; i++)
{
const groupWithOption = optionsByGroupToShow[i];
for (let j = 0; j < groupWithOption.options.length; j++)
{
const loopOption = groupWithOption.options[j];
index++;
const groupMatches = (group == null || group.value == groupWithOption.group.value);
if (groupMatches && option.value == loopOption.value)
{
doSetFocusedIndex(index, tryToScrollIntoView);
return;
}
}
}
}
/*******************************************************************************
** event handler for mouse-over the menu
*******************************************************************************/
function handleMouseOver(event: React.MouseEvent<HTMLAnchorElement> | React.MouseEvent<HTMLDivElement> | React.MouseEvent<HTMLLIElement>, option: Option, group: Group, isDisabled: boolean)
{
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// so we're trying to fix the case where, if you put your mouse over an element, but then press up/down arrows, //
// where the mouse will become over a different element after the scroll, and the focus will follow the mouse instead of keyboard. //
// the last x/y isn't really useful, because the mouse generally isn't left exactly where it was when the mouse-over happened (edge of the element) //
// but the keyboard last-arrow time that we capture, that's what's actually being useful in here //
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
if (event.clientX == lastMouseOverXY.x && event.clientY == lastMouseOverXY.y)
{
// console.log("mouse didn't move, so, doesn't count");
return;
}
const now = new Date().getTime();
// console.log(`Compare now [${now}] to last arrow [${timeOfLastArrow}] (diff: [${now - timeOfLastArrow}])`);
if (now < timeOfLastArrow + 300)
{
// console.log("An arrow event happened less than 300 mills ago, so doesn't count.");
return;
}
// console.log("yay, mouse over...");
if(isDisabled)
{
setFocusedIndex(null);
}
else
{
setFocusedOption(option, group, false);
}
setLastMouseOverXY({x: event.clientX, y: event.clientY});
}
/*******************************************************************************
** event handler for text input changes
*******************************************************************************/
function updateSearch(event: React.ChangeEvent<HTMLInputElement>)
{
setSearchText(event?.target?.value ?? "");
doSetFocusedIndex(0, true);
}
/*******************************************************************************
**
*******************************************************************************/
function doHandleAdornmentClick(option: Option, group: Group, event: React.MouseEvent<any>)
{
console.log("In doHandleAdornmentClick");
closeMenu();
handleAdornmentClick(option, group, event);
}
/////////////////////////////////////////////////////////
// compute the group-level toggle state & count values //
/////////////////////////////////////////////////////////
const groupToggleStates: { [value: string]: boolean } = {};
const groupToggleCounts: { [value: string]: number } = {};
if (isModeToggle)
{
const {allOn, count} = getGroupToggleState(defaultGroup, true);
groupToggleStates[defaultGroup.value] = allOn;
groupToggleCounts[defaultGroup.value] = count;
for (let i = 0; i < defaultGroup.subGroups?.length; i++)
{
const subGroup = defaultGroup.subGroups[i];
const {allOn, count} = getGroupToggleState(subGroup, false);
groupToggleStates[subGroup.value] = allOn;
groupToggleCounts[subGroup.value] = count;
}
}
/*******************************************************************************
**
*******************************************************************************/
function getGroupToggleState(group: Group, isMainGroup: boolean): {allOn: boolean, count: number}
{
const optionsList = [...group.options.values()];
let allOn = true;
let count = 0;
for (let i = 0; i < optionsList.length; i++)
{
const option = optionsList[i];
const name = isMainGroup ? option.value : `${group.value}.${option.value}`;
if(!toggleStates[name])
{
allOn = false;
}
else
{
count++;
}
}
return ({allOn: allOn, count: count});
}
let index = -1;
const textFieldId = `field-list-dropdown-${idPrefix}-textField`;
let listItemPadding = isModeToggle ? "0.125rem" : "0.5rem";
///////////////////////////////////////////////////////////////////////////////////////////////////////////////
// for z-indexes, we set each table header to i+1, then the fields in that table to i (so they go behind it) //
// then we increment i by 2 for the next table (so the next header goes above the previous header) //
// this fixes a thing where, if one table's name wrapped to 2 lines, then when the next table below it would //
// come up, if it was only 1 line, then the second line from the previous one would bleed through. //
///////////////////////////////////////////////////////////////////////////////////////////////////////////////
let zIndex = 1;
return (
<>
<Button onClick={openMenu} {...buttonProps}>
{buttonChildren}
</Button>
<Menu
anchorEl={menuAnchorElement}
anchorOrigin={{vertical: menuDirection == "down" ? "bottom" : "top", horizontal: "left"}}
transformOrigin={{vertical: menuDirection == "down" ? "top" : "bottom", horizontal: "left"}}
open={menuAnchorElement != null}
onClose={closeMenu}
onKeyDown={keyDown} // this is added here so arrow-key-up/down events don't make the whole menu become "focused" (blue outline). it works.
keepMounted
>
<Box width={isModeToggle ? "305px" : "265px"} borderRadius={2} className={`fieldListMenuBody fieldListMenuBody-${idPrefix}`}>
{
heading &&
<Box px={1} py={0.5} fontWeight={"700"}>
{heading}
</Box>
}
<Box p={1} pt={0.5}>
<TextField id={textFieldId} variant="outlined" placeholder={placeholder ?? "Search Fields"} fullWidth value={searchText} onChange={updateSearch} onKeyDown={keyDown} inputProps={{sx: {pr: "2rem"}}} />
{
searchText != "" && <IconButton sx={{position: "absolute", right: "0.5rem", top: "0.5rem"}} onClick={() =>
{
updateSearch(null);
document.getElementById(textFieldId).focus();
}}><Icon fontSize="small">close</Icon></IconButton>
}
</Box>
<Box maxHeight={"445px"} minHeight={"445px"} overflow="auto" mr={"-0.5rem"} sx={{scrollbarGutter: "stable"}}>
<List sx={{px: "0.5rem", cursor: "default"}}>
{
optionsByGroupToShow.map((groupWithOptions) =>
{
let headerContents = null;
const headerGroup = groupWithOptions.group || defaultGroup;
if (groupWithOptions.group || showGroupHeaderEvenIfNoSubGroups)
{
headerContents = (<b>{headerGroup.label}</b>);
}
if (isModeToggle)
{
headerContents = (<FormControlLabel
sx={{display: "flex", alignItems: "flex-start", "& .MuiFormControlLabel-label": {lineHeight: "1.4", fontWeight: "500 !important"}}}
control={<Switch
size="small"
sx={{top: "1px"}}
checked={toggleStates[headerGroup.value]}
onChange={(event) => handleGroupToggle(event, headerGroup)}
/>}
label={<span style={{marginTop: "0.25rem", display: "inline-block"}}><b>{headerGroup.label} Fields</b>&nbsp;<span style={{fontWeight: 400}}>({groupToggleCounts[headerGroup.value]})</span></span>} />);
}
if (isModeToggle)
{
headerContents = (
<>
<IconButton
onClick={() => toggleCollapsedGroup(headerGroup.value)}
sx={{justifyContent: "flex-start", fontSize: "0.875rem", pt: 0.5, pb: 0, mr: "0.25rem"}}
disableRipple={true}
>
<Icon sx={{fontSize: "1.5rem !important", position: "relative", top: "2px"}}>{collapsedGroups[headerGroup.value] ? "expand_less" : "expand_more"}</Icon>
</IconButton>
{headerContents}
</>
);
}
let marginLeft = "unset";
if (isModeToggle)
{
marginLeft = "-1rem";
}
zIndex += 2;
return (
<React.Fragment key={groupWithOptions.group?.value ?? "theGroup"}>
<>
{headerContents && <ListItem sx={{position: "sticky", top: -1, zIndex: zIndex + 1, padding: listItemPadding, ml: marginLeft, display: "flex", alignItems: "flex-start", backgroundImage: "linear-gradient(to bottom, rgba(255,255,255,1), rgba(255,255,255,1) 90%, rgba(255,255,255,0))"}}>{headerContents}</ListItem>}
{
groupWithOptions.options.map((option) =>
{
index++;
const key = `${groupWithOptions?.group?.value}-${option.value}`;
let label: JSX.Element | string = option.label;
const fullOptionValue = groupWithOptions.group && groupWithOptions.group.value != defaultGroup.value ? `${groupWithOptions.group.value}.${option.value}` : option.value;
const isDisabled = disabledStates && disabledStates[fullOptionValue]
if (collapsedGroups[headerGroup.value])
{
return (<React.Fragment key={key} />);
}
let style = {};
if (index == focusedIndex)
{
style = {backgroundColor: "#EFEFEF"};
}
const onClick: ListItemProps = {};
if (isModeSelectOne)
{
onClick.onClick = () =>
{
if(isDisabled)
{
return;
}
if(!keepOpenAfterSelectOne)
{
closeMenu();
}
handleSelectedOption(option, groupWithOptions.group ?? defaultGroup);
};
}
if (optionEndAdornment)
{
label = <Box width="100%" display="inline-flex" justifyContent="space-between">
{label}
<Box onClick={(event) => handleAdornmentClick(option, groupWithOptions.group, event)}>
{optionEndAdornment}
</Box>
</Box>;
}
let contents = <>{label}</>;
let paddingLeft = "0.5rem";
if (isModeToggle)
{
contents = (<FormControlLabel
sx={{display: "flex", alignItems: "flex-start", "& .MuiFormControlLabel-label": {lineHeight: "1.4", color: "#606060", fontWeight: "500 !important"}}}
control={<Switch
size="small"
sx={{top: "-3px"}}
checked={toggleStates[fullOptionValue]}
onChange={(event) => handleOptionToggle(event, option, groupWithOptions.group)}
/>}
label={label} />);
paddingLeft = "2.5rem";
}
const listItem = <ListItem
key={key}
id={`field-list-dropdown-${idPrefix}-${index}`}
sx={{color: isDisabled ? "#C0C0C0" : "#757575", p: 1, borderRadius: ".5rem", padding: listItemPadding, pl: paddingLeft, scrollMarginTop: "3rem", zIndex: zIndex, background: "#FFFFFF", ...style}}
onMouseOver={(event) =>
{
handleMouseOver(event, option, groupWithOptions.group, isDisabled)
}}
{...onClick}
>{contents}</ListItem>;
if(tooltips[fullOptionValue])
{
return <Tooltip key={key} title={tooltips[fullOptionValue]} placement="right" enterDelay={500}>{listItem}</Tooltip>
}
else
{
return listItem
}
})
}
</>
</React.Fragment>
);
})
}
{
index == -1 && <ListItem sx={{p: "0.5rem"}}><i>No options found.</i></ListItem>
}
</List>
</Box>
</Box>
</Menu>
</>
);
}

View File

@ -1,794 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2022. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* 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 {QController} from "@kingsrook/qqq-frontend-core/lib/controllers/QController";
import {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstance";
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
import {QJobComplete} from "@kingsrook/qqq-frontend-core/lib/model/processes/QJobComplete";
import {QJobError} from "@kingsrook/qqq-frontend-core/lib/model/processes/QJobError";
import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord";
import {Alert, Button} from "@mui/material";
import Box from "@mui/material/Box";
import Dialog from "@mui/material/Dialog";
import DialogActions from "@mui/material/DialogActions";
import DialogContent from "@mui/material/DialogContent";
import DialogTitle from "@mui/material/DialogTitle";
import Divider from "@mui/material/Divider";
import Icon from "@mui/material/Icon";
import ListItemIcon from "@mui/material/ListItemIcon";
import Menu from "@mui/material/Menu";
import MenuItem from "@mui/material/MenuItem";
import TextField from "@mui/material/TextField";
import Tooltip from "@mui/material/Tooltip";
import {TooltipProps} from "@mui/material/Tooltip/Tooltip";
import FormData from "form-data";
import QContext from "QContext";
import colors from "qqq/assets/theme/base/colors";
import {QCancelButton, QDeleteButton, QSaveButton} from "qqq/components/buttons/DefaultButtons";
import {BulkLoadMapping, BulkLoadTableStructure, FileDescription} from "qqq/models/processes/BulkLoadModels";
import Client from "qqq/utils/qqq/Client";
import {SavedBulkLoadProfileUtils} from "qqq/utils/qqq/SavedBulkLoadProfileUtils";
import React, {useContext, useEffect, useRef, useState} from "react";
import {useLocation} from "react-router-dom";
interface Props
{
metaData: QInstance,
tableMetaData: QTableMetaData,
tableStructure: BulkLoadTableStructure,
currentSavedBulkLoadProfileRecord: QRecord,
currentMapping: BulkLoadMapping,
bulkLoadProfileOnChangeCallback?: (record: QRecord | null) => void,
allowSelectingProfile?: boolean,
fileDescription?: FileDescription,
bulkLoadProfileResetToSuggestedMappingCallback?: () => void,
isBulkEdit?: boolean;
}
SavedBulkLoadProfiles.defaultProps = {
allowSelectingProfile: true
};
const qController = Client.getInstance();
/***************************************************************************
** menu-button, text elements, and modal(s) that let you work with saved
** bulk-load profiles.
***************************************************************************/
function SavedBulkLoadProfiles({metaData, tableMetaData, tableStructure, currentSavedBulkLoadProfileRecord, bulkLoadProfileOnChangeCallback, currentMapping, allowSelectingProfile, fileDescription, bulkLoadProfileResetToSuggestedMappingCallback, isBulkEdit}: Props): JSX.Element
{
const [yourSavedBulkLoadProfiles, setYourSavedBulkLoadProfiles] = useState([] as QRecord[]);
const [bulkLoadProfilesSharedWithYou, setBulkLoadProfilesSharedWithYou] = useState([] as QRecord[]);
const [savedBulkLoadProfilesMenu, setSavedBulkLoadProfilesMenu] = useState(null);
const [savedBulkLoadProfilesHaveLoaded, setSavedBulkLoadProfilesHaveLoaded] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [savePopupOpen, setSavePopupOpen] = useState(false);
const [isSaveAsAction, setIsSaveAsAction] = useState(false);
const [isRenameAction, setIsRenameAction] = useState(false);
const [isDeleteAction, setIsDeleteAction] = useState(false);
const [savedBulkLoadProfileNameInputValue, setSavedBulkLoadProfileNameInputValue] = useState(null as string);
const [popupAlertContent, setPopupAlertContent] = useState("");
const [savedSuccessMessage, setSavedSuccessMessage] = useState(null as string);
const [savedFailedMessage, setSavedFailedMessage] = useState(null as string);
const anchorRef = useRef<HTMLDivElement>(null);
const location = useLocation();
const [saveOptionsOpen, setSaveOptionsOpen] = useState(false);
const SAVE_OPTION = "Save...";
const DUPLICATE_OPTION = "Duplicate...";
const RENAME_OPTION = "Rename...";
const DELETE_OPTION = "Delete...";
const CLEAR_OPTION = "New Profile";
const RESET_TO_SUGGESTION = "Reset to Suggested Mapping";
const {accentColor, accentColorLight, userId: currentUserId} = useContext(QContext);
const openSavedBulkLoadProfilesMenu = (event: any) => setSavedBulkLoadProfilesMenu(event.currentTarget);
const closeSavedBulkLoadProfilesMenu = () => setSavedBulkLoadProfilesMenu(null);
////////////////////////////////////////////////////////////////////////
// load records on first run (if user is allowed to select a profile) //
////////////////////////////////////////////////////////////////////////
useEffect(() =>
{
if (allowSelectingProfile)
{
loadSavedBulkLoadProfiles()
.then(() =>
{
setSavedBulkLoadProfilesHaveLoaded(true);
});
}
}, []);
const baseBulkLoadMapping: BulkLoadMapping = currentSavedBulkLoadProfileRecord ? BulkLoadMapping.fromSavedProfileRecord(tableStructure, currentSavedBulkLoadProfileRecord) : new BulkLoadMapping(tableStructure);
const bulkLoadProfileDiffs: any[] = SavedBulkLoadProfileUtils.diffBulkLoadMappings(tableStructure, fileDescription, baseBulkLoadMapping, currentMapping);
let bulkLoadProfileIsModified = false;
if (bulkLoadProfileDiffs.length > 0)
{
bulkLoadProfileIsModified = true;
}
/*******************************************************************************
** make request to load all saved profiles from backend
*******************************************************************************/
async function loadSavedBulkLoadProfiles()
{
if (!tableMetaData)
{
return;
}
const formData = new FormData();
formData.append("tableName", tableMetaData.name);
formData.append("isBulkEdit", isBulkEdit.toString());
const savedBulkLoadProfiles = await makeSavedBulkLoadProfileRequest("querySavedBulkLoadProfile", formData);
const yourSavedBulkLoadProfiles: QRecord[] = [];
const bulkLoadProfilesSharedWithYou: QRecord[] = [];
for (let i = 0; i < savedBulkLoadProfiles.length; i++)
{
const record = savedBulkLoadProfiles[i];
if (record.values.get("userId") == currentUserId)
{
yourSavedBulkLoadProfiles.push(record);
}
else
{
bulkLoadProfilesSharedWithYou.push(record);
}
}
setYourSavedBulkLoadProfiles(yourSavedBulkLoadProfiles);
setBulkLoadProfilesSharedWithYou(bulkLoadProfilesSharedWithYou);
}
/*******************************************************************************
** fired when a saved record is clicked from the dropdown
*******************************************************************************/
const handleSavedBulkLoadProfileRecordOnClick = async (record: QRecord) =>
{
setSavePopupOpen(false);
closeSavedBulkLoadProfilesMenu();
if (bulkLoadProfileOnChangeCallback)
{
bulkLoadProfileOnChangeCallback(record);
}
};
/*******************************************************************************
** fired when a save option is selected from the save... button/dropdown combo
*******************************************************************************/
const handleDropdownOptionClick = (optionName: string) =>
{
setSaveOptionsOpen(false);
setPopupAlertContent("");
closeSavedBulkLoadProfilesMenu();
setSavePopupOpen(true);
setIsSaveAsAction(false);
setIsRenameAction(false);
setIsDeleteAction(false);
switch (optionName)
{
case SAVE_OPTION:
if (currentSavedBulkLoadProfileRecord == null)
{
setSavedBulkLoadProfileNameInputValue("");
}
break;
case DUPLICATE_OPTION:
setSavedBulkLoadProfileNameInputValue("");
setIsSaveAsAction(true);
break;
case CLEAR_OPTION:
setSavePopupOpen(false);
if (bulkLoadProfileOnChangeCallback)
{
bulkLoadProfileOnChangeCallback(null);
}
break;
case RESET_TO_SUGGESTION:
setSavePopupOpen(false);
if (bulkLoadProfileResetToSuggestedMappingCallback)
{
bulkLoadProfileResetToSuggestedMappingCallback();
}
break;
case RENAME_OPTION:
if (currentSavedBulkLoadProfileRecord != null)
{
setSavedBulkLoadProfileNameInputValue(currentSavedBulkLoadProfileRecord.values.get("label"));
}
setIsRenameAction(true);
break;
case DELETE_OPTION:
setIsDeleteAction(true);
break;
}
};
/*******************************************************************************
** fired when save or delete button saved on confirmation dialogs
*******************************************************************************/
async function handleDialogButtonOnClick()
{
try
{
setPopupAlertContent("");
setIsSubmitting(true);
const formData = new FormData();
if (isDeleteAction)
{
formData.append("id", currentSavedBulkLoadProfileRecord.values.get("id"));
await makeSavedBulkLoadProfileRequest("deleteSavedBulkLoadProfile", formData);
setSavePopupOpen(false);
setSaveOptionsOpen(false);
await (async () =>
{
handleDropdownOptionClick(CLEAR_OPTION);
})();
}
else
{
formData.append("tableName", tableMetaData.name);
/////////////////////////////////////////////////////////////////////////////////////////
// convert the BulkLoadMapping object to a BulkLoadProfile - the thing that gets saved //
/////////////////////////////////////////////////////////////////////////////////////////
const bulkLoadProfile = currentMapping.toProfile();
const mappingJson = JSON.stringify(bulkLoadProfile.profile);
formData.append("mappingJson", mappingJson);
formData.append("isBulkEdit", isBulkEdit.toString());
if (isSaveAsAction || isRenameAction || currentSavedBulkLoadProfileRecord == null)
{
formData.append("label", savedBulkLoadProfileNameInputValue);
if (currentSavedBulkLoadProfileRecord != null && isRenameAction)
{
formData.append("id", currentSavedBulkLoadProfileRecord.values.get("id"));
}
}
else
{
formData.append("id", currentSavedBulkLoadProfileRecord.values.get("id"));
formData.append("label", currentSavedBulkLoadProfileRecord?.values.get("label"));
}
const recordList = await makeSavedBulkLoadProfileRequest("storeSavedBulkLoadProfile", formData);
await (async () =>
{
if (recordList && recordList.length > 0)
{
setSavedBulkLoadProfilesHaveLoaded(false);
setSavedSuccessMessage("Profile Saved.");
setTimeout(() => setSavedSuccessMessage(null), 2500);
if (allowSelectingProfile)
{
loadSavedBulkLoadProfiles();
handleSavedBulkLoadProfileRecordOnClick(recordList[0]);
}
else
{
if (bulkLoadProfileOnChangeCallback)
{
bulkLoadProfileOnChangeCallback(recordList[0]);
}
}
}
})();
}
setSavePopupOpen(false);
setSaveOptionsOpen(false);
}
catch (e: any)
{
let message = JSON.stringify(e);
if (typeof e == "string")
{
message = e;
}
else if (typeof e == "object" && e.message)
{
message = e.message;
}
setPopupAlertContent(message);
console.log(`Setting error: ${message}`);
}
finally
{
setIsSubmitting(false);
}
}
/*******************************************************************************
** stores the current dialog input text to state
*******************************************************************************/
const handleSaveDialogInputChange = (event: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>) =>
{
setSavedBulkLoadProfileNameInputValue(event.target.value);
};
/*******************************************************************************
** closes current dialog
*******************************************************************************/
const handleSavePopupClose = () =>
{
setSavePopupOpen(false);
};
/*******************************************************************************
** make a request to the backend for various savedBulkLoadProfile processes
*******************************************************************************/
async function makeSavedBulkLoadProfileRequest(processName: string, formData: FormData): Promise<QRecord[]>
{
/////////////////////////
// fetch saved records //
/////////////////////////
let savedBulkLoadProfiles = [] as QRecord[];
try
{
//////////////////////////////////////////////////////////////////
// we don't want this job to go async, so, pass a large timeout //
//////////////////////////////////////////////////////////////////
formData.append(QController.STEP_TIMEOUT_MILLIS_PARAM_NAME, 60 * 1000);
const processResult = await qController.processInit(processName, formData, qController.defaultMultipartFormDataHeaders());
if (processResult instanceof QJobError)
{
const jobError = processResult as QJobError;
throw (jobError.error);
}
else
{
const result = processResult as QJobComplete;
if (result.values.savedBulkLoadProfileList)
{
for (let i = 0; i < result.values.savedBulkLoadProfileList.length; i++)
{
const qRecord = new QRecord(result.values.savedBulkLoadProfileList[i]);
savedBulkLoadProfiles.push(qRecord);
}
}
}
}
catch (e)
{
throw (e);
}
return (savedBulkLoadProfiles);
}
const bulkAction = isBulkEdit ? "Edit" : "Load";
const hasStorePermission = metaData?.processes.has("storeSavedBulkLoadProfile");
const hasDeletePermission = metaData?.processes.has("deleteSavedBulkLoadProfile");
const hasQueryPermission = metaData?.processes.has("querySavedBulkLoadProfile");
const tooltipMaxWidth = (maxWidth: string) =>
{
return ({
slotProps: {
tooltip: {
sx: {
maxWidth: maxWidth
}
}
}
});
};
const menuTooltipAttribs = {...tooltipMaxWidth("250px"), placement: "left", enterDelay: 1000} as TooltipProps;
let disabledBecauseNotOwner = false;
let notOwnerTooltipText = null;
if (currentSavedBulkLoadProfileRecord && currentSavedBulkLoadProfileRecord.values.get("userId") != currentUserId)
{
disabledBecauseNotOwner = true;
notOwnerTooltipText = "You may not save changes to this bulk load profile, because you are not its owner.";
}
const menuWidth = "300px";
const renderSavedBulkLoadProfilesMenu = tableMetaData && (
<Menu
anchorEl={savedBulkLoadProfilesMenu}
anchorOrigin={{vertical: "bottom", horizontal: "left",}}
transformOrigin={{vertical: "top", horizontal: "left",}}
open={Boolean(savedBulkLoadProfilesMenu)}
onClose={closeSavedBulkLoadProfilesMenu}
keepMounted
PaperProps={{style: {maxHeight: "calc(100vh - 200px)", minWidth: menuWidth}}}
>
{
<MenuItem sx={{width: menuWidth}} disabled style={{opacity: "initial"}}><b>Bulk {bulkAction} Profile Actions</b></MenuItem>
}
{
!allowSelectingProfile &&
<MenuItem sx={{width: menuWidth}} disabled style={{opacity: "initial", whiteSpace: "wrap", display: "block"}}>
{
currentSavedBulkLoadProfileRecord ?
<span>You are using the bulk {bulkAction.toLowerCase()} profile:<br /><b style={{paddingLeft: "1rem"}}>{currentSavedBulkLoadProfileRecord.values.get("label")}</b><br /><br />You can manage this profile on this screen.</span>
: <span>You are not using a saved bulk {bulkAction.toLowerCase()} profile.<br /><br />You can save your profile on this screen.</span>
}
</MenuItem>
}
{
!allowSelectingProfile && <Divider />
}
{
hasStorePermission &&
<Tooltip {...menuTooltipAttribs} title={notOwnerTooltipText ?? <>Save your current mapping, for quick re-use at a later time.<br /><br />You will be prompted to enter a name if you choose this option.</>}>
<span>
<MenuItem disabled={disabledBecauseNotOwner} onClick={() => handleDropdownOptionClick(SAVE_OPTION)}>
<ListItemIcon><Icon>save</Icon></ListItemIcon>
{currentSavedBulkLoadProfileRecord ? "Save..." : "Save As..."}
</MenuItem>
</span>
</Tooltip>
}
{
hasStorePermission && currentSavedBulkLoadProfileRecord != null &&
<Tooltip {...menuTooltipAttribs} title={notOwnerTooltipText ?? "Change the name for this saved bulk {bulkAction.toLowerCase()} profile."}>
<span>
<MenuItem disabled={currentSavedBulkLoadProfileRecord === null || disabledBecauseNotOwner} onClick={() => handleDropdownOptionClick(RENAME_OPTION)}>
<ListItemIcon><Icon>edit</Icon></ListItemIcon>
Rename...
</MenuItem>
</span>
</Tooltip>
}
{
hasStorePermission && currentSavedBulkLoadProfileRecord != null &&
<Tooltip {...menuTooltipAttribs} title="Save a new copy this bulk {bulkAction.toLowerCase()} profile, with a different name, separate from the original.">
<span>
<MenuItem disabled={currentSavedBulkLoadProfileRecord === null} onClick={() => handleDropdownOptionClick(DUPLICATE_OPTION)}>
<ListItemIcon><Icon>content_copy</Icon></ListItemIcon>
Save As...
</MenuItem>
</span>
</Tooltip>
}
{
hasDeletePermission && currentSavedBulkLoadProfileRecord != null &&
<Tooltip {...menuTooltipAttribs} title={notOwnerTooltipText ?? "Delete this saved bulk {bulkAction.toLowerCase()} profile."}>
<span>
<MenuItem disabled={currentSavedBulkLoadProfileRecord === null || disabledBecauseNotOwner} onClick={() => handleDropdownOptionClick(DELETE_OPTION)}>
<ListItemIcon><Icon>delete</Icon></ListItemIcon>
Delete...
</MenuItem>
</span>
</Tooltip>
}
{
allowSelectingProfile &&
<Tooltip {...menuTooltipAttribs} title="Create a new blank bulk {bulkAction.toLowerCase()} profile for this table, removing all mappings.">
<span>
<MenuItem onClick={() => handleDropdownOptionClick(CLEAR_OPTION)}>
<ListItemIcon><Icon>monitor</Icon></ListItemIcon>
New Bulk {bulkAction} Profile
</MenuItem>
</span>
</Tooltip>
}
{
allowSelectingProfile &&
<Box>
{
<Divider />
}
<MenuItem disabled style={{"opacity": "initial"}}><b>Your Saved Bulk {bulkAction} Profiles</b></MenuItem>
{
yourSavedBulkLoadProfiles && yourSavedBulkLoadProfiles.length > 0 ? (
yourSavedBulkLoadProfiles.map((record: QRecord, index: number) =>
<MenuItem sx={{paddingLeft: "50px"}} key={`savedFiler-${index}`} onClick={() => handleSavedBulkLoadProfileRecordOnClick(record)}>
{record.values.get("label")}
</MenuItem>
)
) : (
<MenuItem disabled sx={{opacity: "1 !important"}}>
<i>You do not have any saved bulk {bulkAction.toLowerCase()} profiles for this table.</i>
</MenuItem>
)
}
<MenuItem disabled style={{"opacity": "initial"}}><b>Bulk {bulkAction} Profiles Shared with you</b></MenuItem>
{
bulkLoadProfilesSharedWithYou && bulkLoadProfilesSharedWithYou.length > 0 ? (
bulkLoadProfilesSharedWithYou.map((record: QRecord, index: number) =>
<MenuItem sx={{paddingLeft: "50px"}} key={`savedFiler-${index}`} onClick={() => handleSavedBulkLoadProfileRecordOnClick(record)}>
{record.values.get("label")}
</MenuItem>
)
) : (
<MenuItem disabled sx={{opacity: "1 !important"}}>
<i>You do not have any bulk {bulkAction.toLowerCase()} profiles shared with you for this table.</i>
</MenuItem>
)
}
</Box>
}
</Menu>
);
let buttonText = `Saved Bulk ${bulkAction} Profiles`;
let buttonBackground = "none";
let buttonBorder = colors.grayLines.main;
let buttonColor = colors.gray.main;
if (currentSavedBulkLoadProfileRecord)
{
if (bulkLoadProfileIsModified)
{
buttonBackground = accentColorLight;
buttonBorder = buttonBackground;
buttonColor = accentColor;
}
else
{
buttonBackground = accentColor;
buttonBorder = buttonBackground;
buttonColor = "#FFFFFF";
}
}
const buttonStyles = {
border: `1px solid ${buttonBorder}`,
backgroundColor: buttonBackground,
color: buttonColor,
"&:focus:not(:hover)": {
color: buttonColor,
backgroundColor: buttonBackground,
},
"&:hover": {
color: buttonColor,
backgroundColor: buttonBackground,
}
};
/*******************************************************************************
**
*******************************************************************************/
function isSaveButtonDisabled(): boolean
{
if (isSubmitting)
{
return (true);
}
const haveInputText = (savedBulkLoadProfileNameInputValue != null && savedBulkLoadProfileNameInputValue.trim() != "");
if (isSaveAsAction || isRenameAction || currentSavedBulkLoadProfileRecord == null)
{
if (!haveInputText)
{
return (true);
}
}
return (false);
}
const linkButtonStyle = {
minWidth: "unset",
textTransform: "none",
fontSize: "0.875rem",
fontWeight: "500",
padding: "0.5rem"
};
return (
hasQueryPermission && tableMetaData ? (
<>
<Box order="1" mr={"0.5rem"}>
<Button
onClick={openSavedBulkLoadProfilesMenu}
sx={{
borderRadius: "0.75rem",
textTransform: "none",
fontWeight: 500,
fontSize: "0.875rem",
p: "0.5rem",
...buttonStyles
}}
>
<Icon sx={{mr: "0.5rem"}}>save</Icon>
{buttonText}
<Icon sx={{ml: "0.5rem"}}>keyboard_arrow_down</Icon>
</Button>
{renderSavedBulkLoadProfilesMenu}
</Box>
<Box order="3" display="flex" justifyContent="center" flexDirection="column">
<Box pl={2} pr={2} fontSize="0.875rem" sx={{display: "flex", alignItems: "center"}}>
{
savedSuccessMessage && <Box color={colors.success.main}>{savedSuccessMessage}</Box>
}
{
savedFailedMessage && <Box color={colors.error.main}>{savedFailedMessage}</Box>
}
{
!currentSavedBulkLoadProfileRecord /*&& bulkLoadProfileIsModified*/ && <>
{
<>
<Tooltip {...tooltipMaxWidth("24rem")} sx={{cursor: "pointer"}} title={<>
<b>Unsaved Mapping</b>
<ul style={{padding: "0.5rem 1rem"}}>
<li>You are not using a saved bulk {bulkAction.toLowerCase()} profile.</li>
{
/*bulkLoadProfileDiffs.map((s: string, i: number) => <li key={i}>{s}</li>)*/
}
</ul>
</>}>
<Button disableRipple={true} sx={linkButtonStyle} onClick={() => handleDropdownOptionClick(SAVE_OPTION)}>Save Bulk {bulkAction} Profile As&hellip;</Button>
</Tooltip>
{/* vertical rule */}
{allowSelectingProfile && <Box display="inline-block" borderLeft={`1px solid ${colors.grayLines.main}`} height="1rem" width="1px" position="relative" />}
</>
}
{/* for the no-profile use-case, don't give a reset-link on screens other than the first (file mapping) one - which is tied to the allowSelectingProfile attribute */}
{allowSelectingProfile && <>
<Box pl="0.5rem">Reset to:</Box>
<Button disableRipple={true} sx={{color: colors.gray.main, ...linkButtonStyle}} onClick={() => handleDropdownOptionClick(CLEAR_OPTION)}>Empty Mapping</Button>
<Box display="inline-block" borderLeft={`1px solid ${colors.grayLines.main}`} height="1rem" width="1px" position="relative" />
<Button disableRipple={true} sx={{color: colors.gray.main, ...linkButtonStyle}} onClick={() => handleDropdownOptionClick(RESET_TO_SUGGESTION)}>Suggested Mapping</Button>
</>}
</>
}
{
currentSavedBulkLoadProfileRecord && bulkLoadProfileIsModified && <>
<Tooltip {...tooltipMaxWidth("24rem")} sx={{cursor: "pointer"}} title={<>
<b>Unsaved Changes</b>
<ul style={{padding: "0.5rem 1rem"}}>
{
bulkLoadProfileDiffs.map((s: string, i: number) => <li key={i}>{s}</li>)
}
</ul>
{
notOwnerTooltipText && <i>{notOwnerTooltipText}</i>
}
</>}>
<Box display="inline" sx={{...linkButtonStyle, p: 0, cursor: "default", position: "relative", top: "-1px"}}>{bulkLoadProfileDiffs.length} Unsaved Change{bulkLoadProfileDiffs.length == 1 ? "" : "s"}</Box>
</Tooltip>
{disabledBecauseNotOwner ? <>&nbsp;&nbsp;</> : <Button disableRipple={true} sx={linkButtonStyle} onClick={() => handleDropdownOptionClick(SAVE_OPTION)}>Save&hellip;</Button>}
{/* vertical rule */}
{/* also, don't give a reset-link on screens other than the first (file mapping) one - which is tied to the allowSelectingProfile attribute */}
{/* partly because it isn't correctly resetting the values, but also because, it's a litle unclear that what, it would reset changes from other screens too?? */}
{
allowSelectingProfile && <>
<Box display="inline-block" borderLeft={`1px solid ${colors.grayLines.main}`} height="1rem" width="1px" position="relative" />
<Button disableRipple={true} sx={{color: colors.gray.main, ...linkButtonStyle}} onClick={() => handleSavedBulkLoadProfileRecordOnClick(currentSavedBulkLoadProfileRecord)}>Reset All Changes</Button>
</>
}
</>
}
</Box>
</Box>
{
<Dialog
open={savePopupOpen}
onClose={handleSavePopupClose}
aria-labelledby="alert-dialog-title"
aria-describedby="alert-dialog-description"
onKeyPress={(e) =>
{
////////////////////////////////////////////////////
// make user actually hit delete button //
// but for other modes, let Enter submit the form //
////////////////////////////////////////////////////
if (e.key == "Enter" && !isDeleteAction)
{
handleDialogButtonOnClick();
}
}}
>
{
currentSavedBulkLoadProfileRecord ? (
isDeleteAction ? (
<DialogTitle id="alert-dialog-title">Delete Bulk {bulkAction} Profile</DialogTitle>
) : (
isSaveAsAction ? (
<DialogTitle id="alert-dialog-title">Save Bulk {bulkAction} Profile As</DialogTitle>
) : (
isRenameAction ? (
<DialogTitle id="alert-dialog-title">Rename Bulk {bulkAction} Profile</DialogTitle>
) : (
<DialogTitle id="alert-dialog-title">Update Existing Bulk {bulkAction} Profile</DialogTitle>
)
)
)
) : (
<DialogTitle id="alert-dialog-title">Save New Bulk {bulkAction} Profile</DialogTitle>
)
}
<DialogContent sx={{width: "500px"}}>
{popupAlertContent ? (
<Box mb={1}>
<Alert severity="error" onClose={() => setPopupAlertContent("")}>{popupAlertContent}</Alert>
</Box>
) : ("")}
{
(!currentSavedBulkLoadProfileRecord || isSaveAsAction || isRenameAction) && !isDeleteAction ? (
<Box>
{
isSaveAsAction ? (
<Box mb={3}>Enter a name for this new saved bulk {bulkAction.toLowerCase()} profile.</Box>
) : (
<Box mb={3}>Enter a new name for this saved bulk {bulkAction.toLowerCase()} profile.</Box>
)
}
<TextField
autoFocus
name="custom-delimiter-value"
placeholder={`Bulk ${bulkAction} Profile Name`}
inputProps={{width: "100%", maxLength: 100}}
value={savedBulkLoadProfileNameInputValue}
sx={{width: "100%"}}
onChange={handleSaveDialogInputChange}
onFocus={event =>
{
event.target.select();
}}
/>
</Box>
) : (
isDeleteAction ? (
<Box>Are you sure you want to delete the bulk {bulkAction.toLowerCase()} profile {`'${currentSavedBulkLoadProfileRecord?.values.get("label")}'`}?</Box>
) : (
<Box>Are you sure you want to update the bulk {bulkAction.toLowerCase()} profile {`'${currentSavedBulkLoadProfileRecord?.values.get("label")}'`}?</Box>
)
)
}
</DialogContent>
<DialogActions>
<QCancelButton onClickHandler={handleSavePopupClose} disabled={false} />
{
isDeleteAction ?
<QDeleteButton onClickHandler={handleDialogButtonOnClick} disabled={isSubmitting} />
:
<QSaveButton label="Save" onClickHandler={handleDialogButtonOnClick} disabled={isSaveButtonDisabled()} />
}
</DialogActions>
</Dialog>
}
</>
) : null
);
}
export default SavedBulkLoadProfiles;

View File

@ -1,343 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. 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 {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
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";
import Icon from "@mui/material/Icon";
import RadioGroup from "@mui/material/RadioGroup";
import TextField from "@mui/material/TextField";
import {useFormikContext} from "formik";
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
{
bulkLoadField: BulkLoadField,
isRequired: boolean,
removeFieldCallback?: () => void,
fileDescription: FileDescription,
forceParentUpdate?: () => void,
isBulkEdit?: boolean
}
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.
***************************************************************************/
export default function BulkLoadFileMappingField({bulkLoadField, isRequired, removeFieldCallback, fileDescription, forceParentUpdate, isBulkEdit}: BulkLoadMappingFieldProps): JSX.Element
{
const columnNames = fileDescription.getColumnNames();
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);
const dynamicFieldInObject: any = {};
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, null, "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++)
{
const label = columnNames[i];
if (!usedLabels[label])
{
columnOptions.push({label: label, value: i});
usedLabels[label] = true;
}
}
//////////////////////////////////////////////////////////////////////
// try to pick up changes in the hasHeaderRow toggle from way above //
//////////////////////////////////////////////////////////////////////
if (bulkLoadField.columnIndex != null && bulkLoadField.columnIndex != undefined && selectedColumn.label && columnNames[bulkLoadField.columnIndex] != selectedColumn.label)
{
setSelectedColumn({label: columnNames[bulkLoadField.columnIndex], value: bulkLoadField.columnIndex});
setSelectedColumnInputValue(columnNames[bulkLoadField.columnIndex]);
}
const mainFontSize = "0.875rem";
const smallerFontSize = "0.75rem";
/////////////////////////////////////////////////////////////////////////////////////////////
// some field types get their value from formik. //
// so for a pre-populated value, do an on-load useEffect, that'll set the value in formik. //
/////////////////////////////////////////////////////////////////////////////////////////////
const {setFieldValue} = useFormikContext();
useEffect(() =>
{
if (valueType == "defaultValue")
{
setFieldValue(`${bulkLoadField.field.name}.defaultValue`, bulkLoadField.defaultValue);
}
}, []);
/***************************************************************************
**
***************************************************************************/
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)
{
bulkLoadField.headerName = newValue == null ? null : newValue.label;
}
bulkLoadField.error = null;
bulkLoadField.warning = null;
forceParentUpdate && forceParentUpdate();
}
/***************************************************************************
**
***************************************************************************/
function defaultValueChanged(newValue: any)
{
setFieldValue(`${bulkLoadField.field.name}.defaultValue`, newValue);
bulkLoadField.defaultValue = newValue;
bulkLoadField.error = null;
bulkLoadField.warning = null;
forceParentUpdate && forceParentUpdate();
}
/***************************************************************************
**
***************************************************************************/
function valueTypeChanged(isColumn: boolean)
{
const newValueType = isColumn ? "column" : "defaultValue";
bulkLoadField.valueType = newValueType;
setValueType(newValueType);
bulkLoadField.error = null;
bulkLoadField.warning = null;
forceParentUpdate && forceParentUpdate();
}
/***************************************************************************
**
***************************************************************************/
function mapValuesChanged(value: boolean)
{
bulkLoadField.doValueMapping = value;
forceParentUpdate && forceParentUpdate();
}
/***************************************************************************
**
***************************************************************************/
function clearIfEmptyChanged(value: boolean)
{
bulkLoadField.clearIfEmpty = value;
forceParentUpdate && forceParentUpdate();
}
/***************************************************************************
**
***************************************************************************/
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}
}}>
<Box display="flex" alignItems="flex-start">
{
(!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()}
</Box>
</Box>
<RadioGroup name="valueType" value={valueType}>
<Box>
<Box display="flex" alignItems="center" sx={{height: "45px"}}>
<FormControlLabel value="column" control={<Radio size="small" onChange={(event, checked) => valueTypeChanged(checked)} />} label={"File column"} sx={{minWidth: "140px", whiteSpace: "nowrap"}} />
{
valueType == "column" && <Box width="100%">
<Autocomplete
id={bulkLoadField.field.name}
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={selectedColumnInputValue}
onChange={columnChanged}
getOptionLabel={(option) => typeof (option) == "string" ? option : (option?.label ?? "")}
isOptionEqualToValue={(option, value) => option == null && value == null || option.value == value.value}
renderOption={(props, option, state) => (<li {...props}>{option?.label ?? ""}</li>)}
sx={{"& .MuiOutlinedInput-root": {padding: "0"}}}
/>
</Box>
}
</Box>
<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" && actuallyDoingInitialLoadOfPossibleValue && <Box width="100%">Loading...</Box>
}
{
valueType == "defaultValue" && !actuallyDoingInitialLoadOfPossibleValue && <Box width="100%">
<QDynamicFormField
name={`${bulkLoadField.field.name}.defaultValue`}
displayFormat={""}
label={""}
formFieldObject={dynamicField}
type={dynamicField.type}
value={bulkLoadField.defaultValue}
onChangeCallback={defaultValueChanged}
/>
</Box>
}
</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" className="bulkLoadFieldError">
{bulkLoadField.error}
</Box>
}
</RadioGroup>
<Box ml="1rem">
{
valueType == "column" && <>
<Box display="flex" alignItems="center" sx={{height: "45px"}}>
<FormControlLabel value="mapValues" control={<Checkbox size="small" defaultChecked={bulkLoadField.doValueMapping} onChange={(event, checked) => mapValuesChanged(checked)} />} label={"Map values"} sx={{minWidth: "140px", whiteSpace: "nowrap"}} />
{
isBulkEdit && !isRequired && <FormControlLabel value="clearIfEmpty" control={<Checkbox size="small" defaultChecked={bulkLoadField.clearIfEmpty} onChange={(event, checked) => clearIfEmptyChanged(checked)} />} label={"Clear if empty"} sx={{minWidth: "140px", whiteSpace: "nowrap"}} />
}
</Box>
<Box fontSize={mainFontSize} mt="0.5rem">
Preview Values: <span style={{color: "gray"}}>{(fileDescription.getPreviewValues(selectedColumn?.value) ?? [""]).join(", ")}</span>
</Box>
</>
}
</Box>
</Box>
</Box>);
}

View File

@ -1,316 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. 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 Box from "@mui/material/Box";
import Icon from "@mui/material/Icon";
import colors from "qqq/assets/theme/base/colors";
import QHierarchyAutoComplete, {Group, Option} from "qqq/components/misc/QHierarchyAutoComplete";
import BulkLoadFileMappingField from "qqq/components/processes/BulkLoadFileMappingField";
import {BulkLoadField, BulkLoadMapping, FileDescription} from "qqq/models/processes/BulkLoadModels";
import React, {useEffect, useReducer, useState} from "react";
interface BulkLoadMappingFieldsProps
{
bulkLoadMapping: BulkLoadMapping,
fileDescription: FileDescription,
forceParentUpdate?: () => void,
isBulkEdit?: boolean
}
const ADD_SINGLE_FIELD_TOOLTIP = "Click to add this field to your mapping.";
const ADD_MANY_FIELD_TOOLTIP = "Click to add this field to your mapping as many times as you need.";
const ALREADY_ADDED_FIELD_TOOLTIP = "This field has already been added to your mapping.";
/***************************************************************************
** The section of the bulk load mapping screen with all the fields.
***************************************************************************/
export default function BulkLoadFileMappingFields({bulkLoadMapping, fileDescription, forceParentUpdate, isBulkEdit}: BulkLoadMappingFieldsProps): JSX.Element
{
const [, forceUpdate] = useReducer((x) => x + 1, 0);
const [forceHierarchyAutoCompleteRerender, setForceHierarchyAutoCompleteRerender] = useState(0);
////////////////////////////////////////////
// build list of fields that can be added //
////////////////////////////////////////////
const [addFieldsGroup, setAddFieldsGroup] = useState({
label: bulkLoadMapping.tablesByPath[""]?.label,
value: "mainTable",
options: [],
subGroups: []
} as Group);
// const [addFieldsToggleStates, setAddFieldsToggleStates] = useState({} as { [name: string]: boolean });
const [addFieldsDisableStates, setAddFieldsDisableStates] = useState({} as { [name: string]: boolean });
const [tooltips, setTooltips] = useState({} as { [name: string]: string });
useEffect(() =>
{
const newDisableStates: { [name: string]: boolean } = {};
const newTooltips: { [name: string]: string } = {};
/////////////////////////////////////////////////////////////////////////////////////////////
// do the unused fields array first, as we've got some use-case where i think a field from //
// suggested mappings (or profiles?) are in this list, even though they shouldn't be? //
/////////////////////////////////////////////////////////////////////////////////////////////
for (let field of bulkLoadMapping.unusedFields)
{
const qualifiedName = field.getQualifiedName();
newTooltips[qualifiedName] = field.isMany() ? ADD_MANY_FIELD_TOOLTIP : ADD_SINGLE_FIELD_TOOLTIP;
}
//////////////////////////////////////////////////
// then do all the required & additional fields //
//////////////////////////////////////////////////
for (let field of [...(bulkLoadMapping.requiredFields ?? []), ...(bulkLoadMapping.additionalFields ?? [])])
{
const qualifiedName = field.getQualifiedName();
if (bulkLoadMapping.layout == "WIDE" && field.isMany())
{
newDisableStates[qualifiedName] = false;
newTooltips[qualifiedName] = ADD_MANY_FIELD_TOOLTIP;
}
else
{
newDisableStates[qualifiedName] = true;
newTooltips[qualifiedName] = ALREADY_ADDED_FIELD_TOOLTIP;
}
}
setAddFieldsDisableStates(newDisableStates);
setTooltips(newTooltips);
setForceHierarchyAutoCompleteRerender(forceHierarchyAutoCompleteRerender + 1);
}, [bulkLoadMapping, bulkLoadMapping.layout]);
///////////////////////////////////////////////
// initialize this structure on first render //
///////////////////////////////////////////////
if (addFieldsGroup.options.length == 0)
{
for (let qualifiedFieldName in bulkLoadMapping.fieldsByTablePrefix[""])
{
const bulkLoadField = bulkLoadMapping.fieldsByTablePrefix[""][qualifiedFieldName];
const field = bulkLoadField.field;
addFieldsGroup.options.push({label: field.label, value: field.name, bulkLoadField: bulkLoadField});
}
for (let prefix in bulkLoadMapping.fieldsByTablePrefix)
{
if (prefix == "")
{
continue;
}
const associationOptions: Option[] = [];
const tableStructure = bulkLoadMapping.tablesByPath[prefix];
addFieldsGroup.subGroups.push({label: tableStructure.label, value: tableStructure.associationPath, options: associationOptions});
for (let qualifiedFieldName in bulkLoadMapping.fieldsByTablePrefix[prefix])
{
const bulkLoadField = bulkLoadMapping.fieldsByTablePrefix[prefix][qualifiedFieldName];
const field = bulkLoadField.field;
associationOptions.push({label: field.label, value: field.name, bulkLoadField: bulkLoadField});
}
}
}
/***************************************************************************
**
***************************************************************************/
function removeField(bulkLoadField: BulkLoadField)
{
addFieldsDisableStates[bulkLoadField.getQualifiedName()] = false;
setAddFieldsDisableStates(Object.assign({}, addFieldsDisableStates));
if (bulkLoadMapping.layout == "WIDE" && bulkLoadField.isMany())
{
//////////////////////////////////////////////////////////////////////////
// ok, you can add more - so don't disable and don't change the tooltip //
//////////////////////////////////////////////////////////////////////////
}
else
{
tooltips[bulkLoadField.getQualifiedName()] = ADD_SINGLE_FIELD_TOOLTIP;
}
bulkLoadMapping.removeField(bulkLoadField);
forceUpdate();
forceParentUpdate();
setForceHierarchyAutoCompleteRerender(forceHierarchyAutoCompleteRerender + 1);
}
/***************************************************************************
**
***************************************************************************/
function handleToggleField(option: Option, group: Group, newValue: boolean)
{
const fieldKey = group.value == "mainTable" ? option.value : group.value + "." + option.value;
// addFieldsToggleStates[fieldKey] = newValue;
// setAddFieldsToggleStates(Object.assign({}, addFieldsToggleStates));
addFieldsDisableStates[fieldKey] = newValue;
setAddFieldsDisableStates(Object.assign({}, addFieldsDisableStates));
const bulkLoadField = bulkLoadMapping.fields[fieldKey];
if (bulkLoadField)
{
if (newValue)
{
bulkLoadMapping.addField(bulkLoadField);
}
else
{
bulkLoadMapping.removeField(bulkLoadField);
}
forceUpdate();
forceParentUpdate();
}
}
/***************************************************************************
**
***************************************************************************/
function handleAddField(option: Option, group: Group)
{
const fieldKey = group.value == "mainTable" ? option.value : group.value + "." + option.value;
const bulkLoadField = bulkLoadMapping.fields[fieldKey];
if (bulkLoadField)
{
bulkLoadMapping.addField(bulkLoadField);
// addFieldsDisableStates[fieldKey] = true;
// setAddFieldsDisableStates(Object.assign({}, addFieldsDisableStates));
if (bulkLoadMapping.layout == "WIDE" && bulkLoadField.isMany())
{
//////////////////////////////////////////////////////////////////////////
// ok, you can add more - so don't disable and don't change the tooltip //
//////////////////////////////////////////////////////////////////////////
}
else
{
addFieldsDisableStates[fieldKey] = true;
setAddFieldsDisableStates(Object.assign({}, addFieldsDisableStates));
tooltips[fieldKey] = ALREADY_ADDED_FIELD_TOOLTIP;
}
forceUpdate();
forceParentUpdate();
document.getElementById("addFieldsButton")?.scrollIntoView();
}
}
let buttonBackground = "none";
let buttonBorder = colors.grayLines.main;
let buttonColor = colors.gray.main;
const addFieldMenuButtonStyles = {
borderRadius: "0.75rem",
border: `1px solid ${buttonBorder}`,
color: buttonColor,
textTransform: "none",
fontWeight: 500,
fontSize: "0.875rem",
p: "0.5rem",
backgroundColor: buttonBackground,
"&:focus:not(:hover)": {
color: buttonColor,
backgroundColor: buttonBackground,
},
"&:hover": {
color: buttonColor,
backgroundColor: buttonBackground,
}
};
return (
<>
{isBulkEdit ? <h5>Key Fields</h5> : <h5>Required Fields</h5>}
<Box pl={"1rem"}>
{
bulkLoadMapping.requiredFields.length == 0 &&
(
isBulkEdit ?
<i style={{fontSize: "0.875rem"}}>Select table key fields to continue.</i>
:
<i style={{fontSize: "0.875rem"}}>There are no required fields in this table.</i>
)
}
{bulkLoadMapping.requiredFields.map((bulkLoadField) => (
<BulkLoadFileMappingField
fileDescription={fileDescription}
key={bulkLoadField.getKey()}
bulkLoadField={bulkLoadField}
isRequired={true}
forceParentUpdate={forceParentUpdate}
isBulkEdit={isBulkEdit}
/>
))}
</Box>
<Box mt="1rem">
{isBulkEdit ? <h5>Fields To Update</h5> : <h5>Additional Fields</h5>}
<Box pl={"1rem"}>
{bulkLoadMapping.additionalFields.map((bulkLoadField) => (
<BulkLoadFileMappingField
fileDescription={fileDescription}
key={bulkLoadField.getKey()}
bulkLoadField={bulkLoadField}
isRequired={false}
removeFieldCallback={() => removeField(bulkLoadField)}
forceParentUpdate={forceParentUpdate}
isBulkEdit={isBulkEdit}
/>
))}
<Box display="flex" pt="1rem" pl="12.5rem">
<QHierarchyAutoComplete
idPrefix="addFieldAutocomplete"
defaultGroup={addFieldsGroup}
menuDirection="up"
buttonProps={{id: "addFieldsButton", sx: addFieldMenuButtonStyles}}
buttonChildren={<><Icon sx={{mr: "0.5rem"}}>add</Icon> Add Fields <Icon sx={{ml: "0.5rem"}}>keyboard_arrow_down</Icon></>}
isModeSelectOne
keepOpenAfterSelectOne
handleSelectedOption={handleAddField}
forceRerender={forceHierarchyAutoCompleteRerender}
disabledStates={addFieldsDisableStates}
tooltips={tooltips}
/>
</Box>
</Box>
</Box>
</>
);
}

View File

@ -1,695 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. 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 {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 DynamicFormUtils from "qqq/components/forms/DynamicFormUtils";
import MDTypography from "qqq/components/legacy/MDTypography";
import HelpContent from "qqq/components/misc/HelpContent";
import SavedBulkLoadProfiles from "qqq/components/misc/SavedBulkLoadProfiles";
import BulkLoadFileMappingFields from "qqq/components/processes/BulkLoadFileMappingFields";
import {BulkLoadField, BulkLoadMapping, BulkLoadProfile, BulkLoadTableStructure, FileDescription, Wrapper} from "qqq/models/processes/BulkLoadModels";
import {SubFormPreSubmitCallbackResultType} from "qqq/pages/processes/ProcessRun";
import Client from "qqq/utils/qqq/Client";
import React, {forwardRef, useEffect, useImperativeHandle, useReducer, useState} from "react";
import ProcessViewForm from "./ProcessViewForm";
const qController = Client.getInstance();
interface BulkLoadMappingFormProps
{
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, frontendStep, processMetaData}: BulkLoadMappingFormProps, ref) =>
{
const {setFieldValue} = useFormikContext();
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);
const [bulkLoadMapping, setBulkLoadMapping] = useState(BulkLoadMapping.fromBulkLoadProfile(tableStructure, processValues.bulkLoadProfile, processMetaData.name));
const [wrappedBulkLoadMapping] = useState(new Wrapper<BulkLoadMapping>(bulkLoadMapping));
const [fileDescription] = useState(new FileDescription(processValues.headerValues, processValues.headerLetters, processValues.bodyValuesPreview));
fileDescription.setHasHeaderRow(bulkLoadMapping.hasHeaderRow);
const [, forceUpdate] = useReducer((x) => x + 1, 0);
/////////////////////////////////////////////////////////////////////////////////////////////////
// ok - so - ... Autocomplete, at least as we're using it for the layout field - doesn't like //
// to change its initial value. So, we want to work hard to force the Header sub-component to //
// re-render upon external changes to the layout (e.g., new profile being selected). //
// use this state-counter to make that happen (and let's please never speak of it again). //
/////////////////////////////////////////////////////////////////////////////////////////////////
const [rerenderHeader, setRerenderHeader] = useState(1);
////////////////////////////////////////////////////////
// ref-based callback for integration with ProcessRun //
////////////////////////////////////////////////////////
useImperativeHandle(ref, () =>
{
return {
preSubmit(): SubFormPreSubmitCallbackResultType
{
///////////////////////////////////////////////////////////////////////////////////////////////
// convert the BulkLoadMapping to a BulkLoadProfile - the thing that the backend understands //
///////////////////////////////////////////////////////////////////////////////////////////////
const {haveErrors: haveProfileErrors, profile} = wrappedBulkLoadMapping.get().toProfile();
const values: { [name: string]: any } = {};
////////////////////////////////////////////////////
// always re-submit the full profile //
// note mostly a copy in BulkLoadValueMappingForm //
////////////////////////////////////////////////////
values["version"] = profile.version;
values["fieldListJSON"] = JSON.stringify(profile.fieldList);
values["savedBulkLoadProfileId"] = wrappedCurrentSavedBulkLoadProfile.get()?.values?.get("id");
values["layout"] = wrappedBulkLoadMapping.get().layout;
values["hasHeaderRow"] = wrappedBulkLoadMapping.get().hasHeaderRow;
values["isBulkEdit"] = wrappedBulkLoadMapping.get().isBulkEdit;
values["keyFields"] = wrappedBulkLoadMapping.get().keyFields;
let haveLocalErrors = false;
const fieldErrors: { [fieldName: string]: string } = {};
if (!values["layout"])
{
haveLocalErrors = true;
fieldErrors["layout"] = "This field is required.";
}
if (values["hasHeaderRow"] == null || values["hasHeaderRow"] == undefined)
{
haveLocalErrors = true;
fieldErrors["hasHeaderRow"] = "This field is required.";
}
setFieldErrors(fieldErrors);
if (values["isBulkEdit"] && (values["keyFields"] == null || values["keyFields"] == undefined))
{
haveLocalErrors = true;
fieldErrors["keyFields"] = "This field is required.";
}
setFieldErrors(fieldErrors);
if (wrappedBulkLoadMapping.get().requiredFields.length == 0 && wrappedBulkLoadMapping.get().additionalFields.length == 0)
{
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};
}
};
});
/***************************************************************************
**
***************************************************************************/
function bulkLoadProfileOnChangeCallback(profileRecord: QRecord | null)
{
setCurrentSavedBulkLoadProfile(profileRecord);
wrappedCurrentSavedBulkLoadProfile.set(profileRecord);
let newBulkLoadMapping: BulkLoadMapping;
if (profileRecord)
{
newBulkLoadMapping = BulkLoadMapping.fromSavedProfileRecord(processValues.tableStructure, profileRecord);
}
else
{
newBulkLoadMapping = new BulkLoadMapping(processValues.tableStructure);
}
handleNewBulkLoadMapping(newBulkLoadMapping);
}
/***************************************************************************
**
***************************************************************************/
function bulkLoadProfileResetToSuggestedMappingCallback()
{
handleNewBulkLoadMapping(BulkLoadMapping.fromBulkLoadProfile(processValues.tableStructure, suggestedBulkLoadProfile, processValues.name));
}
/***************************************************************************
**
***************************************************************************/
function handleNewBulkLoadMapping(newBulkLoadMapping: BulkLoadMapping)
{
const newRequiredFields: BulkLoadField[] = [];
for (let field of newBulkLoadMapping.requiredFields)
{
newRequiredFields.push(BulkLoadField.clone(field));
}
newBulkLoadMapping.requiredFields = newRequiredFields;
setBulkLoadMapping(newBulkLoadMapping);
wrappedBulkLoadMapping.set(newBulkLoadMapping);
setFieldValue("isBulkEdit", newBulkLoadMapping.isBulkEdit);
setFieldValue("keyFields", newBulkLoadMapping.keyFields);
setFieldValue("hasHeaderRow", newBulkLoadMapping.hasHeaderRow);
setFieldValue("layout", newBulkLoadMapping.layout);
setRerenderHeader(rerenderHeader + 1);
}
if (currentSavedBulkLoadProfile)
{
setActiveStepLabel(`File Mapping / ${currentSavedBulkLoadProfile.values.get("label")}`);
}
else
{
setActiveStepLabel("File Mapping");
}
return (<Box>
<Box py="1rem" display="flex">
<SavedBulkLoadProfiles
metaData={metaData}
tableMetaData={tableMetaData}
tableStructure={tableStructure}
currentSavedBulkLoadProfileRecord={currentSavedBulkLoadProfile}
currentMapping={bulkLoadMapping}
bulkLoadProfileOnChangeCallback={bulkLoadProfileOnChangeCallback}
bulkLoadProfileResetToSuggestedMappingCallback={bulkLoadProfileResetToSuggestedMappingCallback}
fileDescription={fileDescription}
isBulkEdit={processValues.isBulkEdit}
/>
</Box>
<BulkLoadMappingHeader
tableMetaData={tableMetaData}
isBulkEdit={processValues.isBulkEdit}
key={rerenderHeader}
bulkLoadMapping={bulkLoadMapping}
fileDescription={fileDescription}
tableStructure={tableStructure}
fileName={processValues.fileBaseName}
fieldErrors={fieldErrors}
frontendStep={frontendStep}
processMetaData={processMetaData}
forceParentUpdate={() => forceUpdate()}
/>
<Box mt="2rem">
<BulkLoadFileMappingFields
isBulkEdit={processValues.isBulkEdit}
bulkLoadMapping={bulkLoadMapping}
fileDescription={fileDescription}
forceParentUpdate={() =>
{
setRerenderHeader(rerenderHeader + 1);
forceUpdate();
}}
/>
{
noMappedFieldsError && <Box color={colors.error.main} textAlign="right">{noMappedFieldsError}</Box>
}
</Box>
</Box>);
});
export default BulkLoadFileMappingForm;
interface BulkLoadMappingHeaderProps
{
isBulkEdit?: boolean,
fileDescription: FileDescription,
fileName: string,
bulkLoadMapping?: BulkLoadMapping,
fieldErrors: { [fieldName: string]: string },
tableStructure: BulkLoadTableStructure,
forceParentUpdate?: () => void,
frontendStep: QFrontendStepMetaData,
processMetaData: QProcessMetaData,
tableMetaData: QTableMetaData,
}
/***************************************************************************
** private subcomponent - the header section of the bulk load file mapping screen.
***************************************************************************/
function BulkLoadMappingHeader({isBulkEdit, fileDescription, fileName, bulkLoadMapping, fieldErrors, tableStructure, forceParentUpdate, frontendStep, processMetaData, tableMetaData}: BulkLoadMappingHeaderProps): JSX.Element
{
const [dynamicField, setDynamicField] = useState(null);
const viewFields = [
new QFieldMetaData({name: "fileName", label: "File Name", type: "STRING"}),
new QFieldMetaData({name: "fileDetails", label: "File Details", type: "STRING"}),
];
const viewValues = {
"fileName": fileName,
"fileDetails": `${fileDescription.getColumnNames().length} column${fileDescription.getColumnNames().length == 1 ? "" : "s"}`
};
const hasHeaderRowFormField = {name: "hasHeaderRow", label: "Does the file have a header row?", type: "checkbox", isRequired: true, isEditable: true};
const layoutOptions = [
{label: "Flat", id: "FLAT"},
{label: "Tall", id: "TALL"},
{label: "Wide", id: "WIDE"},
];
if (!tableStructure.associations)
{
layoutOptions.splice(1);
}
const selectedLayout = layoutOptions.filter(o => o.id == bulkLoadMapping.layout)[0] ?? null;
useEffect(() =>
{
(async () =>
{
if (isBulkEdit)
{
/////////////////////////////////////////////////////////////////////////
// if doing a bulk edit, the selected keyFields and set as the display //
/////////////////////////////////////////////////////////////////////////
const displayValues = new Map<string, string>;
if (bulkLoadMapping.keyFields)
{
const possibleValues = await qController.possibleValues(null, processMetaData.name, "tableKeyFields", bulkLoadMapping.keyFields, null);
console.log("Received possible values of: " + JSON.stringify(possibleValues));
displayValues.set("tableKeyFields", possibleValues[0].label);
}
const tableKeyFieldsField = processMetaData.frontendSteps.find(s => s.name == "fileMapping")?.formFields.find(f => f.name == "tableKeyFields");
const newDynamicField = DynamicFormUtils.getDynamicField(tableKeyFieldsField);
const dynamicFieldInObject: any = {};
dynamicFieldInObject[tableKeyFieldsField["name"]] = newDynamicField;
DynamicFormUtils.addPossibleValueProps(dynamicFieldInObject, [tableKeyFieldsField], null, processMetaData.name, displayValues);
keyFieldsChanged(bulkLoadMapping.keyFields);
setDynamicField(newDynamicField);
forceParentUpdate();
}
})();
}, [JSON.stringify(bulkLoadMapping)]);
/***************************************************************************
**
***************************************************************************/
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.switchLayout(newValue ? newValue.id : null);
fieldErrors.layout = null;
forceParentUpdate();
}
/***************************************************************************
**
***************************************************************************/
async function keyFieldsChanged(newValue: any)
{
fieldErrors.keyFields = null;
if (newValue && newValue.length > 0)
{
//////////////////////////////////////////////////////////
// validate that the fields in the key have been mapped //
//////////////////////////////////////////////////////////
console.log("Received key fields of: " + newValue);
const keyFields = newValue.split("|");
const unmappedKeyFields: string[] = [];
const requiredFields: BulkLoadField[] = [];
const additionalFields: BulkLoadField[] = [];
////////////////////////////////////////////////////////////////////////////////////////////////
// iterate over all fields in the table, when there are key fields found, make them required, //
// otherwise add them to addition fields //
////////////////////////////////////////////////////////////////////////////////////////////////
for (let bulkLoadField of [...bulkLoadMapping.requiredFields, ...bulkLoadMapping.additionalFields])
{
const qualifiedName = bulkLoadField.getQualifiedName();
const keyField = keyFields.find((k: string) => k == qualifiedName);
if (keyField)
{
requiredFields.push(bulkLoadField);
var fieldsByTablePrefix = bulkLoadMapping.fieldsByTablePrefix[""][keyField];
if (!fieldsByTablePrefix || fieldsByTablePrefix.columnIndex == null)
{
unmappedKeyFields.push(tableMetaData.fields.get(keyField).label);
}
}
else
{
additionalFields.push(bulkLoadField);
}
}
bulkLoadMapping.requiredFields = requiredFields;
bulkLoadMapping.additionalFields = additionalFields;
if (unmappedKeyFields.length > 0)
{
fieldErrors.keyFields = "The following key fields are not mapped: " + unmappedKeyFields.join(", ");
}
bulkLoadMapping.handleChangeToKeyFields(newValue);
}
forceParentUpdate();
}
/***************************************************************************
**
***************************************************************************/
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} bulkLoadMapping={bulkLoadMapping} />
<Grid container pt="1rem">
<Grid item xs={12} md={6}>
<DynamicFormFieldLabel name={hasHeaderRowFormField.name} label={`${hasHeaderRowFormField.label} *`} />
<QDynamicFormField name={hasHeaderRowFormField.name} displayFormat={""} label={""} formFieldObject={hasHeaderRowFormField} type={"checkbox"} value={bulkLoadMapping.hasHeaderRow} onChangeCallback={hasHeaderRowChanged} />
{
fieldErrors.hasHeaderRow &&
<MDTypography component="div" variant="caption" color="error" fontWeight="regular" mt="0.25rem">
{<div className="fieldErrorMessage">{fieldErrors.hasHeaderRow}</div>}
</MDTypography>
}
{getFormattedHelpContent("hasHeaderRow")}
</Grid>
<Grid item xs={12} md={6}>
{
!isBulkEdit ? (
<>
<DynamicFormFieldLabel name={"layout"} label={"File Layout *"} />
<Autocomplete
id={"layout"}
renderInput={(params) => (<TextField {...params} label={""} fullWidth variant="outlined" autoComplete="off" type="search" InputProps={{...params.InputProps}} sx={{"& .MuiOutlinedInput-root": {borderRadius: "0.75rem"}}} />)}
options={layoutOptions}
multiple={false}
defaultValue={selectedLayout}
onChange={layoutChanged}
getOptionLabel={(option) => typeof (option) == "string" ? option : (option?.label ?? "")}
isOptionEqualToValue={(option, value) => option == null && value == null || option.id == value.id}
renderOption={(props, option, state) => (<li {...props}>{option?.label ?? ""}</li>)}
disableClearable
sx={{"& .MuiOutlinedInput-root": {padding: "0"}}}
/>
{
fieldErrors.layout &&
<MDTypography component="div" variant="caption" color="error" fontWeight="regular" mt="0.25rem">
{<div className="fieldErrorMessage">{fieldErrors.layout}</div>}
</MDTypography>
}
{getFormattedHelpContent("layout")}
</>
) : (
<>
{
dynamicField &&
<>
<DynamicFormFieldLabel name={dynamicField.name} label={`${dynamicField.label} *`} />
<QDynamicFormField name={dynamicField.name} displayFormat={""} label={""} formFieldObject={dynamicField} type={"pvs"} value={bulkLoadMapping.keyFields} onChangeCallback={keyFieldsChanged} />
{
fieldErrors.keyFields &&
<MDTypography component="div" variant="caption" color="error" fontWeight="regular" mt="0.25rem">
{<div className="fieldErrorMessage">{fieldErrors.keyFields}</div>}
</MDTypography>
}
{getFormattedHelpContent("tableKeyFields")}
</>
}
</>
)
}
</Grid>
</Grid>
</Box>
</Box>
);
}
interface BulkLoadMappingFilePreviewProps
{
fileDescription: FileDescription,
bulkLoadMapping?: BulkLoadMapping
}
/***************************************************************************
** private subcomponent - the file-preview section of the bulk load file mapping screen.
***************************************************************************/
function BulkLoadMappingFilePreview({fileDescription, bulkLoadMapping}: BulkLoadMappingFilePreviewProps): JSX.Element
{
const rows: number[] = [];
for (let i = 0; i < fileDescription.bodyValuesPreview[0].length; i++)
{
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", height: "1.75rem"}}>
<td></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, 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}>{getValue(i, j)}</td>)}
</tr>
))}
</tbody>
</table>
</Box>
</Box>
);
}

View File

@ -1,103 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. 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 {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstance";
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord";
import Box from "@mui/material/Box";
import SavedBulkLoadProfiles from "qqq/components/misc/SavedBulkLoadProfiles";
import {BulkLoadMapping, BulkLoadProfile, BulkLoadTableStructure, FileDescription, Wrapper} from "qqq/models/processes/BulkLoadModels";
import {SubFormPreSubmitCallbackResultType} from "qqq/pages/processes/ProcessRun";
import React, {forwardRef, useImperativeHandle, useState} from "react";
interface BulkLoadValueMappingFormProps
{
processValues: any,
tableMetaData: QTableMetaData,
metaData: QInstance
}
/***************************************************************************
** For review & result screens of bulk load - this process component shows
** the SavedBulkLoadProfiles button.
***************************************************************************/
const BulkLoadProfileForm = forwardRef(({processValues, tableMetaData, metaData}: BulkLoadValueMappingFormProps, ref) =>
{
const savedBulkLoadProfileRecordProcessValue = processValues.savedBulkLoadProfileRecord;
const [savedBulkLoadProfileRecord, setSavedBulkLoadProfileRecord] = useState(savedBulkLoadProfileRecordProcessValue == null ? null : new QRecord(savedBulkLoadProfileRecordProcessValue));
const [tableStructure] = useState(processValues.tableStructure as BulkLoadTableStructure);
const [bulkLoadProfile, setBulkLoadProfile] = useState(processValues.bulkLoadProfile as BulkLoadProfile);
const [currentMapping, setCurrentMapping] = useState(BulkLoadMapping.fromBulkLoadProfile(tableStructure, bulkLoadProfile));
const [wrappedCurrentSavedBulkLoadProfile] = useState(new Wrapper<QRecord>(savedBulkLoadProfileRecord));
const [fileDescription] = useState(new FileDescription(processValues.headerValues, processValues.headerLetters, processValues.bodyValuesPreview));
fileDescription.setHasHeaderRow(currentMapping.hasHeaderRow);
useImperativeHandle(ref, () =>
{
return {
preSubmit(): SubFormPreSubmitCallbackResultType
{
const values: { [name: string]: any } = {};
values["savedBulkLoadProfileId"] = wrappedCurrentSavedBulkLoadProfile.get()?.values?.get("id");
return ({maySubmit: true, values});
}
};
});
/***************************************************************************
**
***************************************************************************/
function bulkLoadProfileOnChangeCallback(profileRecord: QRecord | null)
{
setSavedBulkLoadProfileRecord(profileRecord);
wrappedCurrentSavedBulkLoadProfile.set(profileRecord);
const newBulkLoadMapping = BulkLoadMapping.fromSavedProfileRecord(tableStructure, profileRecord);
setCurrentMapping(newBulkLoadMapping);
}
return (<Box>
<Box py="1rem" display="flex">
<SavedBulkLoadProfiles
metaData={metaData}
tableMetaData={tableMetaData}
tableStructure={tableStructure}
currentSavedBulkLoadProfileRecord={savedBulkLoadProfileRecord}
currentMapping={currentMapping}
allowSelectingProfile={false}
fileDescription={fileDescription}
bulkLoadProfileOnChangeCallback={bulkLoadProfileOnChangeCallback}
isBulkEdit={processValues.isBulkEdit}
/>
</Box>
</Box>);
});
export default BulkLoadProfileForm;

View File

@ -1,234 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. 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 {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
import {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstance";
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord";
import Box from "@mui/material/Box";
import Icon from "@mui/material/Icon";
import colors from "qqq/assets/theme/base/colors";
import QDynamicFormField from "qqq/components/forms/DynamicFormField";
import SavedBulkLoadProfiles from "qqq/components/misc/SavedBulkLoadProfiles";
import {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";
interface BulkLoadValueMappingFormProps
{
processValues: any,
setActiveStepLabel: (label: string) => void,
tableMetaData: QTableMetaData,
metaData: QInstance,
formFields: any[]
}
/***************************************************************************
** process component used in bulk-load - on a screen that gets looped for
** each field whose values are being mapped.
***************************************************************************/
const BulkLoadValueMappingForm = forwardRef(({processValues, setActiveStepLabel, tableMetaData, metaData, formFields}: BulkLoadValueMappingFormProps, ref) =>
{
const [field, setField] = useState(processValues.valueMappingField ? new QFieldMetaData(processValues.valueMappingField) : null);
const [fieldFullName, setFieldFullName] = useState(processValues.valueMappingFullFieldName);
const [fileValues, setFileValues] = useState((processValues.fileValues ?? []) as string[]);
const [valueErrors, setValueErrors] = useState({} as { [fileValue: string]: any });
const [bulkLoadProfile, setBulkLoadProfile] = useState(processValues.bulkLoadProfile as BulkLoadProfile);
const savedBulkLoadProfileRecordProcessValue = processValues.savedBulkLoadProfileRecord;
const [savedBulkLoadProfileRecord, setSavedBulkLoadProfileRecord] = useState(savedBulkLoadProfileRecordProcessValue == null ? null : new QRecord(savedBulkLoadProfileRecordProcessValue));
const [wrappedCurrentSavedBulkLoadProfile] = useState(new Wrapper<QRecord>(savedBulkLoadProfileRecord));
const [tableStructure] = useState(processValues.tableStructure as BulkLoadTableStructure);
const [currentMapping, setCurrentMapping] = useState(initializeCurrentBulkLoadMapping());
const [wrappedBulkLoadMapping] = useState(new Wrapper<BulkLoadMapping>(currentMapping));
const [fileDescription] = useState(new FileDescription(processValues.headerValues, processValues.headerLetters, processValues.bodyValuesPreview));
fileDescription.setHasHeaderRow(currentMapping.hasHeaderRow);
const [, forceUpdate] = useReducer((x) => x + 1, 0);
/*******************************************************************************
**
*******************************************************************************/
function initializeCurrentBulkLoadMapping(): BulkLoadMapping
{
const bulkLoadMapping = BulkLoadMapping.fromBulkLoadProfile(tableStructure, bulkLoadProfile, processValues.name);
if (!bulkLoadMapping.valueMappings[fieldFullName])
{
bulkLoadMapping.valueMappings[fieldFullName] = {};
}
return (bulkLoadMapping);
}
useEffect(() =>
{
if (processValues.valueMappingField)
{
setField(new QFieldMetaData(processValues.valueMappingField));
}
else
{
setField(null);
}
}, [processValues.valueMappingField]);
////////////////////////////////////////////////////////
// ref-based callback for integration with ProcessRun //
////////////////////////////////////////////////////////
useImperativeHandle(ref, () =>
{
return {
preSubmit(): SubFormPreSubmitCallbackResultType
{
const values: { [name: string]: any } = {};
let anyErrors = false;
const mappedValues = currentMapping.valueMappings[fieldFullName];
if (field.isRequired)
{
for (let fileValue of fileValues)
{
valueErrors[fileValue] = null;
if (mappedValues[fileValue] == null || mappedValues[fileValue] == undefined || mappedValues[fileValue] == "")
{
valueErrors[fileValue] = "A value is required for this mapping";
anyErrors = true;
}
}
}
///////////////////////////////////////////////////
// always re-submit the full profile //
// note mostly a copy in BulkLoadFileMappingForm //
///////////////////////////////////////////////////
const {haveErrors, profile} = wrappedBulkLoadMapping.get().toProfile();
values["version"] = profile.version;
values["fieldListJSON"] = JSON.stringify(profile.fieldList);
values["savedBulkLoadProfileId"] = wrappedCurrentSavedBulkLoadProfile.get()?.values?.get("id");
values["layout"] = wrappedBulkLoadMapping.get().layout;
values["hasHeaderRow"] = wrappedBulkLoadMapping.get().hasHeaderRow;
values["mappedValuesJSON"] = JSON.stringify(mappedValues);
return ({maySubmit: !anyErrors, values});
}
};
});
if (!field)
{
//////////////////////////////////////////////////////////////////////////////////////
// this happens like between steps - render empty rather than a flash of half-stuff //
//////////////////////////////////////////////////////////////////////////////////////
return (<Box></Box>);
}
/***************************************************************************
**
***************************************************************************/
function mappedValueChanged(fileValue: string, newValue: any)
{
valueErrors[fileValue] = null;
if (newValue == null)
{
delete currentMapping.valueMappings[fieldFullName][fileValue];
}
else
{
currentMapping.valueMappings[fieldFullName][fileValue] = newValue;
}
forceUpdate();
}
/***************************************************************************
**
***************************************************************************/
function bulkLoadProfileOnChangeCallback(profileRecord: QRecord | null)
{
setSavedBulkLoadProfileRecord(profileRecord);
wrappedCurrentSavedBulkLoadProfile.set(profileRecord);
const newBulkLoadMapping = BulkLoadMapping.fromSavedProfileRecord(tableStructure, profileRecord);
setCurrentMapping(newBulkLoadMapping);
wrappedBulkLoadMapping.set(newBulkLoadMapping);
}
setActiveStepLabel(`Value Mapping: ${field.label} (${processValues.valueMappingFieldIndex + 1} of ${processValues.fieldNamesToDoValueMapping?.length})`);
return (<Box>
<Box py="1rem" display="flex">
<SavedBulkLoadProfiles
metaData={metaData}
tableMetaData={tableMetaData}
tableStructure={tableStructure}
currentSavedBulkLoadProfileRecord={savedBulkLoadProfileRecord}
currentMapping={currentMapping}
allowSelectingProfile={false}
bulkLoadProfileOnChangeCallback={bulkLoadProfileOnChangeCallback}
fileDescription={fileDescription}
isBulkEdit={processValues.isBulkEdit}
/>
</Box>
{
fileValues.map((fileValue, i) => (
<Box key={i} py="0.5rem" sx={{borderBottom: "0px solid lightgray", width: "100%", overflow: "auto"}}>
<Box display="grid" gridTemplateColumns="40% auto 60%" fontSize="1rem" gap="0.5rem">
<Box mt="0.5rem" textAlign="right">{fileValue}</Box>
<Box mt="0.625rem"><Icon>arrow_forward</Icon></Box>
<Box maxWidth="300px">
<QDynamicFormField
name={`${fieldFullName}.value.${i}`}
displayFormat={""}
label={""}
formFieldObject={formFields[i]}
type={formFields[i].type}
value={currentMapping.valueMappings[fieldFullName][fileValue]}
onChangeCallback={(newValue) => mappedValueChanged(fileValue, newValue)}
/>
{
valueErrors[fileValue] &&
<Box fontSize={"0.875rem"} mt={"-0.75rem"} color={colors.error.main}>
{valueErrors[fileValue]}
</Box>
}
</Box>
</Box>
</Box>
))
}
</Box>);
});
export default BulkLoadValueMappingForm;

View File

@ -84,7 +84,7 @@ function ProcessSummaryResults({
);
return (
<Box m={{xs: 0, md: 3}} mt={"3rem!important"}>
<Box m={3} mt={6}>
<Grid container>
<Grid item xs={0} lg={2} />
<Grid item xs={12} lg={8}>

View File

@ -1,71 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. 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 {AdornmentType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/AdornmentType";
import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
import Grid from "@mui/material/Grid";
import MDTypography from "qqq/components/legacy/MDTypography";
import ValueUtils from "qqq/utils/qqq/ValueUtils";
interface ProcessViewFormProps
{
fields: QFieldMetaData[];
values: { [fieldName: string]: any };
columns?: number;
}
ProcessViewForm.defaultProps = {
columns: 2
};
/***************************************************************************
** a "view form" within a process step
**
***************************************************************************/
export default function ProcessViewForm({fields, values, columns}: ProcessViewFormProps): JSX.Element
{
const sm = Math.floor(12 / columns);
return <Grid container>
{fields.map((field: QFieldMetaData) => (
field.hasAdornment(AdornmentType.ERROR) ? (
values[field.name] && (
<Grid item xs={12} sm={sm} key={field.name} display="flex" py={1} pr={2}>
<MDTypography variant="button" fontWeight="regular">
{ValueUtils.getValueForDisplay(field, values[field.name], undefined, "view")}
</MDTypography>
</Grid>
)
) : (
<Grid item xs={12} sm={sm} key={field.name} display="flex" py={1} pr={2}>
<MDTypography variant="button" fontWeight="bold">
{field.label}
: &nbsp;
</MDTypography>
<MDTypography variant="button" fontWeight="regular" color="text">
{ValueUtils.getValueForDisplay(field, values[field.name], undefined, "view")}
</MDTypography>
</Grid>
)))
}
</Grid>;
}

View File

@ -24,45 +24,29 @@ 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 {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord";
import {Button, FormControlLabel, ListItem, Radio, RadioGroup, Typography} from "@mui/material";
import Box from "@mui/material/Box";
import {Box, Button, FormControlLabel, ListItem, Radio, RadioGroup, Typography} from "@mui/material";
import Grid from "@mui/material/Grid";
import Icon from "@mui/material/Icon";
import IconButton from "@mui/material/IconButton";
import List from "@mui/material/List";
import ListItemText from "@mui/material/ListItemText";
import React, {useState} from "react";
import MDTypography from "qqq/components/legacy/MDTypography";
import CustomWidthTooltip from "qqq/components/tooltips/CustomWidthTooltip";
import RecordGridWidget, {ChildRecordListData} from "qqq/components/widgets/misc/RecordGridWidget";
import {ProcessSummaryLine} from "qqq/models/processes/ProcessSummaryLine";
import {renderSectionOfFields} from "qqq/pages/records/view/RecordView";
import Client from "qqq/utils/qqq/Client";
import TableUtils from "qqq/utils/qqq/TableUtils";
import ValueUtils from "qqq/utils/qqq/ValueUtils";
import React, {useEffect, useState} from "react";
interface Props
{
qInstance: QInstance,
process: QProcessMetaData,
table: QTableMetaData,
processValues: any,
step: QFrontendStepMetaData,
previewRecords: QRecord[],
formValues: any,
doFullValidationRadioChangedHandler: any,
loadingRecords?: boolean
}
////////////////////////////////////////////////////////////////////////////
// e.g., for bulk-load, where we want to show associations under a record //
// the processValue will have these data, to drive this screen. //
////////////////////////////////////////////////////////////////////////////
interface AssociationPreview
{
tableName: string;
widgetName: string;
associationName: string;
qInstance: QInstance;
process: QProcessMetaData;
table: QTableMetaData;
processValues: any;
step: QFrontendStepMetaData;
previewRecords: QRecord[];
formValues: any;
doFullValidationRadioChangedHandler: any
}
/*******************************************************************************
@ -71,76 +55,21 @@ interface AssociationPreview
** results when they are available.
*******************************************************************************/
function ValidationReview({
qInstance, process, table = null, processValues, step, previewRecords = [], formValues, doFullValidationRadioChangedHandler, loadingRecords
qInstance, process, table = null, processValues, step, previewRecords = [], formValues, doFullValidationRadioChangedHandler,
}: Props): JSX.Element
{
const [previewRecordIndex, setPreviewRecordIndex] = useState(0);
const [sourceTableMetaData, setSourceTableMetaData] = useState(null as QTableMetaData);
const [previewTableMetaData, setPreviewTableMetaData] = useState(null as QTableMetaData);
const [childTableMetaData, setChildTableMetaData] = useState({} as { [name: string]: QTableMetaData });
const [associationPreviewsByWidgetName, setAssociationPreviewsByWidgetName] = useState({} as { [widgetName: string]: AssociationPreview });
if (processValues.sourceTable && !sourceTableMetaData)
if(processValues.sourceTable && !sourceTableMetaData)
{
(async () =>
{
const sourceTableMetaData = await Client.getInstance().loadTableMetaData(processValues.sourceTable);
const sourceTableMetaData = await Client.getInstance().loadTableMetaData(processValues.sourceTable)
setSourceTableMetaData(sourceTableMetaData);
})();
}
////////////////////////////////////////////////////////////////////////////////////////
// load meta-data and set up associations-data structure, if so directed from backend //
////////////////////////////////////////////////////////////////////////////////////////
useEffect(() =>
{
if (processValues.formatPreviewRecordUsingTableLayout && !previewTableMetaData)
{
(async () =>
{
const previewTableMetaData = await Client.getInstance().loadTableMetaData(processValues.formatPreviewRecordUsingTableLayout);
setPreviewTableMetaData(previewTableMetaData);
})();
}
try
{
const previewRecordAssociatedTableNames: string[] = processValues.previewRecordAssociatedTableNames ?? [];
const previewRecordAssociatedWidgetNames: string[] = processValues.previewRecordAssociatedWidgetNames ?? [];
const previewRecordAssociationNames: string[] = processValues.previewRecordAssociationNames ?? [];
const associationPreviewsByWidgetName: { [widgetName: string]: AssociationPreview } = {};
for (let i = 0; i < Math.min(previewRecordAssociatedTableNames.length, previewRecordAssociatedWidgetNames.length, previewRecordAssociationNames.length); i++)
{
const associationPreview = {tableName: previewRecordAssociatedTableNames[i], widgetName: previewRecordAssociatedWidgetNames[i], associationName: previewRecordAssociationNames[i]};
associationPreviewsByWidgetName[associationPreview.widgetName] = associationPreview;
}
setAssociationPreviewsByWidgetName(associationPreviewsByWidgetName);
if (Object.keys(associationPreviewsByWidgetName))
{
(async () =>
{
for (let key in associationPreviewsByWidgetName)
{
const associationPreview = associationPreviewsByWidgetName[key];
childTableMetaData[associationPreview.tableName] = await Client.getInstance().loadTableMetaData(associationPreview.tableName);
setChildTableMetaData(Object.assign({}, childTableMetaData));
}
})();
}
}
catch (e)
{
console.log(`Error setting up association previews: ${e}`);
}
}, []);
/***************************************************************************
**
***************************************************************************/
const updatePreviewRecordIndex = (offset: number) =>
{
let newIndex = previewRecordIndex + offset;
@ -156,10 +85,6 @@ function ValidationReview({
setPreviewRecordIndex(newIndex);
};
/***************************************************************************
**
***************************************************************************/
const buildDoFullValidationRadioListItem = (value: "true" | "false", labelText: string, tooltipHTML: JSX.Element): JSX.Element =>
{
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
@ -266,7 +191,6 @@ function ValidationReview({
</List>
);
const recordPreviewWidget = step.recordListFields && (
<Box border="1px solid rgb(70%, 70%, 70%)" borderRadius="10px" p={2} mt={2}>
<Box mx={2} mt={-5} p={1} sx={{width: "fit-content", borderColor: "rgb(70%, 70%, 70%)", borderWidth: "2px", borderStyle: "solid", borderRadius: ".25em", backgroundColor: "#FFFFFF"}} width="initial" color="white">
@ -276,47 +200,43 @@ function ValidationReview({
<MDTypography color="body" variant="body2" component="div" mb={2}>
<Box display="flex">
{
loadingRecords ? <i>Loading...</i> : <>
{
processValues?.previewMessage && previewRecords && previewRecords.length > 0 ? (
<>
<i>{processValues?.previewMessage}</i>
<CustomWidthTooltip
title={(
<div>
Note that the number of preview records available may be fewer than the total number of records being processed.
</div>
)}
>
<IconButton sx={{py: 0}}><Icon fontSize="small">info_outlined</Icon></IconButton>
</CustomWidthTooltip>
</>
) : (
<>
<i>No record previews are available at this time.</i>
<CustomWidthTooltip
title={(
<div>
{
processValues.validationSummary ? (
<>
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.
</>
)
}
</div>
)}
>
<IconButton sx={{py: 0}}><Icon fontSize="small">info_outlined</Icon></IconButton>
</CustomWidthTooltip>
</>
)
}
</>
processValues?.previewMessage && previewRecords && previewRecords.length > 0 ? (
<>
<i>{processValues?.previewMessage}</i>
<CustomWidthTooltip
title={(
<div>
Note that the number of preview records available may be fewer than the total number of records being processed.
</div>
)}
>
<IconButton sx={{py: 0}}><Icon fontSize="small">info_outlined</Icon></IconButton>
</CustomWidthTooltip>
</>
) : (
<>
<i>No record previews are available at this time.</i>
<CustomWidthTooltip
title={(
<div>
{
processValues.validationSummary ? (
<>
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.
</>
)
}
</div>
)}
>
<IconButton sx={{py: 0}}><Icon fontSize="small">info_outlined</Icon></IconButton>
</CustomWidthTooltip>
</>
)
}
</Box>
</MDTypography>
@ -324,27 +244,16 @@ function ValidationReview({
<Box sx={{maxHeight: "calc(100vh - 640px)", overflow: "auto", minHeight: "300px", marginRight: "-40px"}}>
<Box sx={{paddingRight: "40px"}}>
{
previewRecords && !processValues.formatPreviewRecordUsingTableLayout && previewRecords[previewRecordIndex] && step.recordListFields.map((field) => (
previewRecords && previewRecords[previewRecordIndex] && step.recordListFields.map((field) => (
<Box key={field.name} style={{marginBottom: "12px"}}>
<b>{`${field.label}:`}</b>
{" "}
&nbsp;
&nbsp;
{" "}
{ValueUtils.getDisplayValue(field, previewRecords[previewRecordIndex], "view")}
</Box>
))
}
{
previewRecords && processValues.formatPreviewRecordUsingTableLayout && previewRecords[previewRecordIndex] &&
<PreviewRecordUsingTableLayout
index={previewRecordIndex}
record={previewRecords[previewRecordIndex]}
tableMetaData={previewTableMetaData}
qInstance={qInstance}
associationPreviewsByWidgetName={associationPreviewsByWidgetName}
childTableMetaData={childTableMetaData}
/>
}
</Box>
</Box>
{
@ -364,7 +273,7 @@ function ValidationReview({
);
return (
<Box m={{xs: 0, md: 3}} mt={"3rem!important"}>
<Box m={3}>
<Grid container spacing={2}>
<Grid item xs={12} lg={6}>
<MDTypography color="body" variant="button">
@ -379,84 +288,4 @@ 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;

View File

@ -183,7 +183,7 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo
{
// todo - sometimes i want contains instead of equals on strings (client.name, for example...)
let defaultOperator = field?.possibleValueSourceName ? QCriteriaOperator.IN : QCriteriaOperator.EQUALS;
if (field?.type == QFieldType.DATE_TIME)
if (field?.type == QFieldType.DATE_TIME || field?.type == QFieldType.DATE)
{
defaultOperator = QCriteriaOperator.GREATER_THAN;
}

View File

@ -50,7 +50,7 @@ export function EvaluatedExpression({field, expression}: EvaluatedExpressionProp
return () => clearInterval(interval);
}, []);
return <span style={{fontVariantNumeric: "tabular-nums"}}>{`${evaluateExpression(timeForEvaluations, field, expression)}`}</span>;
return <>{`${evaluateExpression(timeForEvaluations, field, expression)}`}</>;
}
const HOUR_MS = 60 * 60 * 1000;

View File

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

View File

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

View File

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

View File

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

View File

@ -38,7 +38,7 @@ import XIcon from "qqq/components/query/XIcon";
import {QueryScreenUsage} from "qqq/pages/records/query/RecordQuery";
import FilterUtils from "qqq/utils/qqq/FilterUtils";
import TableUtils from "qqq/utils/qqq/TableUtils";
import React, {SyntheticEvent, useContext, useEffect, useReducer, useState} from "react";
import React, {SyntheticEvent, useContext, useReducer, useState} from "react";
export type CriteriaParamType = QFilterCriteriaWithId | null | "tooComplex";
@ -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, return0thOptionInsteadOfNull: boolean = false): OperatorOption =>
const getOperatorSelectedValue = (operatorOptions: OperatorOption[], criteria: QFilterCriteriaWithId, defaultOperator: QCriteriaOperator): OperatorOption =>
{
if (criteria)
{
@ -135,23 +135,6 @@ 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);
};
@ -174,7 +157,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, true));
const [operatorSelectedValue, setOperatorSelectedValue] = useState(getOperatorSelectedValue(operatorOptions, criteria, defaultOperator));
const [operatorInputValue, setOperatorInputValue] = useState(operatorSelectedValue?.label);
const {criteriaIsValid, criteriaStatusTooltip} = validateCriteria(criteria, operatorSelectedValue);
@ -186,13 +169,6 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData
//////////////////////
const [, forceUpdate] = useReducer((x) => x + 1, 0);
useEffect(() =>
{
//////////////////////////////////////////////////////////////////////////////
// was not seeing criteria changes take place until watching it stringified //
//////////////////////////////////////////////////////////////////////////////
setCriteria(criteria);
}, [JSON.stringify(criteria)]);
/*******************************************************************************
**

View File

@ -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: "calc(100% - 0.5rem)"}}>
<Typography variant="body2" sx={{maxWidth: "1200px", margin: "auto", height: "100%"}}>
<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: "0.75rem", borderBottomLeftRadius: "0.75rem"}}
style={{borderBottomRightRadius: "1rem", borderBottomLeftRadius: "1rem"}}
/>
</Typography>
</Box>

View File

@ -40,17 +40,16 @@ import Snackbar from "@mui/material/Snackbar";
import TextField from "@mui/material/TextField";
import Tooltip from "@mui/material/Tooltip";
import FormData from "form-data";
import React, {useEffect, useReducer, useRef, useState} from "react";
import AceEditor from "react-ace";
import {QCancelButton, QSaveButton} from "qqq/components/buttons/DefaultButtons";
import DynamicSelect from "qqq/components/forms/DynamicSelect";
import ScriptDocsForm from "qqq/components/scripts/ScriptDocsForm";
import ScriptTestForm from "qqq/components/scripts/ScriptTestForm";
import Client from "qqq/utils/qqq/Client";
import "ace-builds/src-noconflict/ace";
import "ace-builds/src-noconflict/mode-javascript";
import "ace-builds/src-noconflict/theme-github";
import React, {useEffect, useReducer, useRef, useState} from "react";
import AceEditor from "react-ace";
import "ace-builds/src-noconflict/ext-language_tools";
export interface ScriptEditorProps
@ -70,15 +69,15 @@ const qController = Client.getInstance();
function buildInitialFileContentsMap(scriptRevisionRecord: QRecord, scriptTypeFileSchemaList: QRecord[]): { [name: string]: string }
{
const rs: { [name: string]: string } = {};
const rs: {[name: string]: string} = {};
if (!scriptTypeFileSchemaList)
if(!scriptTypeFileSchemaList)
{
console.log("Missing scriptTypeFileSchemaList");
}
else
{
let files = scriptRevisionRecord?.associatedRecords?.get("files");
let files = scriptRevisionRecord?.associatedRecords?.get("files")
for (let i = 0; i < scriptTypeFileSchemaList.length; i++)
{
@ -89,7 +88,7 @@ function buildInitialFileContentsMap(scriptRevisionRecord: QRecord, scriptTypeFi
for (let j = 0; j < files?.length; j++)
{
let file = files[j];
if (file.values.get("fileName") == name)
if(file.values.get("fileName") == name)
{
contents = file.values.get("contents");
}
@ -104,9 +103,9 @@ function buildInitialFileContentsMap(scriptRevisionRecord: QRecord, scriptTypeFi
function buildFileTypeMap(scriptTypeFileSchemaList: QRecord[]): { [name: string]: string }
{
const rs: { [name: string]: string } = {};
const rs: {[name: string]: string} = {};
if (!scriptTypeFileSchemaList)
if(!scriptTypeFileSchemaList)
{
console.log("Missing scriptTypeFileSchemaList");
}
@ -126,21 +125,21 @@ function ScriptEditor({title, scriptId, scriptRevisionRecord, closeCallback, tab
{
const [closing, setClosing] = useState(false);
const [apiName, setApiName] = useState(scriptRevisionRecord ? scriptRevisionRecord.values.get("apiName") : null);
const [apiNameLabel, setApiNameLabel] = useState(scriptRevisionRecord ? scriptRevisionRecord.displayValues.get("apiName") : null);
const [apiVersion, setApiVersion] = useState(scriptRevisionRecord ? scriptRevisionRecord.values.get("apiVersion") : null);
const [apiVersionLabel, setApiVersionLabel] = useState(scriptRevisionRecord ? scriptRevisionRecord.displayValues.get("apiVersion") : null);
const [apiName, setApiName] = useState(scriptRevisionRecord ? scriptRevisionRecord.values.get("apiName") : null)
const [apiNameLabel, setApiNameLabel] = useState(scriptRevisionRecord ? scriptRevisionRecord.displayValues.get("apiName") : null)
const [apiVersion, setApiVersion] = useState(scriptRevisionRecord ? scriptRevisionRecord.values.get("apiVersion") : null)
const [apiVersionLabel, setApiVersionLabel] = useState(scriptRevisionRecord ? scriptRevisionRecord.displayValues.get("apiVersion") : null)
const fileNamesFromSchema = scriptTypeFileSchemaList.map((schemaRecord) => schemaRecord.values.get("name"));
const fileNamesFromSchema = scriptTypeFileSchemaList.map((schemaRecord) => schemaRecord.values.get("name"))
const [availableFileNames, setAvailableFileNames] = useState(fileNamesFromSchema);
const [openEditorFileNames, setOpenEditorFileNames] = useState([fileNamesFromSchema[0]]);
const [fileContents, setFileContents] = useState(buildInitialFileContentsMap(scriptRevisionRecord, scriptTypeFileSchemaList));
const [fileTypes, setFileTypes] = useState(buildFileTypeMap(scriptTypeFileSchemaList));
const [openEditorFileNames, setOpenEditorFileNames] = useState([fileNamesFromSchema[0]])
const [fileContents, setFileContents] = useState(buildInitialFileContentsMap(scriptRevisionRecord, scriptTypeFileSchemaList))
const [fileTypes, setFileTypes] = useState(buildFileTypeMap(scriptTypeFileSchemaList))
console.log(`file types: ${JSON.stringify(fileTypes)}`);
const [commitMessage, setCommitMessage] = useState("");
const [commitMessage, setCommitMessage] = useState("")
const [openTool, setOpenTool] = useState(null);
const [errorAlert, setErrorAlert] = useState("");
const [errorAlert, setErrorAlert] = useState("")
const [promptForCommitMessageOpen, setPromptForCommitMessageOpen] = useState(false);
const [, forceUpdate] = useReducer((x) => x + 1, 0);
const ref = useRef();
@ -242,19 +241,19 @@ function ScriptEditor({title, scriptId, scriptRevisionRecord, closeCallback, tab
// need this to make Ace recognize new height.
setTimeout(() =>
{
window.dispatchEvent(new Event("resize"));
window.dispatchEvent(new Event("resize"))
}, 100);
};
const saveClicked = (overrideCommitMessage?: string) =>
{
if (!apiName || !apiVersion)
if(!apiName || !apiVersion)
{
setErrorAlert("You must select a value for both API Name and API Version.");
setErrorAlert("You must select a value for both API Name and API Version.")
return;
}
if (!commitMessage && !overrideCommitMessage)
if(!commitMessage && !overrideCommitMessage)
{
setPromptForCommitMessageOpen(true);
return;
@ -268,18 +267,18 @@ function ScriptEditor({title, scriptId, scriptRevisionRecord, closeCallback, tab
formData.append("scriptId", scriptId);
formData.append("commitMessage", overrideCommitMessage ?? commitMessage);
if (apiName)
if(apiName)
{
formData.append("apiName", apiName);
}
if (apiVersion)
if(apiVersion)
{
formData.append("apiVersion", apiVersion);
}
const fileNamesFromSchema = scriptTypeFileSchemaList.map((schemaRecord) => schemaRecord.values.get("name"));
const fileNamesFromSchema = scriptTypeFileSchemaList.map((schemaRecord) => schemaRecord.values.get("name"))
formData.append("fileNames", fileNamesFromSchema.join(","));
for (let fileName in fileContents)
@ -300,58 +299,58 @@ function ScriptEditor({title, scriptId, scriptRevisionRecord, closeCallback, tab
if (processResult instanceof QJobError)
{
const jobError = processResult as QJobError;
setErrorAlert(jobError.userFacingError ?? jobError.error);
const jobError = processResult as QJobError
setErrorAlert(jobError.userFacingError ?? jobError.error)
setClosing(false);
return;
}
closeCallback(null, "saved", "Saved New Script Version");
}
catch (e)
catch(e)
{
// @ts-ignore
setErrorAlert(e.message ?? "Unexpected error saving script");
setErrorAlert(e.message ?? "Unexpected error saving script")
setClosing(false);
}
})();
};
}
const cancelClicked = () =>
{
setClosing(true);
closeCallback(null, "cancelled");
};
}
const updateCode = (value: string, event: any, index: number) =>
{
fileContents[openEditorFileNames[index]] = value;
forceUpdate();
};
}
const updateCommitMessage = (event: React.ChangeEvent<HTMLInputElement>) =>
{
setCommitMessage(event.target.value);
};
}
const closePromptForCommitMessage = (wasSaveClicked: boolean, message?: string) =>
{
setPromptForCommitMessageOpen(false);
if (wasSaveClicked)
if(wasSaveClicked)
{
setCommitMessage(message);
setCommitMessage(message)
saveClicked(message);
}
else
{
setClosing(false);
}
};
}
const changeApiName = (apiNamePossibleValue?: QPossibleValue) =>
{
if (apiNamePossibleValue)
if(apiNamePossibleValue)
{
setApiName(apiNamePossibleValue.id);
}
@ -359,11 +358,11 @@ function ScriptEditor({title, scriptId, scriptRevisionRecord, closeCallback, tab
{
setApiName(null);
}
};
}
const changeApiVersion = (apiVersionPossibleValue?: QPossibleValue) =>
{
if (apiVersionPossibleValue)
if(apiVersionPossibleValue)
{
setApiVersion(apiVersionPossibleValue.id);
}
@ -371,33 +370,33 @@ function ScriptEditor({title, scriptId, scriptRevisionRecord, closeCallback, tab
{
setApiVersion(null);
}
};
}
const handleSelectingFile = (event: SelectChangeEvent, index: number) =>
{
openEditorFileNames[index] = event.target.value;
openEditorFileNames[index] = event.target.value
setOpenEditorFileNames(openEditorFileNames);
forceUpdate();
};
}
const splitEditorClicked = () =>
{
openEditorFileNames.push(availableFileNames[0]);
openEditorFileNames.push(availableFileNames[0])
setOpenEditorFileNames(openEditorFileNames);
forceUpdate();
};
}
const closeEditorClicked = (index: number) =>
{
openEditorFileNames.splice(index, 1);
openEditorFileNames.splice(index, 1)
setOpenEditorFileNames(openEditorFileNames);
forceUpdate();
};
}
const computeEditorWidth = (): string =>
{
return (100 / openEditorFileNames.length) + "%";
};
return (100 / openEditorFileNames.length) + "%"
}
return (
<Box className="scriptEditor" sx={{position: "absolute", overflowY: "auto", height: "100%", width: "100%"}} p={6}>
@ -409,7 +408,7 @@ function ScriptEditor({title, scriptId, scriptRevisionRecord, closeCallback, tab
{
return;
}
setErrorAlert("");
setErrorAlert("")
}} anchorOrigin={{vertical: "top", horizontal: "center"}}>
<Alert color="error" onClose={() => setErrorAlert("")}>
{errorAlert}
@ -441,10 +440,10 @@ function ScriptEditor({title, scriptId, scriptRevisionRecord, closeCallback, tab
<Box sx={{height: openTool ? "45%" : "100%"}}>
<Grid container alignItems="flex-end">
<Box maxWidth={"50%"} minWidth={300}>
<DynamicSelect fieldPossibleValueProps={{tableName: "scriptRevision", fieldName: "apiName", initialDisplayValue: apiNameLabel}} initialValue={apiName} fieldLabel={"API Name *"} inForm={false} onChange={changeApiName} useCase="form" />
<DynamicSelect fieldName={"apiName"} initialValue={apiName} initialDisplayValue={apiNameLabel} fieldLabel={"API Name *"} tableName={"scriptRevision"} inForm={false} onChange={changeApiName} />
</Box>
<Box maxWidth={"50%"} minWidth={300} pl={2}>
<DynamicSelect fieldPossibleValueProps={{tableName: "scriptRevision", fieldName: "apiVersion", initialDisplayValue: apiVersionLabel}} initialValue={apiVersion} fieldLabel={"API Version *"} inForm={false} onChange={changeApiVersion} useCase="form" />
<DynamicSelect fieldName={"apiVersion"} initialValue={apiVersion} initialDisplayValue={apiVersionLabel} fieldLabel={"API Version *"} tableName={"scriptRevision"} inForm={false} onChange={changeApiVersion} />
</Box>
</Grid>
<Box display="flex" sx={{height: "100%"}}>
@ -465,19 +464,19 @@ function ScriptEditor({title, scriptId, scriptRevisionRecord, closeCallback, tab
<Box>
{
openEditorFileNames.length > 1 &&
<Tooltip title="Close this editor split" enterDelay={500}>
<IconButton size="small" onClick={() => closeEditorClicked(index)}>
<Icon>close</Icon>
</IconButton>
</Tooltip>
<Tooltip title="Close this editor split" enterDelay={500}>
<IconButton size="small" onClick={() => closeEditorClicked(index)}>
<Icon>close</Icon>
</IconButton>
</Tooltip>
}
{
index == openEditorFileNames.length - 1 &&
<Tooltip title="Open a new editor split" enterDelay={500}>
<IconButton size="small" onClick={splitEditorClicked}>
<Icon>vertical_split</Icon>
</IconButton>
</Tooltip>
<Tooltip title="Open a new editor split" enterDelay={500}>
<IconButton size="small" onClick={splitEditorClicked}>
<Icon>vertical_split</Icon>
</IconButton>
</Tooltip>
}
</Box>
</Box>
@ -527,29 +526,29 @@ function ScriptEditor({title, scriptId, scriptRevisionRecord, closeCallback, tab
</Grid>
</Box>
<CommitMessagePrompt isOpen={promptForCommitMessageOpen} closeHandler={closePromptForCommitMessage} />
<CommitMessagePrompt isOpen={promptForCommitMessageOpen} closeHandler={closePromptForCommitMessage}/>
</Card>
</Box>
);
}
function CommitMessagePrompt(props: { isOpen: boolean, closeHandler: (wasSaveClicked: boolean, message?: string) => void })
function CommitMessagePrompt(props: {isOpen: boolean, closeHandler: (wasSaveClicked: boolean, message?: string) => void})
{
const [commitMessage, setCommitMessage] = useState("No commit message given");
const [commitMessage, setCommitMessage] = useState("No commit message given")
const updateCommitMessage = (event: React.ChangeEvent<HTMLInputElement>) =>
{
setCommitMessage(event.target.value);
};
}
const keyPressHandler = (e: React.KeyboardEvent<HTMLDivElement>) =>
{
if (e.key === "Enter")
if(e.key === "Enter")
{
props.closeHandler(true, commitMessage);
}
};
}
return (
<Dialog
@ -580,10 +579,10 @@ function CommitMessagePrompt(props: { isOpen: boolean, closeHandler: (wasSaveCli
</DialogContent>
<DialogActions>
<QCancelButton onClickHandler={() => props.closeHandler(false)} disabled={false} />
<QSaveButton label="Save" onClickHandler={() => props.closeHandler(true, commitMessage)} disabled={false} />
<QSaveButton label="Save" onClickHandler={() => props.closeHandler(true, commitMessage)} disabled={false}/>
</DialogActions>
</Dialog>
);
)
}
export default ScriptEditor;

View File

@ -391,12 +391,12 @@ export default function ShareModal({open, onClose, tableMetaData, record}: Share
<Box display="flex" flexDirection="row" alignItems="center">
<Box width="550px" pr={2} mb={-1.5}>
<DynamicSelect
fieldPossibleValueProps={{possibleValueSourceName: shareableTableMetaData.audiencePossibleValueSourceName, initialDisplayValue: selectedAudienceOption?.label}}
possibleValueSourceName={shareableTableMetaData.audiencePossibleValueSourceName}
fieldLabel="User or Group" // todo should come from shareableTableMetaData
initialValue={selectedAudienceOption?.id}
initialDisplayValue={selectedAudienceOption?.label}
inForm={false}
onChange={handleAudienceChange}
useCase="form"
/>
</Box>
{/*

View File

@ -22,25 +22,16 @@
import {QWidgetMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QWidgetMetaData";
import {Box, Skeleton} from "@mui/material";
import Card from "@mui/material/Card";
import Modal from "@mui/material/Modal";
import parse from "html-react-parser";
import {BlockData} from "qqq/components/widgets/blocks/BlockModels";
import WidgetBlock from "qqq/components/widgets/WidgetBlock";
import ProcessWidgetBlockUtils from "qqq/pages/processes/ProcessWidgetBlockUtils";
import React, {useEffect, useState} from "react";
import React from "react";
export interface CompositeData
interface CompositeData
{
blockId: string;
blocks: BlockData[];
styleOverrides?: any;
layout?: string;
overlayHtml?: string;
overlayStyleOverrides?: any;
modalMode: string;
styles?: any;
}
@ -48,15 +39,13 @@ interface CompositeWidgetProps
{
widgetMetaData: QWidgetMetaData;
data: CompositeData;
actionCallback?: (blockData: BlockData, eventValues?: { [name: string]: any }) => boolean;
values?: { [key: string]: any };
}
/*******************************************************************************
** Widget which is a list of Blocks.
*******************************************************************************/
export default function CompositeWidget({widgetMetaData, data, actionCallback, values}: CompositeWidgetProps): JSX.Element
export default function CompositeWidget({widgetMetaData, data}: CompositeWidgetProps): JSX.Element
{
if (!data || !data.blocks)
{
@ -82,12 +71,6 @@ export default function CompositeWidget({widgetMetaData, data, actionCallback, v
boxStyle.flexWrap = "wrap";
boxStyle.gap = "0.5rem";
}
else if (layout == "FLEX_ROW")
{
boxStyle.display = "flex";
boxStyle.flexDirection = "row";
boxStyle.gap = "0.5rem";
}
else if (layout == "FLEX_ROW_SPACE_BETWEEN")
{
boxStyle.display = "flex";
@ -95,14 +78,6 @@ export default function CompositeWidget({widgetMetaData, data, actionCallback, v
boxStyle.justifyContent = "space-between";
boxStyle.gap = "0.25rem";
}
else if (layout == "FLEX_ROW_CENTER")
{
boxStyle.display = "flex";
boxStyle.flexDirection = "row";
boxStyle.justifyContent = "center";
boxStyle.gap = "0.25rem";
boxStyle.flexWrap = "wrap";
}
else if (layout == "TABLE_SUB_ROW_DETAILS")
{
boxStyle.display = "flex";
@ -122,96 +97,20 @@ export default function CompositeWidget({widgetMetaData, data, actionCallback, v
boxStyle.borderRadius = "0.5rem";
boxStyle.background = "#FFFFFF";
}
if (data?.styleOverrides)
{
boxStyle = {...boxStyle, ...data.styleOverrides};
}
if (data.styles?.backgroundColor)
{
boxStyle.backgroundColor = ProcessWidgetBlockUtils.processColorFromStyleMap(data.styles.backgroundColor);
}
if (data.styles?.padding)
{
boxStyle.paddingTop = data.styles?.padding.top + "px"
boxStyle.paddingBottom = data.styles?.padding.bottom + "px"
boxStyle.paddingLeft = data.styles?.padding.left + "px"
boxStyle.paddingRight = data.styles?.padding.right + "px"
}
let overlayStyle: any = {};
if (data?.overlayStyleOverrides)
{
overlayStyle = {...overlayStyle, ...data.overlayStyleOverrides};
}
const content = (
<>
{
data?.overlayHtml &&
<Box sx={overlayStyle} className="blockWidgetOverlay">{parse(data.overlayHtml)}</Box>
}
<Box sx={boxStyle} className="compositeWidget">
{
data.blocks.map((block: BlockData, index) => (
<React.Fragment key={index}>
<WidgetBlock widgetMetaData={widgetMetaData} block={block} actionCallback={actionCallback} values={values} />
</React.Fragment>
))
}
</Box>
</>
);
if (data.modalMode)
{
const [isModalOpen, setIsModalOpen] = useState(values && (values[data.blockId] == true));
/***************************************************************************
**
***************************************************************************/
const controlCallback = (newValue: boolean) =>
return (<Box sx={boxStyle} className="compositeWidget">
{
setIsModalOpen(newValue);
};
/***************************************************************************
**
***************************************************************************/
const modalOnClose = (event: object, reason: string) =>
{
values[data.blockId] = false;
setIsModalOpen(false);
actionCallback({blockTypeName: "BUTTON", values: {}}, {controlCode: `hideModal:${data.blockId}`});
};
//////////////////////////////////////////////////////////////////////////////////////////
// register the control-callback function - so when buttons are clicked, we can be told //
//////////////////////////////////////////////////////////////////////////////////////////
useEffect(() =>
{
if (actionCallback)
{
actionCallback(null, {
registerControlCallbackName: data.blockId,
registerControlCallbackFunction: controlCallback
});
}
}, []);
return (<Modal open={isModalOpen} onClose={modalOnClose}>
<Box sx={{position: "absolute", overflowY: "auto", maxHeight: "100%", width: "100%"}}>
<Card sx={{my: 5, mx: "auto", p: "1rem", maxWidth: "1024px"}}>
{content}
</Card>
</Box>
</Modal>);
}
else
{
return content;
}
data.blocks.map((block: BlockData, index) => (
<React.Fragment key={index}>
<WidgetBlock widgetMetaData={widgetMetaData} block={block} />
</React.Fragment>
))
}
</Box>);
}

View File

@ -18,18 +18,15 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
import {QWidgetMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QWidgetMetaData";
import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord";
import {Alert, Skeleton} from "@mui/material";
import Box from "@mui/material/Box";
import Grid from "@mui/material/Grid";
import Modal from "@mui/material/Modal";
import Tab from "@mui/material/Tab";
import Tabs from "@mui/material/Tabs";
import parse from "html-react-parser";
import QContext from "QContext";
import EntityForm from "qqq/components/forms/EntityForm";
import MDTypography from "qqq/components/legacy/MDTypography";
import TabPanel from "qqq/components/misc/TabPanel";
import BarChart from "qqq/components/widgets/charts/barchart/BarChart";
@ -46,10 +43,11 @@ import FieldValueListWidget from "qqq/components/widgets/misc/FieldValueListWidg
import FilterAndColumnsSetupWidget from "qqq/components/widgets/misc/FilterAndColumnsSetupWidget";
import PivotTableSetupWidget from "qqq/components/widgets/misc/PivotTableSetupWidget";
import QuickSightChart from "qqq/components/widgets/misc/QuickSightChart";
import RecordGridWidget, {ChildRecordListData} from "qqq/components/widgets/misc/RecordGridWidget";
import RecordGridWidget from "qqq/components/widgets/misc/RecordGridWidget";
import ScriptViewer from "qqq/components/widgets/misc/ScriptViewer";
import StepperCard from "qqq/components/widgets/misc/StepperCard";
import USMapWidget from "qqq/components/widgets/misc/USMapWidget";
import WorkflowViewer from "qqq/components/widgets/misc/WorkflowViewer";
import ParentWidget from "qqq/components/widgets/ParentWidget";
import MultiStatisticsCard from "qqq/components/widgets/statistics/MultiStatisticsCard";
import StatisticsCard from "qqq/components/widgets/statistics/StatisticsCard";
@ -74,9 +72,6 @@ interface Props
childUrlParams?: string;
parentWidgetMetaData?: QWidgetMetaData;
wrapWidgetsInTabPanels: boolean;
actionCallback?: (data: any, eventValues?: { [name: string]: any }) => boolean;
initialWidgetDataList: any[];
values?: { [key: string]: any };
}
DashboardWidgets.defaultProps = {
@ -88,14 +83,11 @@ DashboardWidgets.defaultProps = {
childUrlParams: "",
parentWidgetMetaData: null,
wrapWidgetsInTabPanels: false,
actionCallback: null,
initialWidgetDataList: null,
values: {}
};
function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, record, omitWrappingGridContainer, areChildren, childUrlParams, parentWidgetMetaData, wrapWidgetsInTabPanels, actionCallback, initialWidgetDataList, values}: Props): JSX.Element
function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, record, omitWrappingGridContainer, areChildren, childUrlParams, parentWidgetMetaData, wrapWidgetsInTabPanels}: Props): JSX.Element
{
const [widgetData, setWidgetData] = useState(initialWidgetDataList == null ? [] as any[] : initialWidgetDataList);
const [widgetData, setWidgetData] = useState([] as any[]);
const [widgetCounter, setWidgetCounter] = useState(0);
const [, forceUpdate] = useReducer((x) => x + 1, 0);
@ -103,11 +95,6 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, reco
const [haveLoadedParams, setHaveLoadedParams] = useState(false);
const {accentColor} = useContext(QContext);
/////////////////////////
// modal form controls //
/////////////////////////
const [showEditChildForm, setShowEditChildForm] = useState(null as any);
let initialSelectedTab = 0;
let selectedTabKey: string = null;
if (parentWidgetMetaData && wrapWidgetsInTabPanels)
@ -128,15 +115,7 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, reco
useEffect(() =>
{
if (initialWidgetDataList && initialWidgetDataList.length > 0)
{
// todo actually, should this check each element of the array, down in the loop? yeah, when we need to, do it that way.
console.log("We already have initial widget data, so not fetching from backend.");
return;
}
setWidgetData([]);
for (let i = 0; i < widgetMetaDataList.length; i++)
{
const widgetMetaData = widgetMetaDataList[i];
@ -173,7 +152,7 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, reco
const reloadWidget = async (index: number, data: string) =>
{
await (async () =>
(async () =>
{
const urlParams = getQueryParams(widgetMetaDataList[index], data);
setCurrentUrlParams(urlParams);
@ -292,151 +271,6 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, reco
return (rs);
}
/*******************************************************************************
**
*******************************************************************************/
const closeEditChildForm = (event: object, reason: string) =>
{
if (reason === "backdropClick" || reason === "escapeKeyDown")
{
return;
}
setShowEditChildForm(null);
};
/*******************************************************************************
**
*******************************************************************************/
function deleteChildRecord(name: string, widgetIndex: number, rowIndex: number)
{
updateChildRecordList(name, "delete", rowIndex);
forceUpdate();
actionCallback(widgetData[widgetIndex]);
};
/*******************************************************************************
**
*******************************************************************************/
function openEditChildRecord(name: string, widgetData: any, rowIndex: number)
{
let defaultValues = widgetData.queryOutput.records[rowIndex].values;
let disabledFields = widgetData.disabledFieldsForNewChildRecords;
if (!disabledFields)
{
disabledFields = widgetData.defaultValuesForNewChildRecords;
}
doOpenEditChildForm(name, widgetData.childTableMetaData, rowIndex, defaultValues, disabledFields);
}
/*******************************************************************************
**
*******************************************************************************/
function openAddChildRecord(name: string, widgetData: any)
{
let defaultValues = widgetData.defaultValuesForNewChildRecords;
let disabledFields = widgetData.disabledFieldsForNewChildRecords;
if (!disabledFields)
{
disabledFields = widgetData.defaultValuesForNewChildRecords;
}
doOpenEditChildForm(name, widgetData.childTableMetaData, null, defaultValues, disabledFields);
}
/*******************************************************************************
**
*******************************************************************************/
function doOpenEditChildForm(widgetName: string, table: QTableMetaData, rowIndex: number, defaultValues: any, disabledFields: any)
{
const showEditChildForm: any = {};
showEditChildForm.widgetName = widgetName;
showEditChildForm.table = table;
showEditChildForm.rowIndex = rowIndex;
showEditChildForm.defaultValues = defaultValues;
showEditChildForm.disabledFields = disabledFields;
setShowEditChildForm(showEditChildForm);
}
/*******************************************************************************
**
*******************************************************************************/
function submitEditChildForm(values: any, tableName: string)
{
updateChildRecordList(showEditChildForm.widgetName, showEditChildForm.rowIndex == null ? "insert" : "edit", showEditChildForm.rowIndex, values);
let widgetIndex = determineChildRecordListIndex(showEditChildForm.widgetName);
actionCallback(widgetData[widgetIndex]);
}
/*******************************************************************************
**
*******************************************************************************/
function determineChildRecordListIndex(widgetName: string): number
{
let widgetIndex = -1;
for (var i = 0; i < widgetMetaDataList.length; i++)
{
const widgetMetaData = widgetMetaDataList[i];
if (widgetMetaData.name == widgetName)
{
widgetIndex = i;
break;
}
}
return (widgetIndex);
}
/*******************************************************************************
**
*******************************************************************************/
function updateChildRecordList(widgetName: string, action: "insert" | "edit" | "delete", rowIndex?: number, values?: any)
{
////////////////////////////////////////////////
// find the correct child record widget index //
////////////////////////////////////////////////
let widgetIndex = determineChildRecordListIndex(widgetName);
if (!widgetData[widgetIndex].queryOutput.records)
{
widgetData[widgetIndex].queryOutput.records = [];
}
const newChildListWidgetData: ChildRecordListData = widgetData[widgetIndex];
if (!newChildListWidgetData.queryOutput.records)
{
newChildListWidgetData.queryOutput.records = [];
}
switch (action)
{
case "insert":
newChildListWidgetData.queryOutput.records.push({values: values});
break;
case "edit":
newChildListWidgetData.queryOutput.records[rowIndex] = {values: values};
break;
case "delete":
newChildListWidgetData.queryOutput.records.splice(rowIndex, 1);
break;
}
newChildListWidgetData.totalRows = newChildListWidgetData.queryOutput.records.length;
widgetData[widgetIndex] = newChildListWidgetData;
setWidgetData(widgetData);
setShowEditChildForm(null);
}
const renderWidget = (widgetMetaData: QWidgetMetaData, i: number): JSX.Element =>
{
const labelAdditionalComponentsRight: LabelComponent[] = [];
@ -476,7 +310,7 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, reco
)
}
{
widgetMetaData.type === "alert" && widgetData[i]?.html && !widgetData[i]?.hideWidget && (
widgetMetaData.type === "alert" && widgetData[i]?.html && (
<Widget
omitPadding={true}
widgetMetaData={widgetMetaData}
@ -486,16 +320,7 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, reco
labelAdditionalComponentsRight={labelAdditionalComponentsRight}
labelAdditionalComponentsLeft={labelAdditionalComponentsLeft}
>
<Alert severity={widgetData[i]?.alertType?.toLowerCase()}>
{parse(widgetData[i]?.html)}
{widgetData[i]?.bulletList && (
<div style={{fontSize: "14px"}}>
{widgetData[i].bulletList.map((bullet: string, index: number) =>
<li key={`widget-${i}-${index}`}>{parse(bullet)}</li>
)}
</div>
)}
</Alert>
<Alert severity={widgetData[i]?.alertType?.toLowerCase()}>{parse(widgetData[i]?.html)}</Alert>
</Widget>
)
}
@ -677,7 +502,9 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, reco
}
{
widgetMetaData.type === "divider" && (
<DividerWidget />
<Box>
<DividerWidget />
</Box>
)
}
{
@ -711,15 +538,8 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, reco
widgetMetaData.type === "childRecordList" && (
widgetData && widgetData[i] &&
<RecordGridWidget
disableRowClick={widgetData[i]?.disableRowClick}
allowRecordEdit={widgetData[i]?.allowRecordEdit}
allowRecordDelete={widgetData[i]?.allowRecordDelete}
deleteRecordCallback={(rowIndex) => deleteChildRecord(widgetMetaData.name, i, rowIndex)}
editRecordCallback={(rowIndex) => openEditChildRecord(widgetMetaData.name, widgetData[i], rowIndex)}
addNewRecordCallback={widgetData[i]?.isInProcess ? () => openAddChildRecord(widgetMetaData.name, widgetData[i]) : null}
widgetMetaData={widgetMetaData}
data={widgetData[i]}
parentRecord={record}
/>
)
@ -744,7 +564,7 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, reco
labelAdditionalComponentsRight={labelAdditionalComponentsRight}
labelAdditionalComponentsLeft={labelAdditionalComponentsLeft}
>
<CompositeWidget widgetMetaData={widgetMetaData} data={widgetData[i]} actionCallback={actionCallback} values={values} />
<CompositeWidget widgetMetaData={widgetMetaData} data={widgetData[i]} />
</Widget>
)
}
@ -762,6 +582,14 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, reco
</Widget>
)
}
{
widgetMetaData.type === "workflow" && (
widgetData && widgetData[i] && widgetData[i].queryParams &&
<Widget widgetMetaData={widgetMetaData}>
<WorkflowViewer workflowId={widgetData[i].queryParams.id} />
</Widget>
)
}
{
widgetMetaData.type === "dataBagViewer" && (
widgetData && widgetData[i] && widgetData[i].queryParams &&
@ -819,28 +647,8 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, reco
if (!omitWrappingGridContainer)
{
const gridProps: { [key: string]: any } = {};
for (let size of ["xs", "sm", "md", "lg", "xl", "xxl"])
{
const key = `gridCols:sizeClass:${size}`;
if (widgetMetaData?.defaultValues?.has(key))
{
gridProps[size] = widgetMetaData?.defaultValues.get(key);
}
}
if (!gridProps["xxl"])
{
gridProps["xxl"] = widgetMetaData.gridColumns ? widgetMetaData.gridColumns : 12;
}
if (!gridProps["xs"])
{
gridProps["xs"] = 12;
}
renderedWidget = (<Grid id={widgetMetaData.name} item {...gridProps} sx={{display: "flex", alignItems: "stretch", scrollMarginTop: "100px"}}>
// @ts-ignore
renderedWidget = (<Grid id={widgetMetaData.name} item xxl={widgetMetaData.gridColumns ? widgetMetaData.gridColumns : 12} xs={12} sx={{display: "flex", alignItems: "stretch", scrollMarginTop: "100px"}}>
{renderedWidget}
</Grid>);
}
@ -891,22 +699,6 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, reco
</Grid>
)
}
{
showEditChildForm &&
<Modal open={showEditChildForm as boolean} onClose={(event, reason) => closeEditChildForm(event, reason)}>
<div className="modalEditForm">
<EntityForm
isModal={true}
closeModalHandler={closeEditChildForm}
table={showEditChildForm.table}
defaultValues={showEditChildForm.defaultValues}
disabledFields={showEditChildForm.disabledFields}
onSubmitCallback={submitEditChildForm}
overrideHeading={`${showEditChildForm.rowIndex != null ? "Editing" : "Creating New"} ${showEditChildForm.table.label}`}
/>
</div>
</Modal>
}
</>
) : null
);

View File

@ -22,9 +22,6 @@
import {QWidgetMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QWidgetMetaData";
import {Alert, Skeleton} from "@mui/material";
import ButtonBlock from "qqq/components/widgets/blocks/ButtonBlock";
import AudioBlock from "qqq/components/widgets/blocks/AudioBlock";
import InputFieldBlock from "qqq/components/widgets/blocks/InputFieldBlock";
import React from "react";
import BigNumberBlock from "qqq/components/widgets/blocks/BigNumberBlock";
import {BlockData} from "qqq/components/widgets/blocks/BlockModels";
@ -35,22 +32,19 @@ import TableSubRowDetailRowBlock from "qqq/components/widgets/blocks/TableSubRow
import TextBlock from "qqq/components/widgets/blocks/TextBlock";
import UpOrDownNumberBlock from "qqq/components/widgets/blocks/UpOrDownNumberBlock";
import CompositeWidget from "qqq/components/widgets/CompositeWidget";
import ImageBlock from "./blocks/ImageBlock";
interface WidgetBlockProps
{
widgetMetaData: QWidgetMetaData;
block: BlockData;
actionCallback?: (blockData: BlockData, eventValues?: {[name: string]: any}) => boolean;
values?: { [key: string]: any };
}
/*******************************************************************************
** Component to render a single Block in the widget framework!
*******************************************************************************/
export default function WidgetBlock({widgetMetaData, block, actionCallback, values}: WidgetBlockProps): JSX.Element
export default function WidgetBlock({widgetMetaData, block}: WidgetBlockProps): JSX.Element
{
if(!block)
{
@ -70,7 +64,7 @@ export default function WidgetBlock({widgetMetaData, block, actionCallback, valu
if(block.blockTypeName == "COMPOSITE")
{
// @ts-ignore - special case for composite type block...
return (<CompositeWidget widgetMetaData={widgetMetaData} data={block} actionCallback={actionCallback} values={values} />);
return (<CompositeWidget widgetMetaData={widgetMetaData} data={block} />);
}
switch(block.blockTypeName)
@ -89,14 +83,6 @@ export default function WidgetBlock({widgetMetaData, block, actionCallback, valu
return (<DividerBlock widgetMetaData={widgetMetaData} data={block} />);
case "BIG_NUMBER":
return (<BigNumberBlock widgetMetaData={widgetMetaData} data={block} />);
case "INPUT_FIELD":
return (<InputFieldBlock widgetMetaData={widgetMetaData} data={block} actionCallback={actionCallback} />);
case "BUTTON":
return (<ButtonBlock widgetMetaData={widgetMetaData} data={block} actionCallback={actionCallback} />);
case "AUDIO":
return (<AudioBlock widgetMetaData={widgetMetaData} data={block} />);
case "IMAGE":
return (<ImageBlock widgetMetaData={widgetMetaData} data={block} actionCallback={actionCallback} />);
default:
return (<Alert sx={{m: "0.5rem"}} color="warning">Unsupported block type: {block.blockTypeName}</Alert>)
}

View File

@ -1,40 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. 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 Box from "@mui/material/Box";
import BlockElementWrapper from "qqq/components/widgets/blocks/BlockElementWrapper";
import {StandardBlockComponentProps} from "qqq/components/widgets/blocks/BlockModels";
import DumpJsonBox from "qqq/utils/DumpJsonBox";
import React from "react";
/*******************************************************************************
** Block that renders ... an audio tag
**
** <audio src=${path} ${autoPlay} ${showControls} />
*******************************************************************************/
export default function AudioBlock({widgetMetaData, data}: StandardBlockComponentProps): JSX.Element
{
return (
<BlockElementWrapper metaData={widgetMetaData} data={data} slot="">
<audio src={data.values?.path} autoPlay={data.values?.autoPlay} controls={data.values?.showControls} />
</BlockElementWrapper>
);
}

View File

@ -21,19 +21,18 @@
import {QWidgetMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QWidgetMetaData";
import {Box, Tooltip} from "@mui/material";
import {Tooltip} from "@mui/material";
import React, {ReactElement, useContext} from "react";
import {Link} from "react-router-dom";
import QContext from "QContext";
import HelpContent, {hasHelpContent} from "qqq/components/misc/HelpContent";
import {BlockData, BlockLink, BlockTooltip} from "qqq/components/widgets/blocks/BlockModels";
import CompositeWidget from "qqq/components/widgets/CompositeWidget";
import React, {ReactElement, useContext} from "react";
import {Link} from "react-router-dom";
interface BlockElementWrapperProps
{
data: BlockData;
metaData: QWidgetMetaData;
slot: string;
slot: string
linkProps?: any;
children: ReactElement;
}
@ -48,16 +47,16 @@ export default function BlockElementWrapper({data, metaData, slot, linkProps, ch
let link: BlockLink;
let tooltip: BlockTooltip;
if (slot)
if(slot)
{
link = data.linkMap && data.linkMap[slot.toUpperCase()];
if (!link)
if(!link)
{
link = data.link;
}
tooltip = data.tooltipMap && data.tooltipMap[slot.toUpperCase()];
if (!tooltip)
if(!tooltip)
{
tooltip = data.tooltip;
}
@ -68,9 +67,9 @@ export default function BlockElementWrapper({data, metaData, slot, linkProps, ch
tooltip = data.tooltip;
}
if (!tooltip)
if(!tooltip)
{
const helpRoles = ["ALL_SCREENS"];
const helpRoles = ["ALL_SCREENS"]
///////////////////////////////////////////////////////////////////////////////////////////////
// the full keys in the helpContent table will look like: //
@ -81,39 +80,26 @@ export default function BlockElementWrapper({data, metaData, slot, linkProps, ch
const key = data.blockId ? `${data.blockId},${slot}` : slot;
const showHelp = helpHelpActive || hasHelpContent(metaData?.helpContent?.get(key), helpRoles);
if (showHelp)
if(showHelp)
{
const formattedHelpContent = <HelpContent helpContents={metaData?.helpContent?.get(key)} roles={helpRoles} helpContentKey={`widget:${metaData?.name};slot:${key}`} />;
tooltip = {title: formattedHelpContent, placement: "bottom"};
tooltip = {title: formattedHelpContent, placement: "bottom"}
}
}
let rs = children;
if (link && link.href)
if(link)
{
rs = <Link to={link.href} target={link.target} style={{color: "#546E7A"}} {...linkProps}>{rs}</Link>;
rs = <Link to={link.href} target={link.target} style={{color: "#546E7A"}} {...linkProps}>{rs}</Link>
}
if (tooltip)
if(tooltip)
{
let placement = tooltip.placement ? tooltip.placement.toLowerCase() : "bottom";
let placement = tooltip.placement ? tooltip.placement.toLowerCase() : "bottom"
// @ts-ignore - placement possible values
if (tooltip.blockData)
{
// @ts-ignore - special case for composite type block...
rs = <Tooltip title={
<Box sx={{width: "200px"}}>
<CompositeWidget widgetMetaData={metaData} data={tooltip?.blockData} />
</Box>
}>{rs}</Tooltip>;
}
else
{
// @ts-ignore - placement possible values
rs = <Tooltip title={tooltip.title} placement={placement}>{rs}</Tooltip>;
}
rs = <Tooltip title={tooltip.title} placement={placement}>{rs}</Tooltip>
}
return (rs);

View File

@ -20,7 +20,6 @@
*/
import {QWidgetMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QWidgetMetaData";
import {CompositeData} from "qqq/components/widgets/CompositeWidget";
export interface BlockData
@ -30,19 +29,16 @@ export interface BlockData
tooltip?: BlockTooltip;
link?: BlockLink;
tooltipMap?: { [slot: string]: BlockTooltip };
linkMap?: { [slot: string]: BlockLink };
tooltipMap?: {[slot: string]: BlockTooltip};
linkMap?: {[slot: string]: BlockLink};
values: any;
styles?: any;
conditional?: string;
}
export interface BlockTooltip
{
blockData?: CompositeData;
title: string | JSX.Element;
placement: string;
}
@ -59,6 +55,5 @@ export interface StandardBlockComponentProps
{
widgetMetaData: QWidgetMetaData;
data: BlockData;
actionCallback?: (blockData: BlockData, eventValues?: {[name: string]: any}) => boolean;
}

View File

@ -1,86 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. 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 Box from "@mui/material/Box";
import Icon from "@mui/material/Icon";
import {standardWidth} from "qqq/components/buttons/DefaultButtons";
import MDButton from "qqq/components/legacy/MDButton";
import BlockElementWrapper from "qqq/components/widgets/blocks/BlockElementWrapper";
import {StandardBlockComponentProps} from "qqq/components/widgets/blocks/BlockModels";
import React from "react";
/*******************************************************************************
** Block that renders ... a button...
**
*******************************************************************************/
export default function ButtonBlock({widgetMetaData, data, actionCallback}: StandardBlockComponentProps): JSX.Element
{
const startIcon = data.values.startIcon?.name ? <Icon>{data.values.startIcon.name}</Icon> : null;
const endIcon = data.values.endIcon?.name ? <Icon>{data.values.endIcon.name}</Icon> : null;
function onClick()
{
if (actionCallback)
{
actionCallback(data, data.values);
}
else
{
console.log("ButtonBlock onClick with no actionCallback present, so, noop");
}
}
let buttonVariant: "gradient" | "outlined" | "text" = "gradient";
if (data.styles?.format == "outlined")
{
buttonVariant = "outlined";
}
else if (data.styles?.format == "text")
{
buttonVariant = "text";
}
else if (data.styles?.format == "filled")
{
buttonVariant = "gradient";
}
// todo - button colors... but to do RGB's, might need to move away from MDButton?
return (
<BlockElementWrapper metaData={widgetMetaData} data={data} slot="">
<Box mx={1} my={1} minWidth={standardWidth}>
<MDButton
type="button"
variant={buttonVariant}
color="dark"
size="small"
fullWidth
startIcon={startIcon}
endIcon={endIcon}
onClick={onClick}
>
{data.values.label ?? "Button"}
</MDButton>
</Box>
</BlockElementWrapper>
);
}

View File

@ -1,59 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. 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 Box from "@mui/material/Box";
import BlockElementWrapper from "qqq/components/widgets/blocks/BlockElementWrapper";
import {StandardBlockComponentProps} from "qqq/components/widgets/blocks/BlockModels";
import DumpJsonBox from "qqq/utils/DumpJsonBox";
import React from "react";
/*******************************************************************************
** Block that renders ... an image tag
**
** <audio src=${path} ${autoPlay} ${showControls} />
*******************************************************************************/
export default function AudioBlock({widgetMetaData, data}: StandardBlockComponentProps): JSX.Element
{
let imageStyle: any = {};
if(data.styles?.width)
{
imageStyle.width = data.styles?.width;
}
if(data.styles?.height)
{
imageStyle.height = data.styles?.height;
}
if(data.styles?.bordered)
{
imageStyle.border = "1px solid #C0C0C0";
imageStyle.borderRadius = "0.5rem";
}
return (
<BlockElementWrapper metaData={widgetMetaData} data={data} slot="">
<img src={data.values?.path} alt={data.values?.alt} style={imageStyle} />
</BlockElementWrapper>
);
}

View File

@ -1,139 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. 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 {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
import Box from "@mui/material/Box";
import QDynamicFormField from "qqq/components/forms/DynamicFormField";
import DynamicFormUtils from "qqq/components/forms/DynamicFormUtils";
import BlockElementWrapper from "qqq/components/widgets/blocks/BlockElementWrapper";
import {StandardBlockComponentProps} from "qqq/components/widgets/blocks/BlockModels";
import React, {SyntheticEvent, useState} from "react";
/*******************************************************************************
** Block that renders ... a text input
**
*******************************************************************************/
export default function InputFieldBlock({widgetMetaData, data, actionCallback}: StandardBlockComponentProps): JSX.Element
{
const [blurCount, setBlurCount] = useState(0)
const fieldMetaData = new QFieldMetaData(data.values.fieldMetaData);
const dynamicField = DynamicFormUtils.getDynamicField(fieldMetaData);
let autoFocus = data.values.autoFocus as boolean
let value = data.values.value;
if(value == null || value == undefined)
{
value = "";
}
////////////////////////////////////////////////////////////////////////////////
// for an autoFocus field... //
// we're finding that if we blur it when clicking an action button, that //
// an un-desirable "now it's been touched, so show an error" happens. //
// so let us remove the default blur handler, for the first (auto) focus/blur //
// cycle, and we seem to have a better time. //
////////////////////////////////////////////////////////////////////////////////
let dynamicFormFieldRest: {onBlur?: any, sx?: any} = {}
if(autoFocus && blurCount == 0)
{
dynamicFormFieldRest.onBlur = (event: React.SyntheticEvent) =>
{
event.stopPropagation();
event.preventDefault();
setBlurCount(blurCount + 1);
}
}
/***************************************************************************
**
***************************************************************************/
function eventHandler(event: KeyboardEvent)
{
if(data.values.submitOnEnter && event.key == "Enter")
{
// @ts-ignore target.value...
const inputValue = event.target.value?.trim()
// todo - make this behavior opt-in for inputBlocks?
if(inputValue && `${inputValue}`.startsWith("->"))
{
const actionCode = inputValue.substring(2);
if(actionCallback)
{
actionCallback(data, {actionCode: actionCode, _fieldToClearIfError: fieldMetaData.name});
///////////////////////////////////////////////////////
// return, so we don't submit the actionCode as text //
///////////////////////////////////////////////////////
return;
}
}
if(fieldMetaData.isRequired && inputValue == "")
{
console.log("input field is required, but missing value, so not submitting");
return;
}
if(actionCallback)
{
console.log("InputFieldBlock calling actionCallback for submitOnEnter");
let values: {[name: string]: any} = {};
values[fieldMetaData.name] = inputValue;
actionCallback(data, values);
}
else
{
console.log("InputFieldBlock was set as submitOnEnter, but no actionCallback was present, so, noop");
}
}
}
const labelElement = <Box fontSize="1rem" fontWeight="500" marginBottom="0.25rem">
<label htmlFor={fieldMetaData.name}>{fieldMetaData.label}</label>
</Box>
return (
<Box mt="0.5rem">
<BlockElementWrapper metaData={widgetMetaData} data={data} slot="">
<>
{labelElement}
<QDynamicFormField
name={fieldMetaData.name}
displayFormat={null}
label=""
placeholder={data.values?.placeholder}
backgroundColor="#FFFFFF"
formFieldObject={dynamicField}
type={fieldMetaData.type}
value={value}
autoFocus={autoFocus}
onKeyUp={eventHandler}
{...dynamicFormFieldRest} />
</>
</BlockElementWrapper>
</Box>
);
}

View File

@ -19,12 +19,8 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import Box from "@mui/material/Box";
import Icon from "@mui/material/Icon";
import BlockElementWrapper from "qqq/components/widgets/blocks/BlockElementWrapper";
import {StandardBlockComponentProps} from "qqq/components/widgets/blocks/BlockModels";
import ProcessWidgetBlockUtils from "qqq/pages/processes/ProcessWidgetBlockUtils";
import React from "react";
/*******************************************************************************
** Block that renders ... just some text.
@ -33,132 +29,9 @@ import React from "react";
*******************************************************************************/
export default function TextBlock({widgetMetaData, data}: StandardBlockComponentProps): JSX.Element
{
let color = "rgba(0, 0, 0, 0.87)";
if (data.styles?.color)
{
color = ProcessWidgetBlockUtils.processColorFromStyleMap(data.styles.color);
}
let boxStyle = {};
if (data.styles?.format == "alert")
{
boxStyle =
{
border: `1px solid ${color}`,
background: `${color}40`,
padding: "0.5rem",
borderRadius: "0.5rem",
};
}
else if (data.styles?.format == "banner")
{
boxStyle =
{
background: `${color}40`,
padding: "0.5rem",
};
}
let fontSize = "1rem";
if (data.styles?.size)
{
switch (data.styles.size.toLowerCase())
{
case "largest":
fontSize = "3rem";
break;
case "headline":
fontSize = "2rem";
break;
case "title":
fontSize = "1.5rem";
break;
case "body":
fontSize = "1rem";
break;
case "smallest":
fontSize = "0.75rem";
break;
default:
{
if (data.styles.size.match(/^\d+$/))
{
fontSize = `${data.styles.size}px`;
}
else
{
fontSize = "1rem";
}
}
}
}
let fontWeight = "400";
if (data.styles?.weight)
{
switch (data.styles.weight.toLowerCase())
{
case "thin":
case "100":
fontWeight = "100";
break;
case "extralight":
case "200":
fontWeight = "200";
break;
case "light":
case "300":
fontWeight = "300";
break;
case "normal":
case "400":
fontWeight = "400";
break;
case "medium":
case "500":
fontWeight = "500";
break;
case "semibold":
case "600":
fontWeight = "600";
break;
case "bold":
case "700":
fontWeight = "700";
break;
case "extrabold":
case "800":
fontWeight = "800";
break;
case "black":
case "900":
fontWeight = "900";
break;
}
}
const text = data.values.interpolatedText ?? data.values.text;
const lines = text.split("\n");
const startIcon = data.values.startIcon?.name ? <Icon>{data.values.startIcon.name}</Icon> : null;
const endIcon = data.values.endIcon?.name ? <Icon>{data.values.endIcon.name}</Icon> : null;
return (
<BlockElementWrapper metaData={widgetMetaData} data={data} slot="">
<Box display="inline-block" lineHeight="1.2" sx={boxStyle}>
<span style={{fontSize: fontSize, color: color, fontWeight: fontWeight}}>
{lines.map((line: string, index: number) =>
(
<div key={index}>
<>
{index == 0 && startIcon ? {startIcon} : null}
{line}
{index == lines.length - 1 && endIcon ? {endIcon} : null}
</>
</div>
))
}</span>
</Box>
<span style={{fontSize: "1.000rem"}}>{data.values.text}</span>
</BlockElementWrapper>
);
}

View File

@ -58,7 +58,7 @@ export default function UpOrDownNumberBlock({widgetMetaData, data}: StandardBloc
return (
<>
<div style={{display: "flex", flexDirection: data.styles.isStacked ? "column" : "row", alignItems: data.styles.isStacked ? "flex-end" : "baseline", marginLeft: "auto"}}>
<div style={{display: "flex", flexDirection: data.styles.isStacked ? "column" : "row", alignItems: data.styles.isStacked ? "flex-end" : "baseline"}}>
<div style={{display: "flex", alignItems: "baseline", fontWeight: 700, fontSize: ".875rem"}}>
<BlockElementWrapper metaData={widgetMetaData} data={data} slot="number">

View File

@ -50,7 +50,6 @@ import DeveloperModeUtils from "qqq/utils/DeveloperModeUtils";
import Client from "qqq/utils/qqq/Client";
import ValueUtils from "qqq/utils/qqq/ValueUtils";
import "ace-builds/src-noconflict/ace";
import "ace-builds/src-noconflict/mode-java";
import "ace-builds/src-noconflict/mode-javascript";
import "ace-builds/src-noconflict/mode-json";

View File

@ -19,16 +19,13 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import Box from "@mui/material/Box";
import Divider from "@mui/material/Divider";
function DividerWidget(): JSX.Element
{
return (
<Box pl={3} pt={3} pb={3} width="100%">
<Divider sx={{width: "100%", height: "1px", background: "grey"}} />
</Box>
<Divider sx={{padding: "1px", background: "red"}}/>
);
}

View File

@ -46,12 +46,11 @@ 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,
label?: string
isEditable: boolean;
widgetMetaData: QWidgetMetaData;
widgetData: any;
recordValues: { [name: string]: any };
onSaveCallback?: (values: { [name: string]: any }) => void;
}
FilterAndColumnsSetupWidget.defaultProps = {
@ -84,16 +83,12 @@ 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, label}: FilterAndColumnsSetupWidgetProps): JSX.Element
export default function FilterAndColumnsSetupWidget({isEditable, widgetMetaData, widgetData, recordValues, onSaveCallback}: FilterAndColumnsSetupWidgetProps): JSX.Element
{
const [modalOpen, setModalOpen] = useState(false);
const [hideColumns] = useState(widgetData?.hideColumns);
const [hidePreview] = useState(widgetData?.hidePreview);
const [hideColumns, setHideColumns] = useState(widgetData?.hideColumns);
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);
//////////////////////////////////////////////////////////////////////////////////////////////////
@ -112,7 +107,7 @@ export default function FilterAndColumnsSetupWidget({isEditable, widgetMetaData,
/////////////////////////////
let columns: QQueryColumns = null;
let usingDefaultEmptyFilter = false;
let queryFilter = recordValues[filterFieldName] && JSON.parse(recordValues[filterFieldName]) as QQueryFilter;
let queryFilter = recordValues["queryFilterJson"] && JSON.parse(recordValues["queryFilterJson"]) as QQueryFilter;
const defaultFilterFields = widgetData?.filterDefaultFieldNames;
if (!queryFilter)
{
@ -146,9 +141,9 @@ export default function FilterAndColumnsSetupWidget({isEditable, widgetMetaData,
});
}
if (recordValues[columnsFieldName])
if (recordValues["columnsJson"])
{
columns = QQueryColumns.buildFromJSON(recordValues[columnsFieldName]);
columns = QQueryColumns.buildFromJSON(recordValues["columnsJson"]);
}
//////////////////////////////////////////////////////////////////////
@ -234,10 +229,7 @@ export default function FilterAndColumnsSetupWidget({isEditable, widgetMetaData,
setFrontendQueryFilter(view.queryFilter);
const filter = FilterUtils.prepQueryFilterForBackend(tableMetaData, view.queryFilter);
const rs: { [key: string]: any } = {};
rs[filterFieldName] = JSON.stringify(filter);
rs[columnsFieldName] = JSON.stringify(view.queryColumns);
onSaveCallback(rs);
onSaveCallback({queryFilterJson: JSON.stringify(filter), columnsJson: JSON.stringify(view.queryColumns)});
closeEditor();
}
@ -280,7 +272,7 @@ export default function FilterAndColumnsSetupWidget({isEditable, widgetMetaData,
/*******************************************************************************
**
*******************************************************************************/
function mayShowQuery(): boolean
function mayShowQueryPreview(): boolean
{
if (tableMetaData)
{
@ -296,7 +288,7 @@ export default function FilterAndColumnsSetupWidget({isEditable, widgetMetaData,
/*******************************************************************************
**
*******************************************************************************/
function mayShowColumns(): boolean
function mayShowColumnsPreview(): boolean
{
if (tableMetaData)
{
@ -363,15 +355,15 @@ export default function FilterAndColumnsSetupWidget({isEditable, widgetMetaData,
</Collapse>
<Box pt="0.5rem">
<Box display="flex" justifyContent="space-between" alignItems="center">
<h5>{label ?? "Query Filter"}</h5>
<Box fontSize="0.75rem" fontWeight="700">{mayShowQuery() && getCurrentSortIndicator(frontendQueryFilter, tableMetaData, null)}</Box>
<h5>Query Filter</h5>
<Box fontSize="0.75rem" fontWeight="700">{mayShowQueryPreview() && getCurrentSortIndicator(frontendQueryFilter, tableMetaData, null)}</Box>
</Box>
{
mayShowQuery() &&
mayShowQueryPreview() &&
<AdvancedQueryPreview tableMetaData={tableMetaData} queryFilter={frontendQueryFilter} isEditable={false} isQueryTooComplex={frontendQueryFilter.subFilters?.length > 0} removeCriteriaByIndexCallback={null} />
}
{
!mayShowQuery() &&
!mayShowQueryPreview() &&
<Box width="100%" sx={{fontSize: "1rem", background: "#FFFFFF"}} minHeight={"2.5rem"} p={"0.5rem"} pb={"0.125rem"} borderRadius="0.75rem" border={`1px solid ${colors.grayLines.main}`}>
{
isEditable &&
@ -390,11 +382,11 @@ export default function FilterAndColumnsSetupWidget({isEditable, widgetMetaData,
<h5>Columns</h5>
<Box display="flex" flexWrap="wrap" fontSize="1rem">
{
mayShowColumns() && columns &&
mayShowColumnsPreview() &&
columns.columns.map((column, i) => <React.Fragment key={`column-${i}`}>{renderColumn(column)}</React.Fragment>)
}
{
!mayShowColumns() &&
!mayShowColumnsPreview() &&
<Box width="100%" sx={{fontSize: "1rem", background: "#FFFFFF"}} minHeight={"2.375rem"} p={"0.5rem"} pb={"0.125rem"}>
{
isEditable &&
@ -410,21 +402,6 @@ export default function FilterAndColumnsSetupWidget({isEditable, widgetMetaData,
</Box>
</Box>
)}
{!hidePreview && !isEditable && frontendQueryFilter && tableMetaData && (
<Box pt="1rem">
<h5>Preview</h5>
<RecordQuery
allowVariables={widgetData?.allowVariables}
ref={recordQueryRef}
table={tableMetaData}
isPreview={true}
usage="reportSetup"
isModal={true}
initialQueryFilter={frontendQueryFilter}
initialColumns={columns}
/>
</Box>
)}
{
modalOpen &&
<Modal open={modalOpen} onClose={(event, reason) => closeEditor(event, reason)}>

View File

@ -28,7 +28,7 @@ import Icon from "@mui/material/Icon";
import IconButton from "@mui/material/IconButton";
import Tooltip from "@mui/material/Tooltip/Tooltip";
import Typography from "@mui/material/Typography";
import {DataGridPro, GridCallbackDetails, GridDensity, GridEventListener, GridRenderCellParams, GridRowParams, GridToolbarContainer, MuiEvent, useGridApiContext, useGridApiEventHandler} from "@mui/x-data-grid-pro";
import {DataGridPro, GridCallbackDetails, GridEventListener, GridRenderCellParams, GridRowParams, GridToolbarContainer, MuiEvent, useGridApiContext, useGridApiEventHandler} from "@mui/x-data-grid-pro";
import Widget, {AddNewRecordButton, LabelComponent, WidgetData} from "qqq/components/widgets/Widget";
import DataGridUtils from "qqq/utils/DataGridUtils";
import HtmlUtils from "qqq/utils/HtmlUtils";
@ -39,44 +39,39 @@ import {Link, useNavigate} from "react-router-dom";
export interface ChildRecordListData extends WidgetData
{
title?: string;
queryOutput?: { records: { values: any, displayValues?: any } [] };
childTableMetaData?: QTableMetaData;
tablePath?: string;
viewAllLink?: string;
totalRows?: number;
canAddChildRecord?: boolean;
defaultValuesForNewChildRecords?: { [fieldName: string]: any };
disabledFieldsForNewChildRecords?: { [fieldName: string]: any };
defaultValuesForNewChildRecordsFromParentFields?: { [fieldName: string]: string };
title: string;
queryOutput: { records: { values: any }[] };
childTableMetaData: QTableMetaData;
tablePath: string;
viewAllLink: string;
totalRows: number;
canAddChildRecord: boolean;
defaultValuesForNewChildRecords: { [fieldName: string]: any };
disabledFieldsForNewChildRecords: { [fieldName: string]: any };
}
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,
parentRecord?: QRecord
widgetMetaData: QWidgetMetaData;
data: ChildRecordListData;
addNewRecordCallback?: () => void;
disableRowClick: boolean;
allowRecordEdit: boolean;
editRecordCallback?: (rowIndex: number) => void;
allowRecordDelete: boolean;
deleteRecordCallback?: (rowIndex: number) => void;
}
RecordGridWidget.defaultProps =
{
disableRowClick: false,
allowRecordEdit: false,
allowRecordDelete: false,
gridOnly: false,
allowRecordDelete: false
};
const qController = Client.getInstance();
function RecordGridWidget({widgetMetaData, data, addNewRecordCallback, disableRowClick, allowRecordEdit, editRecordCallback, allowRecordDelete, deleteRecordCallback, gridOnly, gridDensity, parentRecord}: Props): JSX.Element
function RecordGridWidget({widgetMetaData, data, addNewRecordCallback, disableRowClick, allowRecordEdit, editRecordCallback, allowRecordDelete, deleteRecordCallback}: Props): JSX.Element
{
const instance = useRef({timer: null});
const [rows, setRows] = useState([]);
@ -99,19 +94,12 @@ function RecordGridWidget({widgetMetaData, data, addNewRecordCallback, disableRo
{
for (let i = 0; i < queryOutputRecords.length; i++)
{
if (queryOutputRecords[i] instanceof QRecord)
{
records.push(queryOutputRecords[i] as QRecord);
}
else
{
records.push(new QRecord(queryOutputRecords[i]));
}
records.push(new QRecord(queryOutputRecords[i]));
}
}
const tableMetaData = data.childTableMetaData instanceof QTableMetaData ? data.childTableMetaData as QTableMetaData : new QTableMetaData(data.childTableMetaData);
const rows = DataGridUtils.makeRows(records, tableMetaData, undefined, true);
const tableMetaData = new QTableMetaData(data.childTableMetaData);
const rows = DataGridUtils.makeRows(records, tableMetaData, true);
/////////////////////////////////////////////////////////////////////////////////
// note - tablePath may be null, if the user doesn't have access to the table. //
@ -188,7 +176,7 @@ function RecordGridWidget({widgetMetaData, data, addNewRecordCallback, disableRo
setCsv(csv);
setFileName(fileName);
}
}, [JSON.stringify(data?.queryOutput)]);
}, [data]);
///////////////////
// view all link //
@ -254,22 +242,7 @@ function RecordGridWidget({widgetMetaData, data, addNewRecordCallback, disableRo
{
disabledFields = data.defaultValuesForNewChildRecords;
}
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));
labelAdditionalComponentsRight.push(new AddNewRecordButton(data.childTableMetaData, data.defaultValuesForNewChildRecords, "Add new", disabledFields, addNewRecordCallback));
}
@ -322,62 +295,6 @@ function RecordGridWidget({widgetMetaData, data, addNewRecordCallback, disableRo
return (<GridToolbarContainer />);
}
let containerPadding = -3;
if (data?.isInProcess)
{
containerPadding = 0;
}
const grid = (
<DataGridPro
autoHeight
sx={{
borderBottom: "none",
borderLeft: "none",
borderRight: "none"
}}
rows={rows}
disableSelectionOnClick
columns={columns}
rowBuffer={10}
getRowClassName={(params) => (params.indexRelativeToCurrentPage % 2 === 0 ? "even" : "odd")}
onRowClick={handleRowClick}
getRowId={(row) => row.__rowIndex}
// getRowHeight={() => "auto"} // maybe nice? wraps values in cells...
components={{
Toolbar: CustomToolbar
}}
// pinnedColumns={pinnedColumns}
// onPinnedColumnsChange={handlePinnedColumnsChange}
// pagination
// paginationMode="server"
// rowsPerPageOptions={[20]}
// sortingMode="server"
// filterMode="server"
// page={pageNumber}
// checkboxSelection
rowCount={data && data.totalRows}
// onPageSizeChange={handleRowsPerPageChange}
// onStateChange={handleStateChange}
density={gridDensity ?? "standard"}
// loading={loading}
// filterModel={filterModel}
// onFilterModelChange={handleFilterChange}
// columnVisibilityModel={columnVisibilityModel}
// onColumnVisibilityModelChange={handleColumnVisibilityChange}
// onColumnOrderChange={handleColumnOrderChange}
// onSelectionModelChange={selectionChanged}
// onSortModelChange={handleSortChange}
// sortingOrder={[ "asc", "desc" ]}
// sortModel={columnSortModel}
/>
);
if (gridOnly)
{
return (grid);
}
return (
<Widget
@ -387,9 +304,50 @@ function RecordGridWidget({widgetMetaData, data, addNewRecordCallback, disableRo
labelAdditionalComponentsRight={labelAdditionalComponentsRight}
labelBoxAdditionalSx={{position: "relative", top: "-0.375rem"}}
>
<Box mx={containerPadding} mb={containerPadding}>
<Box mx={-3} mb={-3}>
<Box>
{grid}
<DataGridPro
autoHeight
sx={{
borderBottom: "none",
borderLeft: "none",
borderRight: "none"
}}
rows={rows}
disableSelectionOnClick
columns={columns}
rowBuffer={10}
getRowClassName={(params) => (params.indexRelativeToCurrentPage % 2 === 0 ? "even" : "odd")}
onRowClick={handleRowClick}
getRowId={(row) => row.__rowIndex}
// getRowHeight={() => "auto"} // maybe nice? wraps values in cells...
components={{
Toolbar: CustomToolbar
}}
// pinnedColumns={pinnedColumns}
// onPinnedColumnsChange={handlePinnedColumnsChange}
// pagination
// paginationMode="server"
// rowsPerPageOptions={[20]}
// sortingMode="server"
// filterMode="server"
// page={pageNumber}
// checkboxSelection
rowCount={data && data.totalRows}
// onPageSizeChange={handleRowsPerPageChange}
// onStateChange={handleStateChange}
// density={density}
// loading={loading}
// filterModel={filterModel}
// onFilterModelChange={handleFilterChange}
// columnVisibilityModel={columnVisibilityModel}
// onColumnVisibilityModelChange={handleColumnVisibilityChange}
// onColumnOrderChange={handleColumnOrderChange}
// onSelectionModelChange={selectionChanged}
// onSortModelChange={handleSortChange}
// sortingOrder={[ "asc", "desc" ]}
// sortModel={columnSortModel}
/>
</Box>
</Box>
</Widget>

View File

@ -393,7 +393,7 @@ export default function ScriptViewer({scriptId, associatedScriptTableName, assoc
}
return (
<Grid container className="scriptViewer" my={-3} mx={-3} pt={4} width={"calc(100% + 3rem)"}>
<Grid container className="scriptViewer" m={-2} pt={4} width={"calc(100% + 2rem)"}>
<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} pt={1}>
<Box sx={{height: "455px"}} px={2} pb={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} pt={1}>
<Box sx={{height: "455px"}} px={2} pb={1}>
<ScriptDocsForm helpText={scriptTypeRecord?.values.get("helpText")} exampleCode={scriptTypeRecord?.values.get("sampleCode")} />
</Box>
</TabPanel>

View File

@ -0,0 +1,383 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2022. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* 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 {QException} from "@kingsrook/qqq-frontend-core/lib/exceptions/QException";
import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord";
import {QCriteriaOperator} from "@kingsrook/qqq-frontend-core/lib/model/query/QCriteriaOperator";
import {QFilterCriteria} from "@kingsrook/qqq-frontend-core/lib/model/query/QFilterCriteria";
import {QFilterOrderBy} from "@kingsrook/qqq-frontend-core/lib/model/query/QFilterOrderBy";
import {QQueryFilter} from "@kingsrook/qqq-frontend-core/lib/model/query/QQueryFilter";
import {Chip} from "@mui/material";
import Alert from "@mui/material/Alert";
import Avatar from "@mui/material/Avatar";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import Divider from "@mui/material/Divider";
import Grid from "@mui/material/Grid";
import List from "@mui/material/List";
import ListItem from "@mui/material/ListItem";
import ListItemAvatar from "@mui/material/ListItemAvatar";
import ListItemText from "@mui/material/ListItemText";
import Modal from "@mui/material/Modal";
import Snackbar from "@mui/material/Snackbar";
import Tab from "@mui/material/Tab";
import Tabs from "@mui/material/Tabs";
import Typography from "@mui/material/Typography";
import TabPanel from "qqq/components/misc/TabPanel";
import CustomWidthTooltip from "qqq/components/tooltips/CustomWidthTooltip";
import WorkflowEditor, {WorkflowEditorProps} from "qqq/components/workflows/WorkflowEditor";
import WorkflowPreview from "qqq/components/workflows/WorkflowPreview";
import {LoadingState} from "qqq/models/LoadingState";
import DeveloperModeUtils from "qqq/utils/DeveloperModeUtils";
import Client from "qqq/utils/qqq/Client";
import ValueUtils from "qqq/utils/qqq/ValueUtils";
import "ace-builds/src-noconflict/mode-java";
import "ace-builds/src-noconflict/mode-javascript";
import "ace-builds/src-noconflict/mode-json";
import "ace-builds/src-noconflict/theme-github";
import React, {useReducer, useState} from "react";
import AceEditor from "react-ace";
import "ace-builds/src-noconflict/ext-language_tools";
const qController = Client.getInstance();
// Declaring props types for ViewForm
interface Props
{
workflowId?: number;
}
WorkflowViewer.defaultProps =
{};
export default function WorkflowViewer({workflowId}: Props): JSX.Element
{
const [workflowRecord, setWorkflowRecord] = useState(null as QRecord);
const [asyncLoadInited, setAsyncLoadInited] = useState(false);
const [versionRecordList, setVersionRecordList] = useState(null as QRecord[]);
const [selectedRevisionRecord, setSelectedRevisionRecord] = useState(null as QRecord);
const [currentVersionId, setCurrentVersionId] = useState(null as number);
const [notFoundMessage, setNotFoundMessage] = useState(null);
const [selectedTab, setSelectedTab] = useState(0);
const [editorProps, setEditorProps] = useState(null as WorkflowEditorProps);
const [successText, setSuccessText] = useState(null as string);
const [failText, setFailText] = useState(null as string);
const [, forceUpdate] = useReducer((x) => x + 1, 0);
const [loadingSelectedVersion, _] = useState(new LoadingState(forceUpdate, "loading"));
if (!asyncLoadInited)
{
setAsyncLoadInited(true);
(async () =>
{
try
{
const workflowRecord = await qController.get("workflow", workflowId);
setWorkflowRecord(workflowRecord);
const criteria = [new QFilterCriteria("workflowId", QCriteriaOperator.EQUALS, [workflowId])];
const orderBys = [new QFilterOrderBy("sequenceNo", false)];
const filter = new QQueryFilter(criteria, orderBys, null, "AND", 0, 25);
const versions = await qController.query("workflowRevision", filter);
console.log("Fetched versions:");
console.log(versions);
setVersionRecordList(versions);
if (versions && versions.length > 0)
{
setCurrentVersionId(versions[0].values.get("id"));
const latestVersion = await qController.get("workflowRevision", versions[0].values.get("id"));
console.log("Fetched latestVersion:");
console.log(latestVersion);
setSelectedRevisionRecord(latestVersion);
loadingSelectedVersion.setNotLoading();
forceUpdate();
}
}
catch (e)
{
if (e instanceof QException)
{
if ((e as QException).status === 404)
{
setNotFoundMessage("Workflow data could not be found.");
return;
}
}
setNotFoundMessage("Error loading workflow data: " + e);
}
})();
}
const editContents = (contents: string) =>
{
const editorProps = {} as WorkflowEditorProps;
editorProps.title = (contents ? "Editing Workflow: " : "Initializing Workflow: ") + workflowRecord?.values?.get("name");
editorProps.contents = contents;
editorProps.workflowId = workflowId;
setEditorProps(editorProps);
};
const closeEditingWorkflow = (event: object, reason: string, alert: string = null) =>
{
if (reason === "backdropClick" || reason === "escapeKeyDown")
{
return;
}
if (reason === "saved")
{
setAsyncLoadInited(false);
forceUpdate();
if (alert)
{
setSuccessText(alert);
}
}
else if (reason === "failed")
{
setAsyncLoadInited(false);
forceUpdate();
if (alert)
{
setFailText(alert);
}
}
setEditorProps(null);
};
const changeTab = (newValue: number) =>
{
setSelectedTab(newValue);
forceUpdate();
};
const selectVersion = (version: QRecord) =>
{
(async () =>
{
// fetch the full version
setSelectedRevisionRecord(version);
loadingSelectedVersion.setLoading();
const selectedVersion = await qController.get("workflowRevision", version.values.get("id"));
console.log("Fetched selectedVersion:");
console.log(selectedVersion);
setSelectedRevisionRecord(selectedVersion);
loadingSelectedVersion.setNotLoading();
forceUpdate();
})();
};
function getVersionsList(versionRecordList: QRecord[], selectedVersionRecord: QRecord)
{
return <List sx={{pl: 3, height: "400px", overflow: "auto"}}>
{
(versionRecordList == null || versionRecordList.length == 0) ?
<Typography variant="body2">
There are not any versions of this workflow.
</Typography>
: <></>
}
{
versionRecordList?.map((version: any) => (
<React.Fragment key={version.values.get("id")}>
<ListItem sx={{p: 1}} alignItems="flex-start" selected={selectedVersionRecord?.values?.get("id") == version.values.get("id")} onClick={(event) => selectVersion(version)}>
<ListItemAvatar>
<Avatar sx={{bgcolor: DeveloperModeUtils.revToColor("", workflowId, version.values.get("sequenceNo"))}}>{`${version.values.get("sequenceNo")}`}</Avatar>
</ListItemAvatar>
<ListItemText
primaryTypographyProps={{fontSize: "1rem"}}
secondaryTypographyProps={{fontSize: ".85rem"}}
primary={
<div style={{overflow: "hidden", textOverflow: "ellipsis", maxHeight: "5rem"}} title={version.values.get("commitMessage")}>
{currentVersionId == version?.values?.get("id") && <Chip label="CURRENT" color="success" variant="outlined" size="small" sx={{mr: 1, fontSize: "0.75rem"}} />}
{version.values.get("commitMessage")}
</div>
}
secondary={
<>
{ValueUtils.formatDateTime(version.values.get("createDate"))}
<br />
{version.values.get("author")}
</>
}
/>
</ListItem>
<Divider sx={{my: 0.5}} variant="inset" component="li" />
</React.Fragment>
))
}
</List>;
}
let editButtonTooltip = "";
let editButtonText = "Create New Version";
if (currentVersionId)
{
if (currentVersionId === selectedRevisionRecord?.values?.get("id"))
{
editButtonTooltip = "If you make any changes to this workflow, a new version will be created when you hit Save.";
editButtonText = "Edit";
}
else
{
editButtonTooltip = "If you want to make this previous Version active, bring up the Edit window, make any changes " +
"to the old Version if they are needed, then click Save. A new Version will be created, and set as Current.";
editButtonText = "Edit and Activate";
}
}
return (
<Grid container>
<Grid item xs={12}>
<Box>
{
<Box>
{
successText ? (
<Snackbar open={successText !== null && successText !== ""} autoHideDuration={6000} onClose={() => setSuccessText(null)} anchorOrigin={{vertical: "top", horizontal: "center"}}>
<Alert color="success" onClose={() => setSuccessText(null)}>
{successText}
</Alert>
</Snackbar>
) : ("")
}
{
failText ? (
<Snackbar open={failText !== null && failText !== ""} autoHideDuration={6000} onClose={() => setFailText(null)} anchorOrigin={{vertical: "top", horizontal: "center"}}>
<Alert color="error" onClose={() => setFailText(null)}>
{failText}
</Alert>
</Snackbar>
) : ("")
}
<Grid container spacing={3}>
<Grid item xs={12}>
<>
<Tabs
sx={{m: 0, mb: 1, mt: 0}}
value={selectedTab}
onChange={(event, newValue) => changeTab(newValue)}
variant="standard"
>
<Tab label="Versions" id="simple-tab-0" aria-controls="simple-tabpanel-0" />
<Tab label="Preview" id="simple-tab-1" aria-controls="simple-tabpanel-1" />
<Tab label="Something Else" id="simple-tab-2" aria-controls="simple-tabpanel-2" />
</Tabs>
<TabPanel index={0} value={selectedTab}>
<Grid container>
<Grid item xs={4}>
<Box display="flex" alignItems="center" gap={2} pb={1} height="40px">
<Typography variant="h6" pl={3}>Versions</Typography>
</Box>
{getVersionsList(versionRecordList, selectedRevisionRecord)}
</Grid>
<Grid item xs={8}>
<Box display="flex" alignItems="center" justifyContent="space-between" gap={2} pb={1} height="40px">
{
selectedRevisionRecord ?
<Typography variant="h6">
Version {selectedRevisionRecord.values.get("sequenceNo")}
{
currentVersionId === selectedRevisionRecord.values.get("id")
? (<> (Current)</>)
: <></>
}
</Typography>
: <></>
}
<CustomWidthTooltip title={editButtonTooltip}>
<Button sx={{py: 0}} onClick={() => editContents(selectedRevisionRecord?.values?.get("contents"))}>
{editButtonText}
</Button>
</CustomWidthTooltip>
</Box>
<WorkflowPreview />
</Grid>
</Grid>
</TabPanel>
<TabPanel index={1} value={selectedTab}>
<Grid container height="440px">
<Grid item xs={4}>
<Box display="flex" alignItems="center" gap={2} pb={1} height="40px">
<Typography variant="h6" pl={3}>Versions</Typography>
</Box>
{getVersionsList(versionRecordList, selectedRevisionRecord)}
</Grid>
<Grid item xs={8}>
<Box display="flex" alignItems="center" gap={2} pb={1} height="40px">
<Typography variant="h6" pl={3}>Data Preview (Version {selectedRevisionRecord?.values?.get("sequenceNo")})</Typography>
</Box>
<Box height="400px" overflow="auto" ml={1} fontSize="14px">
{
loadingSelectedVersion.isNotLoading() && selectedRevisionRecord && selectedRevisionRecord.values.get("contents") ? (
<>
<AceEditor
mode="json"
theme="github"
name={"viewData"}
readOnly
highlightActiveLine={false}
setOptions={{useWorker: false}}
editorProps={{$blockScrolling: true}}
width="100%"
height="400px"
value={selectedRevisionRecord?.values?.get("contents")}
/>
</>
) : null
}
{
loadingSelectedVersion.isLoadingSlow() && selectedRevisionRecord && <Box fontSize="14px" pl={3}>Loading...</Box>
}
</Box>
</Grid>
</Grid>
</TabPanel>
</>
</Grid>
</Grid>
{
editorProps &&
<Modal open={editorProps !== null} onClose={(event, reason) => closeEditingWorkflow(event, reason)}>
<WorkflowEditor
closeCallback={closeEditingWorkflow}
{...editorProps}
/>
</Modal>
}
</Box>
}
</Box>
</Grid>
</Grid>
);
}

View File

@ -19,12 +19,15 @@
*/
import {QWidgetMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QWidgetMetaData";
import {Box, tooltipClasses, TooltipProps} from "@mui/material";
import {tooltipClasses, TooltipProps} from "@mui/material";
import Autocomplete from "@mui/material/Autocomplete";
import Box from "@mui/material/Box";
import Icon from "@mui/material/Icon";
import {styled} from "@mui/material/styles";
import Table from "@mui/material/Table";
import TableBody from "@mui/material/TableBody";
import TableContainer from "@mui/material/TableContainer";
import TableRow from "@mui/material/TableRow";
import Tooltip from "@mui/material/Tooltip";
import parse from "html-react-parser";
import colors from "qqq/assets/theme/base/colors";
@ -163,7 +166,7 @@ function DataTable({
})}
>
{/* float this icon to keep it "out of the flow" - in other words, to keep it from making the row taller than it otherwise would be... */}
<Icon fontSize="medium" sx={{float: "left"}}>{row.isExpanded ? "expand_less" : "chevron_left"}</Icon>
<Icon fontSize="medium" sx={{float: "left"}}>{row.isExpanded ? "expand_less" : "chevron_right"}</Icon>
</span>
) : null,
},
@ -309,7 +312,7 @@ function DataTable({
{
boxStyle = isFooter
? {borderTop: `0.0625rem solid ${colors.grayLines.main};`, backgroundColor: "#EEEEEE"}
: {height: fixedHeight ? `${fixedHeight}px` : "auto", flexGrow: 1, overflowY: "scroll", scrollbarGutter: "stable", marginBottom: "-1px"};
: {flexGrow: 1, overflowY: "scroll", scrollbarGutter: "stable", marginBottom: "-1px"};
}
let innerBoxStyle = {};
@ -318,139 +321,143 @@ function DataTable({
innerBoxStyle = {overflowY: "auto", scrollbarGutter: "stable"};
}
///////////////////////////////////////////////////////////////////////////////////
// note - at one point, we had the table's sx including: whiteSpace: "nowrap"... //
///////////////////////////////////////////////////////////////////////////////////
return <Box sx={boxStyle}><Box sx={innerBoxStyle}>
<Table {...getTableProps()} component="div" sx={{display: "grid", gridTemplateRows: "auto", gridTemplateColumns: gridTemplateColumns}}>
<Table {...getTableProps()}>
{
includeHead && (
headerGroups.map((headerGroup: any, i: number) => (
headerGroup.headers.map((column: any) => (
column.type !== "hidden" && (
<DataTableHeadCell
sx={{position: "sticky", top: 0, background: "white", zIndex: 10, alignItems: "flex-end"}}
key={i++}
{...column.getHeaderProps(isSorted && column.getSortByToggleProps())}
align={column.align ? column.align : "left"}
sorted={setSortedValue(column)}
tooltip={column.tooltip}
>
{column.render("header")}
</DataTableHeadCell>
)
))
))
<Box component="thead" sx={{position: "sticky", top: 0, background: "white", zIndex: 10}}>
{headerGroups.map((headerGroup: any, i: number) => (
<TableRow key={i} {...headerGroup.getHeaderGroupProps()} sx={{display: "grid", alignItems: "flex-end", gridTemplateColumns: gridTemplateColumns}}>
{headerGroup.headers.map((column: any) => (
column.type !== "hidden" && (
<DataTableHeadCell
key={i++}
{...column.getHeaderProps(isSorted && column.getSortByToggleProps())}
align={column.align ? column.align : "left"}
sorted={setSortedValue(column)}
tooltip={column.tooltip}
>
{column.render("header")}
</DataTableHeadCell>
)
))}
</TableRow>
))}
</Box>
)
}
{rows.map((row: any, key: any) =>
{
prepareRow(row);
let overrideNoEndBorder = false;
//////////////////////////////////////////////////////////////////////////////////
// don't do an end-border on nested rows - unless they're the last one in a set //
//////////////////////////////////////////////////////////////////////////////////
if (row.depth > 0)
<TableBody {...getTableBodyProps()}>
{rows.map((row: any, key: any) =>
{
overrideNoEndBorder = true;
if (key + 1 < rows.length && rows[key + 1].depth == 0)
prepareRow(row);
let overrideNoEndBorder = false;
//////////////////////////////////////////////////////////////////////////////////
// don't do an end-border on nested rows - unless they're the last one in a set //
//////////////////////////////////////////////////////////////////////////////////
if (row.depth > 0)
{
overrideNoEndBorder = false;
overrideNoEndBorder = true;
if (key + 1 < rows.length && rows[key + 1].depth == 0)
{
overrideNoEndBorder = false;
}
}
}
///////////////////////////////////////
// don't do end-border on the footer //
///////////////////////////////////////
if (isFooter)
{
overrideNoEndBorder = true;
}
///////////////////////////////////////
// don't do end-border on the footer //
///////////////////////////////////////
if (isFooter)
{
overrideNoEndBorder = true;
}
let background = "initial";
if (isFooter)
{
background = "#EEEEEE";
}
else if (row.depth > 0 || row.isExpanded)
{
background = "#FAFAFA";
}
let background = "initial";
if (isFooter)
{
background = "#EEEEEE";
}
else if (row.depth > 0 || row.isExpanded)
{
background = "#FAFAFA";
}
return (
row.cells.map((cell: any) => (
cell.column.type !== "hidden" && (
<DataTableBodyCell
key={key}
sx={{verticalAlign: "top", background: background}}
noBorder={noEndBorder || overrideNoEndBorder || row.isExpanded}
depth={row.depth}
align={cell.column.align ? cell.column.align : "left"}
{...cell.getCellProps()}
>
{
cell.column.type === "default" && (
cell.value && "number" === typeof cell.value ? (
<DefaultCell isFooter={isFooter}>{cell.value.toLocaleString()}</DefaultCell>
) : (<DefaultCell isFooter={isFooter}>{cell.render("Cell")}</DefaultCell>)
)
}
{
cell.column.type === "htmlAndTooltip" && (
<DefaultCell isFooter={isFooter}>
<NoMaxWidthTooltip title={parse(row.values["tooltip"])}>
<Box>
{parse(cell.value)}
</Box>
</NoMaxWidthTooltip>
</DefaultCell>
)
}
{
cell.column.type === "html" && (
<DefaultCell isFooter={isFooter}>{parse(cell.value ?? "")}</DefaultCell>
)
}
{
cell.column.type === "composite" && (
<DefaultCell isFooter={isFooter}>
<CompositeWidget widgetMetaData={widgetMetaData} data={cell.value} />
</DefaultCell>
)
}
{
cell.column.type === "block" && (
<DefaultCell isFooter={isFooter}>
<WidgetBlock widgetMetaData={widgetMetaData} block={cell.value} />
</DefaultCell>
)
}
{
cell.column.type === "image" && row.values["imageTotal"] && (
<ImageCell imageUrl={row.values["imageUrl"]} label={row.values["imageLabel"]} total={row.values["imageTotal"].toLocaleString()} totalType={row.values["imageTotalType"]} />
)
}
{
cell.column.type === "image" && !row.values["imageTotal"] && (
<ImageCell imageUrl={row.values["imageUrl"]} label={row.values["imageLabel"]} />
)
}
{
(cell.column.id === "__expander") && cell.render("cell")
}
</DataTableBodyCell>
)
))
);
})}
return (
<TableRow sx={{verticalAlign: "top", display: "grid", gridTemplateColumns: gridTemplateColumns, background: background}} key={key} {...row.getRowProps()}>
{row.cells.map((cell: any) => (
cell.column.type !== "hidden" && (
<DataTableBodyCell
key={key}
noBorder={noEndBorder || overrideNoEndBorder || row.isExpanded}
depth={row.depth}
align={cell.column.align ? cell.column.align : "left"}
{...cell.getCellProps()}
>
{
cell.column.type === "default" && (
cell.value && "number" === typeof cell.value ? (
<DefaultCell isFooter={isFooter}>{cell.value.toLocaleString()}</DefaultCell>
) : (<DefaultCell isFooter={isFooter}>{cell.render("Cell")}</DefaultCell>)
)
}
{
cell.column.type === "htmlAndTooltip" && (
<DefaultCell isFooter={isFooter}>
<NoMaxWidthTooltip title={parse(row.values["tooltip"])}>
<Box>
{parse(cell.value)}
</Box>
</NoMaxWidthTooltip>
</DefaultCell>
)
}
{
cell.column.type === "html" && (
<DefaultCell isFooter={isFooter}>{parse(cell.value ?? "")}</DefaultCell>
)
}
{
cell.column.type === "composite" && (
<DefaultCell isFooter={isFooter}>
<CompositeWidget widgetMetaData={widgetMetaData} data={cell.value} />
</DefaultCell>
)
}
{
cell.column.type === "block" && (
<DefaultCell isFooter={isFooter}>
<WidgetBlock widgetMetaData={widgetMetaData} block={cell.value} />
</DefaultCell>
)
}
{
cell.column.type === "image" && row.values["imageTotal"] && (
<ImageCell imageUrl={row.values["imageUrl"]} label={row.values["imageLabel"]} total={row.values["imageTotal"].toLocaleString()} totalType={row.values["imageTotalType"]} />
)
}
{
cell.column.type === "image" && !row.values["imageTotal"] && (
<ImageCell imageUrl={row.values["imageUrl"]} label={row.values["imageLabel"]} />
)
}
{
(cell.column.id === "__expander") && cell.render("cell")
}
</DataTableBodyCell>
)
))}
</TableRow>
);
})}
</TableBody>
</Table>
</Box></Box>;
}
return (
<TableContainer sx={{boxShadow: "none", height: (fixedHeight && !fixedStickyLastRow) ? `${fixedHeight}px` : "auto"}}>
<TableContainer sx={{boxShadow: "none", height: fixedHeight ? `${fixedHeight}px` : "auto"}}>
{entriesPerPage && ((hidePaginationDropdown !== undefined && !hidePaginationDropdown) || canSearch) ? (
<Box display="flex" justifyContent="space-between" alignItems="center" p={3}>
{entriesPerPage && (hidePaginationDropdown === undefined || !hidePaginationDropdown) && (

View File

@ -1,96 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2022. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* 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 {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
import Modal from "@mui/material/Modal";
import EntityForm from "qqq/components/forms/EntityForm";
import Client from "qqq/utils/qqq/Client";
import React, {useEffect, useReducer, useState} from "react";
////////////////////////////////
// structure of expected data //
////////////////////////////////
export interface ModalEditFormData
{
tableName: string;
defaultValues?: { [key: string]: string };
disabledFields?: { [key: string]: boolean } | string[];
overrideHeading?: string;
onSubmitCallback?: (values: any, tableName: String) => void;
initialShowModalValue?: boolean;
}
const qController = Client.getInstance();
function ModalEditForm({tableName, defaultValues, disabledFields, overrideHeading, onSubmitCallback, initialShowModalValue}: ModalEditFormData,): JSX.Element
{
const [showModal, setShowModal] = useState(initialShowModalValue);
const [table, setTable] = useState(null as QTableMetaData);
const [, forceUpdate] = useReducer((x) => x + 1, 0);
useEffect(() =>
{
if (!tableName)
{
return;
}
(async () =>
{
const tableMetaData = await qController.loadTableMetaData(tableName);
setTable(tableMetaData);
forceUpdate();
})();
}, [tableName]);
/*******************************************************************************
**
*******************************************************************************/
const closeEditChildForm = (event: object, reason: string) =>
{
if (reason === "backdropClick" || reason === "escapeKeyDown")
{
return;
}
setShowModal(null);
};
return (
table && showModal &&
<Modal open={showModal as boolean} onClose={(event, reason) => closeEditChildForm(event, reason)}>
<div className="modalEditForm">
<EntityForm
isModal={true}
closeModalHandler={closeEditChildForm}
table={table}
defaultValues={defaultValues}
disabledFields={disabledFields}
onSubmitCallback={onSubmitCallback}
overrideHeading={overrideHeading}
/>
</div>
</Modal>
);
}
export default ModalEditForm;

View File

@ -93,25 +93,41 @@ function TableCard({noRowsFoundHTML, data, rowsPerPage, hidePaginationDropdown,
/>
: noRowsFoundHTML ?
<Box p={3} pt={0} pb={3} sx={{textAlign: "center"}}>
<MDTypography variant="subtitle2" color="secondary" fontWeight="regular">
{noRowsFoundHTML ? (parse(noRowsFoundHTML)) : "No rows found"}
<MDTypography
variant="subtitle2"
color="secondary"
fontWeight="regular"
>
{
noRowsFoundHTML ? (
parse(noRowsFoundHTML)
) : "No rows found"
}
</MDTypography>
</Box>
:
<TableContainer sx={{boxShadow: "none"}}>
<Table component="div" sx={{display: "grid", gridTemplateRows: "auto", gridTemplateColumns: "1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr"}}>
{Array(8).fill(0).map((_, i) =>
<DataTableHeadCell key={`head-${i}`} sorted={false} width="auto" align="center">
<Skeleton width="100%" />
</DataTableHeadCell>
)}
{Array(5).fill(0).map((_, i) =>
Array(8).fill(0).map((_, j) =>
<DataTableBodyCell key={`cell-${i}-${j}`} align="center">
<DefaultCell isFooter={false}><Skeleton /></DefaultCell>
</DataTableBodyCell>
)
)}
<Table>
<Box component="thead">
<TableRow sx={{alignItems: "flex-end"}} key="header">
{Array(8).fill(0).map((_, i) =>
<DataTableHeadCell key={`head-${i}`} sorted={false} width="auto" align="center">
<Skeleton width="100%" />
</DataTableHeadCell>
)}
</TableRow>
</Box>
<TableBody>
{Array(5).fill(0).map((_, i) =>
<TableRow sx={{verticalAlign: "top"}} key={`row-${i}`}>
{Array(8).fill(0).map((_, j) =>
<DataTableBodyCell key={`cell-${i}-${j}`} align="center">
<DefaultCell isFooter={false}><Skeleton /></DefaultCell>
</DataTableBodyCell>
)}
</TableRow>
)}
</TableBody>
</Table>
</TableContainer>
}

View File

@ -19,7 +19,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {Box} from "@mui/material";
import Box from "@mui/material/Box";
import {Theme} from "@mui/material/styles";
import colors from "qqq/assets/theme/base/colors";
import {ReactNode} from "react";
@ -30,14 +30,13 @@ interface Props
children: ReactNode;
noBorder?: boolean;
align?: "left" | "right" | "center";
sx?: any;
}
function DataTableBodyCell({noBorder, align, sx, children}: Props): JSX.Element
function DataTableBodyCell({noBorder, align, children}: Props): JSX.Element
{
return (
<Box
component="div"
component="td"
textAlign={align}
py={1.5}
px={1.5}
@ -55,7 +54,7 @@ function DataTableBodyCell({noBorder, align, sx, children}: Props): JSX.Element
},
"&:last-child": {
paddingRight: "1rem"
}, ...sx
}
})}
>
<Box
@ -73,7 +72,6 @@ function DataTableBodyCell({noBorder, align, sx, children}: Props): JSX.Element
DataTableBodyCell.defaultProps = {
noBorder: false,
align: "left",
sx: {}
};
export default DataTableBodyCell;

View File

@ -44,14 +44,18 @@ function DataTableHeadCell({width, children, sorted, align, tooltip, ...rest}: P
return (
<Box
component="div"
component="th"
width={width}
py={1.5}
px={1.5}
sx={({palette: {light}, borders: {borderWidth}}: Theme) => ({
borderBottom: `${borderWidth[1]} solid ${colors.grayLines.main}`,
position: "sticky", top: 0, background: "white",
zIndex: 1 // so if body rows scroll behind it, they don't show through
"&:nth-of-type(1)": {
paddingLeft: "1rem"
},
"&:last-child": {
paddingRight: "1rem"
},
})}
>
<Box

View File

@ -0,0 +1,21 @@
import {ChangeEvent} from "react";
import {useRootEditor} from "sequential-workflow-designer-react";
import {WorkflowDefinition} from "./model";
export function RootEditor()
{
const {properties, setProperty, isReadonly} = useRootEditor<WorkflowDefinition>();
function onAlfaChanged(e: ChangeEvent)
{
setProperty("alfa", (e.target as HTMLInputElement).value);
}
return (
<>
<h2>Optimization Workflow Editor</h2>
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.<br /><br />Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
</>
);
}

View File

@ -0,0 +1,69 @@
import {ChangeEvent} from "react";
import {useStepEditor} from "sequential-workflow-designer-react";
import {SwitchStep, TaskStep, WarehouseOptimizationStep} from "./model";
export function StepEditor()
{
const {type, name, step, properties, isReadonly, setName, setProperty, notifyPropertiesChanged, notifyChildrenChanged} =
useStepEditor<TaskStep | SwitchStep | WarehouseOptimizationStep>();
function onNameChanged(e: ChangeEvent)
{
setName((e.target as HTMLInputElement).value);
}
function onXChanged(e: ChangeEvent)
{
setProperty("warehouse", (e.target as HTMLInputElement).value);
}
function onYChanged(e: ChangeEvent)
{
properties["wmsConnection"] = (e.target as HTMLInputElement).value;
notifyPropertiesChanged();
}
function toggleExtraBranch()
{
const switchStep = step as SwitchStep;
if (switchStep.branches["extra"])
{
delete switchStep.branches["extra"];
}
else
{
switchStep.branches["extra"] = [];
}
notifyChildrenChanged();
}
return (
<>
<h2>Step Editor</h2>
<h3>{type}</h3>
<h4>Pre-Script</h4>
<select>
<option>Pre Script #1</option>
<option>Pre Script #2</option>
<option>Pre Script #3</option>
</select>
<h4>Post-Script</h4>
<select>
<option>Post Script #1</option>
<option>Post Script #2</option>
<option>Post Script #3</option>
</select>
{type === "switch" && (
<>
<h4>Extra branch</h4>
<button onClick={toggleExtraBranch} disabled={isReadonly}>
Toggle branch
</button>
</>
)}
</>
);
}

View File

@ -0,0 +1,214 @@
import {Branches, Uid} from "sequential-workflow-designer";
import {ContainerStep, OptimizationStepType, SwitchStep, TaskStep, WarehouseOptimizationStep} from "./model";
export function createTaskStep(): TaskStep
{
return {
id: Uid.next(),
componentType: "task",
type: "task",
name: "blah",
properties: {}
};
}
//////////////////////
// define all steps //
//////////////////////
export function createDetermineWarehouseRoutingStep(): WarehouseOptimizationStep
{
return createStep("Determine Warehouse", "determineWarehouseRouting");
}
export function createDetermineLineHaulLaneStep(): WarehouseOptimizationStep
{
return createStep("Determine Line Haul Lane", "determineLineHaulLane");
}
export function createValidateLineItemsStep(): WarehouseOptimizationStep
{
return createStep("Validate Line Items", "validateLineItems");
}
export function createDetermineCoolingCategoryStep(): WarehouseOptimizationStep
{
return createStep("Determine Cooling Category", "determineCoolingCategory");
}
export function createValidateOptimizationRulesStep(): WarehouseOptimizationStep
{
return createStep("Validate Optimization Rules", "validateOptimizationRules");
}
export function createValidateAddressStep(): WarehouseOptimizationStep
{
return createStep("Validate Address", "validateAddress");
}
export function createDetermineCarrierServiceStep(): WarehouseOptimizationStep
{
return createStep("Determine Carrier Service", "determineCarrierService");
}
export function createDetermineTNTStep(): WarehouseOptimizationStep
{
return createStep("Determine TNT ", "determineTNT");
}
export function createDetermineOrderServiceDatesStep(): WarehouseOptimizationStep
{
return createStep("Determine Order Service Dates ", "determineOrderServiceDates");
}
export function createOrderMatchesFilterSelectorStep(): WarehouseOptimizationStep
{
return createStep("Order Matches Filter Selector", "orderMatchesFilterSelector");
}
////////////////////////
// define all outputs //
////////////////////////
export function createDetermineWarehouseRoutingOuptut(): SwitchStep
{
return (createOutput("Output", {Edison: [], Patterson: [], Stockton: []}));
}
export function createDetermineLineHaulLaneOutput(): SwitchStep
{
return (createOutput("Output", {Chicago: [], Dallas: [], Sheboygan: []}));
}
export function createValidateLineItemsOutput(): SwitchStep
{
return (createOutput("Output", {"Is Valid": [], "Not Valid": []}));
}
export function createDetermineCoolingCategoryOutput(): SwitchStep
{
return (createOutput("Output", {"Ambient": [], "Frozen": [], "Other": []}));
}
export function createValidateOptimizationRulesOutput(): SwitchStep
{
return (createOutput("Output", {"Is Valid": [], "Not Valid": []}));
}
export function createAddressValidationOutput(): SwitchStep
{
return (createOutput("Output", {"Is Valid": [], "Not Valid": []}));
}
export function createDetermineCarrierServiceOutput(): SwitchStep
{
return (createOutput("Output", {"Fedex Ground": [], "UPS Ground": [], "OnTrac Ground": []}));
}
export function createDetermineTNTOutput(): SwitchStep
{
return (createOutput("Output", {1: [], 2: [], 3: [], "4+": []}));
}
export function createDetermineOrderServiceDatesOutput(): SwitchStep
{
return (createOutput("Output", {Monday: [], Tuesday: [], Wednesday: []}));
}
export function createOrderMatchesFilterSelectorOutput(): SwitchStep
{
return (createOutput("Output", {"Matches": [], "No Match": []}));
}
//////////////////////////////
// groups of steps + output //
//////////////////////////////
export function createDetermineWarehouseRoutingGroup(): ContainerStep
{
return (createGroup("Determine Warehouse Routing", [createDetermineWarehouseRoutingStep(), createDetermineWarehouseRoutingOuptut()]));
}
export function createDetermineLineHaulLaneGroup(): ContainerStep
{
return (createGroup("Determine Line Haul Lane", [createDetermineLineHaulLaneStep(), createDetermineLineHaulLaneOutput()]));
}
export function createValidateLineItemsGroup(): ContainerStep
{
return (createGroup("Validate Line Items", [createValidateLineItemsStep(), createValidateLineItemsOutput()]));
}
export function createDetermineCoolingCategoryGroup(): ContainerStep
{
return (createGroup("Determine Cooling Category", [createDetermineCoolingCategoryStep(), createDetermineCoolingCategoryOutput()]));
}
export function createValidateOptimizationRulesGroup(): ContainerStep
{
return (createGroup("Validate Optimization Rules", [createValidateOptimizationRulesStep(), createValidateOptimizationRulesOutput()]));
}
export function createValidateAddressGroup(): ContainerStep
{
return (createGroup("Validate Address", [createValidateAddressStep(), createAddressValidationOutput()]));
}
export function createDetermineCarrierServiceGroup(): ContainerStep
{
return (createGroup("Determine Carrier Service", [createDetermineCarrierServiceStep(), createDetermineCarrierServiceOutput()]));
}
export function createDetermineTNTGroup(): ContainerStep
{
return (createGroup("Determine TNT", [createDetermineTNTStep(), createDetermineTNTOutput()]));
}
export function createDetermineOrderServiceDatesGroup(): ContainerStep
{
return (createGroup("Determine Order Service Dates", [createDetermineOrderServiceDatesStep(), createDetermineOrderServiceDatesOutput()]));
}
export function createOrderMatchesFilterSelector(): ContainerStep
{
return (createGroup("Order Matches Filter Selector", [createOrderMatchesFilterSelectorStep(), createOrderMatchesFilterSelectorOutput()]));
}
///////////
// utils //
///////////
export function createStep(name: string, type: OptimizationStepType): WarehouseOptimizationStep
{
return {
id: Uid.next(),
componentType: "task",
type: type,
name: name,
properties: {}
};
}
export function createOutput(name: string, branches: Branches): SwitchStep
{
return {
id: Uid.next(),
componentType: "switch",
type: "switch",
name: name,
properties: {},
branches: branches
};
}
export function createGroup(name: string, sequence: (WarehouseOptimizationStep | SwitchStep)[]): ContainerStep
{
return {
id: Uid.next(),
componentType: "container",
type: "container",
name: name,
properties: {},
sequence: sequence
};
}

View File

@ -0,0 +1,187 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2022. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* 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 {QJobError} from "@kingsrook/qqq-frontend-core/lib/model/processes/QJobError";
import {ToggleButtonGroup, Typography} from "@mui/material";
import Alert from "@mui/material/Alert";
import Box from "@mui/material/Box";
import Card from "@mui/material/Card";
import Grid from "@mui/material/Grid";
import Snackbar from "@mui/material/Snackbar";
import TextField from "@mui/material/TextField";
import FormData from "form-data";
import {QCancelButton, QSaveButton} from "qqq/components/buttons/DefaultButtons";
import WorkflowPreview from "qqq/components/workflows/WorkflowPreview";
import Client from "qqq/utils/qqq/Client";
import React, {useReducer, useState} from "react";
export interface WorkflowEditorProps
{
title: string;
workflowId: number;
contents: string;
closeCallback: any;
}
const qController = Client.getInstance();
function WorkflowEditor({title, workflowId, contents, closeCallback}: WorkflowEditorProps): JSX.Element
{
const [closing, setClosing] = useState(false);
const [updatedCode, setUpdatedCode] = useState(contents);
const [commitMessage, setCommitMessage] = useState("");
const [openTool, setOpenTool] = useState(null);
const [errorAlert, setErrorAlert] = useState("");
const [, forceUpdate] = useReducer((x) => x + 1, 0);
const changeOpenTool = (event: React.MouseEvent<HTMLElement>, newValue: string | null) =>
{
setOpenTool(newValue);
/////////////////////////////////////////////////
// need this to make Ace recognize new height. //
/////////////////////////////////////////////////
setTimeout(() =>
{
window.dispatchEvent(new Event("resize"));
}, 100);
};
const saveClicked = () =>
{
try
{
JSON.parse(updatedCode);
}
catch (e)
{
setErrorAlert("Cannot save Workflow Contents. Invalid json: " + e);
return;
}
setClosing(true);
(async () =>
{
const formData = new FormData();
formData.append("workflowId", workflowId);
formData.append("contents", updatedCode);
formData.append("commitMessage", commitMessage);
//////////////////////////////////////////////////////////////////
// we don't want this job to go async, so, pass a large timeout //
//////////////////////////////////////////////////////////////////
formData.append("_qStepTimeoutMillis", 60 * 1000);
const formDataHeaders = {
"content-type": "multipart/form-data; boundary=--------------------------320289315924586491558366",
};
const processResult = await qController.processInit("storeWorkflowVersionProcess", formData, formDataHeaders);
if (processResult instanceof QJobError)
{
const jobError = processResult as QJobError;
closeCallback(null, "failed", jobError.userFacingError ?? jobError.error);
}
console.log("process result");
console.log(processResult);
closeCallback(null, "saved", "Saved New Workflow Version");
})();
};
const cancelClicked = () =>
{
setClosing(true);
closeCallback(null, "cancelled");
};
const updateCode = (value: string, event: any) =>
{
console.log("Updating code");
setUpdatedCode(value);
forceUpdate();
};
const updateCommitMessage = (event: React.ChangeEvent<HTMLInputElement>) =>
{
setCommitMessage(event.target.value);
};
return (
<Box sx={{position: "absolute", overflowY: "auto", height: "100%", width: "100%"}} p={6}>
<Card sx={{height: "100%", p: 3}}>
<Snackbar open={errorAlert !== null && errorAlert !== ""} onClose={(event?: React.SyntheticEvent | Event, reason?: string) =>
{
if (reason === "clickaway")
{
return;
}
setErrorAlert("");
}} anchorOrigin={{vertical: "top", horizontal: "center"}}>
<Alert color="error" onClose={() => setErrorAlert("")}>
{errorAlert}
</Alert>
</Snackbar>
<Box display="flex" justifyContent="space-between" alignItems="center">
<Typography variant="h5" pb={1}>
{title}
</Typography>
<Box>
<Typography variant="body2" display="inline" pr={1}>
Tools:
</Typography>
<ToggleButtonGroup
value={openTool}
exclusive
onChange={changeOpenTool}
size="small"
sx={{pb: 1}}
>
</ToggleButtonGroup>
</Box>
</Box>
<Box sx={{height: openTool ? "45%" : "100%"}}>
<WorkflowPreview />
</Box>
<Box pt={1}>
<Grid container alignItems="flex-end">
<Box width="50%">
<TextField id="commitMessage" label="Commit Message" variant="standard" fullWidth value={commitMessage} onChange={updateCommitMessage} />
</Box>
<Grid container justifyContent="flex-end" spacing={3}>
<QCancelButton disabled={closing} onClickHandler={cancelClicked} />
<QSaveButton disabled={closing} onClickHandler={saveClicked} />
</Grid>
</Grid>
</Box>
</Card>
</Box>
);
}
export default WorkflowEditor;

View File

@ -0,0 +1,199 @@
import {useEffect, useMemo, useState} from "react";
import {ObjectCloner, Step, StepsConfiguration, ToolboxConfiguration, ValidatorConfiguration} from "sequential-workflow-designer";
import {SequentialWorkflowDesigner, useSequentialWorkflowDesignerController, wrapDefinition} from "sequential-workflow-designer-react";
import {WorkflowDefinition} from "./model";
import {RootEditor} from "./RootEditor";
import {StepEditor} from "./StepEditor";
import {createDetermineCarrierServiceGroup, createDetermineCoolingCategoryGroup, createDetermineLineHaulLaneGroup, createDetermineOrderServiceDatesGroup, createDetermineTNTGroup, createDetermineWarehouseRoutingGroup, createOrderMatchesFilterSelector, createTaskStep, createValidateAddressGroup, createValidateLineItemsGroup, createValidateOptimizationRulesGroup} from "./StepUtils";
const startDefinition: WorkflowDefinition = {
properties: {
alfa: "bravo"
},
sequence: []
};
function WorkflowPreview()
{
const controller = useSequentialWorkflowDesignerController();
const toolboxConfiguration: ToolboxConfiguration = useMemo(
() => ({
groups: [{
name: "Optimization Steps", steps: [
createDetermineCarrierServiceGroup(),
createDetermineCoolingCategoryGroup(),
createDetermineLineHaulLaneGroup(),
createDetermineOrderServiceDatesGroup(),
createDetermineTNTGroup(),
createDetermineWarehouseRoutingGroup()
]
},
{
name: "Validators", steps: [
createValidateAddressGroup(),
createValidateLineItemsGroup(),
createValidateOptimizationRulesGroup()
]
},
{
name: "Utilities", steps: [
createOrderMatchesFilterSelector()
]
}
]
}),
[]
);
const stepsConfiguration: StepsConfiguration = useMemo(
() => ({
iconUrlProvider: () => null
}),
[]
);
const validatorConfiguration: ValidatorConfiguration = useMemo(
() => ({
step: (step: Step) => Boolean(step.name),
root: (definition: WorkflowDefinition) => Boolean(definition.properties.alfa)
}),
[]
);
const [isVisible, setIsVisible] = useState(true);
const [isToolboxCollapsed, setIsToolboxCollapsed] = useState(false);
const [isEditorCollapsed, setIsEditorCollapsed] = useState(false);
const [definition, setDefinition] = useState(() => wrapDefinition(startDefinition));
const [selectedStepId, setSelectedStepId] = useState<string | null>(null);
const [isReadonly, setIsReadonly] = useState(false);
const [moveViewportToStep, setMoveViewportToStep] = useState<string | null>(null);
const definitionJson = JSON.stringify(definition.value, null, 2);
useEffect(() =>
{
console.log(`definition updated, isValid=${definition.isValid}`);
}, [definition]);
useEffect(() =>
{
if (moveViewportToStep)
{
if (controller.isReady())
{
controller.moveViewportToStep(moveViewportToStep);
}
setMoveViewportToStep(null);
}
}, [controller, moveViewportToStep]);
function toggleVisibilityClicked()
{
setIsVisible(!isVisible);
}
function toggleSelectionClicked()
{
const id = definition.value.sequence[0].id;
setSelectedStepId(selectedStepId ? null : id);
}
function toggleIsReadonlyClicked()
{
setIsReadonly(!isReadonly);
}
function toggleToolboxClicked()
{
setIsToolboxCollapsed(!isToolboxCollapsed);
}
function toggleEditorClicked()
{
setIsEditorCollapsed(!isEditorCollapsed);
}
function moveViewportToFirstStepClicked()
{
const fistStep = definition.value.sequence[0];
if (fistStep)
{
setMoveViewportToStep(fistStep.id);
}
}
async function appendStepClicked()
{
const newStep = createTaskStep();
const newDefinition = ObjectCloner.deepClone(definition.value);
newDefinition.sequence.push(newStep);
// We need to wait for the controller to finish the operation before we can select the new step
await controller.replaceDefinition(newDefinition);
setSelectedStepId(newStep.id);
setMoveViewportToStep(newStep.id);
}
function reloadDefinitionClicked()
{
const newDefinition = ObjectCloner.deepClone(startDefinition);
setSelectedStepId(null);
setDefinition(wrapDefinition(newDefinition));
}
function yesOrNo(value: boolean)
{
return value ? "✅ Yes" : "⛔ No";
}
return (
<>
{isVisible && (
<SequentialWorkflowDesigner
undoStackSize={10}
definition={definition}
onDefinitionChange={setDefinition}
selectedStepId={selectedStepId}
isReadonly={isReadonly}
onSelectedStepIdChanged={setSelectedStepId}
toolboxConfiguration={toolboxConfiguration}
isToolboxCollapsed={isToolboxCollapsed}
onIsToolboxCollapsedChanged={setIsToolboxCollapsed}
stepsConfiguration={stepsConfiguration}
validatorConfiguration={validatorConfiguration}
controlBar={true}
rootEditor={<RootEditor />}
stepEditor={<StepEditor />}
isEditorCollapsed={isEditorCollapsed}
onIsEditorCollapsedChanged={setIsEditorCollapsed}
controller={controller}
/>
)}
<ul>
<li>Definition: {definitionJson.length} bytes</li>
<li>Selected step: {selectedStepId}</li>
<li>Is readonly: {yesOrNo(isReadonly)}</li>
<li>Is valid: {definition.isValid === undefined ? "?" : yesOrNo(definition.isValid)}</li>
<li>Is toolbox collapsed: {yesOrNo(isToolboxCollapsed)}</li>
<li>Is editor collapsed: {yesOrNo(isEditorCollapsed)}</li>
</ul>
<div>
<button onClick={toggleVisibilityClicked}>Toggle visibility</button>
<button onClick={reloadDefinitionClicked}>Reload definition</button>
<button onClick={toggleSelectionClicked}>Toggle selection</button>
<button onClick={toggleIsReadonlyClicked}>Toggle readonly</button>
<button onClick={toggleToolboxClicked}>Toggle toolbox</button>
<button onClick={toggleEditorClicked}>Toggle editor</button>
<button onClick={moveViewportToFirstStepClicked}>Move viewport to first step</button>
<button onClick={appendStepClicked}>Append step</button>
</div>
<div>
<textarea value={definitionJson} readOnly={true} cols={100} rows={15} />
</div>
</>
);
}
export default WorkflowPreview;

View File

@ -0,0 +1,75 @@
import {BranchedStep, Definition, Step} from "sequential-workflow-designer";
export interface WorkflowDefinition extends Definition
{
properties: {
alfa?: string;
};
}
export interface TaskStep extends Step
{
componentType: "task";
type: "task";
properties: {
x?: string;
y?: string;
warehouse?: string;
wmsConnection?: string;
wmsSystem?: string;
};
}
export type OptimizationStepType =
"determineWarehouseRouting" |
"determineLineHaulLane" |
"validateLineItems" |
"determineCoolingCategory" |
"validateOptimizationRules" |
"validateAddress" |
"determineCarrierService" |
"determineTNT" |
"determineOrderServiceDates" |
"orderMatchesFilterSelector";
export interface WarehouseOptimizationStep extends Step
{
componentType: "task";
type: OptimizationStepType;
properties: {
x?: string;
y?: string;
warehouse?: string;
wmsConnection?: string;
wmsSystem?: string;
isValid?: boolean;
};
}
export interface SwitchStep extends BranchedStep
{
componentType: "switch";
type: "switch";
properties: {
x?: string;
y?: string;
warehouse?: string;
wmsConnection?: string;
wmsSystem?: string;
};
}
export interface ContainerStep extends Step
{
componentType: "container";
type: "container";
properties: {
x?: string;
y?: string;
warehouse?: string;
wmsConnection?: string;
wmsSystem?: string;
};
sequence: (WarehouseOptimizationStep | SwitchStep)[];
}

View File

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

View File

@ -1,38 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. 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 {QPossibleValue} from "@kingsrook/qqq-frontend-core/lib/model/QPossibleValue";
/*******************************************************************************
** Properties attached to a (formik?) form field, to denote how it behaves as
** as related to a possible value source.
*******************************************************************************/
export interface FieldPossibleValueProps
{
isPossibleValue?: boolean;
possibleValues?: QPossibleValue[];
initialDisplayValue: string | null;
fieldName?: string;
tableName?: string;
processName?: string;
possibleValueSourceName?: string;
}

View File

@ -1,926 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. 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 {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";
/***************************************************************************
** model of a single field that's part of a bulk-load profile/mapping
***************************************************************************/
export class BulkLoadField
{
field: QFieldMetaData;
tableStructure: BulkLoadTableStructure;
valueType: ValueType;
columnIndex?: number;
headerName?: string = null;
defaultValue?: any = null;
doValueMapping: boolean = false;
clearIfEmpty?: boolean = false;
wideLayoutIndexPath: number[] = [];
error: string = null;
warning: string = null;
key: string;
/***************************************************************************
**
***************************************************************************/
constructor(field: QFieldMetaData, tableStructure: BulkLoadTableStructure, valueType: ValueType = "column", columnIndex?: number, headerName?: string, defaultValue?: any, doValueMapping?: boolean, wideLayoutIndexPath: number[] = [], error: string = null, warning: string = null, clearIfEmpty?: boolean)
{
this.field = field;
this.tableStructure = tableStructure;
this.valueType = valueType;
this.columnIndex = columnIndex;
this.headerName = headerName;
this.defaultValue = defaultValue;
this.doValueMapping = doValueMapping;
this.wideLayoutIndexPath = wideLayoutIndexPath;
this.error = error;
this.warning = warning;
this.key = new Date().getTime().toString();
this.clearIfEmpty = clearIfEmpty ?? false;
}
/***************************************************************************
**
***************************************************************************/
public static clone(source: BulkLoadField): BulkLoadField
{
return (new BulkLoadField(source.field, source.tableStructure, source.valueType, source.columnIndex, source.headerName, source.defaultValue, source.doValueMapping, source.wideLayoutIndexPath, source.error, source.warning, source.clearIfEmpty));
}
/***************************************************************************
**
***************************************************************************/
public getQualifiedName(): string
{
if (this.tableStructure.isMain)
{
return this.field.name;
}
return this.tableStructure.associationPath + "." + this.field.name;
}
/***************************************************************************
**
***************************************************************************/
public getQualifiedNameWithWideSuffix(): string
{
let wideLayoutSuffix = "";
if (this.wideLayoutIndexPath.length > 0)
{
wideLayoutSuffix = "." + this.wideLayoutIndexPath.map(i => i + 1).join(".");
}
if (this.tableStructure.isMain)
{
return this.field.name + wideLayoutSuffix;
}
return this.tableStructure.associationPath + "." + this.field.name + wideLayoutSuffix;
}
/***************************************************************************
**
***************************************************************************/
public getKey(): string
{
let wideLayoutSuffix = "";
if (this.wideLayoutIndexPath.length > 0)
{
wideLayoutSuffix = "." + this.wideLayoutIndexPath.map(i => i + 1).join(".");
}
if (this.tableStructure.isMain)
{
return this.field.name + wideLayoutSuffix + this.key;
}
return this.tableStructure.associationPath + "." + this.field.name + wideLayoutSuffix + this.key;
}
/***************************************************************************
**
***************************************************************************/
public getQualifiedLabel(): string
{
let wideLayoutSuffix = "";
if (this.wideLayoutIndexPath.length > 0)
{
wideLayoutSuffix = " (" + this.wideLayoutIndexPath.map(i => i + 1).join(", ") + ")";
}
if (this.tableStructure.isMain)
{
return this.field.label + wideLayoutSuffix;
}
return this.tableStructure.label + ": " + this.field.label + wideLayoutSuffix;
}
/***************************************************************************
**
***************************************************************************/
public isMany(): boolean
{
return this.tableStructure && this.tableStructure.isMany;
}
}
/***************************************************************************
** this is a type defined in qqq backend - a representation of a bulk-load
** table - e.g., how it fits into qqq - and of note - how child / association
** tables are nested too.
***************************************************************************/
export interface BulkLoadTableStructure
{
isMain: boolean;
isMany: boolean;
tableName: string;
label: string;
associationPath: string;
fields: QFieldMetaData[];
associations: BulkLoadTableStructure[];
isBulkEdit: boolean;
possibleKeyFields: string[];
keyFields?: string;
}
/*******************************************************************************
** this is the internal data structure that the UI works with - but notably,
** is not how we send it to the backend or how backend saves profiles -- see
** BulkLoadProfile for that.
*******************************************************************************/
export class BulkLoadMapping
{
fields: { [qualifiedName: string]: BulkLoadField } = {};
fieldsByTablePrefix: { [prefix: string]: { [qualifiedFieldName: string]: BulkLoadField } } = {};
tablesByPath: { [path: string]: BulkLoadTableStructure } = {};
requiredFields: BulkLoadField[] = [];
additionalFields: BulkLoadField[] = [];
unusedFields: BulkLoadField[] = [];
valueMappings: { [fieldName: string]: { [fileValue: string]: any } } = {};
isBulkEdit: boolean;
keyFields: string;
hasHeaderRow: boolean;
layout: string;
/***************************************************************************
**
***************************************************************************/
constructor(tableStructure: BulkLoadTableStructure)
{
if (tableStructure)
{
this.processTableStructure(tableStructure);
if (!tableStructure.associations)
{
this.layout = "FLAT";
}
}
this.isBulkEdit = tableStructure.isBulkEdit;
this.keyFields = tableStructure.keyFields;
this.hasHeaderRow = true;
}
/***************************************************************************
**
***************************************************************************/
public processTableStructure(tableStructure: BulkLoadTableStructure)
{
const prefix = tableStructure.isMain ? "" : tableStructure.associationPath;
this.fieldsByTablePrefix[prefix] = {};
this.tablesByPath[prefix] = tableStructure;
this.isBulkEdit = tableStructure.isBulkEdit;
this.keyFields = tableStructure.keyFields;
for (let field of tableStructure.fields)
{
// todo delete this - backend should only give it to us if editable: if (field.isEditable)
{
const bulkLoadField = new BulkLoadField(field, tableStructure);
const qualifiedName = bulkLoadField.getQualifiedName();
this.fields[qualifiedName] = bulkLoadField;
this.fieldsByTablePrefix[prefix][qualifiedName] = bulkLoadField;
if (this.isBulkEdit)
{
if (this.keyFields == null)
{
this.unusedFields.push(bulkLoadField);
}
else
{
const keyFields = this.keyFields.split("|");
if (keyFields.includes(qualifiedName))
{
this.requiredFields.push(bulkLoadField);
}
else
{
this.unusedFields.push(bulkLoadField);
}
}
}
else
{
if (tableStructure.isMain && field.isRequired)
{
this.requiredFields.push(bulkLoadField);
}
else
{
this.unusedFields.push(bulkLoadField);
}
}
}
}
for (let associatedTableStructure of tableStructure.associations ?? [])
{
this.processTableStructure(associatedTableStructure);
}
}
/***************************************************************************
** take a saved bulk load profile - and convert it into a working bulkLoadMapping
** for the frontend to use!
***************************************************************************/
public static fromSavedProfileRecord(tableStructure: BulkLoadTableStructure, profileRecord: QRecord): BulkLoadMapping
{
const bulkLoadProfile = JSON.parse(profileRecord.values.get("mappingJson")) as BulkLoadProfile;
return BulkLoadMapping.fromBulkLoadProfile(tableStructure, bulkLoadProfile);
}
/***************************************************************************
** take a saved bulk load profile - and convert it into a working bulkLoadMapping
** for the frontend to use!
***************************************************************************/
public static fromBulkLoadProfile(tableStructure: BulkLoadTableStructure, bulkLoadProfile: BulkLoadProfile, processName?: string): BulkLoadMapping
{
const bulkLoadMapping = new BulkLoadMapping(tableStructure);
if (bulkLoadProfile.version == "v1")
{
bulkLoadMapping.isBulkEdit = bulkLoadProfile.isBulkEdit;
bulkLoadMapping.hasHeaderRow = bulkLoadProfile.hasHeaderRow;
bulkLoadMapping.layout = bulkLoadProfile.layout;
bulkLoadMapping.keyFields = bulkLoadProfile.keyFields;
////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// function to get a bulkLoadMapping field by its (full) name - whether that's in the required fields list, //
// or it's an additional field, in which case, we'll go through the addField method to move what list it's in //
////////////////////////////////////////////////////////////////////////////////////////////////////////////////
function getBulkLoadFieldMovingFromUnusedToActiveIfNeeded(bulkLoadMapping: BulkLoadMapping, name: string): BulkLoadField
{
let wideIndex: number = null;
if (name.match(/,\d+$/))
{
wideIndex = Number(name.match(/\d+$/));
name = name.replace(/,\d+$/, "");
}
for (let field of bulkLoadMapping.requiredFields)
{
if (field.getQualifiedName() == name)
{
return (field);
}
}
for (let field of bulkLoadMapping.unusedFields)
{
if (field.getQualifiedName() == name)
{
const addedField = bulkLoadMapping.addField(field, wideIndex);
return (addedField);
}
}
}
//////////////////////////////////////////////////////////////////
// loop over fields in the profile - adding them to the mapping //
//////////////////////////////////////////////////////////////////
for (let bulkLoadProfileField of ((bulkLoadProfile.fieldList ?? []) as BulkLoadProfileField[]))
{
const bulkLoadField = getBulkLoadFieldMovingFromUnusedToActiveIfNeeded(bulkLoadMapping, bulkLoadProfileField.fieldName);
if (!bulkLoadField)
{
console.log(`Couldn't find bulk-load-field by name from profile record [${bulkLoadProfileField.fieldName}]`);
continue;
}
if ((bulkLoadProfileField.columnIndex != null && bulkLoadProfileField.columnIndex != undefined) || (bulkLoadProfileField.headerName != null && bulkLoadProfileField.headerName != undefined))
{
bulkLoadField.valueType = "column";
bulkLoadField.doValueMapping = bulkLoadProfileField.doValueMapping;
bulkLoadField.clearIfEmpty = bulkLoadProfileField.clearIfEmpty;
bulkLoadField.headerName = bulkLoadProfileField.headerName;
bulkLoadField.columnIndex = bulkLoadProfileField.columnIndex;
if (bulkLoadProfileField.valueMappings)
{
bulkLoadMapping.valueMappings[bulkLoadProfileField.fieldName] = {};
for (let fileValue in bulkLoadProfileField.valueMappings)
{
////////////////////////////////////////////////////
// frontend wants string values here, so, string. //
////////////////////////////////////////////////////
bulkLoadMapping.valueMappings[bulkLoadProfileField.fieldName][String(fileValue)] = bulkLoadProfileField.valueMappings[fileValue];
}
}
}
else
{
bulkLoadField.valueType = "defaultValue";
bulkLoadField.defaultValue = bulkLoadProfileField.defaultValue;
}
}
if (!bulkLoadMapping.keyFields && tableStructure.possibleKeyFields?.length > 0)
{
////////////////////////////////////////////////////////////////////////////////////////////////
// look at each of the possible key fields, compare with the fields in the bulk load profile, //
// on the first one that matches, use that as the default bulk load mapping key field //
////////////////////////////////////////////////////////////////////////////////////////////////
for (let keyField of tableStructure.possibleKeyFields)
{
const parts = keyField.split("|");
const allPartsMatch = parts.every(part =>
(bulkLoadProfile.fieldList ?? []).some((field: BulkLoadProfileField) =>
field.fieldName === part
)
);
if (allPartsMatch)
{
bulkLoadMapping.keyFields = keyField;
break; // stop after the first valid match
}
}
}
return (bulkLoadMapping);
}
else
{
throw ("Unexpected version for bulk load profile: " + bulkLoadProfile.version);
}
}
/***************************************************************************
** take a working bulkLoadMapping from the frontend, and convert it to a
** BulkLoadProfile for the backend / for us to save.
***************************************************************************/
public toProfile(): { haveErrors: boolean, profile: BulkLoadProfile }
{
let haveErrors = false;
const profile = new BulkLoadProfile();
profile.version = "v1";
profile.hasHeaderRow = this.hasHeaderRow;
profile.layout = this.layout;
profile.isBulkEdit = this.isBulkEdit;
profile.keyFields = this.keyFields;
for (let bulkLoadField of [...this.requiredFields, ...this.additionalFields])
{
let fullFieldName = (bulkLoadField.tableStructure.isMain ? "" : bulkLoadField.tableStructure.associationPath + ".") + bulkLoadField.field.name;
if (bulkLoadField.wideLayoutIndexPath != null && bulkLoadField.wideLayoutIndexPath != undefined && bulkLoadField.wideLayoutIndexPath.length)
{
fullFieldName += "," + bulkLoadField.wideLayoutIndexPath.join(".");
}
bulkLoadField.error = null;
if (bulkLoadField.valueType == "column")
{
if (bulkLoadField.columnIndex == undefined || bulkLoadField.columnIndex == null)
{
haveErrors = true;
bulkLoadField.error = "You must select a column.";
}
else
{
const field: BulkLoadProfileField = {fieldName: fullFieldName, columnIndex: bulkLoadField.columnIndex, headerName: bulkLoadField.headerName, doValueMapping: bulkLoadField.doValueMapping, clearIfEmpty: bulkLoadField.clearIfEmpty};
if (this.valueMappings[fullFieldName])
{
field.valueMappings = this.valueMappings[fullFieldName];
}
profile.fieldList.push(field);
}
}
else if (bulkLoadField.valueType == "defaultValue")
{
if (bulkLoadField.defaultValue == undefined || bulkLoadField.defaultValue == null || bulkLoadField.defaultValue == "")
{
haveErrors = true;
bulkLoadField.error = "A value is required.";
}
else
{
profile.fieldList.push({fieldName: fullFieldName, defaultValue: bulkLoadField.defaultValue});
}
}
}
return {haveErrors, profile};
}
/***************************************************************************
**
***************************************************************************/
public addField(bulkLoadField: BulkLoadField, specifiedWideIndex?: number): BulkLoadField
{
if (bulkLoadField.isMany() && this.layout == "WIDE")
{
let index: number;
if (specifiedWideIndex != null && specifiedWideIndex != undefined)
{
index = specifiedWideIndex;
}
else
{
///////////////////////////////////////////////
// find the max index for this field already //
///////////////////////////////////////////////
let maxIndex = -1;
for (let existingField of [...this.requiredFields, ...this.additionalFields])
{
if (existingField.getQualifiedName() == bulkLoadField.getQualifiedName())
{
const thisIndex = existingField.wideLayoutIndexPath[0];
if (thisIndex != null && thisIndex != undefined && thisIndex > maxIndex)
{
maxIndex = thisIndex;
}
}
}
index = maxIndex + 1;
}
const cloneField = BulkLoadField.clone(bulkLoadField);
cloneField.wideLayoutIndexPath = [index];
this.additionalFields.push(cloneField);
return (cloneField);
}
else
{
this.additionalFields.push(bulkLoadField);
return (bulkLoadField);
}
}
/***************************************************************************
**
***************************************************************************/
public removeField(toRemove: BulkLoadField): void
{
const newAdditionalFields: BulkLoadField[] = [];
for (let bulkLoadField of this.additionalFields)
{
if (bulkLoadField.getQualifiedNameWithWideSuffix() != toRemove.getQualifiedNameWithWideSuffix())
{
newAdditionalFields.push(bulkLoadField);
}
}
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 handleChangeToKeyFields(newKeyFields: any)
{
this.keyFields = newKeyFields;
}
/***************************************************************************
**
***************************************************************************/
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;
}
}
}
/***************************************************************************
** meta-data about the file that the user uploaded
***************************************************************************/
export class FileDescription
{
headerValues: string[];
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;
/***************************************************************************
**
***************************************************************************/
constructor(headerValues: string[], headerLetters: string[], bodyValuesPreview: string[][])
{
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;
}
}
/***************************************************************************
**
***************************************************************************/
public setHasHeaderRow(hasHeaderRow: boolean)
{
this.hasHeaderRow = hasHeaderRow;
}
/***************************************************************************
**
***************************************************************************/
public getColumnNames(): string[]
{
if (this.hasHeaderRow)
{
return this.headerValues;
}
else
{
return this.headerLetters.map(l => `Column ${l}`);
}
}
/***************************************************************************
**
***************************************************************************/
public getPreviewValues(columnIndex: number, fieldType?: QFieldType): string[]
{
if (columnIndex == undefined)
{
return [];
}
function getTypedValue(value: any): string
{
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}`);
}
const valueArray: string[] = [];
if (!this.hasHeaderRow)
{
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);
}
}
/***************************************************************************
** this (BulkLoadProfile & ...Field) is the model of what we save, and is
** also what we submit to the backend during the process.
***************************************************************************/
export class BulkLoadProfile
{
version: string;
fieldList: BulkLoadProfileField[] = [];
hasHeaderRow: boolean;
layout: string;
isBulkEdit: boolean;
keyFields: string;
}
type BulkLoadProfileField =
{
fieldName: string,
columnIndex?: number,
headerName?: string,
defaultValue?: any,
doValueMapping?: boolean,
clearIfEmpty?: boolean,
valueMappings?: { [fileValue: string]: any }
};
/***************************************************************************
** In the bulk load forms, we have some forward-ref callback functions, and
** they like to capture/retain a reference when those functions get defined,
** so we had some trouble updating objects in those functions.
**
** We "solved" this by creating instances of this class, which get captured,
** so then we can replace the wrapped object, and have a better time...
***************************************************************************/
export class Wrapper<T>
{
t: T;
/***************************************************************************
**
***************************************************************************/
constructor(t: T)
{
this.t = t;
}
/***************************************************************************
**
***************************************************************************/
public get(): T
{
return this.t;
}
/***************************************************************************
**
***************************************************************************/
public set(t: T)
{
this.t = t;
}
}

View File

@ -53,8 +53,6 @@ export class ProcessSummaryLine
linkText: string;
linkPostText: string;
bulletsOfText: any[];
constructor(processSummaryLine: any)
{
this.status = processSummaryLine.status;
@ -68,8 +66,6 @@ export class ProcessSummaryLine
this.linkText = processSummaryLine.linkText;
this.linkPostText = processSummaryLine.linkPostText;
this.bulletsOfText = processSummaryLine.bulletsOfText;
this.filter = processSummaryLine.filter;
}
@ -146,13 +142,6 @@ export class ProcessSummaryLine
</span>
) : <span>{lastWord}</span>
}
{
this.bulletsOfText && <ul style={{marginLeft: "2rem"}}>
{
this.bulletsOfText.map((bullet, index) => <li key={index}>{bullet}</li>)
}
</ul>
}
</ListItemText>
</Box>
</ListItem>

File diff suppressed because it is too large Load Diff

View File

@ -1,268 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. 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 {QComponentType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QComponentType";
import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
import {QFrontendStepMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFrontendStepMetaData";
import {CompositeData} from "qqq/components/widgets/CompositeWidget";
/*******************************************************************************
** Utility functions used by ProcessRun for working with ad-hoc, block &
** composite type widgets.
**
*******************************************************************************/
export default class ProcessWidgetBlockUtils
{
/*******************************************************************************
**
*******************************************************************************/
public static isActionCodeValid(actionCode: string, step: QFrontendStepMetaData, processValues: any): boolean
{
///////////////////////////////////////////////////////////
// private recursive function to walk the composite tree //
///////////////////////////////////////////////////////////
function recursiveIsActionCodeValidForCompositeData(compositeWidgetData: CompositeData): boolean
{
for (let i = 0; i < compositeWidgetData.blocks.length; i++)
{
const block = compositeWidgetData.blocks[i];
////////////////////////////////////////////////////////////////
// skip the block if it has a 'conditional', which isn't true //
////////////////////////////////////////////////////////////////
const conditionalFieldName = block.conditional;
if (conditionalFieldName)
{
const value = processValues[conditionalFieldName];
if (!value)
{
continue;
}
}
if (block.blockTypeName == "COMPOSITE")
{
///////////////////////////////////////////////////////////////////////////////////////////////////////////////
// recursive call for composites, but only return if a true is found (in case a subsequent block has a true) //
///////////////////////////////////////////////////////////////////////////////////////////////////////////////
const isValidForThisBlock = recursiveIsActionCodeValidForCompositeData(block as unknown as CompositeData);
if (isValidForThisBlock)
{
return (true);
}
// else, continue...
}
else if (block.blockTypeName == "BUTTON")
{
//////////////////////////////////////////
// look at actionCodes on button blocks //
//////////////////////////////////////////
if (block.values?.actionCode == actionCode)
{
return (true);
}
}
}
/////////////////////////////////////////
// if code wasn't found, it is invalid //
/////////////////////////////////////////
return false;
}
/////////////////////////////////////////////////////
// iterate over all components in the current step //
/////////////////////////////////////////////////////
for (let i = 0; i < step.components.length; i++)
{
const component = step.components[i];
if (component.type == "WIDGET" && component.values?.isAdHocWidget)
{
///////////////////////////////////////////////////////////////////////////////////////////////
// for ad-hoc widget components, check if this actionCode exists on any action-button blocks //
///////////////////////////////////////////////////////////////////////////////////////////////
const isValidForThisWidget = recursiveIsActionCodeValidForCompositeData(component.values);
if (isValidForThisWidget)
{
return (true);
}
}
}
////////////////////////////////////
// upon fallthrough, it's a false //
////////////////////////////////////
return false;
}
/***************************************************************************
** perform evaluations on a compositeWidget's data, given current process
** values, to do dynamic stuff, like:
** - removing fields with un-true conditions
***************************************************************************/
public static dynamicEvaluationOfCompositeWidgetData(compositeWidgetData: CompositeData, processValues: any)
{
for (let i = 0; i < compositeWidgetData.blocks.length; i++)
{
const block = compositeWidgetData.blocks[i];
////////////////////////////////////////////////////////////////////
// if the block has a conditional, evaluate, and remove if untrue //
////////////////////////////////////////////////////////////////////
const conditionalFieldName = block.conditional;
if (conditionalFieldName)
{
const value = processValues[conditionalFieldName];
if (!value)
{
console.debug(`Splicing away block based on [${conditionalFieldName}]...`);
compositeWidgetData.blocks.splice(i, 1);
i--;
continue;
}
}
if (block.blockTypeName == "COMPOSITE")
{
/////////////////////////////////////////
// make recursive calls for composites //
/////////////////////////////////////////
ProcessWidgetBlockUtils.dynamicEvaluationOfCompositeWidgetData(block as unknown as CompositeData, processValues);
}
else if (block.blockTypeName == "INPUT_FIELD")
{
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
// for input fields, put the process's value for the field-name into the block's values object as '.value' //
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
const fieldName = block.values?.fieldMetaData?.name;
if (processValues.hasOwnProperty(fieldName))
{
block.values.value = processValues[fieldName];
}
}
else if (block.blockTypeName == "TEXT")
{
//////////////////////////////////////////////////////////////////////////////////////
// for text-blocks - interpolate ${fieldName} expressions into their process-values //
//////////////////////////////////////////////////////////////////////////////////////
let text = block.values?.text;
if (text)
{
for (let key of Object.keys(processValues))
{
text = text.replaceAll("${" + key + "}", processValues[key]);
}
block.values.interpolatedText = text;
}
}
}
}
/***************************************************************************
**
***************************************************************************/
public static addFieldsForCompositeWidget(step: QFrontendStepMetaData, processValues: any, addFieldCallback: (fieldMetaData: QFieldMetaData) => void)
{
///////////////////////////////////////////////////////////
// private recursive function to walk the composite tree //
///////////////////////////////////////////////////////////
function recursiveHelper(widgetData: CompositeData)
{
try
{
for (let block of widgetData.blocks)
{
if (block.blockTypeName == "COMPOSITE")
{
recursiveHelper(block as unknown as CompositeData);
}
else if (block.blockTypeName == "INPUT_FIELD")
{
const fieldMetaData = new QFieldMetaData(block.values?.fieldMetaData);
addFieldCallback(fieldMetaData);
}
}
}
catch (e)
{
console.log("Error adding fields for compositeWidget: " + e);
}
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// foreach component, if it's an adhoc widget or a widget w/ its data in the processValues, then, call recursive helper on it //
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
for (let component of step.components)
{
if (component.type == QComponentType.WIDGET && component.values?.isAdHocWidget)
{
recursiveHelper(component.values as unknown as CompositeData);
}
else if (component.type == QComponentType.WIDGET && processValues[component.values?.widgetName])
{
recursiveHelper(processValues[component.values?.widgetName] as unknown as CompositeData);
}
}
}
/***************************************************************************
**
***************************************************************************/
public static processColorFromStyleMap(colorFromStyleMap?: string): string
{
if (colorFromStyleMap)
{
switch (colorFromStyleMap.toUpperCase())
{
case "SUCCESS":
return("#2BA83F");
case "WARNING":
return("#FBA132");
case "ERROR":
return("#FB4141");
case "INFO":
return("#458CFF");
case "MUTED":
return("#7b809a");
default:
{
if (colorFromStyleMap.match(/^[0-9A-F]{6}$/))
{
return(`#${colorFromStyleMap}`);
}
else if (colorFromStyleMap.match(/^[0-9A-F]{8}$/))
{
return(`#${colorFromStyleMap}`);
}
else
{
return(colorFromStyleMap);
}
}
}
}
}
}

View File

@ -121,7 +121,7 @@ function ColumnStats({tableMetaData, fieldMetaData, fieldTableName, filter}: Pro
}
const valueCounts = [] as QRecord[];
for(let i = 0; i < result.values.valueCounts?.length; i++)
for(let i = 0; i < result.values.valueCounts.length; i++)
{
let valueRecord = new QRecord(result.values.valueCounts[i]);

View File

@ -0,0 +1,947 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2022. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* 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 {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
import {QPossibleValue} from "@kingsrook/qqq-frontend-core/lib/model/QPossibleValue";
import {FormControl, InputLabel, Select, SelectChangeEvent, TextFieldProps} from "@mui/material";
import Box from "@mui/material/Box";
import Card from "@mui/material/Card";
import Grid from "@mui/material/Grid";
import Icon from "@mui/material/Icon";
import Modal from "@mui/material/Modal";
import TextField from "@mui/material/TextField";
import Tooltip from "@mui/material/Tooltip";
import Typography from "@mui/material/Typography";
import {getGridNumericOperators, getGridStringOperators, GridColDef, GridFilterInputMultipleValue, GridFilterInputMultipleValueProps, GridFilterInputValueProps, GridFilterItem} from "@mui/x-data-grid-pro";
import {GridFilterInputValue} from "@mui/x-data-grid/components/panel/filterPanel/GridFilterInputValue";
import {GridApiCommunity} from "@mui/x-data-grid/internals";
import {GridFilterOperator} from "@mui/x-data-grid/models/gridFilterOperator";
import React, {useEffect, useRef, useState} from "react";
import {QCancelButton, QSaveButton} from "qqq/components/buttons/DefaultButtons";
import ChipTextField from "qqq/components/forms/ChipTextField";
import DynamicSelect from "qqq/components/forms/DynamicSelect";
////////////////////////////////
// input element for 'is any' //
////////////////////////////////
function CustomIsAnyInput(type: "number" | "text", props: GridFilterInputValueProps)
{
enum Delimiter
{
DETECT_AUTOMATICALLY = "Detect Automatically",
COMMA = "Comma",
NEWLINE = "Newline",
PIPE = "Pipe",
SPACE = "Space",
TAB = "Tab",
CUSTOM = "Custom",
}
const delimiterToCharacterMap: { [key: string]: string } = {};
delimiterToCharacterMap[Delimiter.COMMA] = "[,\n\r]";
delimiterToCharacterMap[Delimiter.TAB] = "[\t,\n,\r]";
delimiterToCharacterMap[Delimiter.NEWLINE] = "[\n\r]";
delimiterToCharacterMap[Delimiter.PIPE] = "[\\|\r\n]";
delimiterToCharacterMap[Delimiter.SPACE] = "[ \n\r]";
const delimiterDropdownOptions = Object.values(Delimiter);
const mainCardStyles: any = {};
mainCardStyles.width = "60%";
mainCardStyles.minWidth = "500px";
const [gridFilterItem, setGridFilterItem] = useState(props.item);
const [pasteModalIsOpen, setPasteModalIsOpen] = useState(false);
const [inputText, setInputText] = useState("");
const [delimiter, setDelimiter] = useState("");
const [delimiterCharacter, setDelimiterCharacter] = useState("");
const [customDelimiterValue, setCustomDelimiterValue] = useState("");
const [chipData, setChipData] = useState(undefined);
const [detectedText, setDetectedText] = useState("");
const [errorText, setErrorText] = useState("");
//////////////////////////////////////////////////////////////
// handler for when paste icon is clicked in 'any' operator //
//////////////////////////////////////////////////////////////
const handlePasteClick = (event: any) =>
{
event.target.blur();
setPasteModalIsOpen(true);
};
const applyValue = (item: GridFilterItem) =>
{
console.log(`updating grid values: ${JSON.stringify(item.value)}`);
setGridFilterItem(item);
props.applyValue(item);
};
const clearData = () =>
{
setDelimiter("");
setDelimiterCharacter("");
setChipData([]);
setInputText("");
setDetectedText("");
setCustomDelimiterValue("");
setPasteModalIsOpen(false);
};
const handleCancelClicked = () =>
{
clearData();
setPasteModalIsOpen(false);
};
const handleSaveClicked = () =>
{
if (gridFilterItem)
{
////////////////////////////////////////
// if numeric remove any non-numerics //
////////////////////////////////////////
let saveData = [];
for (let i = 0; i < chipData.length; i++)
{
if (type !== "number" || !Number.isNaN(Number(chipData[i])))
{
saveData.push(chipData[i]);
}
}
if (gridFilterItem.value)
{
gridFilterItem.value = [...gridFilterItem.value, ...saveData];
}
else
{
gridFilterItem.value = saveData;
}
setGridFilterItem(gridFilterItem);
props.applyValue(gridFilterItem);
}
clearData();
setPasteModalIsOpen(false);
};
////////////////////////////////////////////////////////////////
// when user selects a different delimiter on the parse modal //
////////////////////////////////////////////////////////////////
const handleDelimiterChange = (event: SelectChangeEvent) =>
{
const newDelimiter = event.target.value;
console.log(`Delimiter Changed to ${JSON.stringify(newDelimiter)}`);
setDelimiter(newDelimiter);
if (newDelimiter === Delimiter.CUSTOM)
{
setDelimiterCharacter(customDelimiterValue);
}
else
{
setDelimiterCharacter(delimiterToCharacterMap[newDelimiter]);
}
};
const handleTextChange = (event: any) =>
{
const inputText = event.target.value;
setInputText(inputText);
};
const handleCustomDelimiterChange = (event: any) =>
{
let inputText = event.target.value;
setCustomDelimiterValue(inputText);
};
///////////////////////////////////////////////////////////////////////////////////////
// iterate over each character, putting them into 'buckets' so that we can determine //
// a good default to use when data is pasted into the textarea //
///////////////////////////////////////////////////////////////////////////////////////
const calculateAutomaticDelimiter = (text: string): string =>
{
const buckets = new Map();
for (let i = 0; i < text.length; i++)
{
let bucketName = "";
switch (text.charAt(i))
{
case "\t":
bucketName = Delimiter.TAB;
break;
case "\n":
case "\r":
bucketName = Delimiter.NEWLINE;
break;
case "|":
bucketName = Delimiter.PIPE;
break;
case " ":
bucketName = Delimiter.SPACE;
break;
case ",":
bucketName = Delimiter.COMMA;
break;
}
if (bucketName !== "")
{
let currentCount = (buckets.has(bucketName)) ? buckets.get(bucketName) : 0;
buckets.set(bucketName, currentCount + 1);
}
}
///////////////////////
// default is commas //
///////////////////////
let highestCount = 0;
let delimiter = Delimiter.COMMA;
for (let j = 0; j < delimiterDropdownOptions.length; j++)
{
let bucketName = delimiterDropdownOptions[j];
if (buckets.has(bucketName) && buckets.get(bucketName) > highestCount)
{
delimiter = bucketName;
highestCount = buckets.get(bucketName);
}
}
setDetectedText(`${delimiter} Detected`);
return (delimiterToCharacterMap[delimiter]);
};
useEffect(() =>
{
let currentDelimiter = delimiter;
let currentDelimiterCharacter = delimiterCharacter;
/////////////////////////////////////////////////////////////////////////////
// if no delimiter already set in the state, call function to determine it //
/////////////////////////////////////////////////////////////////////////////
if (!currentDelimiter || currentDelimiter === Delimiter.DETECT_AUTOMATICALLY)
{
currentDelimiterCharacter = calculateAutomaticDelimiter(inputText);
if (!currentDelimiterCharacter)
{
return;
}
currentDelimiter = Delimiter.DETECT_AUTOMATICALLY;
setDelimiter(Delimiter.DETECT_AUTOMATICALLY);
setDelimiterCharacter(currentDelimiterCharacter);
}
else if (currentDelimiter === Delimiter.CUSTOM)
{
////////////////////////////////////////////////////
// if custom, make sure to split on new lines too //
////////////////////////////////////////////////////
currentDelimiterCharacter = `[${customDelimiterValue}\r\n]`;
}
console.log(`current delimiter is: ${currentDelimiter}, delimiting on: ${currentDelimiterCharacter}`);
let regex = new RegExp(currentDelimiterCharacter);
let parts = inputText.split(regex);
let chipData = [] as string[];
///////////////////////////////////////////////////////
// if delimiter is empty string, dont split anything //
///////////////////////////////////////////////////////
setErrorText("");
if (currentDelimiterCharacter !== "")
{
for (let i = 0; i < parts.length; i++)
{
let part = parts[i].trim();
if (part !== "")
{
chipData.push(part);
///////////////////////////////////////////////////////////
// if numeric, check that first before pushing as a chip //
///////////////////////////////////////////////////////////
if (type === "number" && Number.isNaN(Number(part)))
{
setErrorText("Some values are not numbers");
}
}
}
}
setChipData(chipData);
}, [inputText, delimiterCharacter, customDelimiterValue, detectedText]);
return (
<Box>
{
props &&
(
<Box id="testId" sx={{width: "100%", display: "inline-flex", flexDirection: "row", alignItems: "end", height: 48}}>
<GridFilterInputMultipleValue
sx={{width: "100%"}}
variant="standard"
type={type} {...props}
applyValue={applyValue}
item={gridFilterItem}
/>
<Tooltip title="Quickly add many values to your filter by pasting them from a spreadsheet or any other data source.">
<Icon onClick={handlePasteClick} fontSize="small" color="info" sx={{marginLeft: "10px", cursor: "pointer"}}>paste_content</Icon>
</Tooltip>
</Box>
)
}
{
pasteModalIsOpen &&
(
<Modal open={pasteModalIsOpen}>
<Box sx={{position: "absolute", overflowY: "auto", width: "100%"}}>
<Box py={3} justifyContent="center" sx={{display: "flex", mt: 8}}>
<Card sx={mainCardStyles}>
<Box p={4} pb={2}>
<Grid container>
<Grid item pr={3} xs={12} lg={12}>
<Typography variant="h5">Bulk Add Filter Values</Typography>
<Typography sx={{display: "flex", lineHeight: "1.7", textTransform: "revert"}} variant="button">
Paste into the box on the left.
Review the filter values in the box on the right.
If the filter values are not what are expected, try changing the separator using the dropdown below.
</Typography>
</Grid>
</Grid>
</Box>
<Grid container pl={3} pr={3} justifyContent="center" alignItems="stretch" sx={{display: "flex", height: "100%"}}>
<Grid item pr={3} xs={6} lg={6} sx={{width: "100%", display: "flex", flexDirection: "column", flexGrow: 1}}>
<FormControl sx={{m: 1, width: "100%"}}>
<TextField
id="outlined-multiline-static"
label="PASTE TEXT"
multiline
onChange={handleTextChange}
rows={16}
value={inputText}
/>
</FormControl>
</Grid>
<Grid item xs={6} lg={6} sx={{display: "flex", flexGrow: 1}}>
<FormControl sx={{m: 1, width: "100%"}}>
<ChipTextField
handleChipChange={() =>
{
}}
chipData={chipData}
chipType={type}
multiline
fullWidth
variant="outlined"
id="tags"
rows={0}
name="tags"
label="FILTER VALUES REVIEW"
/>
</FormControl>
</Grid>
</Grid>
<Grid container pl={3} pr={3} justifyContent="center" alignItems="stretch" sx={{display: "flex", height: "100%"}}>
<Grid item pl={1} pr={3} xs={6} lg={6} sx={{width: "100%", display: "flex", flexDirection: "column", flexGrow: 1}}>
<Box sx={{display: "inline-flex", alignItems: "baseline"}}>
<FormControl sx={{mt: 2, width: "50%"}}>
<InputLabel htmlFor="select-native">
SEPARATOR
</InputLabel>
<Select
multiline
native
value={delimiter}
onChange={handleDelimiterChange}
label="SEPARATOR"
size="medium"
inputProps={{
id: "select-native",
}}
>
{delimiterDropdownOptions.map((delimiter) => (
<option key={delimiter} value={delimiter}>
{delimiter}
</option>
))}
</Select>
</FormControl>
{delimiter === Delimiter.CUSTOM.valueOf() && (
<FormControl sx={{pl: 2, top: 5, width: "50%"}}>
<TextField
name="custom-delimiter-value"
placeholder="Custom Separator"
label="Custom Separator"
variant="standard"
value={customDelimiterValue}
onChange={handleCustomDelimiterChange}
inputProps={{maxLength: 1}}
/>
</FormControl>
)}
{inputText && delimiter === Delimiter.DETECT_AUTOMATICALLY.valueOf() && (
<Typography pl={2} variant="button" sx={{top: "1px", textTransform: "revert"}}>
<i>{detectedText}</i>
</Typography>
)}
</Box>
</Grid>
<Grid sx={{display: "flex", justifyContent: "flex-start", alignItems: "flex-start"}} item pl={1} xs={3} lg={3}>
{
errorText && chipData.length > 0 && (
<Box sx={{display: "flex", justifyContent: "flex-start", alignItems: "flex-start"}}>
<Icon color="error">error</Icon>
<Typography sx={{paddingLeft: "4px", textTransform: "revert"}} variant="button">{errorText}</Typography>
</Box>
)
}
</Grid>
<Grid sx={{display: "flex", justifyContent: "flex-end", alignItems: "flex-start"}} item pr={1} xs={3} lg={3}>
{
chipData && chipData.length > 0 && (
<Typography sx={{textTransform: "revert"}} variant="button">{chipData.length.toLocaleString()} {chipData.length === 1 ? "value" : "values"}</Typography>
)
}
</Grid>
</Grid>
<Box p={3} pt={0}>
<Grid container pl={1} pr={1} justifyContent="right" alignItems="stretch" sx={{display: "flex-inline "}}>
<QCancelButton
onClickHandler={handleCancelClicked}
iconName="cancel"
disabled={false} />
<QSaveButton onClickHandler={handleSaveClicked} label="Add Filters" disabled={false} />
</Grid>
</Box>
</Card>
</Box>
</Box>
</Modal>
)
}
</Box>
);
}
//////////////////////
// string operators //
//////////////////////
const stringNotEqualsOperator: GridFilterOperator = {
label: "does not equal",
value: "isNot",
getApplyFilterFn: () => null,
// @ts-ignore
InputComponent: GridFilterInputValue,
};
const stringNotContainsOperator: GridFilterOperator = {
label: "does not contain",
value: "notContains",
getApplyFilterFn: () => null,
// @ts-ignore
InputComponent: GridFilterInputValue,
};
const stringNotStartsWithOperator: GridFilterOperator = {
label: "does not start with",
value: "notStartsWith",
getApplyFilterFn: () => null,
// @ts-ignore
InputComponent: GridFilterInputValue,
};
const stringNotEndWithOperator: GridFilterOperator = {
label: "does not end with",
value: "notEndsWith",
getApplyFilterFn: () => null,
// @ts-ignore
InputComponent: GridFilterInputValue,
};
const getListValueString = (value: GridFilterItem["value"]): string =>
{
if (value && value.length)
{
let labels = [] as string[];
let maxLoops = value.length;
if(maxLoops > 5)
{
maxLoops = 3;
}
for (let i = 0; i < maxLoops; i++)
{
labels.push(value[i]);
}
if(maxLoops < value.length)
{
labels.push(" and " + (value.length - maxLoops) + " other values.");
}
return (labels.join(", "));
}
return (value);
};
const stringIsAnyOfOperator: GridFilterOperator = {
label: "is any of",
value: "isAnyOf",
getValueAsString: getListValueString,
getApplyFilterFn: () => null,
// @ts-ignore
InputComponent: (props: GridFilterInputMultipleValueProps<GridApiCommunity>) => CustomIsAnyInput("text", props)
};
const stringIsNoneOfOperator: GridFilterOperator = {
label: "is none of",
value: "isNone",
getValueAsString: getListValueString,
getApplyFilterFn: () => null,
// @ts-ignore
InputComponent: (props: GridFilterInputMultipleValueProps<GridApiCommunity>) => CustomIsAnyInput("text", props)
};
let gridStringOperators = getGridStringOperators();
let equals = gridStringOperators.splice(1, 1)[0];
let contains = gridStringOperators.splice(0, 1)[0];
let startsWith = gridStringOperators.splice(0, 1)[0];
let endsWith = gridStringOperators.splice(0, 1)[0];
///////////////////////////////////
// remove default isany operator //
///////////////////////////////////
gridStringOperators.splice(2, 1)[0];
gridStringOperators = [equals, stringNotEqualsOperator, contains, stringNotContainsOperator, startsWith, stringNotStartsWithOperator, endsWith, stringNotEndWithOperator, ...gridStringOperators, stringIsAnyOfOperator, stringIsNoneOfOperator];
export const QGridStringOperators = gridStringOperators;
///////////////////////////////////////
// input element for numbers-between //
///////////////////////////////////////
function InputNumberInterval(props: GridFilterInputValueProps)
{
const SUBMIT_FILTER_STROKE_TIME = 500;
const {item, applyValue, focusElementRef = null} = props;
const filterTimeout = useRef<any>();
const [filterValueState, setFilterValueState] = useState<[string, string]>(
item.value ?? "",
);
const [applying, setIsApplying] = useState(false);
useEffect(() =>
{
return () =>
{
clearTimeout(filterTimeout.current);
};
}, []);
useEffect(() =>
{
const itemValue = item.value ?? [undefined, undefined];
setFilterValueState(itemValue);
}, [item.value]);
const updateFilterValue = (lowerBound: string, upperBound: string) =>
{
clearTimeout(filterTimeout.current);
setFilterValueState([lowerBound, upperBound]);
setIsApplying(true);
filterTimeout.current = setTimeout(() =>
{
setIsApplying(false);
applyValue({...item, value: [lowerBound, upperBound]});
}, SUBMIT_FILTER_STROKE_TIME);
};
const handleUpperFilterChange: TextFieldProps["onChange"] = (event) =>
{
const newUpperBound = event.target.value;
updateFilterValue(filterValueState[0], newUpperBound);
};
const handleLowerFilterChange: TextFieldProps["onChange"] = (event) =>
{
const newLowerBound = event.target.value;
updateFilterValue(newLowerBound, filterValueState[1]);
};
return (
<Box
sx={{
display: "inline-flex",
flexDirection: "row",
alignItems: "end",
height: 48,
pl: "20px",
}}
>
<TextField
name="lower-bound-input"
placeholder="From"
label="From"
variant="standard"
value={Number(filterValueState[0])}
onChange={handleLowerFilterChange}
type="number"
inputRef={focusElementRef}
sx={{mr: 2}}
/>
<TextField
name="upper-bound-input"
placeholder="To"
label="To"
variant="standard"
value={Number(filterValueState[1])}
onChange={handleUpperFilterChange}
type="number"
InputProps={applying ? {endAdornment: <Icon>sync</Icon>} : {}}
/>
</Box>
);
}
//////////////////////
// number operators //
//////////////////////
const betweenOperator: GridFilterOperator = {
label: "is between",
value: "between",
getApplyFilterFn: () => null,
// @ts-ignore
InputComponent: InputNumberInterval
};
const notBetweenOperator: GridFilterOperator = {
label: "is not between",
value: "notBetween",
getApplyFilterFn: () => null,
// @ts-ignore
InputComponent: InputNumberInterval
};
const numericIsAnyOfOperator: GridFilterOperator = {
label: "is any of",
value: "isAnyOf",
getApplyFilterFn: () => null,
getValueAsString: getListValueString,
// @ts-ignore
InputComponent: (props: GridFilterInputMultipleValueProps<GridApiCommunity>) => CustomIsAnyInput("number", props)
};
const numericIsNoneOfOperator: GridFilterOperator = {
label: "is none of",
value: "isNone",
getApplyFilterFn: () => null,
getValueAsString: getListValueString,
// @ts-ignore
InputComponent: (props: GridFilterInputMultipleValueProps<GridApiCommunity>) => CustomIsAnyInput("number", props)
};
//////////////////////////////
// remove default is any of //
//////////////////////////////
let gridNumericOperators = getGridNumericOperators();
gridNumericOperators.splice(8, 1)[0];
export const QGridNumericOperators = [...gridNumericOperators, betweenOperator, notBetweenOperator, numericIsAnyOfOperator, numericIsNoneOfOperator];
///////////////////////
// boolean operators //
///////////////////////
const booleanTrueOperator: GridFilterOperator = {
label: "is yes",
value: "isTrue",
getApplyFilterFn: (filterItem: GridFilterItem, column: GridColDef) => null
};
const booleanFalseOperator: GridFilterOperator = {
label: "is no",
value: "isFalse",
getApplyFilterFn: (filterItem: GridFilterItem, column: GridColDef) => null
};
const booleanEmptyOperator: GridFilterOperator = {
label: "is empty",
value: "isEmpty",
getApplyFilterFn: (filterItem: GridFilterItem, column: GridColDef) => null
};
const booleanNotEmptyOperator: GridFilterOperator = {
label: "is not empty",
value: "isNotEmpty",
getApplyFilterFn: (filterItem: GridFilterItem, column: GridColDef) => null
};
export const QGridBooleanOperators = [booleanTrueOperator, booleanFalseOperator, booleanEmptyOperator, booleanNotEmptyOperator];
const blobEmptyOperator: GridFilterOperator = {
label: "is empty",
value: "isEmpty",
getApplyFilterFn: (filterItem: GridFilterItem, column: GridColDef) => null
};
const blobNotEmptyOperator: GridFilterOperator = {
label: "is not empty",
value: "isNotEmpty",
getApplyFilterFn: (filterItem: GridFilterItem, column: GridColDef) => null
};
export const QGridBlobOperators = [blobNotEmptyOperator, blobEmptyOperator];
///////////////////////////////////////
// input element for possible values //
///////////////////////////////////////
function InputPossibleValueSourceSingle(tableName: string, field: QFieldMetaData, props: GridFilterInputValueProps)
{
const SUBMIT_FILTER_STROKE_TIME = 500;
const {item, applyValue, focusElementRef = null} = props;
console.log("Item.value? " + item.value);
const filterTimeout = useRef<any>();
const [filterValueState, setFilterValueState] = useState<any>(item.value ?? null);
const [selectedPossibleValue, setSelectedPossibleValue] = useState((item.value ?? null) as QPossibleValue);
const [applying, setIsApplying] = useState(false);
useEffect(() =>
{
return () =>
{
clearTimeout(filterTimeout.current);
};
}, []);
useEffect(() =>
{
const itemValue = item.value ?? null;
setFilterValueState(itemValue);
}, [item.value]);
const updateFilterValue = (value: QPossibleValue) =>
{
clearTimeout(filterTimeout.current);
setFilterValueState(value);
setIsApplying(true);
filterTimeout.current = setTimeout(() =>
{
setIsApplying(false);
applyValue({...item, value: value});
}, SUBMIT_FILTER_STROKE_TIME);
};
const handleChange = (value: QPossibleValue) =>
{
updateFilterValue(value);
};
return (
<Box
sx={{
display: "inline-flex",
flexDirection: "row",
alignItems: "end",
height: 48,
}}
>
<DynamicSelect
tableName={tableName}
fieldName={field.name}
fieldLabel="Value"
initialValue={selectedPossibleValue?.id}
initialDisplayValue={selectedPossibleValue?.label}
inForm={false}
onChange={handleChange}
// InputProps={applying ? {endAdornment: <Icon>sync</Icon>} : {}}
/>
</Box>
);
}
////////////////////////////////////////////////
// input element for multiple possible values //
////////////////////////////////////////////////
function InputPossibleValueSourceMultiple(tableName: string, field: QFieldMetaData, props: GridFilterInputValueProps)
{
const SUBMIT_FILTER_STROKE_TIME = 500;
const {item, applyValue, focusElementRef = null} = props;
console.log("Item.value? " + item.value);
const filterTimeout = useRef<any>();
const [selectedPossibleValues, setSelectedPossibleValues] = useState(item.value as QPossibleValue[]);
const [applying, setIsApplying] = useState(false);
useEffect(() =>
{
return () =>
{
clearTimeout(filterTimeout.current);
};
}, []);
useEffect(() =>
{
const itemValue = item.value ?? null;
}, [item.value]);
const updateFilterValue = (value: QPossibleValue) =>
{
clearTimeout(filterTimeout.current);
setIsApplying(true);
filterTimeout.current = setTimeout(() =>
{
setIsApplying(false);
applyValue({...item, value: value});
}, SUBMIT_FILTER_STROKE_TIME);
};
const handleChange = (value: QPossibleValue) =>
{
updateFilterValue(value);
};
return (
<Box
sx={{
display: "inline-flex",
flexDirection: "row",
alignItems: "end",
height: 48,
}}
>
<DynamicSelect
tableName={tableName}
fieldName={field.name}
isMultiple={true}
fieldLabel="Value"
initialValues={selectedPossibleValues}
inForm={false}
onChange={handleChange}
/>
</Box>
);
}
const getPvsValueString = (value: GridFilterItem["value"]): string =>
{
if (value && value.length)
{
let labels = [] as string[];
let maxLoops = value.length;
if(maxLoops > 5)
{
maxLoops = 3;
}
for (let i = 0; i < maxLoops; i++)
{
if(value[i] && value[i].label)
{
labels.push(value[i].label);
}
else
{
labels.push(value);
}
}
if(maxLoops < value.length)
{
labels.push(" and " + (value.length - maxLoops) + " other values.");
}
return (labels.join(", "));
}
else if (value && value.label)
{
return (value.label);
}
return (value);
};
//////////////////////////////////
// possible value set operators //
//////////////////////////////////
export const buildQGridPvsOperators = (tableName: string, field: QFieldMetaData): GridFilterOperator[] =>
{
return ([
{
label: "is",
value: "is",
getApplyFilterFn: () => null,
getValueAsString: getPvsValueString,
InputComponent: (props: GridFilterInputValueProps<GridApiCommunity>) => InputPossibleValueSourceSingle(tableName, field, props)
},
{
label: "is not",
value: "isNot",
getApplyFilterFn: () => null,
getValueAsString: getPvsValueString,
InputComponent: (props: GridFilterInputValueProps<GridApiCommunity>) => InputPossibleValueSourceSingle(tableName, field, props)
},
{
label: "is any of",
value: "isAnyOf",
getValueAsString: getPvsValueString,
getApplyFilterFn: () => null,
InputComponent: (props: GridFilterInputValueProps<GridApiCommunity>) => InputPossibleValueSourceMultiple(tableName, field, props)
},
{
label: "is none of",
value: "isNone",
getValueAsString: getPvsValueString,
getApplyFilterFn: () => null,
InputComponent: (props: GridFilterInputValueProps<GridApiCommunity>) => InputPossibleValueSourceMultiple(tableName, field, props)
},
{
label: "is empty",
value: "isEmpty",
getValueAsString: getPvsValueString,
getApplyFilterFn: (filterItem: GridFilterItem, column: GridColDef) => null
},
{
label: "is not empty",
value: "isNotEmpty",
getValueAsString: getPvsValueString,
getApplyFilterFn: (filterItem: GridFilterItem, column: GridColDef) => null
}
]);
};

View File

@ -33,7 +33,8 @@ import {QCriteriaOperator} from "@kingsrook/qqq-frontend-core/lib/model/query/QC
import {QFilterCriteria} from "@kingsrook/qqq-frontend-core/lib/model/query/QFilterCriteria";
import {QFilterOrderBy} from "@kingsrook/qqq-frontend-core/lib/model/query/QFilterOrderBy";
import {QQueryFilter} from "@kingsrook/qqq-frontend-core/lib/model/query/QQueryFilter";
import {Alert, Box, Collapse, Menu, Typography} from "@mui/material";
import {Alert, Collapse, Menu, Typography} from "@mui/material";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import Card from "@mui/material/Card";
import Divider from "@mui/material/Divider";
@ -91,7 +92,6 @@ interface Props
launchProcess?: QProcessMetaData;
usage?: QueryScreenUsage;
isModal?: boolean;
isPreview?: boolean;
initialQueryFilter?: QQueryFilter;
initialColumns?: QQueryColumns;
allowVariables?: boolean;
@ -126,7 +126,7 @@ const getLoadingScreen = (isModal: boolean) =>
**
** Yuge component. The best. Lots of very smart people are saying so.
*******************************************************************************/
const RecordQuery = forwardRef(({table, usage, isModal, isPreview, allowVariables, initialQueryFilter, initialColumns}: Props, ref) =>
const RecordQuery = forwardRef(({table, usage, isModal, allowVariables, initialQueryFilter, initialColumns}: Props, ref) =>
{
const tableName = table.name;
const [searchParams] = useSearchParams();
@ -884,18 +884,6 @@ const RecordQuery = forwardRef(({table, usage, isModal, isPreview, allowVariable
};
/*******************************************************************************
** Opens a new query screen in a new window with the current filter
*******************************************************************************/
const openFilterInNewWindow = () =>
{
let filterForBackend = JSON.parse(JSON.stringify(view.queryFilter));
filterForBackend = FilterUtils.prepQueryFilterForBackend(tableMetaData, filterForBackend);
const url = `${metaData?.getTablePathByName(tableName)}?filter=${encodeURIComponent(JSON.stringify(filterForBackend))}`;
window.open(url);
};
/*******************************************************************************
** This is the method that actually executes a query to update the data in the table.
*******************************************************************************/
@ -1103,7 +1091,7 @@ const RecordQuery = forwardRef(({table, usage, isModal, isPreview, allowVariable
////////////////////////////////
// make the rows for the grid //
////////////////////////////////
const rows = DataGridUtils.makeRows(results, tableMetaData, tableVariant);
const rows = DataGridUtils.makeRows(results, tableMetaData);
setRows(rows);
setLoading(false);
@ -1557,7 +1545,7 @@ const RecordQuery = forwardRef(({table, usage, isModal, isPreview, allowVariable
/*******************************************************************************
** function to open one of the bulk (insert/edit/delete) processes.
*******************************************************************************/
const openBulkProcess = (processNamePart: "Insert" | "Edit" | "Delete" | "EditWithFile", processLabelPart: "Load" | "Edit" | "Delete" | "Edit With File") =>
const openBulkProcess = (processNamePart: "Insert" | "Edit" | "Delete", processLabelPart: "Load" | "Edit" | "Delete") =>
{
const processList = allTableProcesses.filter(p => p.name.endsWith(`.bulk${processNamePart}`));
if (processList.length > 0)
@ -1593,15 +1581,6 @@ const RecordQuery = forwardRef(({table, usage, isModal, isPreview, allowVariable
};
/*******************************************************************************
** Event handler for the bulk-edit-with-file process being selected
*******************************************************************************/
const bulkEditWithFileClicked = () =>
{
openBulkProcess("EditWithFile", "Edit With File");
};
/*******************************************************************************
** Event handler for the bulk-delete process being selected
*******************************************************************************/
@ -1621,22 +1600,6 @@ const RecordQuery = forwardRef(({table, usage, isModal, isPreview, allowVariable
*******************************************************************************/
const processClicked = (process: QProcessMetaData) =>
{
if (process.minInputRecords != null && process.minInputRecords > 0 && getNoOfSelectedRecords() === 0)
{
setAlertContent(`No records were selected for the process: ${process.label}`);
return;
}
else if (process.minInputRecords != null && getNoOfSelectedRecords() < process.minInputRecords)
{
setAlertContent(`Too few records were selected for the process: ${process.label}. A minimum of ${process.minInputRecords} is required.`);
return;
}
else if (process.maxInputRecords != null && getNoOfSelectedRecords() > process.maxInputRecords)
{
setAlertContent(`Too many records were selected for the process: ${process.label}. A maximum of ${process.maxInputRecords} is allowed.`);
return;
}
// todo - let the process specify that it needs initial rows - err if none selected.
// alternatively, let a process itself have an initial screen to select rows...
openModalProcess(process);
@ -2269,25 +2232,12 @@ const RecordQuery = forwardRef(({table, usage, isModal, isPreview, allowVariable
return (
<GridToolbarContainer>
<div>
<Tooltip title="Refresh Query">
<Button id="refresh-button" onClick={() => updateTable("refresh button")} startIcon={<Icon>refresh</Icon>} sx={{pl: "1rem", pr: "0.5rem", minWidth: "unset"}}></Button>
</Tooltip>
<Button id="refresh-button" onClick={() => updateTable("refresh button")} startIcon={<Icon>refresh</Icon>} sx={{pl: "1rem", pr: "0.5rem", minWidth: "unset"}}></Button>
</div>
<div style={{position: "relative"}}>
{/* @ts-ignore */}
<GridToolbarDensitySelector nonce={undefined} />
</div>
{
!isPreview && (
<div style={{position: "relative"}}>
{/* @ts-ignore */}
<GridToolbarDensitySelector nonce={undefined} />
</div>
)
}
{
isPreview && (
<Tooltip title="Open In New Window">
<Button id="open-filter-in-new-window-button" onClick={() => openFilterInNewWindow()} startIcon={<Icon>launch</Icon>} sx={{pl: "1rem", pr: "0.5rem", minWidth: "unset"}}></Button>
</Tooltip>
)
}
{
usage == "queryScreen" &&
@ -2870,7 +2820,6 @@ const RecordQuery = forwardRef(({table, usage, isModal, isPreview, allowVariable
tableProcesses={tableProcesses}
bulkLoadClicked={bulkLoadClicked}
bulkEditClicked={bulkEditClicked}
bulkEditWithFileClicked={bulkEditWithFileClicked}
bulkDeleteClicked={bulkDeleteClicked}
processClicked={processClicked}
/>
@ -2923,7 +2872,7 @@ const RecordQuery = forwardRef(({table, usage, isModal, isPreview, allowVariable
}
{
!isPreview && metaData && tableMetaData &&
metaData && tableMetaData &&
<BasicAndAdvancedQueryControls
ref={basicAndAdvancedQueryControlsRef}
metaData={metaData}

View File

@ -34,7 +34,6 @@ import BaseLayout from "qqq/layouts/BaseLayout";
import Client from "qqq/utils/qqq/Client";
import ValueUtils from "qqq/utils/qqq/ValueUtils";
import "ace-builds/src-noconflict/ace";
import "ace-builds/src-noconflict/mode-java";
import "ace-builds/src-noconflict/mode-javascript";
import "ace-builds/src-noconflict/mode-json";
@ -191,7 +190,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} mx={3} mb={3} mt={0}>
<Box display="flex" alignItems="center" justifyContent="space-between" gap={2}>
{scriptId ?
<ScriptViewer
scriptId={scriptId}

View File

@ -47,7 +47,6 @@ import Menu from "@mui/material/Menu";
import MenuItem from "@mui/material/MenuItem";
import Modal from "@mui/material/Modal";
import Tooltip from "@mui/material/Tooltip/Tooltip";
import {SxProps} from "@mui/system";
import QContext from "QContext";
import colors from "qqq/assets/theme/base/colors";
import AuditBody from "qqq/components/audits/AuditBody";
@ -92,9 +91,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}, tableVariant?: QTableVariant)
export function renderSectionOfFields(key: string, fieldNames: string[], tableMetaData: QTableMetaData, helpHelpActive: boolean, record: QRecord, fieldMap?: {[name: string]: QFieldMetaData} )
{
return <Grid container lg={12} key={key} display="flex" py={1} pr={2}>
return <Box key={key} display="flex" flexDirection="column" py={1} pr={2}>
{
fieldNames.map((fieldName: string) =>
{
@ -103,37 +102,36 @@ 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);
const formattedHelpContent = <HelpContent helpContents={field.helpContents} roles={helpRoles} heading={label} helpContentKey={`table:${tableMetaData?.name};field:${fieldName}`} />;
const labelElement = <Typography variant="button" textTransform="none" fontWeight="bold" pr={1} color="rgb(52, 71, 103)" sx={{cursor: "default", ...(styleOverrides?.label ?? {})}}>{label}:</Typography>;
const labelElement = <Typography variant="button" textTransform="none" fontWeight="bold" pr={1} color="rgb(52, 71, 103)" sx={{cursor: "default"}}>{label}:</Typography>;
return (
<Grid item key={fieldName} lg={gridColumns} flexDirection="column" pr={2}>
<Box key={fieldName} flexDirection="row" pr={2}>
<>
{
showHelp && formattedHelpContent ? <Tooltip title={formattedHelpContent}>{labelElement}</Tooltip> : labelElement
}
<div style={{display: "inline-block", width: 0}}>&nbsp;</div>
<Typography variant="button" textTransform="none" fontWeight="regular" color="rgb(123, 128, 154)" sx={{...(styleOverrides?.value ?? {})}}>
{ValueUtils.getDisplayValue(field, record, "view", fieldName, tableVariant)}
<Typography variant="button" textTransform="none" fontWeight="regular" color="rgb(123, 128, 154)">
{ValueUtils.getDisplayValue(field, record, "view", fieldName)}
</Typography>
</>
</Grid>
</Box>
);
}
})
}
</Grid>;
</Box>;
}
/***************************************************************************
**
***************************************************************************/
**
***************************************************************************/
export function getVisibleJoinTables(tableMetaData: QTableMetaData): Set<string>
{
const visibleJoinTables = new Set<string>();
@ -207,8 +205,6 @@ function RecordView({table, record: overrideRecord, launchProcess}: Props): JSX.
const {accentColor, setPageHeader, tableMetaData, setTableMetaData, tableProcesses, setTableProcesses, dotMenuOpen, keyboardHelpOpen, helpHelpActive, recordAnalytics, userId: currentUserId} = useContext(QContext);
const CREATE_CHILD_KEY = "createChild";
if (localStorage.getItem(tableVariantLocalStorageKey))
{
tableVariant = JSON.parse(localStorage.getItem(tableVariantLocalStorageKey));
@ -311,19 +307,12 @@ function RecordView({table, record: overrideRecord, launchProcess}: Props): JSX.
///////////////////////////////////////////////////////////////////////////////////////////////
// the path for a process looks like: .../table/id/process //
// the path for creating a child record looks like: .../table/id/createChild/:childTableName //
// the path for creating a child record in a process looks like: //
// .../table/id/processName#/createChild=... //
///////////////////////////////////////////////////////////////////////////////////////////////
let hasChildRecordKey = pathParts.some(p => p.includes(CREATE_CHILD_KEY));
if (!hasChildRecordKey)
{
hasChildRecordKey = hashParts.some(h => h.includes(CREATE_CHILD_KEY));
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// if our tableName is in the -3 index, and there is no token for updating child records, try to open process //
////////////////////////////////////////////////////////////////////////////////////////////////////////////////
if (!hasChildRecordKey && pathParts[pathParts.length - 3] === tableName)
//////////////////////////////////////////////////////////////
// if our tableName is in the -3 index, try to open process //
//////////////////////////////////////////////////////////////
if (pathParts[pathParts.length - 3] === tableName)
{
const processName = pathParts[pathParts.length - 1];
const processList = allTableProcesses.filter(p => p.name.endsWith(processName));
@ -360,7 +349,7 @@ function RecordView({table, record: overrideRecord, launchProcess}: Props): JSX.
// if our table is in the -4 index, and there's `createChild` in the -2 index, try to open a createChild form //
// e.g., person/42/createChild/address (to create an address under person 42) //
////////////////////////////////////////////////////////////////////////////////////////////////////////////////
if (pathParts[pathParts.length - 4] === tableName && pathParts[pathParts.length - 2] == CREATE_CHILD_KEY)
if (pathParts[pathParts.length - 4] === tableName && pathParts[pathParts.length - 2] == "createChild")
{
(async () =>
{
@ -379,7 +368,7 @@ function RecordView({table, record: overrideRecord, launchProcess}: Props): JSX.
for (let i = 0; i < hashParts.length; i++)
{
const parts = hashParts[i].split("=");
if (parts.length > 1 && parts[0] == CREATE_CHILD_KEY)
if (parts.length > 1 && parts[0] == "createChild")
{
(async () =>
{
@ -501,7 +490,7 @@ function RecordView({table, record: overrideRecord, launchProcess}: Props): JSX.
////////////////////////////////////////////////////////////////////////////
// if the component took in a record object, then we don't need to GET it //
////////////////////////////////////////////////////////////////////////////
if (overrideRecord)
if(overrideRecord)
{
record = overrideRecord;
}
@ -598,7 +587,7 @@ function RecordView({table, record: overrideRecord, launchProcess}: Props): JSX.
// for a section with field names, render the field values. //
// for the T1 section, the "wrapper" will come out below - but for other sections, produce a wrapper too. //
////////////////////////////////////////////////////////////////////////////////////////////////////////////
const fields = renderSectionOfFields(section.name, section.fieldNames, tableMetaData, helpHelpActive, record, undefined, undefined, tableVariant);
const fields = renderSectionOfFields(section.name, section.fieldNames, tableMetaData, helpHelpActive, record);
if (section.tier === "T1")
{
@ -837,12 +826,12 @@ function RecordView({table, record: overrideRecord, launchProcess}: Props): JSX.
{
let shareDisabled = true;
let disabledTooltipText = "";
if (tableMetaData.shareableTableMetaData.thisTableOwnerIdFieldName && record)
if(tableMetaData.shareableTableMetaData.thisTableOwnerIdFieldName && record)
{
const ownerId = record.values.get(tableMetaData.shareableTableMetaData.thisTableOwnerIdFieldName);
if (ownerId != currentUserId)
if(ownerId != currentUserId)
{
disabledTooltipText = `Only the owner of a ${tableMetaData.label} may share it.`;
disabledTooltipText = `Only the owner of a ${tableMetaData.label} may share it.`
shareDisabled = true;
}
else
@ -1004,10 +993,10 @@ function RecordView({table, record: overrideRecord, launchProcess}: Props): JSX.
}
<Grid container spacing={3}>
<Grid item xs={12} lg={3} className="recordSidebar">
<Grid item xs={12} lg={3}>
<QRecordSidebar tableSections={tableSections} />
</Grid>
<Grid item xs={12} lg={9} className="recordWithSidebar">
<Grid item xs={12} lg={9}>
<Grid container spacing={3}>
<Grid item xs={12} mb={3}>

View File

@ -748,54 +748,35 @@ input[type="search"]::-webkit-search-results-decoration
padding: 8px 0;
}
.helpContentAlert.info,
.banner.info
{
background-color: rgb(234, 242, 255);
color: rgb(20, 51, 102);
}
.helpContentAlert.info .MuiAlert-icon .material-icons-round,
.banner.info .MuiAlert-icon .material-icons-round
{
color: #0062FF;
}
.helpContentAlert.success,
.banner.success
.helpContentAlert.success
{
background-color: rgb(240, 248, 241);
color: rgb(44, 76, 46);
}
.helpContentAlert.success .MuiAlert-icon .material-icons-round,
.banner.success .MuiAlert-icon .material-icons-round
.helpContentAlert.success .MuiAlert-icon .material-icons-round
{
color: #4CAF50;
}
.helpContentAlert.warning,
.banner.warning
.helpContentAlert.warning
{
background-color: rgb(254, 245, 234);
color: rgb(100, 65, 20);
}
.helpContentAlert.warning .MuiAlert-icon .material-icons-round,
.banner.warning .MuiAlert-icon .material-icons-round
.helpContentAlert.warning .MuiAlert-icon .material-icons-round
{
color: #fb8c00;
}
.helpContentAlert.error,
.banner.error
.helpContentAlert.error
{
background-color: rgb(254, 239, 238);
color: rgb(98, 41, 37);
}
.helpContentAlert.error .MuiAlert-icon .material-icons-round,
.banner.error .MuiAlert-icon .material-icons-round
.helpContentAlert.error .MuiAlert-icon .material-icons-round
{
color: #F44335;
}
@ -807,33 +788,503 @@ input[type="search"]::-webkit-search-results-decoration
margin: 2rem 1rem;
}
/* default styles for a block widget overlay */
.blockWidgetOverlay
{
font-weight: 400;
.sqd-designer-react {
width: 100vw;
height: 90vh;
}
.sqd-editor {
padding: 10px;
}
input:read-only {
opacity: 0.35;
}
.sqd-editor {
padding: 10px;
}
input:read-only {
opacity: 0.35;
}
/* internal */
.sqd-theme-light .sqd-toolbox {
background: #fff;
box-shadow: 0 0 8px rgba(0, 0, 0, 0.15), 0 2px 4px rgba(0, 0, 0, 0.15);
border-radius: 10px;
}
.sqd-theme-light .sqd-toolbox-header-title {
color: #000;
}
.sqd-theme-light .sqd-toolbox-filter {
background: #fff;
color: #000;
border: 1px solid #c3c3c3;
border-radius: 10px;
}
.sqd-theme-light .sqd-toolbox-filter:focus {
border-color: #939393;
}
.sqd-theme-light .sqd-toolbox-group-title {
color: #000;
background: #e5e5e5;
border-radius: 10px;
}
.sqd-theme-light .sqd-toolbox-item {
color: #000;
border: 1px solid #c3c3c3;
box-shadow: 0 2px 2px rgba(0, 0, 0, 0.15);
background: #fff;
border-radius: 5px;
}
.sqd-theme-light .sqd-toolbox-item:hover {
border-color: #939393;
background: #fff;
}
.sqd-theme-light .sqd-toolbox-item .sqd-toolbox-item-icon.sqd-no-icon {
background: #c6c6c6;
border-radius: 4px;
}
.sqd-theme-light .sqd-control-bar {
background: #fff;
box-shadow: 0 0 8px rgba(0, 0, 0, 0.15), 0 2px 4px rgba(0, 0, 0, 0.15);
border-radius: 10px;
}
.sqd-theme-light .sqd-control-bar-button {
border: 1px solid #c3c3c3;
background: #fff;
border-radius: 5px;
}
.sqd-theme-light .sqd-control-bar-button:hover {
border-color: #939393;
background: #fff;
}
.sqd-theme-light .sqd-control-bar-button .sqd-icon-path {
fill: #000;
}
.sqd-theme-light .sqd-control-bar-button.sqd-delete .sqd-icon-path {
fill: #e01a24;
}
.sqd-theme-light .sqd-smart-editor {
background: #fff;
box-shadow: 0 0 8px rgba(0, 0, 0, 0.15);
}
.sqd-theme-light .sqd-smart-editor-toggle {
background: #fff;
box-shadow: 0 0 8px rgba(0, 0, 0, 0.15);
}
.sqd-theme-light.sqd-context-menu {
background: #fff;
box-shadow: 0 0 8px rgba(0, 0, 0, 0.2);
border-radius: 4px;
}
.sqd-theme-light .sqd-context-menu-group {
color: #888;
}
.sqd-theme-light .sqd-context-menu-item {
color: #000;
border-radius: 4px;
}
.sqd-theme-light .sqd-context-menu-item:hover {
background: #eee;
}
.sqd-theme-light.sqd-designer {
background: #f9f9f9;
}
.sqd-theme-light .sqd-line-grid-path {
stroke: #e3e3e3;
stroke-width: 1;
}
.sqd-theme-light .sqd-join {
stroke-width: 2;
stroke: #000;
}
.sqd-theme-light .sqd-region {
stroke: #cecece;
stroke-width: 2;
stroke-dasharray: 3;
}
.sqd-theme-light .sqd-region.sqd-selected {
stroke: #ed4800;
stroke-width: 2;
stroke-dasharray: 0;
}
.sqd-theme-light .sqd-placeholder .sqd-placeholder-rect {
fill: #d8d8d8;
stroke: #6a6a6a;
stroke-width: 1;
stroke-dasharray: 3;
}
.sqd-theme-light .sqd-placeholder.sqd-hover .sqd-placeholder-rect {
fill: #ed4800;
}
.sqd-theme-light .sqd-placeholder-icon-path {
fill: #2b2b2b;
}
.sqd-theme-light .sqd-placeholder.sqd-hover .sqd-placeholder-icon-path {
fill: #fff;
}
.sqd-theme-light .sqd-validation-error {
fill: #ffa200;
}
.sqd-theme-light .sqd-validation-error-icon-path {
fill: #000;
}
.sqd-theme-light .sqd-root-start-stop-circle {
fill: #2c18df;
}
.sqd-theme-light .sqd-root-start-stop-icon {
fill: #fff;
}
.sqd-theme-light .sqd-step-task .sqd-step-task-rect {
fill: #fff;
stroke-width: 1;
stroke: #c3c3c3;
filter: drop-shadow(0 1.5px 1.5px rgba(0, 0, 0, 0.15));
}
.sqd-theme-light .sqd-step-task .sqd-step-task-rect.sqd-selected {
stroke: #ed4800;
stroke-width: 2;
}
.sqd-theme-light .sqd-step-task .sqd-step-task-text {
fill: #000;
}
.sqd-theme-light .sqd-step-task .sqd-step-task-empty-icon {
fill: #c6c6c6;
}
.sqd-theme-light .sqd-step-task .sqd-input {
fill: #fff;
stroke-width: 2;
stroke: #000;
}
.sqd-theme-light .sqd-step-task .sqd-output {
fill: #000;
stroke-width: 0;
}
.sqd-theme-light .sqd-step-switch > .sqd-label-primary > .sqd-label-text {
fill: #fff;
}
.sqd-theme-light .sqd-step-switch > .sqd-label-primary > .sqd-label-rect {
fill: #2411db;
stroke-width: 0;
}
.sqd-theme-light .sqd-step-switch > .sqd-label-secondary > .sqd-label-rect {
fill: #000;
stroke-width: 0;
}
.sqd-theme-light .sqd-step-switch > .sqd-label-secondary > .sqd-label-text {
fill: #fff;
}
.sqd-theme-light .sqd-step-switch > g > .sqd-input {
fill: #fff;
stroke-width: 2;
stroke: #000;
}
.sqd-theme-light .sqd-step-container > .sqd-label > .sqd-label-text {
fill: #fff;
}
.sqd-theme-light .sqd-step-container > .sqd-label > .sqd-label-rect {
fill: #2411db;
stroke-width: 0;
}
.sqd-theme-light .sqd-step-container > g > .sqd-input {
fill: #fff;
stroke-width: 2;
stroke: #000;
}
/* .sqd-designer */
.sqd-designer {
position: relative;
top: 15px;
height: 0;
display: flex;
font-size: 14px;
flex-direction: column;
align-items: center;
}
.blockWidgetOverlay a
{
color: #0062FF !important;
width: 100%;
height: 100%;
}
@media (min-width: 1400px)
{
.recordSidebar
{
max-width: 400px !important;
}
.sqd-designer,
.sqd-drag,
.sqd-context-menu {
font-size: 13px;
line-height: 1em;
}
.recordWithSidebar
{
max-width: 100% !important;
flex-grow: 1 !important;
}
}
.sqd-hidden {
display: none !important;
}
.sqd-disabled {
opacity: 0.25;
}
/* .sqd-toolbox */
.sqd-toolbox,
.sqd-toolbox-filter {
font-size: 11px;
line-height: 1.2em;
}
.sqd-toolbox {
position: absolute;
top: 10px;
left: 10px;
z-index: 20;
box-sizing: border-box;
width: 250px;
-webkit-user-select: none;
user-select: none;
}
.sqd-toolbox-header {
position: relative;
padding: 15px 10px;
cursor: pointer;
}
.sqd-toolbox-header-title {
display: block;
font-size: 1.2em;
line-height: 1em;
font-weight: bold;
}
.sqd-toolbox-toggle-icon {
position: absolute;
top: 50%;
right: 10px;
width: 16px;
height: 16px;
margin: -8px 0 0;
}
.sqd-toolbox-header:hover .sqd-toolbox-toggle-icon {
opacity: 0.6;
}
.sqd-scrollbox {
position: relative;
overflow: hidden;
}
.sqd-scrollbox-body {
position: absolute;
top: 0;
left: 0;
width: 100%;
}
.sqd-toolbox-filter {
display: block;
box-sizing: border-box;
padding: 6px 8px;
outline: none;
width: 110px;
margin: 0 10px 10px;
box-sizing: border-box;
}
.sqd-toolbox-group-title {
text-align: center;
padding: 5px 0;
margin: 0 10px 10px;
}
.sqd-toolbox-item {
position: relative;
box-sizing: border-box;
margin: 0 10px 10px;
cursor: move;
width: 90%;
}
.sqd-toolbox-item-icon {
position: absolute;
top: 50%;
left: 5px;
margin-top: -10px;
width: 20px;
height: 20px;
}
.sqd-toolbox-item-icon-image {
width: 100%;
height: 100%;
}
.sqd-toolbox-item-text {
position: relative;
display: block;
padding: 10px 10px 10px 30px;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
.sqd-drag {
position: absolute;
z-index: 9999999;
pointer-events: none;
}
/* .sqd-control-bar */
.sqd-control-bar {
position: absolute;
bottom: 10px;
left: 10px;
z-index: 20;
padding: 8px 0 8px 8px;
white-space: nowrap;
}
.sqd-control-bar-button {
display: inline-block;
width: 32px;
height: 32px;
margin-right: 8px;
cursor: pointer;
box-sizing: border-box;
}
.sqd-control-bar-button-icon {
width: 24px;
height: 24px;
margin: 3px 0 0 3px;
}
.sqd-control-bar-button.sqd-disabled .sqd-control-bar-button-icon {
opacity: 0.2;
}
/* .sqd-smart-editor */
.sqd-smart-editor-toggle {
position: absolute;
top: 0;
z-index: 29;
width: 36px;
height: 64px;
border-bottom-left-radius: 10px;
cursor: pointer;
}
.sqd-smart-editor-toggle-icon {
position: absolute;
top: 50%;
left: 50%;
width: 24px;
height: 24px;
margin: -12px 0 0 -12px;
}
.sqd-smart-editor-toggle:hover .sqd-smart-editor-toggle-icon {
opacity: 0.6;
}
.sqd-smart-editor {
z-index: 30;
}
.sqd-layout-desktop .sqd-smart-editor {
position: relative;
width: 300px;
}
.sqd-layout-desktop .sqd-smart-editor-toggle {
right: 300px;
}
.sqd-layout-desktop .sqd-smart-editor-toggle.sqd-collapsed {
right: 0;
}
.sqd-layout-mobile .sqd-smart-editor {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 41px;
}
.sqd-layout-mobile .sqd-smart-editor-toggle {
left: 5px;
}
.sqd-layout-mobile .sqd-smart-editor-toggle.sqd-collapsed {
left: auto;
right: 0;
}
/* .sqd-context-menu */
.sqd-context-menu {
position: absolute;
z-index: 2000000000;
overflow: hidden;
padding: 5px;
}
.sqd-context-menu-group,
.sqd-context-menu-item {
width: 130px;
padding: 8px 10px;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
.sqd-context-menu-group {
font-size: 11px;
line-height: 1em;
}
.sqd-context-menu-item {
cursor: pointer;
transition: background 70ms;
}
/* .sqd-workspace */
.sqd-workspace {
flex: 1;
position: relative;
display: block;
-webkit-user-select: none;
user-select: none;
}
.sqd-workspace-canvas {
position: absolute;
top: 0;
left: 0;
cursor: move;
}
.sqd-label-text {
text-anchor: middle;
dominant-baseline: central;
}
.sqd-placeholder .sqd-placeholder-rect {
transition: fill 100ms;
}
.sqd-step-task-text {
text-anchor: left;
dominant-baseline: central;
}

View File

@ -24,17 +24,64 @@ 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";
import {GridColDef, GridFilterItem, GridRowsProp, MuiEvent} from "@mui/x-data-grid-pro";
import {GridFilterOperator} from "@mui/x-data-grid/models/gridFilterOperator";
import {GridColumnHeaderParams} from "@mui/x-data-grid/models/params/gridColumnHeaderParams";
import HelpContent, {hasHelpContent} from "qqq/components/misc/HelpContent";
import {buildQGridPvsOperators, QGridBlobOperators, QGridBooleanOperators, QGridNumericOperators, QGridStringOperators} from "qqq/pages/records/query/GridFilterOperators";
import ValueUtils from "qqq/utils/qqq/ValueUtils";
import React from "react";
import {Link, NavigateFunction} from "react-router-dom";
const emptyApplyFilterFn = (filterItem: GridFilterItem, column: GridColDef): null => null;
function NullInputComponent()
{
return (<React.Fragment />);
}
const makeGridFilterOperator = (value: string, label: string, takesValues: boolean = false): GridFilterOperator =>
{
const rs: GridFilterOperator = {value: value, label: label, getApplyFilterFn: emptyApplyFilterFn};
if (takesValues)
{
rs.InputComponent = NullInputComponent;
}
return (rs);
};
////////////////////////////////////////////////////////////////////////////////////////
// at this point, these may only be used to drive the toolitp on the FILTER button... //
////////////////////////////////////////////////////////////////////////////////////////
const QGridDateOperators = [
makeGridFilterOperator("equals", "equals", true),
makeGridFilterOperator("isNot", "does not equal", true),
makeGridFilterOperator("after", "is after", true),
makeGridFilterOperator("onOrAfter", "is on or after", true),
makeGridFilterOperator("before", "is before", true),
makeGridFilterOperator("onOrBefore", "is on or before", true),
makeGridFilterOperator("isEmpty", "is empty"),
makeGridFilterOperator("isNotEmpty", "is not empty"),
makeGridFilterOperator("between", "is between", true),
makeGridFilterOperator("notBetween", "is not between", true),
];
const QGridDateTimeOperators = [
makeGridFilterOperator("equals", "equals", true),
makeGridFilterOperator("isNot", "does not equal", true),
makeGridFilterOperator("after", "is after", true),
makeGridFilterOperator("onOrAfter", "is at or after", true),
makeGridFilterOperator("before", "is before", true),
makeGridFilterOperator("onOrBefore", "is at or before", true),
makeGridFilterOperator("isEmpty", "is empty"),
makeGridFilterOperator("isNotEmpty", "is not empty"),
makeGridFilterOperator("between", "is between", true),
makeGridFilterOperator("notBetween", "is not between", true),
];
export default class DataGridUtils
{
/*******************************************************************************
@ -66,12 +113,12 @@ export default class DataGridUtils
{
console.log(`row-click mouse-up happened ${diff} x or y pixels away from the mouse-down - so not considering it a click.`);
}
};
}
/*******************************************************************************
**
*******************************************************************************/
public static makeRows = (results: QRecord[], tableMetaData: QTableMetaData, tableVariant?: QTableVariant, allowEmptyId = false): GridRowsProp[] =>
public static makeRows = (results: QRecord[], tableMetaData: QTableMetaData, allowEmptyId = false): GridRowsProp[] =>
{
const fields = [...tableMetaData.fields.values()];
const rows = [] as any[];
@ -83,36 +130,36 @@ export default class DataGridUtils
fields.forEach((field) =>
{
row[field.name] = ValueUtils.getDisplayValue(field, record, "query", undefined, tableVariant);
row[field.name] = ValueUtils.getDisplayValue(field, record, "query");
});
if (tableMetaData.exposedJoins)
if(tableMetaData.exposedJoins)
{
for (let i = 0; i < tableMetaData.exposedJoins.length; i++)
{
const join = tableMetaData.exposedJoins[i];
if (join?.joinTable?.fields?.values())
if(join?.joinTable?.fields?.values())
{
const fields = [...join.joinTable.fields.values()];
fields.forEach((field) =>
{
let fieldName = join.joinTable.name + "." + field.name;
row[fieldName] = ValueUtils.getDisplayValue(field, record, "query", fieldName, tableVariant);
row[fieldName] = ValueUtils.getDisplayValue(field, record, "query", fieldName);
});
}
}
}
if (!row["id"])
if(!row["id"])
{
row["id"] = record.values.get(tableMetaData.primaryKeyField) ?? row[tableMetaData.primaryKeyField];
if (row["id"] === null || row["id"] === undefined)
if(row["id"] === null || row["id"] === undefined)
{
/////////////////////////////////////////////////////////////////////////////////////////
// DataGrid gets very upset about a null or undefined here, so, try to make it happier //
/////////////////////////////////////////////////////////////////////////////////////////
if (!allowEmptyId)
if(!allowEmptyId)
{
row["id"] = "--";
}
@ -123,7 +170,7 @@ export default class DataGridUtils
});
return (rows);
};
}
/*******************************************************************************
**
@ -133,24 +180,24 @@ export default class DataGridUtils
const columns = [] as GridColDef[];
this.addColumnsForTable(tableMetaData, linkBase, columns, columnSort, null, null);
if (metaData)
if(metaData)
{
if (tableMetaData.exposedJoins)
if(tableMetaData.exposedJoins)
{
for (let i = 0; i < tableMetaData.exposedJoins.length; i++)
{
const join = tableMetaData.exposedJoins[i];
let joinTableName = join.joinTable.name;
if (metaData.tables.has(joinTableName) && metaData.tables.get(joinTableName).readPermission)
if(metaData.tables.has(joinTableName) && metaData.tables.get(joinTableName).readPermission)
{
let joinLinkBase = null;
joinLinkBase = metaData.getTablePath(join.joinTable);
if (joinLinkBase)
if(joinLinkBase)
{
joinLinkBase += joinLinkBase.endsWith("/") ? "" : "/";
}
if (join?.joinTable?.fields?.values())
if(join?.joinTable?.fields?.values())
{
this.addColumnsForTable(join.joinTable, joinLinkBase, columns, columnSort, joinTableName + ".", join.label + ": ");
}
@ -173,7 +220,7 @@ export default class DataGridUtils
////////////////////////////////////////////////////////////////////////
// this sorted by sections - e.g., manual sorting by the meta-data... //
////////////////////////////////////////////////////////////////////////
if (columnSort === "bySection")
if(columnSort === "bySection")
{
for (let i = 0; i < tableMetaData.sections.length; i++)
{
@ -194,23 +241,19 @@ export default class DataGridUtils
///////////////////////////
// sort by labels... mmm //
///////////////////////////
sortedKeys.push(...tableMetaData.fields.keys());
sortedKeys.push(...tableMetaData.fields.keys())
sortedKeys.sort((a: string, b: string): number =>
{
return (tableMetaData.fields.get(a).label.localeCompare(tableMetaData.fields.get(b).label));
});
return (tableMetaData.fields.get(a).label.localeCompare(tableMetaData.fields.get(b).label))
})
}
sortedKeys.forEach((key) =>
{
const field = tableMetaData.fields.get(key);
if (!field)
if(field.isHeavy)
{
return;
}
if (field.isHeavy)
{
if (field.type == QFieldType.BLOB)
if(field.type == QFieldType.BLOB)
{
////////////////////////////////////////////////////////
// assume we DO want heavy blobs - as download links. //
@ -227,7 +270,7 @@ export default class DataGridUtils
const column = this.makeColumnFromField(field, tableMetaData, namePrefix, labelPrefix);
if (key === tableMetaData.primaryKeyField && linkBase && namePrefix == null)
if(key === tableMetaData.primaryKeyField && linkBase && namePrefix == null)
{
columns.splice(0, 0, column);
}
@ -252,10 +295,11 @@ export default class DataGridUtils
public static makeColumnFromField = (field: QFieldMetaData, tableMetaData: QTableMetaData, namePrefix?: string, labelPrefix?: string): GridColDef =>
{
let columnType = "string";
let filterOperators: GridFilterOperator<any>[] = QGridStringOperators;
if (field.possibleValueSourceName)
{
// noop here
filterOperators = buildQGridPvsOperators(tableMetaData.name, field);
}
else
{
@ -264,17 +308,22 @@ export default class DataGridUtils
case QFieldType.DECIMAL:
case QFieldType.INTEGER:
columnType = "number";
filterOperators = QGridNumericOperators;
break;
case QFieldType.DATE:
columnType = "date";
filterOperators = QGridDateOperators;
break;
case QFieldType.DATE_TIME:
columnType = "dateTime";
filterOperators = QGridDateTimeOperators;
break;
case QFieldType.BOOLEAN:
columnType = "string"; // using boolean gives an odd 'no' for nulls.
filterOperators = QGridBooleanOperators;
break;
case QFieldType.BLOB:
filterOperators = QGridBlobOperators;
break;
default:
// noop - leave as string
@ -290,15 +339,16 @@ export default class DataGridUtils
headerName: headerName,
width: DataGridUtils.getColumnWidthForField(field, tableMetaData),
renderCell: null as any,
filterOperators: filterOperators,
};
column.renderCell = (cellValues: any) => (
(cellValues.value)
);
const helpRoles = ["QUERY_SCREEN", "READ_SCREENS", "ALL_SCREENS"];
const helpRoles = ["QUERY_SCREEN", "READ_SCREENS", "ALL_SCREENS"]
const showHelp = hasHelpContent(field.helpContents, helpRoles); // todo - maybe - take helpHelpActive from context all the way down to here?
if (showHelp)
if(showHelp)
{
const formattedHelpContent = <HelpContent helpContents={field.helpContents} roles={helpRoles} heading={headerName} helpContentKey={`table:${tableMetaData.name};field:${fieldName}`} />;
column.renderHeader = (params: GridColumnHeaderParams) => (
@ -311,7 +361,7 @@ export default class DataGridUtils
}
return (column);
};
}
/*******************************************************************************
@ -340,7 +390,7 @@ export default class DataGridUtils
}
}
if (field.possibleValueSourceName)
if(field.possibleValueSourceName)
{
return (200);
}
@ -365,6 +415,6 @@ export default class DataGridUtils
}
return (200);
};
}
}

View File

@ -1,50 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. 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 Box from "@mui/material/Box";
import React from "react";
interface DumpJsonBoxProps
{
data: any;
title?: string;
}
/***************************************************************************
** Utillity for debugging an object as JSON
***************************************************************************/
export default function DumpJsonBox({data, title}: DumpJsonBoxProps): JSX.Element
{
return (
<Box border="1px solid gray" my="1rem" borderRadius="0.5rem">
{
title &&
<Box borderBottom="1px solid gray" mb="0.5rem" px="0.25rem" borderRadius="0.5rem 0.5rem 0 0" fontSize="1rem" fontWeight="600kkk" sx={{backgroundColor: "#D0D0D0"}}>
{title}
</Box>
}
<Box maxHeight="200px" p="0.25rem" overflow="auto" sx={{whiteSpace: "pre-wrap", fontFamily: "monospace", fontSize: "0.75rem", lineHeight: "1.2"}}>
{JSON.stringify(data, null, 3)}
</Box>
</Box>
);
}

View File

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

View File

@ -19,8 +19,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
import {QFieldType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldType";
import Client from "qqq/utils/qqq/Client";
/*******************************************************************************
** Utility functions for basic html/webpage/browser things.
@ -69,16 +68,10 @@ export default class HtmlUtils
** it was originally built like this when we had to submit full access token to backend...
**
*******************************************************************************/
static downloadUrlViaIFrame = (field: QFieldMetaData, url: string, filename: string) =>
static downloadUrlViaIFrame = (url: string, filename: string) =>
{
if (url.startsWith("data:") || url.startsWith("http"))
if(url.startsWith("data:"))
{
if (url.startsWith("http"))
{
const separator = url.includes("?") ? "&" : "?";
url += encodeURIComponent(`${separator}response-content-disposition=attachment; ${filename}`);
}
const link = document.createElement("a");
link.download = filename;
link.href = url;
@ -100,14 +93,8 @@ export default class HtmlUtils
// todo - onload event handler to let us know when done?
document.body.appendChild(iframe);
var method = "get";
if (QFieldType.BLOB == field.type)
{
method = "post";
}
const form = document.createElement("form");
form.setAttribute("method", method);
form.setAttribute("method", "post");
form.setAttribute("action", url);
form.setAttribute("target", "downloadIframe");
iframe.appendChild(form);
@ -130,7 +117,7 @@ export default class HtmlUtils
*******************************************************************************/
static openInNewWindow = (url: string, filename: string) =>
{
if (url.startsWith("data:"))
if(url.startsWith("data:"))
{
/////////////////////////////////////////////////////////////////////////////////////////////
/////////////////////////////////////////////////////////////////////////////////////////////
@ -166,4 +153,4 @@ export default class HtmlUtils
};
}
}

View File

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

View File

@ -1,318 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. 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 {BulkLoadField, BulkLoadMapping, BulkLoadTableStructure, FileDescription} from "qqq/models/processes/BulkLoadModels";
type FieldMapping = { [name: string]: BulkLoadField }
/***************************************************************************
** Utillity methods for working with saved bulk load profiles.
***************************************************************************/
export class SavedBulkLoadProfileUtils
{
/***************************************************************************
**
***************************************************************************/
private static diffFieldContents = (fileDescription: FileDescription, baseFieldsMap: FieldMapping, compareFieldsMap: FieldMapping, orderedFieldArray: BulkLoadField[]): string[] =>
{
const rs: string[] = [];
for (let bulkLoadField of orderedFieldArray)
{
const fieldName = bulkLoadField.getQualifiedName()
const compareField = compareFieldsMap[fieldName];
const baseField = baseFieldsMap[fieldName];
if(!compareField)
{
continue;
}
if (baseField)
{
if (baseField.valueType != compareField.valueType)
{
/////////////////////////////////////////////////////////////////
// if we changed from a default value to a column, report that //
/////////////////////////////////////////////////////////////////
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 ? `(${column})` : ""}`);
}
else if (compareField.valueType == "defaultValue")
{
const column = fileDescription.getColumnNames()[baseField.columnIndex];
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")
{
//////////////////////////////////////////////////
// if we changed the default value, report that //
//////////////////////////////////////////////////
if (baseField.defaultValue != 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")
{
///////////////////////////////////////////
// if we changed the column, report that //
///////////////////////////////////////////
let isDiff = false;
if (fileDescription.hasHeaderRow)
{
if (baseField.headerName != compareField.headerName)
{
isDiff = true;
}
}
else
{
if (baseField.columnIndex != compareField.columnIndex)
{
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) //
/////////////////////////////////////////////////////////////////////////////////////////////////////
if ((baseField.doValueMapping == true) != (compareField.doValueMapping == true))
{
rs.push(`Changed ${compareField.getQualifiedLabel()} to ${compareField.doValueMapping ? "" : "not"} map values`);
}
}
}
}
return (rs);
};
/***************************************************************************
**
***************************************************************************/
private static diffFieldSets = (baseFieldsMap: FieldMapping, compareFieldsMap: FieldMapping, messagePrefix: string, orderedFieldArray: BulkLoadField[]): string[] =>
{
const fieldLabels: string[] = [];
for (let bulkLoadField of orderedFieldArray)
{
const fieldName = bulkLoadField.getQualifiedName()
const compareField = compareFieldsMap[fieldName];
if(!compareField)
{
continue;
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// else - we're not checking for changes to individual fields - rather - we're just checking if fields were added or removed. //
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
if (!baseFieldsMap[fieldName])
{
fieldLabels.push(compareField.getQualifiedLabel());
}
}
if (fieldLabels.length)
{
const s = fieldLabels.length == 1 ? "" : "s";
return ([`${messagePrefix} mapping${s} for ${fieldLabels.length} field${s}: ${fieldLabels.join(", ")}`]);
}
else
{
return ([]);
}
};
/***************************************************************************
**
***************************************************************************/
private static getOrderedActiveFields(mapping: BulkLoadMapping): BulkLoadField[]
{
return [...(mapping.requiredFields ?? []), ...(mapping.additionalFields ?? [])]
}
/***************************************************************************
**
***************************************************************************/
private static extractUsedFieldMapFromMapping(mapping: BulkLoadMapping): FieldMapping
{
let rs: { [name: string]: BulkLoadField } = {};
for (let bulkLoadField of this.getOrderedActiveFields(mapping))
{
rs[bulkLoadField.getQualifiedNameWithWideSuffix()] = bulkLoadField;
}
return (rs);
}
/***************************************************************************
**
***************************************************************************/
private static joinUpToN(values: string[], n: number)
{
if(values.length <= n)
{
return (values.join(", "));
}
const others = values.length - n;
return (values.slice(0, n-1).join(", ") + ` and ${others} other${others == 1 ? "" : "s"}`);
}
/***************************************************************************
**
***************************************************************************/
private static diffFieldValueMappings(bulkLoadField: BulkLoadField, baseMapping: { [p: string]: any }, activeMapping: { [p: string]: any }): string
{
const addedMappings: string[] = [];
const removedMappings: string[] = [];
const changedMappings: string[] = [];
/////////////////////////////
// look for added mappings //
/////////////////////////////
for (let value of Object.keys(activeMapping))
{
if(!baseMapping[value])
{
addedMappings.push(value);
}
}
///////////////////////////////
// look for removed mappings //
///////////////////////////////
for (let value of Object.keys(baseMapping))
{
if(!activeMapping[value])
{
removedMappings.push(value);
}
}
///////////////////////////////
// look for changed mappings //
///////////////////////////////
for (let value of Object.keys(activeMapping))
{
if(baseMapping[value] && activeMapping[value] != baseMapping[value])
{
changedMappings.push(value);
}
}
if(addedMappings.length || removedMappings.length || changedMappings.length)
{
let rs = `Updated value mapping for ${bulkLoadField.getQualifiedLabel()}: `
const parts: string[] = [];
if(addedMappings.length)
{
parts.push(`Added value${addedMappings.length == 1 ? "" : "s"} for: ${this.joinUpToN(addedMappings, 5)}`);
}
if(removedMappings.length)
{
parts.push(`Removed value${removedMappings.length == 1 ? "" : "s"} for: ${this.joinUpToN(removedMappings, 5)}`);
}
if(changedMappings.length)
{
parts.push(`Changed value${changedMappings.length == 1 ? "" : "s"} for: ${this.joinUpToN(changedMappings, 5)}`);
}
return rs + parts.join("; ");
}
return null;
}
/***************************************************************************
**
***************************************************************************/
public static diffBulkLoadMappings = (tableStructure: BulkLoadTableStructure, fileDescription: FileDescription, baseMapping: BulkLoadMapping, activeMapping: BulkLoadMapping): string[] =>
{
const diffs: string[] = [];
const baseFieldsMap = this.extractUsedFieldMapFromMapping(baseMapping);
const activeFieldsMap = this.extractUsedFieldMapFromMapping(activeMapping);
const orderedBaseFields = this.getOrderedActiveFields(baseMapping);
const orderedActiveFields = this.getOrderedActiveFields(activeMapping);
////////////////////////
// header-level diffs //
////////////////////////
if ((baseMapping.hasHeaderRow == true) != (activeMapping.hasHeaderRow == true))
{
diffs.push(`Changed does the file have a header row? from ${baseMapping.hasHeaderRow ? "Yes" : "No"} to ${activeMapping.hasHeaderRow ? "Yes" : "No"}`);
}
if (baseMapping.layout != activeMapping.layout)
{
const format = (layout: string) => (layout ?? " ").substring(0, 1) + (layout ?? " ").substring(1).toLowerCase();
diffs.push(`Changed layout from ${format(baseMapping.layout)} to ${format(activeMapping.layout)}`);
}
///////////////////////
// field-level diffs //
///////////////////////
// todo - keep sorted like screen is by ... idk, loop over fields in mapping first
diffs.push(...this.diffFieldSets(baseFieldsMap, activeFieldsMap, "Added", orderedActiveFields));
diffs.push(...this.diffFieldSets(activeFieldsMap, baseFieldsMap, "Removed", orderedBaseFields));
diffs.push(...this.diffFieldContents(fileDescription, baseFieldsMap, activeFieldsMap, orderedActiveFields));
for (let bulkLoadField of orderedActiveFields)
{
try
{
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)
{
diffs.push(valueMappingDiff);
}
}
catch(e)
{
console.log(`Error diffing profiles: ${e}`);
}
}
return diffs;
};
}

View File

@ -23,23 +23,23 @@ 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";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import IconButton from "@mui/material/IconButton";
import Tooltip from "@mui/material/Tooltip";
import {makeStyles} from "@mui/styles";
import parse from "html-react-parser";
import React, {Fragment, useReducer, useState} from "react";
import AceEditor from "react-ace";
import {Link} from "react-router-dom";
import HtmlUtils from "qqq/utils/HtmlUtils";
import Client from "qqq/utils/qqq/Client";
import "ace-builds/src-noconflict/ace";
import "ace-builds/src-noconflict/mode-sql";
import React, {Fragment, useReducer, useState} from "react";
import AceEditor from "react-ace";
import "ace-builds/src-noconflict/mode-velocity";
import {Link} from "react-router-dom";
/*******************************************************************************
** Utility class for working with QQQ Values
@ -77,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, tableVariant?: QTableVariant): string | JSX.Element | JSX.Element[]
public static getDisplayValue(field: QFieldMetaData, record: QRecord, usage: "view" | "query" = "view", overrideFieldName?: string): string | JSX.Element | JSX.Element[]
{
const fieldName = overrideFieldName ?? field.name;
const displayValue = record.displayValues ? record.displayValues.get(fieldName) : undefined;
const rawValue = record.values ? record.values.get(fieldName) : undefined;
return ValueUtils.getValueForDisplay(field, rawValue, displayValue, usage, tableVariant, record, fieldName);
return ValueUtils.getValueForDisplay(field, rawValue, displayValue, usage);
}
@ -92,35 +92,14 @@ class ValueUtils
** When you have a field and a value (either just a raw value, or a raw and
** display value), call this method to get a string Element to display.
*******************************************************************************/
public static getValueForDisplay(field: QFieldMetaData, rawValue: any, displayValue: any = rawValue, usage: "view" | "query" = "view", tableVariant?: QTableVariant, record?: QRecord, fieldName?: string): string | JSX.Element | JSX.Element[]
public static getValueForDisplay(field: QFieldMetaData, rawValue: any, displayValue: any = rawValue, usage: "view" | "query" = "view"): string | JSX.Element | JSX.Element[]
{
if (field.hasAdornment(AdornmentType.LINK))
{
const adornment = field.getAdornment(AdornmentType.LINK);
let href = String(rawValue);
let toRecordFromTable = adornment.getValue("toRecordFromTable");
/////////////////////////////////////////////////////////////////////////////////////
// if the link adornment has a 'toRecordFromTableDynamic', then look for a display //
// value named `fieldName`:toRecordFromTableDynamic for the table name. //
/////////////////////////////////////////////////////////////////////////////////////
if(adornment.getValue("toRecordFromTableDynamic"))
{
const toRecordFromTableDynamic = record?.displayValues?.get(fieldName + ":toRecordFromTableDynamic");
if(toRecordFromTableDynamic)
{
toRecordFromTable = toRecordFromTableDynamic;
}
else
{
///////////////////////////////////////////////////////////////////
// if the table name isn't known, then return w/o the adornment. //
///////////////////////////////////////////////////////////////////
return (ValueUtils.getUnadornedValueForDisplay(field, rawValue, displayValue));
}
}
let href = rawValue;
const toRecordFromTable = adornment.getValue("toRecordFromTable");
if (toRecordFromTable)
{
if (ValueUtils.getQInstance())
@ -129,7 +108,7 @@ class ValueUtils
if (!tablePath)
{
console.log("Couldn't find path for table: " + toRecordFromTable);
return (ValueUtils.getUnadornedValueForDisplay(field, rawValue, displayValue));
return (displayValue ?? rawValue);
}
if (!tablePath.endsWith("/"))
@ -219,46 +198,14 @@ class ValueUtils
);
}
if (field.type == QFieldType.BLOB || field.hasAdornment(AdornmentType.FILE_DOWNLOAD))
if (field.type == QFieldType.BLOB)
{
let url = rawValue;
if(tableVariant)
{
url += "?tableVariant=" + encodeURIComponent(JSON.stringify(tableVariant));
}
//////////////////////////////////////////////////////////////////////////////
// if the field has the download adornment with a downloadUrlDynamic value, //
// then get the url from a displayValue of `fieldName`:downloadUrlDynamic. //
//////////////////////////////////////////////////////////////////////////////
if(field.hasAdornment(AdornmentType.FILE_DOWNLOAD))
{
const adornment = field.getAdornment(AdornmentType.FILE_DOWNLOAD);
let downloadUrlDynamicAdornmentValue = adornment.getValue("downloadUrlDynamic");
if(downloadUrlDynamicAdornmentValue)
{
const downloadUrlDynamicValue = record?.displayValues?.get(fieldName + ":downloadUrlDynamic");
if (downloadUrlDynamicValue)
{
url = downloadUrlDynamicValue;
}
else
{
////////////////////////////////////////////////////////////////
// if the url isn't available, then return w/o the adornment. //
////////////////////////////////////////////////////////////////
return (ValueUtils.getUnadornedValueForDisplay(field, rawValue, displayValue));
}
}
}
return (<BlobComponent field={field} url={url} filename={displayValue} usage={usage} />);
return (<BlobComponent field={field} url={rawValue} filename={displayValue} usage={usage} />);
}
return (ValueUtils.getUnadornedValueForDisplay(field, rawValue, displayValue));
}
/*******************************************************************************
** After we know there's no element to be returned (e.g., because no adornment),
** this method does the string formatting.
@ -267,18 +214,12 @@ class ValueUtils
{
if (!displayValue && field.defaultValue)
{
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// note, at one point in time, we used a field's default value here if no displayValue... but that feels 100% wrong, //
// e.g., a null field would show up (on a query or view screen) has having some value! //
// not sure if this was maybe supposed to be displayValue = rawValue, but, keep that in mind, and keep this block here //
// in case we run into issues and need to revisit/rethink //
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// displayValue = field.defaultValue;
displayValue = field.defaultValue;
}
if (field.type === QFieldType.DATE_TIME)
{
if (displayValue && displayValue != rawValue)
if(displayValue && displayValue != rawValue)
{
//////////////////////////////////////////////////////////////////////////////
// if the date-time actually has a displayValue set, and it isn't just the //
@ -327,15 +268,7 @@ class ValueUtils
{
if (!(date instanceof Date))
{
////////////////////////////////////////////////////////////////////////////////////
// so, a new Date here will interpret the string as being at midnight UTC, but //
// the data object will be in the user/browser timezone. //
// so "2024-08-22", for a user in US/Central, will be "2024-08-21T19:00:00-0500". //
// correct for that by adding the date's timezone offset (converted from minutes //
// to millis) back to it //
////////////////////////////////////////////////////////////////////////////////////
date = new Date(date);
date.setTime(date.getTime() + date.getTimezoneOffset() * 60 * 1000);
}
// @ts-ignore
return (`${date.toString("yyyy-MM-dd")}`);
@ -533,7 +466,7 @@ class ValueUtils
*******************************************************************************/
public static cleanForCsv(param: any): string
{
if (param === undefined || param === null)
if(param === undefined || param === null)
{
return ("");
}
@ -558,7 +491,7 @@ class ValueUtils
////////////////////////////////////////////////////////////////////////////////////////////////
// little private component here, for rendering an AceEditor with some buttons/controls/state //
////////////////////////////////////////////////////////////////////////////////////////////////
function CodeViewer({name, mode, code}: { name: string; mode: string; code: string; }): JSX.Element
function CodeViewer({name, mode, code}: {name: string; mode: string; code: string;}): JSX.Element
{
const [activeCode, setActiveCode] = useState(code);
const [isFormatted, setIsFormatted] = useState(false);
@ -655,7 +588,7 @@ function CodeViewer({name, mode, code}: { name: string; mode: string; code: stri
////////////////////////////////////////////////////////////////////////////////////////////////////////////
// little private component here, for rendering "secret-ish" values, that you can click to reveal or copy //
////////////////////////////////////////////////////////////////////////////////////////////////////////////
function RevealComponent({fieldName, value, usage}: { fieldName: string, value: string, usage: string; }): JSX.Element
function RevealComponent({fieldName, value, usage}: {fieldName: string, value: string, usage: string;}): JSX.Element
{
const [adornmentFieldsMap, setAdornmentFieldsMap] = useState(new Map<string, boolean>);
const [, forceUpdate] = useReducer((x) => x + 1, 0);
@ -712,7 +645,7 @@ function RevealComponent({fieldName, value, usage}: { fieldName: string, value:
</Tooltip>
</ClickAwayListener>
</Box>
) : (
):(
<Box display="inline"><Icon onClick={(e) => handleRevealIconClick(e, fieldName)} sx={{cursor: "pointer", fontSize: "15px !important", position: "relative", top: "3px", marginRight: "5px"}}>visibility_off</Icon>{displayValue}</Box>
)
)
@ -739,7 +672,7 @@ function BlobComponent({field, url, filename, usage}: BlobComponentProps): JSX.E
const download = (event: React.MouseEvent<HTMLSpanElement>) =>
{
event.stopPropagation();
HtmlUtils.downloadUrlViaIFrame(field, url, filename);
HtmlUtils.downloadUrlViaIFrame(url, filename);
};
const open = (event: React.MouseEvent<HTMLSpanElement>) =>
@ -748,7 +681,7 @@ function BlobComponent({field, url, filename, usage}: BlobComponentProps): JSX.E
HtmlUtils.openInNewWindow(url, filename);
};
if (!filename || !url)
if(!filename || !url)
{
return (<React.Fragment />);
}
@ -763,22 +696,10 @@ function BlobComponent({field, url, filename, usage}: BlobComponentProps): JSX.E
usage == "view" && filename
}
<Tooltip placement={tooltipPlacement} title="Open file">
{
field.type == QFieldType.BLOB ? (
<Icon className={"blobIcon"} fontSize="small" onClick={(e) => open(e)}>open_in_new</Icon>
) : (
<a style={{color: "inherit"}} rel="noopener noreferrer" href={url} target="_blank"><Icon className={"blobIcon"} fontSize="small">open_in_new</Icon></a>
)
}
<Icon className={"blobIcon"} fontSize="small" onClick={(e) => open(e)}>open_in_new</Icon>
</Tooltip>
<Tooltip placement={tooltipPlacement} title="Download file">
{
field.type == QFieldType.BLOB ? (
<Icon className={"blobIcon"} fontSize="small" onClick={(e) => download(e)}>save_alt</Icon>
) : (
<a style={{color: "inherit"}} href={url} download="test.pdf"><Icon className={"blobIcon"} fontSize="small">save_alt</Icon></a>
)
}
<Icon className={"blobIcon"} fontSize="small" onClick={(e) => download(e)}>save_alt</Icon>
</Tooltip>
{
usage == "query" && filename
@ -788,4 +709,5 @@ function BlobComponent({field, url, filename, usage}: BlobComponentProps): JSX.E
}
export default ValueUtils;

View File

@ -57,5 +57,4 @@ module.exports = function (app)
app.use("/images", getRequestHandler());
app.use("/api*", getRequestHandler());
app.use("/*api", getRequestHandler());
app.use("/qqq/*", getRequestHandler());
};

View File

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

View File

@ -22,7 +22,6 @@
package com.kingsrook.qqq.frontend.materialdashboard.selenium.tests;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.QBaseSeleniumTest;
import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.javalin.QSeleniumJavalin;
import org.junit.jupiter.api.Test;
@ -33,9 +32,6 @@ import org.junit.jupiter.api.Test;
*******************************************************************************/
public class BulkEditTest extends QBaseSeleniumTest
{
private static final QLogger LOG = QLogger.getLogger(BulkEditTest.class);
/*******************************************************************************
**
@ -80,13 +76,6 @@ public class BulkEditTest extends QBaseSeleniumTest
qSeleniumLib.waitForSelectorContaining("li", "This page").click();
qSeleniumLib.waitForSelectorContaining("div", "records on this page are selected");
/////////////////////////////////////////////////////////////////////////////////
// locally, passing fine, but in CI, failing around here ... trying a sleep... //
/////////////////////////////////////////////////////////////////////////////////
LOG.debug("Trying a sleep...");
qSeleniumLib.waitForMillis(1000);
LOG.debug("Proceeding post-sleep");
qSeleniumLib.waitForSelectorContaining("button", "action").click();
qSeleniumLib.waitForSelectorContaining("li", "bulk edit").click();

View File

@ -56,8 +56,8 @@ public class DashboardTableWidgetExportTest extends QBaseSeleniumTest
"label": "Sample Table Widget",
"footerHTML": "<span class='material-icons-round notranslate MuiIcon-root MuiIcon-fontSizeInherit' aria-hidden='true'><span class='dashboard-schedule-icon'>schedule</span></span>Updated at 2023-10-17 09:11:38 AM CDT",
"columns": [
{ "type": "html", "header": "Id", "accessor": "id", "width": "30px" },
{ "type": "html", "header": "Name", "accessor": "name", "width": "1fr" }
{ "type": "html", "header": "Id", "accessor": "id", "width": "1%" },
{ "type": "html", "header": "Name", "accessor": "name", "width": "99%" }
],
"rows": [
{ "id": "1", "name": "<a href='/setup/person/1'>Homer S.</a>" },
@ -83,7 +83,7 @@ public class DashboardTableWidgetExportTest extends QBaseSeleniumTest
// assert that the table widget rendered its header and some contents //
////////////////////////////////////////////////////////////////////////
qSeleniumLib.waitForSelectorContaining("#SampleTableWidget h6", "Sample Table Widget");
qSeleniumLib.waitForSelectorContaining("#SampleTableWidget a", "Homer S.");
qSeleniumLib.waitForSelectorContaining("#SampleTableWidget table a", "Homer S.");
qSeleniumLib.waitForSelectorContaining("#SampleTableWidget div", "Updated at 2023-10-17 09:11:38 AM CDT");
/////////////////////////////

View File

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