Compare commits

...

22 Commits

Author SHA1 Message Date
7ea50dd7bb Merge branch 'main' into feature/CE-609-infrastructure-remove-permissions-from-header
# Conflicts:
#	package.json
2023-08-17 11:42:51 -05:00
53d5bc58c1 updated snapshot 2023-08-17 10:18:38 -05:00
eac166b877 circle ci config update 2023-08-17 10:06:44 -05:00
f49ac38e24 changed circle ci to only deploy main automatically 2023-08-17 09:53:57 -05:00
28bdfc19e8 rebuilt package-lock 2023-08-17 09:47:24 -05:00
fa076733fb CE-567 Update version of webdrivermanager, because apparently you have to do that sometimes 2023-08-16 11:46:20 -05:00
8bebef1abe CE-567 Pass table name to init (for table-generic processes that might want it) 2023-08-15 19:44:58 -05:00
37fa578a59 CE-567 show more lines of commit messages 2023-08-15 19:44:13 -05:00
b6b7d8d8b3 CE-609 - Removed DNDTest WIP module 2023-08-15 09:25:45 -05:00
7bf515554d CE-609 - staged-rollout-ready - keeping the auth header, but also setting sessionUUID cookie; placeholder for quick-rollback; added todo#authHeader comments to mark where follow-up needs to happen after happy with new code 2023-08-15 09:08:44 -05:00
069cbf52e1 Merge pull request #28 from Kingsrook/feature/CE-607-mvp-of-transportation-plan-record
CE-607 Support fields from an exposed-join on a view screen.
2023-08-09 12:28:16 -05:00
7fa42a6eb5 Initial WIP Checkpoint of auth0 userSessions 2023-08-09 09:48:22 -05:00
efc423e819 CE-607 Support fields from an exposed-join on a view screen. 2023-08-08 15:54:57 -05:00
a268219156 CE-563: new version 2023-08-03 13:00:14 -05:00
9ec442e218 CE-563: new version 2023-08-03 12:46:23 -05:00
f1dacea6f5 Merge pull request #27 from Kingsrook/dev
dev into sprint-30
2023-08-01 18:46:03 -05:00
b9d81e730f Add percents to ColumnStats 2023-07-27 08:39:58 -05:00
c7622c12f5 Move getColumnWidthForField out into its own method 2023-07-27 08:38:22 -05:00
953c4cc569 Add homeCityId field (used by new filter test) 2023-07-26 13:21:39 -05:00
63430e1283 Update to qqq 0.17.0 (should fix filter test) 2023-07-26 13:21:23 -05:00
f189083a5a Fix bug w/ filter in URL not having any values not being respected. Add selenium test for it!! 2023-07-26 12:39:58 -05:00
efcf137a0f Merge pull request #26 from Kingsrook/feature/CE-551-change-logic-for-fed-ex
Feature/ce 551 change logic for fed ex
2023-07-26 08:43:22 -05:00
21 changed files with 1557 additions and 1224 deletions

View File

@ -115,7 +115,7 @@ workflows:
context: [ qqq-maven-registry-credentials, kingsrook-slack, build-qqq-sample-app ] context: [ qqq-maven-registry-credentials, kingsrook-slack, build-qqq-sample-app ]
filters: filters:
branches: branches:
ignore: /dev/ ignore: /main/
tags: tags:
ignore: /(version|snapshot)-.*/ ignore: /(version|snapshot)-.*/
deploy: deploy:
@ -124,7 +124,7 @@ workflows:
context: [ qqq-maven-registry-credentials, kingsrook-slack, build-qqq-sample-app ] context: [ qqq-maven-registry-credentials, kingsrook-slack, build-qqq-sample-app ]
filters: filters:
branches: branches:
only: /dev/ only: /main/
tags: tags:
only: /(version|snapshot)-.*/ only: /(version|snapshot)-.*/

2101
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -29,7 +29,7 @@
<packaging>jar</packaging> <packaging>jar</packaging>
<properties> <properties>
<revision>0.16.0-SNAPSHOT</revision> <revision>0.19.0-SNAPSHOT</revision>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding> <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
@ -66,7 +66,7 @@
<dependency> <dependency>
<groupId>com.kingsrook.qqq</groupId> <groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-backend-core</artifactId> <artifactId>qqq-backend-core</artifactId>
<version>feature-CTLE-503-optimization-weather-api-data-20230701.011918-3</version> <version>0.17.0-SNAPSHOT</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.slf4j</groupId> <groupId>org.slf4j</groupId>
@ -83,7 +83,7 @@
<dependency> <dependency>
<groupId>io.github.bonigarcia</groupId> <groupId>io.github.bonigarcia</groupId>
<artifactId>webdrivermanager</artifactId> <artifactId>webdrivermanager</artifactId>
<version>5.3.1</version> <version>5.4.1</version>
<scope>test</scope> <scope>test</scope>
</dependency> </dependency>
<dependency> <dependency>

View File

@ -33,7 +33,7 @@ import CssBaseline from "@mui/material/CssBaseline";
import Icon from "@mui/material/Icon"; import Icon from "@mui/material/Icon";
import {ThemeProvider} from "@mui/material/styles"; import {ThemeProvider} from "@mui/material/styles";
import {LicenseInfo} from "@mui/x-license-pro"; import {LicenseInfo} from "@mui/x-license-pro";
import React, {JSXElementConstructor, Key, ReactElement, useContext, useEffect, useState,} from "react"; import React, {JSXElementConstructor, Key, ReactElement, useEffect, useState,} from "react";
import {useCookies} from "react-cookie"; import {useCookies} from "react-cookie";
import {Navigate, Route, Routes, useLocation,} from "react-router-dom"; import {Navigate, Route, Routes, useLocation,} from "react-router-dom";
import {Md5} from "ts-md5/dist/md5"; import {Md5} from "ts-md5/dist/md5";
@ -57,11 +57,11 @@ import ProcessUtils from "qqq/utils/qqq/ProcessUtils";
const qController = Client.getInstance(); const qController = Client.getInstance();
export const SESSION_ID_COOKIE_NAME = "sessionId"; export const SESSION_UUID_COOKIE_NAME = "sessionUUID";
export default function App() export default function App()
{ {
const [, setCookie, removeCookie] = useCookies([SESSION_ID_COOKIE_NAME]); const [, setCookie, removeCookie] = useCookies([SESSION_UUID_COOKIE_NAME]);
const {user, getAccessTokenSilently, logout} = useAuth0(); const {user, getAccessTokenSilently, logout} = useAuth0();
const [loadingToken, setLoadingToken] = useState(false); const [loadingToken, setLoadingToken] = useState(false);
const [isFullyAuthenticated, setIsFullyAuthenticated] = useState(false); const [isFullyAuthenticated, setIsFullyAuthenticated] = useState(false);
@ -69,8 +69,67 @@ export default function App()
const [branding, setBranding] = useState({} as QBrandingMetaData); const [branding, setBranding] = useState({} as QBrandingMetaData);
const [metaData, setMetaData] = useState({} as QInstance); const [metaData, setMetaData] = useState({} as QInstance);
const [needLicenseKey, setNeedLicenseKey] = useState(true); const [needLicenseKey, setNeedLicenseKey] = useState(true);
const [loggedInUser, setLoggedInUser] = useState({} as { name?: string, email?: string });
const [defaultRoute, setDefaultRoute] = useState("/no-apps"); const [defaultRoute, setDefaultRoute] = useState("/no-apps");
const decodeJWT = (jwt: string): any =>
{
const base64Url = jwt.split(".")[1];
const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/");
const jsonPayload = decodeURIComponent(window.atob(base64).split("").map(function (c)
{
return "%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2);
}).join(""));
return JSON.parse(jsonPayload);
};
const shouldStoreNewToken = (newToken: string, oldToken: string): boolean =>
{
if (!oldToken)
{
return (true);
}
try
{
const oldJSON = decodeJWT(oldToken);
const newJSON = decodeJWT(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.");
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.");
}
return (different);
}
catch(e)
{
console.log("Caught in shouldStoreNewToken: " + e)
}
return (true);
};
useEffect(() => useEffect(() =>
{ {
if (loadingToken) if (loadingToken)
@ -92,20 +151,38 @@ export default function App()
{ {
console.log("Loading token from auth0..."); console.log("Loading token from auth0...");
const accessToken = await getAccessTokenSilently(); const accessToken = await getAccessTokenSilently();
qController.setAuthorizationHeaderValue("Bearer " + accessToken);
///////////////////////////////////////////////////////////////////////////////// const lsAccessToken = localStorage.getItem("accessToken");
// we've stopped using session id cook with auth0, so make sure it is not set. // if (shouldStoreNewToken(accessToken, lsAccessToken))
///////////////////////////////////////////////////////////////////////////////// {
removeCookie(SESSION_ID_COOKIE_NAME); console.log("Sending accessToken to backend, requesting a sessionUUID...");
const newSessionUuid = await qController.manageSession(accessToken, null);
setCookie(SESSION_UUID_COOKIE_NAME, newSessionUuid, {path: "/"});
localStorage.setItem("accessToken", accessToken);
}
/*
////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// todo#authHeader - this is our quick rollback plan - if we feel the need to stop using the cookie approach. //
// we turn off the shouldStoreNewToken block above, and turn on these 2 lines. //
////////////////////////////////////////////////////////////////////////////////////////////////////////////////
removeCookie(SESSION_UUID_COOKIE_NAME, {path: "/"});
localStorage.removeItem("accessToken");
*/
setIsFullyAuthenticated(true); setIsFullyAuthenticated(true);
qController.setGotAuthentication();
qController.setAuthorizationHeaderValue("Bearer " + accessToken);
setLoggedInUser(user);
console.log("Token load complete."); console.log("Token load complete.");
} }
catch (e) catch (e)
{ {
console.log(`Error loading token: ${JSON.stringify(e)}`); console.log(`Error loading token: ${JSON.stringify(e)}`);
qController.clearAuthenticationMetaDataLocalStorage(); qController.clearAuthenticationMetaDataLocalStorage();
localStorage.removeItem("accessToken")
removeCookie(SESSION_UUID_COOKIE_NAME, {path: "/"});
logout(); logout();
return; return;
} }
@ -116,9 +193,9 @@ export default function App()
// use a random token if anonymous or mock // // use a random token if anonymous or mock //
///////////////////////////////////////////// /////////////////////////////////////////////
console.log("Generating random token..."); console.log("Generating random token...");
qController.setAuthorizationHeaderValue(null); qController.setAuthorizationHeaderValue(Md5.hashStr(`${new Date()}`));
setIsFullyAuthenticated(true); setIsFullyAuthenticated(true);
setCookie(SESSION_ID_COOKIE_NAME, Md5.hashStr(`${new Date()}`), {path: "/"}); setCookie(SESSION_UUID_COOKIE_NAME, Md5.hashStr(`${new Date()}`), {path: "/"});
console.log("Token generation complete."); console.log("Token generation complete.");
return; return;
} }
@ -149,7 +226,7 @@ export default function App()
const [needToLoadRoutes, setNeedToLoadRoutes] = useState(true); const [needToLoadRoutes, setNeedToLoadRoutes] = useState(true);
const [sideNavRoutes, setSideNavRoutes] = useState([]); const [sideNavRoutes, setSideNavRoutes] = useState([]);
const [appRoutes, setAppRoutes] = useState(null as any); const [appRoutes, setAppRoutes] = useState(null as any);
const [pathToLabelMap, setPathToLabelMap] = useState({} as {[path: string]: string}); const [pathToLabelMap, setPathToLabelMap] = useState({} as { [path: string]: string });
//////////////////////////////////////////// ////////////////////////////////////////////
// load qqq meta data to make more routes // // load qqq meta data to make more routes //
@ -267,14 +344,14 @@ export default function App()
name: `${app.label}`, name: `${app.label}`,
key: app.name, key: app.name,
route: path, route: path,
component: <RecordQuery table={table} key={table.name}/>, component: <RecordQuery table={table} key={table.name} />,
}); });
routeList.push({ routeList.push({
name: `${app.label}`, name: `${app.label}`,
key: app.name, key: app.name,
route: `${path}/savedFilter/:id`, route: `${path}/savedFilter/:id`,
component: <RecordQuery table={table} key={table.name}/>, component: <RecordQuery table={table} key={table.name} />,
}); });
routeList.push({ routeList.push({
@ -429,11 +506,11 @@ export default function App()
let profileRoutes = {}; let profileRoutes = {};
const gravatarBase = "https://www.gravatar.com/avatar/"; const gravatarBase = "https://www.gravatar.com/avatar/";
const hash = Md5.hashStr(user?.email || "user"); const hash = Md5.hashStr(loggedInUser?.email || "user");
const profilePicture = `${gravatarBase}${hash}`; const profilePicture = `${gravatarBase}${hash}`;
profileRoutes = { profileRoutes = {
type: "collapse", type: "collapse",
name: user?.name, name: loggedInUser?.name ?? "Anonymous",
key: "username", key: "username",
noCollapse: true, noCollapse: true,
icon: <Avatar src={profilePicture} alt="{user?.name}" />, icon: <Avatar src={profilePicture} alt="{user?.name}" />,
@ -469,7 +546,7 @@ export default function App()
} }
const pathToLabelMap: {[path: string]: string} = {} const pathToLabelMap: {[path: string]: string} = {}
for(let i =0; i<appRoutesList.length; i++) for (let i = 0; i < appRoutesList.length; i++)
{ {
const route = appRoutesList[i]; const route = appRoutesList[i];
pathToLabelMap[route.route] = route.name; pathToLabelMap[route.route] = route.name;
@ -495,7 +572,10 @@ export default function App()
{ {
if ((e as QException).status === "401") if ((e as QException).status === "401")
{ {
console.log("Exception is a QException with status = 401. Clearing some of localStorage & cookies");
qController.clearAuthenticationMetaDataLocalStorage(); qController.clearAuthenticationMetaDataLocalStorage();
localStorage.removeItem("accessToken")
removeCookie(SESSION_UUID_COOKIE_NAME, {path: "/"});
////////////////////////////////////////////////////// //////////////////////////////////////////////////////
// todo - this is auth0 logout... make more generic // // todo - this is auth0 logout... make more generic //
@ -596,7 +676,7 @@ export default function App()
}}> }}>
<ThemeProvider theme={theme}> <ThemeProvider theme={theme}>
<CssBaseline /> <CssBaseline />
<CommandMenu metaData={metaData}/> <CommandMenu metaData={metaData} />
<Sidenav <Sidenav
color={sidenavColor} color={sidenavColor}
icon={branding.icon} icon={branding.icon}

View File

@ -22,7 +22,7 @@
import {useAuth0} from "@auth0/auth0-react"; import {useAuth0} from "@auth0/auth0-react";
import React, {useEffect} from "react"; import React, {useEffect} from "react";
import {useCookies} from "react-cookie"; import {useCookies} from "react-cookie";
import {SESSION_ID_COOKIE_NAME} from "App"; import {SESSION_UUID_COOKIE_NAME} from "App";
interface Props interface Props
{ {
@ -33,13 +33,13 @@ interface Props
function HandleAuthorizationError({errorMessage}: Props) function HandleAuthorizationError({errorMessage}: Props)
{ {
const [, , removeCookie] = useCookies([SESSION_ID_COOKIE_NAME]); const [, , removeCookie] = useCookies([SESSION_UUID_COOKIE_NAME]);
const {logout} = useAuth0(); const {logout} = useAuth0();
useEffect(() => useEffect(() =>
{ {
logout(); logout();
removeCookie(SESSION_ID_COOKIE_NAME, {path: "/"}); removeCookie(SESSION_UUID_COOKIE_NAME, {path: "/"});
}); });
return ( return (

View File

@ -346,6 +346,12 @@ function EntityForm(props: Props): JSX.Element
const fieldName = section.fieldNames[j]; const fieldName = section.fieldNames[j];
const field = tableMetaData.fields.get(fieldName); const field = tableMetaData.fields.get(fieldName);
if(!field)
{
console.log(`Omitting un-found field ${fieldName} from form`);
continue;
}
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// if id !== null (and we're not copying) - means we're on the edit screen -- show all fields on the edit screen. // // if id !== null (and we're not copying) - means we're on the edit screen -- show all fields on the edit screen. //
// || (or) we're on the insert screen in which case, only show editable fields. // // || (or) we're on the insert screen in which case, only show editable fields. //

View File

@ -216,7 +216,7 @@ export default function DataBagViewer({dataBagId}: Props): JSX.Element
primaryTypographyProps={{fontSize: "1rem"}} primaryTypographyProps={{fontSize: "1rem"}}
secondaryTypographyProps={{fontSize: ".85rem"}} secondaryTypographyProps={{fontSize: ".85rem"}}
primary={ primary={
<div style={{whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis"}} title={version.values.get("commitMessage")}> <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"}} />} {currentVersionId == version?.values?.get("id") && <Chip label="CURRENT" color="success" variant="outlined" size="small" sx={{mr: 1, fontSize: "0.75rem"}} />}
{version.values.get("commitMessage")} {version.values.get("commitMessage")}
</div> </div>

View File

@ -314,7 +314,7 @@ export default function ScriptViewer({scriptId, associatedScriptTableName, assoc
primaryTypographyProps={{fontSize: "1rem"}} primaryTypographyProps={{fontSize: "1rem"}}
secondaryTypographyProps={{fontSize: ".85rem"}} secondaryTypographyProps={{fontSize: ".85rem"}}
primary={ primary={
<div style={{whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis"}} title={version.values.get("commitMessage")}> <div style={{overflow: "hidden", textOverflow: "ellipsis", maxHeight: "5rem"}} title={version.values.get("commitMessage")}>
{scriptRecord.values.get("currentScriptRevisionId") == version?.values?.get("id") && <Chip label="CURRENT" color="success" variant="outlined" size="small" sx={{mr: 1, fontSize: "0.75rem"}} />} {scriptRecord.values.get("currentScriptRevisionId") == version?.values?.get("id") && <Chip label="CURRENT" color="success" variant="outlined" size="small" sx={{mr: 1, fontSize: "0.75rem"}} />}
{version.values.get("commitMessage")} {version.values.get("commitMessage")}
</div> </div>

View File

@ -221,12 +221,19 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
const download = (url: string, fileName: string) => const download = (url: string, fileName: string) =>
{ {
const qController = Client.getInstance(); /////////////////////////////////////////////////////////////////////////////////////////////
// todo - this could be simplified. //
// it was originally built like this when we had to submit full access token to backend... //
/////////////////////////////////////////////////////////////////////////////////////////////
let xhr = new XMLHttpRequest(); let xhr = new XMLHttpRequest();
xhr.open("POST", url); xhr.open("POST", url);
xhr.responseType = "blob"; xhr.responseType = "blob";
let formData = new FormData(); let formData = new FormData();
////////////////////////////////////
// todo#authHeader - delete this. //
////////////////////////////////////
const qController = Client.getInstance();
formData.append("Authorization", qController.getAuthorizationHeaderValue()); formData.append("Authorization", qController.getAuthorizationHeaderValue());
// @ts-ignore // @ts-ignore
@ -985,6 +992,10 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
} }
setQJobRunning(null); setQJobRunning(null);
} }
else
{
console.warn(`Process response was not of an expected type (need an npm clean?) ${JSON.stringify(lastProcessResponse)}`);
}
} }
}, [lastProcessResponse]); }, [lastProcessResponse]);
@ -1143,6 +1154,11 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
} }
} }
if(tableMetaData)
{
queryStringPairsForInit.push(`tableName=${tableMetaData.name}`)
}
try try
{ {
const processResponse = await Client.getInstance().processInit(processName, queryStringPairsForInit.join("&")); const processResponse = await Client.getInstance().processInit(processName, queryStringPairsForInit.join("&"));

View File

@ -88,7 +88,7 @@ function ColumnStats({tableMetaData, fieldMetaData, fieldTableName, filter}: Pro
} }
const processResult = await qController.processRun("columnStats", formData); const processResult = await qController.processRun("columnStats", formData);
setStatusString(null) setStatusString(null);
if (processResult instanceof QJobError) if (processResult instanceof QJobError)
{ {
const jobError = processResult as QJobError; const jobError = processResult as QJobError;
@ -107,7 +107,7 @@ function ColumnStats({tableMetaData, fieldMetaData, fieldTableName, filter}: Pro
const newStatsFields = [] as QFieldMetaData[]; const newStatsFields = [] as QFieldMetaData[];
for(let i = 0; i<statFieldObjects.length; i++) for(let i = 0; i<statFieldObjects.length; i++)
{ {
newStatsFields.push(new QFieldMetaData(statFieldObjects[i])) newStatsFields.push(new QFieldMetaData(statFieldObjects[i]));
} }
setStatsFields(newStatsFields); setStatsFields(newStatsFields);
} }
@ -139,15 +139,15 @@ function ColumnStats({tableMetaData, fieldMetaData, fieldTableName, filter}: Pro
fakeTableMetaData.fields = new Map<string, QFieldMetaData>(); fakeTableMetaData.fields = new Map<string, QFieldMetaData>();
fakeTableMetaData.fields.set(fieldMetaData.name, fieldMetaData); fakeTableMetaData.fields.set(fieldMetaData.name, fieldMetaData);
fakeTableMetaData.fields.set("count", new QFieldMetaData({name: "count", label: "Count", type: "INTEGER"})); fakeTableMetaData.fields.set("count", new QFieldMetaData({name: "count", label: "Count", type: "INTEGER"}));
fakeTableMetaData.fields.set("percent", new QFieldMetaData({name: "percent", label: "Percent", type: "DECIMAL"}));
fakeTableMetaData.sections = [] as QTableSection[]; fakeTableMetaData.sections = [] as QTableSection[];
fakeTableMetaData.sections.push(new QTableSection({fieldNames: [fieldMetaData.name, "count"]})); fakeTableMetaData.sections.push(new QTableSection({fieldNames: [fieldMetaData.name, "count", "percent"]}));
const rows = DataGridUtils.makeRows(valueCounts, fakeTableMetaData); const rows = DataGridUtils.makeRows(valueCounts, fakeTableMetaData);
const columns = DataGridUtils.setupGridColumns(fakeTableMetaData, null, null, "bySection"); const columns = DataGridUtils.setupGridColumns(fakeTableMetaData, null, null, "bySection");
columns.forEach((c) => columns.forEach((c) =>
{ {
c.width = 200;
c.filterable = false; c.filterable = false;
c.hideable = false; c.hideable = false;
}) })
@ -162,7 +162,7 @@ function ColumnStats({tableMetaData, fieldMetaData, fieldTableName, filter}: Pro
function CustomPagination() function CustomPagination()
{ {
return ( return (
<Box pr={3}> <Box pr={3} fontSize="0.85rem">
{rows && rows.length && countDistinct && rows.length < countDistinct ? <span>Showing the first {rows.length.toLocaleString()} of {countDistinct.toLocaleString()} values</span> : <></>} {rows && rows.length && countDistinct && rows.length < countDistinct ? <span>Showing the first {rows.length.toLocaleString()} of {countDistinct.toLocaleString()} values</span> : <></>}
{rows && rows.length && countDistinct && rows.length >= countDistinct && rows.length == 1 ? <span>Showing the only value</span> : <></>} {rows && rows.length && countDistinct && rows.length >= countDistinct && rows.length == 1 ? <span>Showing the only value</span> : <></>}
{rows && rows.length && countDistinct && rows.length >= countDistinct && rows.length > 1 ? <span>Showing all {rows.length.toLocaleString()} values</span> : <></>} {rows && rows.length && countDistinct && rows.length >= countDistinct && rows.length > 1 ? <span>Showing all {rows.length.toLocaleString()} values</span> : <></>}
@ -172,9 +172,9 @@ function ColumnStats({tableMetaData, fieldMetaData, fieldTableName, filter}: Pro
const refresh = () => const refresh = () =>
{ {
setLoading(true) setLoading(true);
setStatusString("Refreshing...") setStatusString("Refreshing...");
} };
const doExport = () => const doExport = () =>
{ {
@ -188,7 +188,7 @@ function ColumnStats({tableMetaData, fieldMetaData, fieldTableName, filter}: Pro
const fileName = tableMetaData.label + " - " + fieldMetaData.label + " Column Stats " + ValueUtils.formatDateTimeForFileName(new Date()) + ".csv"; const fileName = tableMetaData.label + " - " + fieldMetaData.label + " Column Stats " + ValueUtils.formatDateTimeForFileName(new Date()) + ".csv";
HtmlUtils.download(fileName, csv); HtmlUtils.download(fileName, csv);
} };
function Loading() function Loading()
{ {

View File

@ -71,6 +71,7 @@ import DataGridUtils from "qqq/utils/DataGridUtils";
import Client from "qqq/utils/qqq/Client"; import Client from "qqq/utils/qqq/Client";
import FilterUtils from "qqq/utils/qqq/FilterUtils"; import FilterUtils from "qqq/utils/qqq/FilterUtils";
import ProcessUtils from "qqq/utils/qqq/ProcessUtils"; import ProcessUtils from "qqq/utils/qqq/ProcessUtils";
import TableUtils from "qqq/utils/qqq/TableUtils";
import ValueUtils from "qqq/utils/qqq/ValueUtils"; import ValueUtils from "qqq/utils/qqq/ValueUtils";
const CURRENT_SAVED_FILTER_ID_LOCAL_STORAGE_KEY_ROOT = "qqq.currentSavedFilterId"; const CURRENT_SAVED_FILTER_ID_LOCAL_STORAGE_KEY_ROOT = "qqq.currentSavedFilterId";
@ -628,6 +629,8 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
let models = await FilterUtils.determineFilterAndSortModels(qController, tableMetaData, null, searchParams, filterLocalStorageKey, sortLocalStorageKey); let models = await FilterUtils.determineFilterAndSortModels(qController, tableMetaData, null, searchParams, filterLocalStorageKey, sortLocalStorageKey);
setFilterModel(models.filter); setFilterModel(models.filter);
setColumnSortModel(models.sort); setColumnSortModel(models.sort);
setWarningAlert(models.warning);
setQueryFilter(FilterUtils.buildQFilterFromGridFilter(tableMetaData, models.filter, models.sort, rowsPerPage)); setQueryFilter(FilterUtils.buildQFilterFromGridFilter(tableMetaData, models.filter, models.sort, rowsPerPage));
return; return;
} }
@ -708,16 +711,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
if (tableMetaData?.exposedJoins) if (tableMetaData?.exposedJoins)
{ {
const visibleJoinTables = getVisibleJoinTables(); const visibleJoinTables = getVisibleJoinTables();
queryJoins = TableUtils.getQueryJoins(tableMetaData, visibleJoinTables);
queryJoins = [];
for (let i = 0; i < tableMetaData.exposedJoins.length; i++)
{
const join = tableMetaData.exposedJoins[i];
if (visibleJoinTables.has(join.joinTable.name))
{
queryJoins.push(new QueryJoin(join.joinTable.name, true, "LEFT"));
}
}
} }
////////////////////////////////////////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////////////////////////////////////
@ -1145,6 +1139,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
<body> <body>
Generating file <u>${filename}</u>${totalRecords ? " with " + totalRecords.toLocaleString() + " record" + (totalRecords == 1 ? "" : "s") : ""}... Generating file <u>${filename}</u>${totalRecords ? " with " + totalRecords.toLocaleString() + " record" + (totalRecords == 1 ? "" : "s") : ""}...
<form id="exportForm" method="post" action="${url}" > <form id="exportForm" method="post" action="${url}" >
<!-- todo#authHeader - remove this. -->
<input type="hidden" name="Authorization" value="${qController.getAuthorizationHeaderValue()}"> <input type="hidden" name="Authorization" value="${qController.getAuthorizationHeaderValue()}">
<input type="hidden" name="fields" value="${visibleFields.join(",")}"> <input type="hidden" name="fields" value="${visibleFields.join(",")}">
<input type="hidden" name="filter" id="filter"> <input type="hidden" name="filter" id="filter">
@ -1400,6 +1395,8 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
const models = await FilterUtils.determineFilterAndSortModels(qController, tableMetaData, qRecord.values.get("filterJson"), null, null, null); const models = await FilterUtils.determineFilterAndSortModels(qController, tableMetaData, qRecord.values.get("filterJson"), null, null, null);
handleFilterChange(models.filter); handleFilterChange(models.filter);
handleSortChange(models.sort, models.filter); handleSortChange(models.sort, models.filter);
setWarningAlert(models.warning);
localStorage.setItem(currentSavedFilterLocalStorageKey, selectedSavedFilterId.toString()); localStorage.setItem(currentSavedFilterLocalStorageKey, selectedSavedFilterId.toString());
} }
else else
@ -1431,35 +1428,13 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
return (qRecord); return (qRecord);
} }
const getFieldAndTable = (fieldName: string): [QFieldMetaData, QTableMetaData] =>
{
if(fieldName.indexOf(".") > -1)
{
const nameParts = fieldName.split(".", 2);
for (let i = 0; i < tableMetaData?.exposedJoins?.length; i++)
{
const join = tableMetaData?.exposedJoins[i];
if(join?.joinTable.name == nameParts[0])
{
return ([join.joinTable.fields.get(nameParts[1]), join.joinTable]);
}
}
}
else
{
return ([tableMetaData.fields.get(fieldName), tableMetaData]);
}
return (null);
}
const copyColumnValues = async (column: GridColDef) => const copyColumnValues = async (column: GridColDef) =>
{ {
let data = ""; let data = "";
let counter = 0; let counter = 0;
if (latestQueryResults && latestQueryResults.length) if (latestQueryResults && latestQueryResults.length)
{ {
let [qFieldMetaData, fieldTable] = getFieldAndTable(column.field); let [qFieldMetaData, fieldTable] = TableUtils.getFieldAndTable(tableMetaData, column.field);
for (let i = 0; i < latestQueryResults.length; i++) for (let i = 0; i < latestQueryResults.length; i++)
{ {
let record = latestQueryResults[i] as QRecord; let record = latestQueryResults[i] as QRecord;
@ -1489,7 +1464,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
setFilterForColumnStats(buildQFilter(tableMetaData, filterModel)); setFilterForColumnStats(buildQFilter(tableMetaData, filterModel));
setColumnStatsFieldName(column.field); setColumnStatsFieldName(column.field);
const [field, fieldTable] = getFieldAndTable(column.field); const [field, fieldTable] = TableUtils.getFieldAndTable(tableMetaData, column.field);
setColumnStatsField(field); setColumnStatsField(field);
setColumnStatsFieldTableName(fieldTable.name); setColumnStatsFieldTableName(fieldTable.name);
}; };
@ -1962,7 +1937,7 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
{ {
(warningAlert) ? ( (warningAlert) ? (
<Collapse in={Boolean(warningAlert)}> <Collapse in={Boolean(warningAlert)}>
<Alert color="warning" sx={{mb: 3}} onClose={() => setWarningAlert(null)}>{warningAlert}</Alert> <Alert color="warning" icon={<Icon>warning</Icon>} sx={{mb: 3}} onClose={() => setWarningAlert(null)}>{warningAlert}</Alert>
</Collapse> </Collapse>
) : null ) : null
} }

View File

@ -27,6 +27,7 @@ import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QT
import {QTableSection} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableSection"; import {QTableSection} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableSection";
import {QTableVariant} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableVariant"; import {QTableVariant} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableVariant";
import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord"; import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord";
import {QueryJoin} from "@kingsrook/qqq-frontend-core/lib/model/query/QueryJoin";
import {Alert, Typography} from "@mui/material"; import {Alert, Typography} from "@mui/material";
import Avatar from "@mui/material/Avatar"; import Avatar from "@mui/material/Avatar";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
@ -103,7 +104,7 @@ function RecordView({table, launchProcess}: Props): JSX.Element
const [allTableProcesses, setAllTableProcesses] = useState([] as QProcessMetaData[]); const [allTableProcesses, setAllTableProcesses] = useState([] as QProcessMetaData[]);
const [actionsMenu, setActionsMenu] = useState(null); const [actionsMenu, setActionsMenu] = useState(null);
const [notFoundMessage, setNotFoundMessage] = useState(null as string); const [notFoundMessage, setNotFoundMessage] = useState(null as string);
const [errorMessage, setErrorMessage] = useState(null as string) const [errorMessage, setErrorMessage] = useState(null as string);
const [successMessage, setSuccessMessage] = useState(null as string); const [successMessage, setSuccessMessage] = useState(null as string);
const [warningMessage, setWarningMessage] = useState(null as string); const [warningMessage, setWarningMessage] = useState(null as string);
const [activeModalProcess, setActiveModalProcess] = useState(null as QProcessMetaData); const [activeModalProcess, setActiveModalProcess] = useState(null as QProcessMetaData);
@ -325,6 +326,31 @@ function RecordView({table, launchProcess}: Props): JSX.Element
reload(); reload();
}, [location.pathname, location.hash]); }, [location.pathname, location.hash]);
const getVisibleJoinTables = (tableMetaData: QTableMetaData): Set<string> =>
{
const visibleJoinTables = new Set<string>();
for (let i = 0; i < tableMetaData?.sections.length; i++)
{
const section = tableMetaData?.sections[i];
if (section.isHidden || !section.fieldNames || !section.fieldNames.length)
{
continue;
}
section.fieldNames.forEach((fieldName) =>
{
const [field, tableForField] = TableUtils.getFieldAndTable(tableMetaData, fieldName);
if(tableForField && tableForField.name != tableMetaData.name)
{
visibleJoinTables.add(tableForField.name);
}
})
}
return (visibleJoinTables);
};
if (!asyncLoadInited) if (!asyncLoadInited)
{ {
setAsyncLoadInited(true); setAsyncLoadInited(true);
@ -368,13 +394,20 @@ function RecordView({table, launchProcess}: Props): JSX.Element
setActiveModalProcess(launchingProcess); setActiveModalProcess(launchingProcess);
} }
let queryJoins: QueryJoin[] = null;
const visibleJoinTables = getVisibleJoinTables(tableMetaData);
if(visibleJoinTables.size > 0)
{
queryJoins = TableUtils.getQueryJoins(tableMetaData, visibleJoinTables);
}
///////////////////// /////////////////////
// load the record // // load the record //
///////////////////// /////////////////////
let record: QRecord; let record: QRecord;
try try
{ {
record = await qController.get(tableName, id, tableVariant); record = await qController.get(tableName, id, tableVariant, null, queryJoins);
setRecord(record); setRecord(record);
} }
catch (e) catch (e)
@ -465,17 +498,22 @@ function RecordView({table, launchProcess}: Props): JSX.Element
const fields = ( const fields = (
<Box key={section.name} display="flex" flexDirection="column" py={1} pr={2}> <Box key={section.name} display="flex" flexDirection="column" py={1} pr={2}>
{ {
section.fieldNames.map((fieldName: string) => ( section.fieldNames.map((fieldName: string) =>
<Box key={fieldName} flexDirection="row" pr={2}> {
<Typography variant="button" textTransform="none" fontWeight="bold" pr={1} color="rgb(52, 71, 103)"> let [field, tableForField] = TableUtils.getFieldAndTable(tableMetaData, fieldName);
{tableMetaData.fields.get(fieldName).label}: let label = field.label;
<div style={{display: "inline-block", width: 0}}>&nbsp;</div> return (
</Typography> <Box key={fieldName} flexDirection="row" pr={2}>
<Typography variant="button" textTransform="none" fontWeight="regular" color="rgb(123, 128, 154)"> <Typography variant="button" textTransform="none" fontWeight="bold" pr={1} color="rgb(52, 71, 103)">
{ValueUtils.getDisplayValue(tableMetaData.fields.get(fieldName), record, "view")} {label}:
</Typography> <div style={{display: "inline-block", width: 0}}>&nbsp;</div>
</Box> </Typography>
)) <Typography variant="button" textTransform="none" fontWeight="regular" color="rgb(123, 128, 154)">
{ValueUtils.getDisplayValue(field, record, "view", fieldName)}
</Typography>
</Box>
)
})
} }
</Box> </Box>
); );

View File

@ -259,7 +259,6 @@ export default class DataGridUtils
public static makeColumnFromField = (field: QFieldMetaData, tableMetaData: QTableMetaData, namePrefix?: string, labelPrefix?: string): GridColDef => public static makeColumnFromField = (field: QFieldMetaData, tableMetaData: QTableMetaData, namePrefix?: string, labelPrefix?: string): GridColDef =>
{ {
let columnType = "string"; let columnType = "string";
let columnWidth = 200;
let filterOperators: GridFilterOperator<any>[] = QGridStringOperators; let filterOperators: GridFilterOperator<any>[] = QGridStringOperators;
if (field.possibleValueSourceName) if (field.possibleValueSourceName)
@ -273,28 +272,18 @@ export default class DataGridUtils
case QFieldType.DECIMAL: case QFieldType.DECIMAL:
case QFieldType.INTEGER: case QFieldType.INTEGER:
columnType = "number"; columnType = "number";
columnWidth = 100;
if (field.name === tableMetaData.primaryKeyField && field.label.length < 3)
{
columnWidth = 75;
}
filterOperators = QGridNumericOperators; filterOperators = QGridNumericOperators;
break; break;
case QFieldType.DATE: case QFieldType.DATE:
columnType = "date"; columnType = "date";
columnWidth = 100;
filterOperators = QGridDateOperators; filterOperators = QGridDateOperators;
break; break;
case QFieldType.DATE_TIME: case QFieldType.DATE_TIME:
columnType = "dateTime"; columnType = "dateTime";
columnWidth = 200;
filterOperators = QGridDateTimeOperators; filterOperators = QGridDateTimeOperators;
break; break;
case QFieldType.BOOLEAN: case QFieldType.BOOLEAN:
columnType = "string"; // using boolean gives an odd 'no' for nulls. columnType = "string"; // using boolean gives an odd 'no' for nulls.
columnWidth = 75;
filterOperators = QGridBooleanOperators; filterOperators = QGridBooleanOperators;
break; break;
case QFieldType.BLOB: case QFieldType.BLOB:
@ -305,6 +294,31 @@ export default class DataGridUtils
} }
} }
let headerName = labelPrefix ? labelPrefix + field.label : field.label;
let fieldName = namePrefix ? namePrefix + field.name : field.name;
const column: GridColDef = {
field: fieldName,
type: columnType,
headerName: headerName,
width: DataGridUtils.getColumnWidthForField(field, tableMetaData),
renderCell: null as any,
filterOperators: filterOperators,
};
column.renderCell = (cellValues: any) => (
(cellValues.value)
);
return (column);
}
/*******************************************************************************
**
*******************************************************************************/
public static getColumnWidthForField = (field: QFieldMetaData, table?: QTableMetaData): number =>
{
if (field.hasAdornment(AdornmentType.SIZE)) if (field.hasAdornment(AdornmentType.SIZE))
{ {
const sizeAdornment = field.getAdornment(AdornmentType.SIZE); const sizeAdornment = field.getAdornment(AdornmentType.SIZE);
@ -318,7 +332,7 @@ export default class DataGridUtils
]); ]);
if (widths.has(width)) if (widths.has(width))
{ {
columnWidth = widths.get(width); return widths.get(width);
} }
else else
{ {
@ -326,23 +340,31 @@ export default class DataGridUtils
} }
} }
let headerName = labelPrefix ? labelPrefix + field.label : field.label; if(field.possibleValueSourceName)
let fieldName = namePrefix ? namePrefix + field.name : field.name; {
return (200);
}
const column: GridColDef = { switch (field.type)
field: fieldName, {
type: columnType, case QFieldType.DECIMAL:
headerName: headerName, case QFieldType.INTEGER:
width: columnWidth,
renderCell: null as any,
filterOperators: filterOperators,
};
column.renderCell = (cellValues: any) => ( if (table && field.name === table.primaryKeyField && field.label.length < 3)
(cellValues.value) {
); return (75);
}
return (column); return (100);
case QFieldType.DATE:
return (100);
case QFieldType.DATE_TIME:
return (200);
case QFieldType.BOOLEAN:
return (75);
}
return (200);
} }
} }

View File

@ -95,6 +95,11 @@ export default class HtmlUtils
form.setAttribute("target", "downloadIframe"); form.setAttribute("target", "downloadIframe");
iframe.appendChild(form); iframe.appendChild(form);
/////////////////////////////////////////////////////////////////////////////////////////////
// todo#authHeader - remove after comfortable with sessionUUID //
// todo - this could be simplified (i think?) //
// it was originally built like this when we had to submit full access token to backend... //
/////////////////////////////////////////////////////////////////////////////////////////////
const authorizationInput = document.createElement("input"); const authorizationInput = document.createElement("input");
authorizationInput.setAttribute("type", "hidden"); authorizationInput.setAttribute("type", "hidden");
authorizationInput.setAttribute("id", "authorizationInput"); authorizationInput.setAttribute("id", "authorizationInput");
@ -118,6 +123,11 @@ export default class HtmlUtils
{ {
if(url.startsWith("data:")) if(url.startsWith("data:"))
{ {
/////////////////////////////////////////////////////////////////////////////////////////////
// todo#authHeader - remove the Authorization input after comfortable with sessionUUID //
// todo - this could be simplified (i think?) //
// it was originally built like this when we had to submit full access token to backend... //
/////////////////////////////////////////////////////////////////////////////////////////////
const openInWindow = window.open("", "_blank"); const openInWindow = window.open("", "_blank");
openInWindow.document.write(`<html lang="en"> openInWindow.document.write(`<html lang="en">
<body style="margin: 0"> <body style="margin: 0">

View File

@ -31,6 +31,7 @@ import {QFilterOrderBy} from "@kingsrook/qqq-frontend-core/lib/model/query/QFilt
import {QQueryFilter} from "@kingsrook/qqq-frontend-core/lib/model/query/QQueryFilter"; import {QQueryFilter} from "@kingsrook/qqq-frontend-core/lib/model/query/QQueryFilter";
import {ThisOrLastPeriodExpression} from "@kingsrook/qqq-frontend-core/lib/model/query/ThisOrLastPeriodExpression"; import {ThisOrLastPeriodExpression} from "@kingsrook/qqq-frontend-core/lib/model/query/ThisOrLastPeriodExpression";
import {GridFilterItem, GridFilterModel, GridLinkOperator, GridSortItem} from "@mui/x-data-grid-pro"; import {GridFilterItem, GridFilterModel, GridLinkOperator, GridSortItem} from "@mui/x-data-grid-pro";
import TableUtils from "qqq/utils/qqq/TableUtils";
import ValueUtils from "qqq/utils/qqq/ValueUtils"; import ValueUtils from "qqq/utils/qqq/ValueUtils";
const CURRENT_SAVED_FILTER_ID_LOCAL_STORAGE_KEY_ROOT = "qqq.currentSavedFilterId"; const CURRENT_SAVED_FILTER_ID_LOCAL_STORAGE_KEY_ROOT = "qqq.currentSavedFilterId";
@ -375,10 +376,11 @@ class FilterUtils
** Get the default filter to use on the page - either from given filter string, query string param, or ** Get the default filter to use on the page - either from given filter string, query string param, or
** local storage, or a default (empty). ** local storage, or a default (empty).
*******************************************************************************/ *******************************************************************************/
public static async determineFilterAndSortModels(qController: QController, tableMetaData: QTableMetaData, filterString: string, searchParams: URLSearchParams, filterLocalStorageKey: string, sortLocalStorageKey: string): Promise<{ filter: GridFilterModel, sort: GridSortItem[] }> public static async determineFilterAndSortModels(qController: QController, tableMetaData: QTableMetaData, filterString: string, searchParams: URLSearchParams, filterLocalStorageKey: string, sortLocalStorageKey: string): Promise<{ filter: GridFilterModel, sort: GridSortItem[], warning: string }>
{ {
let defaultFilter = {items: []} as GridFilterModel; let defaultFilter = {items: []} as GridFilterModel;
let defaultSort = [] as GridSortItem[]; let defaultSort = [] as GridSortItem[];
let warningParts = [] as string[];
if (tableMetaData && tableMetaData.fields !== undefined) if (tableMetaData && tableMetaData.fields !== undefined)
{ {
@ -396,30 +398,11 @@ class FilterUtils
for (let i = 0; i < qQueryFilter?.criteria?.length; i++) for (let i = 0; i < qQueryFilter?.criteria?.length; i++)
{ {
const criteria = qQueryFilter.criteria[i]; const criteria = qQueryFilter.criteria[i];
let fieldTable = tableMetaData; let [field, fieldTable] = TableUtils.getFieldAndTable(tableMetaData, criteria.fieldName);
let field = null;
if (criteria.fieldName.indexOf(".") > -1)
{
const nameParts = criteria.fieldName.split(".", 2);
for (let i = 0; i < tableMetaData?.exposedJoins?.length; i++)
{
const joinTable = tableMetaData.exposedJoins[i].joinTable;
if (joinTable.name == nameParts[0])
{
fieldTable = joinTable;
field = joinTable.fields.get(nameParts[1]);
break;
}
}
}
else
{
field = tableMetaData.fields.get(criteria.fieldName);
}
if (field == null) if (field == null)
{ {
console.log("Couldn't find field for filter: " + criteria.fieldName); console.log("Couldn't find field for filter: " + criteria.fieldName);
warningParts.push("Your filter contained an unrecognized field name: " + criteria.fieldName)
continue; continue;
} }
@ -449,12 +432,15 @@ class FilterUtils
////////////////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////////////
// replace objects that look like expressions with expression instances // // replace objects that look like expressions with expression instances //
////////////////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////////////
for(let i = 0; i < values.length; i++) if(values && values.length)
{ {
const expression = this.gridCriteriaValueToExpression(values[i]) for (let i = 0; i < values.length; i++)
if(expression)
{ {
values[i] = expression; const expression = this.gridCriteriaValueToExpression(values[i])
if (expression)
{
values[i] = expression;
}
} }
} }
@ -497,7 +483,7 @@ class FilterUtils
localStorage.setItem(sortLocalStorageKey, JSON.stringify(defaultSort)); localStorage.setItem(sortLocalStorageKey, JSON.stringify(defaultSort));
} }
return ({filter: defaultFilter, sort: defaultSort}); return ({filter: defaultFilter, sort: defaultSort, warning: warningParts.length > 0 ? "Warning: " + warningParts.join("; ") : ""});
} }
catch (e) catch (e)
{ {
@ -548,7 +534,7 @@ class FilterUtils
}); });
} }
return ({filter: defaultFilter, sort: defaultSort}); return ({filter: defaultFilter, sort: defaultSort, warning: warningParts.length > 0 ? "Warning: " + warningParts.join("; ") : ""});
} }

View File

@ -19,8 +19,10 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData"; import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
import {QTableSection} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableSection"; import {QTableSection} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableSection";
import {QueryJoin} from "@kingsrook/qqq-frontend-core/lib/model/query/QueryJoin";
/******************************************************************************* /*******************************************************************************
** Utility class for working with QQQ Tables ** Utility class for working with QQQ Tables
@ -28,7 +30,6 @@ import {QTableSection} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTa
*******************************************************************************/ *******************************************************************************/
class TableUtils class TableUtils
{ {
/******************************************************************************* /*******************************************************************************
** **
*******************************************************************************/ *******************************************************************************/
@ -85,6 +86,61 @@ class TableUtils
})]); })]);
} }
} }
/*******************************************************************************
**
*******************************************************************************/
public static getFieldAndTable(tableMetaData: QTableMetaData, fieldName: string): [QFieldMetaData, QTableMetaData]
{
if (fieldName.indexOf(".") > -1)
{
const nameParts = fieldName.split(".", 2);
for (let i = 0; i < tableMetaData?.exposedJoins?.length; i++)
{
const join = tableMetaData?.exposedJoins[i];
if (join?.joinTable.name == nameParts[0])
{
return ([join.joinTable.fields.get(nameParts[1]), join.joinTable]);
}
}
}
else
{
return ([tableMetaData.fields.get(fieldName), tableMetaData]);
}
return (null);
}
/*******************************************************************************
**
*******************************************************************************/
public static getQueryJoins(tableMetaData: QTableMetaData, visibleJoinTables: Set<string>): QueryJoin[]
{
const queryJoins = [];
for (let i = 0; i < tableMetaData.exposedJoins.length; i++)
{
const join = tableMetaData.exposedJoins[i];
if (visibleJoinTables.has(join.joinTable.name))
{
let joinName = null;
if (join.joinPath && join.joinPath.length == 1 && join.joinPath[0].name)
{
joinName = join.joinPath[0].name;
}
//////////////////////////////////////////////////////////////////////////////////////////////////////////////
// todo - what about a join with a longer path? it would be nice to pass such joinNames through there too, //
// but what, that would actually be multiple queryJoins? needs a fair amount of thought. //
//////////////////////////////////////////////////////////////////////////////////////////////////////////////
queryJoins.push(new QueryJoin(join.joinTable.name, true, "LEFT", null, null, joinName));
}
}
return queryJoins;
}
} }
export default TableUtils; export default TableUtils;

View File

@ -49,6 +49,7 @@ module.exports = function (app)
app.use("/data/*", getRequestHandler()); app.use("/data/*", getRequestHandler());
app.use("/widget/*", getRequestHandler()); app.use("/widget/*", getRequestHandler());
app.use("/serverInfo", getRequestHandler()); app.use("/serverInfo", getRequestHandler());
app.use("/manageSession", getRequestHandler());
app.use("/processes", getRequestHandler()); app.use("/processes", getRequestHandler());
app.use("/reports", getRequestHandler()); app.use("/reports", getRequestHandler());
app.use("/images", getRequestHandler()); app.use("/images", getRequestHandler());

View File

@ -0,0 +1,185 @@
/*
* 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/>.
*/
package com.kingsrook.qqq.materialdashboard.tests;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.time.temporal.ChronoUnit;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.expressions.NowWithOffset;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.expressions.ThisOrLastPeriod;
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
import com.kingsrook.qqq.materialdashboard.lib.QBaseSeleniumTest;
import com.kingsrook.qqq.materialdashboard.lib.QQQMaterialDashboardSelectors;
import com.kingsrook.qqq.materialdashboard.lib.javalin.QSeleniumJavalin;
import org.junit.jupiter.api.Test;
import org.openqa.selenium.WebElement;
/*******************************************************************************
** Test for the record query screen when a filter is given in the URL
*******************************************************************************/
public class QueryScreenFilterInUrlTest extends QBaseSeleniumTest
{
/*******************************************************************************
**
*******************************************************************************/
@Override
protected void addJavalinRoutes(QSeleniumJavalin qSeleniumJavalin)
{
super.addJavalinRoutes(qSeleniumJavalin);
qSeleniumJavalin
.withRouteToFile("/data/person/count", "data/person/count.json")
.withRouteToFile("/data/person/query", "data/person/index.json")
.withRouteToFile("/data/person/possibleValues/homeCityId", "data/person/possibleValues/homeCityId.json")
.withRouteToFile("/data/person/variants", "data/person/variants.json")
.withRouteToFile("/processes/querySavedFilter/init", "processes/querySavedFilter/init.json");
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void testUrlWithFilter()
{
////////////////////////////////////////
// not-blank -- criteria w/ no values //
////////////////////////////////////////
String filterJSON = JsonUtils.toJson(new QQueryFilter()
.withCriteria(new QFilterCriteria("annualSalary", QCriteriaOperator.IS_NOT_BLANK)));
qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person");
waitForQueryToHaveRan();
assertFilterButtonBadge(1);
clickFilterButton();
qSeleniumLib.waitForSelector("input[value=\"is not empty\"]");
///////////////////////////////
// between on a number field //
///////////////////////////////
filterJSON = JsonUtils.toJson(new QQueryFilter()
.withCriteria(new QFilterCriteria("annualSalary", QCriteriaOperator.BETWEEN, 1701, 74656)));
qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person");
waitForQueryToHaveRan();
assertFilterButtonBadge(1);
clickFilterButton();
qSeleniumLib.waitForSelector("input[value=\"is between\"]");
qSeleniumLib.waitForSelector("input[value=\"1701\"]");
qSeleniumLib.waitForSelector("input[value=\"74656\"]");
//////////////////////////////////////////
// not-equals on a possible-value field //
//////////////////////////////////////////
filterJSON = JsonUtils.toJson(new QQueryFilter()
.withCriteria(new QFilterCriteria("homeCityId", QCriteriaOperator.NOT_EQUALS, 1)));
qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person");
waitForQueryToHaveRan();
assertFilterButtonBadge(1);
clickFilterButton();
qSeleniumLib.waitForSelector("input[value=\"does not equal\"]");
qSeleniumLib.waitForSelector("input[value=\"St. Louis\"]");
//////////////////////////////////////
// an IN for a possible-value field //
//////////////////////////////////////
filterJSON = JsonUtils.toJson(new QQueryFilter()
.withCriteria(new QFilterCriteria("homeCityId", QCriteriaOperator.IN, 1, 2)));
qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person");
waitForQueryToHaveRan();
assertFilterButtonBadge(1);
clickFilterButton();
qSeleniumLib.waitForSelector("input[value=\"is any of\"]");
qSeleniumLib.waitForSelectorContaining(".MuiChip-label", "St. Louis");
qSeleniumLib.waitForSelectorContaining(".MuiChip-label", "Chesterfield");
/////////////////////////////////////////
// greater than a date-time expression //
/////////////////////////////////////////
filterJSON = JsonUtils.toJson(new QQueryFilter()
.withCriteria(new QFilterCriteria("createDate", QCriteriaOperator.GREATER_THAN, NowWithOffset.minus(5, ChronoUnit.DAYS))));
qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person");
waitForQueryToHaveRan();
assertFilterButtonBadge(1);
clickFilterButton();
qSeleniumLib.waitForSelector("input[value=\"is after\"]");
qSeleniumLib.waitForSelector("input[value=\"5 days ago\"]");
///////////////////////
// multiple criteria //
///////////////////////
filterJSON = JsonUtils.toJson(new QQueryFilter()
.withCriteria(new QFilterCriteria("firstName", QCriteriaOperator.STARTS_WITH, "Dar"))
.withCriteria(new QFilterCriteria("createDate", QCriteriaOperator.LESS_THAN_OR_EQUALS, ThisOrLastPeriod.this_(ChronoUnit.YEARS))));
qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/peopleApp/greetingsApp/person?filter=" + URLEncoder.encode(filterJSON, StandardCharsets.UTF_8), "Person");
waitForQueryToHaveRan();
assertFilterButtonBadge(2);
clickFilterButton();
qSeleniumLib.waitForSelector("input[value=\"is at or before\"]");
qSeleniumLib.waitForSelector("input[value=\"start of this year\"]");
qSeleniumLib.waitForSelector("input[value=\"starts with\"]");
qSeleniumLib.waitForSelector("input[value=\"Dar\"]");
////////////////
// remove one //
////////////////
qSeleniumLib.waitForSelectorContaining(".MuiIcon-root", "close").click();
assertFilterButtonBadge(1);
qSeleniumLib.waitForever();
}
/*******************************************************************************
**
*******************************************************************************/
private WebElement assertFilterButtonBadge(int valueInBadge)
{
return qSeleniumLib.waitForSelectorContaining(".MuiBadge-root", String.valueOf(valueInBadge));
}
/*******************************************************************************
**
*******************************************************************************/
private WebElement waitForQueryToHaveRan()
{
return qSeleniumLib.waitForSelector(QQQMaterialDashboardSelectors.QUERY_GRID_CELL);
}
/*******************************************************************************
**
*******************************************************************************/
private void clickFilterButton()
{
qSeleniumLib.waitForSelectorContaining(".MuiDataGrid-toolbarContainer BUTTON", "Filter").click();
}
}

View File

@ -0,0 +1,12 @@
{
"options": [
{
"id": 1,
"label": "St. Louis"
},
{
"id": 2,
"label": "Chesterfield"
}
]
}

View File

@ -74,6 +74,15 @@
"isEditable": true, "isEditable": true,
"displayFormat": "%s" "displayFormat": "%s"
}, },
"homeCityId": {
"name": "homeCityId",
"label": "Home City",
"type": "INTEGER",
"possibleValueSourceName": "city",
"isRequired": false,
"isEditable": true,
"displayFormat": "%s"
},
"email": { "email": {
"name": "email", "name": "email",
"label": "Email", "label": "Email",