mirror of
https://github.com/Kingsrook/qqq-frontend-material-dashboard.git
synced 2025-07-21 22:58:43 +00:00
Compare commits
86 Commits
snapshot-f
...
wip/CE-148
Author | SHA1 | Date | |
---|---|---|---|
9a6fcd8bb1 | |||
d31215f6c0 | |||
262855b9c0 | |||
4d082c3c57 | |||
47fb7cc2e3 | |||
647c63f5a3 | |||
f545649882 | |||
4d4610801f | |||
3ec43fbbd3 | |||
28bc07cce4 | |||
c7d31fa39e | |||
69f1cfe92f | |||
2ed95ff77a | |||
66336a28ed | |||
826bed4537 | |||
40bd83cd96 | |||
ca460e65e1 | |||
122fef152c | |||
d0ed0ce949 | |||
b8aa36455d | |||
a778b7497a | |||
c3503a719f | |||
2afa82c770 | |||
d03e908a9d | |||
dc62f97219 | |||
fe9e20715a | |||
71a1bfaa6b | |||
d9e9a0be08 | |||
aefb282a0e | |||
fb57718c1c | |||
ba213b038b | |||
69daf47021 | |||
1d24b9b40c | |||
f44ba8d6d3 | |||
7b562aea50 | |||
3bf1cea9dd | |||
dc131d5189 | |||
2b5cc1610f | |||
a36bdb1474 | |||
c2926d26e8 | |||
eb42a86655 | |||
b7f715f832 | |||
16a08cfd42 | |||
f5919c66ab | |||
0831a87674 | |||
dd5cd459ce | |||
c200cc9fab | |||
17f378131d | |||
376a7a342e | |||
fcadea3192 | |||
086ab775fc | |||
5693661d20 | |||
8c9224aceb | |||
d750ef0930 | |||
267ead925b | |||
f925ad9116 | |||
1859dd603d | |||
74f8f11737 | |||
0629172270 | |||
1bf1f09e9d | |||
e0f689544d | |||
f3d08ef683 | |||
1aff749f72 | |||
ccc622e0e9 | |||
a6662eeb07 | |||
c8b673fb46 | |||
f19e36a6bf | |||
c708ec3b9a | |||
7e40fa90e9 | |||
680d185eb5 | |||
4f37488d37 | |||
d20700edb1 | |||
d17c7f6990 | |||
0d7849b7dc | |||
7316b6141b | |||
8bc2479716 | |||
010f80def3 | |||
13d7cc6825 | |||
ca715af84a | |||
65aaf4fce1 | |||
8dc8ae0b6d | |||
8707aa8a94 | |||
e7d870a7fa | |||
38b8f47409 | |||
4c6955b6ed | |||
11f1250d73 |
@ -115,7 +115,7 @@ workflows:
|
||||
context: [ qqq-maven-registry-credentials, kingsrook-slack, build-qqq-sample-app ]
|
||||
filters:
|
||||
branches:
|
||||
ignore: /(main|integration.*)/
|
||||
ignore: /(main|dev|integration.*)/
|
||||
tags:
|
||||
ignore: /(version|snapshot)-.*/
|
||||
deploy:
|
||||
@ -124,7 +124,7 @@ workflows:
|
||||
context: [ qqq-maven-registry-credentials, kingsrook-slack, build-qqq-sample-app ]
|
||||
filters:
|
||||
branches:
|
||||
only: /(main|integration.*)/
|
||||
only: /(main|dev|integration.*)/
|
||||
tags:
|
||||
only: /(version|snapshot)-.*/
|
||||
|
||||
|
@ -1,12 +0,0 @@
|
||||
import {defineConfig} from "cypress";
|
||||
|
||||
export default defineConfig({
|
||||
e2e: {
|
||||
viewportHeight: 1000,
|
||||
viewportWidth: 1200,
|
||||
setupNodeEvents(on, config)
|
||||
{
|
||||
// implement node event listeners here
|
||||
},
|
||||
},
|
||||
});
|
24039
package-lock.json
generated
24039
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
16
package.json
16
package.json
@ -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.97",
|
||||
"@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,7 +33,7 @@
|
||||
"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",
|
||||
"jwt-decode": "3.1.2",
|
||||
"rapidoc": "9.3.4",
|
||||
@ -46,12 +46,16 @@
|
||||
"react-dom": "18.0.0",
|
||||
"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-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"
|
||||
},
|
||||
@ -59,7 +63,7 @@
|
||||
"build": "react-scripts build",
|
||||
"clean": "rm -rf node_modules package-lock.json lib",
|
||||
"eject": "react-scripts eject",
|
||||
"clean-and-install": "rm -rf node_modules/ && rm -rf package-lock.json && rm -rf lib/ && npm install --legacy-peer-deps",
|
||||
"clean-and-install": "rm -rf node_modules/ && rm -rf package-lock.json && rm -rf lib/ && npm install --legacy-peer-deps && npm dedupe --force",
|
||||
"npm-install": "npm install --legacy-peer-deps",
|
||||
"prepublishOnly": "tsc -p ./ --outDir lib/",
|
||||
"start": "BROWSER=none react-scripts --max-http-header-size=65535 start",
|
||||
|
2
pom.xml
2
pom.xml
@ -29,7 +29,7 @@
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<properties>
|
||||
<revision>0.20.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>
|
||||
|
@ -49,6 +49,7 @@ import EntityEdit from "qqq/pages/records/edit/RecordEdit";
|
||||
import RecordQuery from "qqq/pages/records/query/RecordQuery";
|
||||
import RecordDeveloperView from "qqq/pages/records/view/RecordDeveloperView";
|
||||
import RecordView from "qqq/pages/records/view/RecordView";
|
||||
import RecordViewByUniqueKey from "qqq/pages/records/view/RecordViewByUniqueKey";
|
||||
import GoogleAnalyticsUtils, {AnalyticsModel} from "qqq/utils/GoogleAnalyticsUtils";
|
||||
import Client from "qqq/utils/qqq/Client";
|
||||
import ProcessUtils from "qqq/utils/qqq/ProcessUtils";
|
||||
@ -392,6 +393,13 @@ export default function App()
|
||||
component: <RecordView table={table} />,
|
||||
});
|
||||
|
||||
routeList.push({
|
||||
name: `${app.label} View`,
|
||||
key: `${app.name}.view`,
|
||||
route: `${path}/key`,
|
||||
component: <RecordViewByUniqueKey table={table} />,
|
||||
});
|
||||
|
||||
routeList.push({
|
||||
name: `${app.label}`,
|
||||
key: `${app.name}.edit`,
|
||||
|
@ -36,7 +36,7 @@ import Icon from "@mui/material/Icon";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import {makeStyles} from "@mui/styles";
|
||||
import {Command} from "cmdk";
|
||||
import React, {useContext, useEffect, useRef} from "react";
|
||||
import React, {useContext, useEffect, useRef, useState} from "react";
|
||||
import {useNavigate} from "react-router-dom";
|
||||
import QContext from "QContext";
|
||||
import HistoryUtils, {QHistoryEntry} from "qqq/utils/HistoryUtils";
|
||||
@ -62,16 +62,21 @@ const useStyles = makeStyles((theme: any) => ({
|
||||
}
|
||||
}));
|
||||
|
||||
const A_FIRST = -1;
|
||||
const B_FIRST = 1;
|
||||
|
||||
const CommandMenu = ({metaData}: Props) =>
|
||||
{
|
||||
const [searchString, setSearchString] = useState("");
|
||||
|
||||
const navigate = useNavigate();
|
||||
const pathParts = location.pathname.replace(/\/+$/, "").split("/");
|
||||
|
||||
const {accentColor, tableMetaData, dotMenuOpen, setDotMenuOpen, keyboardHelpOpen, setKeyboardHelpOpen, setTableMetaData, tableProcesses} = useContext(QContext);
|
||||
const {accentColor, tableMetaData, dotMenuOpen, setDotMenuOpen, keyboardHelpOpen, setKeyboardHelpOpen, setTableMetaData, tableProcesses, recordAnalytics} = useContext(QContext);
|
||||
|
||||
const classes = useStyles();
|
||||
|
||||
function evalueKeyPress(e: KeyboardEvent)
|
||||
function evaluateKeyPress(e: KeyboardEvent)
|
||||
{
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
// if a dot pressed, not from a "text" element, then toggle command menu //
|
||||
@ -82,6 +87,7 @@ const CommandMenu = ({metaData}: Props) =>
|
||||
if (e.key === "." && !keyboardHelpOpen)
|
||||
{
|
||||
e.preventDefault();
|
||||
recordAnalytics({category: "globalEvents", action: "dotMenuKeyboardShortcut"});
|
||||
setDotMenuOpen(true);
|
||||
}
|
||||
else if (e.key === "?" && !dotMenuOpen)
|
||||
@ -107,20 +113,20 @@ const CommandMenu = ({metaData}: Props) =>
|
||||
|
||||
const down = (e: KeyboardEvent) =>
|
||||
{
|
||||
evalueKeyPress(e);
|
||||
}
|
||||
evaluateKeyPress(e);
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", down)
|
||||
document.addEventListener("keydown", down);
|
||||
return () =>
|
||||
{
|
||||
document.removeEventListener("keydown", down)
|
||||
}
|
||||
}, [tableMetaData, dotMenuOpen, keyboardHelpOpen])
|
||||
document.removeEventListener("keydown", down);
|
||||
};
|
||||
}, [tableMetaData, dotMenuOpen, keyboardHelpOpen]);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
setDotMenuOpen(false);
|
||||
}, [location.pathname])
|
||||
}, [location.pathname]);
|
||||
|
||||
function goToItem(path: string)
|
||||
{
|
||||
@ -162,73 +168,117 @@ const CommandMenu = ({metaData}: Props) =>
|
||||
return (null);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** sort a section (e.g, tables, apps).
|
||||
**
|
||||
** put labels that start-with the search word first.
|
||||
*******************************************************************************/
|
||||
function comparator(labelA: string, labelB: string)
|
||||
{
|
||||
if (searchString != "")
|
||||
{
|
||||
let aStartsWith = labelA.toLowerCase().startsWith(searchString.toLowerCase());
|
||||
let bStartsWith = labelB.toLowerCase().startsWith(searchString.toLowerCase());
|
||||
|
||||
if (aStartsWith && !bStartsWith)
|
||||
{
|
||||
return A_FIRST;
|
||||
}
|
||||
else if (bStartsWith && !aStartsWith)
|
||||
{
|
||||
return B_FIRST;
|
||||
}
|
||||
|
||||
const indexOfSpace = searchString.indexOf(" ");
|
||||
if (indexOfSpace > 0)
|
||||
{
|
||||
aStartsWith = labelA.toLowerCase().startsWith(searchString.substring(0, indexOfSpace).toLowerCase());
|
||||
bStartsWith = labelB.toLowerCase().startsWith(searchString.substring(0, indexOfSpace).toLowerCase());
|
||||
|
||||
if (aStartsWith && !bStartsWith)
|
||||
{
|
||||
return A_FIRST;
|
||||
}
|
||||
else if (bStartsWith && !aStartsWith)
|
||||
{
|
||||
return B_FIRST;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (labelA.localeCompare(labelB));
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
function ActionsSection()
|
||||
{
|
||||
let tableNames : string[]= [];
|
||||
let tableNames: string[] = [];
|
||||
metaData.tables.forEach((value: QTableMetaData, key: string) =>
|
||||
{
|
||||
tableNames.push(value.name);
|
||||
})
|
||||
tableNames = tableNames.sort((a: string, b:string) =>
|
||||
});
|
||||
tableNames = tableNames.sort((a: string, b: string) =>
|
||||
{
|
||||
const labelA = metaData.tables.get(a).label ?? "";
|
||||
const labelB = metaData.tables.get(b).label ?? "";
|
||||
return (labelA.localeCompare(labelB));
|
||||
})
|
||||
return comparator(labelA, labelB);
|
||||
});
|
||||
|
||||
const path = location.pathname;
|
||||
return tableMetaData && !path.endsWith("/edit") && !path.endsWith("/create") && !path.endsWith("#audit") && ! path.endsWith("copy") &&
|
||||
return tableMetaData && !path.endsWith("/edit") && !path.endsWith("/create") && !path.endsWith("#audit") && !path.endsWith("copy") &&
|
||||
(
|
||||
<Command.Group heading={`${tableMetaData.label} Actions`}>
|
||||
{
|
||||
tableMetaData.capabilities.has(Capability.TABLE_INSERT) && tableMetaData.insertPermission &&
|
||||
<Command.Item onSelect={() => goToItem(`${pathParts.slice(0, -1).join("/")}/create`)} key={`${tableMetaData.label}-new`} value="New"><Icon sx={{color: accentColor}}>add</Icon>New</Command.Item>
|
||||
<Command.Item onSelect={() => goToItem(`${pathParts.slice(0, -1).join("/")}/create`)} key={`${tableMetaData.label}-new`} value="New"><Icon sx={{color: accentColor}}>add</Icon>New</Command.Item>
|
||||
}
|
||||
{
|
||||
tableMetaData.capabilities.has(Capability.TABLE_INSERT) && tableMetaData.insertPermission &&
|
||||
<Command.Item onSelect={() => goToItem(`${pathParts.join("/")}/copy`)} key={`${tableMetaData.label}-copy`} value="Copy"><Icon sx={{color: accentColor}}>copy</Icon>Copy</Command.Item>
|
||||
<Command.Item onSelect={() => goToItem(`${pathParts.join("/")}/copy`)} key={`${tableMetaData.label}-copy`} value="Copy"><Icon sx={{color: accentColor}}>copy</Icon>Copy</Command.Item>
|
||||
}
|
||||
{
|
||||
tableMetaData.capabilities.has(Capability.TABLE_UPDATE) && tableMetaData.editPermission &&
|
||||
<Command.Item onSelect={() => goToItem(`${pathParts.join("/")}/edit`)} key={`${tableMetaData.label}-edit`} value="Edit"><Icon sx={{color: accentColor}}>edit</Icon>Edit</Command.Item>
|
||||
<Command.Item onSelect={() => goToItem(`${pathParts.join("/")}/edit`)} key={`${tableMetaData.label}-edit`} value="Edit"><Icon sx={{color: accentColor}}>edit</Icon>Edit</Command.Item>
|
||||
}
|
||||
{
|
||||
metaData && metaData.tables.has("audit") &&
|
||||
<Command.Item onSelect={() => goToItem(`${pathParts.join("/")}#audit`)} key={`${tableMetaData.label}-audit`} value="Audit"><Icon sx={{color: accentColor}}>checklist</Icon>Audit</Command.Item>
|
||||
<Command.Item onSelect={() => goToItem(`${pathParts.join("/")}#audit`)} key={`${tableMetaData.label}-audit`} value="Audit"><Icon sx={{color: accentColor}}>checklist</Icon>Audit</Command.Item>
|
||||
}
|
||||
{
|
||||
tableProcesses && tableProcesses.length > 0 &&
|
||||
(
|
||||
tableProcesses.map((process) => (
|
||||
<Command.Item onSelect={() => goToItem(`${pathParts.join("/")}/${process.name}`)} key={`${process.name}`} value={`${process.label}`}><Icon sx={{color: accentColor}}>{getIconName(process.iconName, "play_arrow")}</Icon>{process.label}</Command.Item>
|
||||
))
|
||||
)
|
||||
(
|
||||
tableProcesses.map((process) => (
|
||||
<Command.Item onSelect={() => goToItem(`${pathParts.join("/")}/${process.name}`)} key={`${process.name}`} value={`${process.label}`}><Icon sx={{color: accentColor}}>{getIconName(process.iconName, "play_arrow")}</Icon>{process.label}</Command.Item>
|
||||
))
|
||||
)
|
||||
}
|
||||
<Command.Separator />
|
||||
</Command.Group>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
function TablesSection()
|
||||
{
|
||||
let tableNames : string[]= [];
|
||||
let tableNames: string[] = [];
|
||||
metaData.tables.forEach((value: QTableMetaData, key: string) =>
|
||||
{
|
||||
tableNames.push(value.name);
|
||||
})
|
||||
tableNames = tableNames.sort((a: string, b:string) =>
|
||||
});
|
||||
tableNames = tableNames.sort((a: string, b: string) =>
|
||||
{
|
||||
const labelA = metaData.tables.get(a).label ?? "";
|
||||
const labelB = metaData.tables.get(b).label ?? "";
|
||||
return (labelA.localeCompare(labelB));
|
||||
})
|
||||
return(
|
||||
return comparator(labelA, labelB);
|
||||
});
|
||||
return (
|
||||
<Command.Group heading="Tables">
|
||||
{
|
||||
tableNames.map((tableName: string, index: number) =>
|
||||
@ -243,6 +293,7 @@ const CommandMenu = ({metaData}: Props) =>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@ -252,16 +303,16 @@ const CommandMenu = ({metaData}: Props) =>
|
||||
metaData.apps.forEach((value: QAppMetaData, key: string) =>
|
||||
{
|
||||
appNames.push(value.name);
|
||||
})
|
||||
});
|
||||
|
||||
appNames = appNames.sort((a: string, b:string) =>
|
||||
appNames = appNames.sort((a: string, b: string) =>
|
||||
{
|
||||
const labelA = getFullAppLabel(metaData.appTree, a, 1, "") ?? "";
|
||||
const labelB = getFullAppLabel(metaData.appTree, b, 1, "") ?? "";
|
||||
return (labelA.localeCompare(labelB));
|
||||
})
|
||||
return comparator(labelA, labelB);
|
||||
});
|
||||
|
||||
return(
|
||||
return (
|
||||
<Command.Group heading="Apps">
|
||||
{
|
||||
appNames.map((appName: string, index: number) =>
|
||||
@ -276,33 +327,37 @@ const CommandMenu = ({metaData}: Props) =>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
function RecentlyViewedSection()
|
||||
{
|
||||
const history = HistoryUtils.get();
|
||||
const options = [] as any;
|
||||
history.entries.reverse().forEach((entry, index) =>
|
||||
options.push({label: `${entry.label} index`, id: index, key: index, path: entry.path, iconName: entry.iconName})
|
||||
)
|
||||
);
|
||||
|
||||
let appNames: string[] = [];
|
||||
metaData.apps.forEach((value: QAppMetaData, key: string) =>
|
||||
{
|
||||
appNames.push(value.name);
|
||||
})
|
||||
});
|
||||
|
||||
appNames = appNames.sort((a: string, b:string) =>
|
||||
appNames = appNames.sort((a: string, b: string) =>
|
||||
{
|
||||
const labelA = metaData.apps.get(a).label ?? "";
|
||||
const labelB = metaData.apps.get(b).label ?? "";
|
||||
return (labelA.localeCompare(labelB));
|
||||
})
|
||||
return comparator(labelA, labelB);
|
||||
});
|
||||
|
||||
const entryMap = new Map<string, boolean>();
|
||||
return(
|
||||
return (
|
||||
<Command.Group heading="Recently Viewed Records">
|
||||
{
|
||||
history.entries.reverse().map((entry: QHistoryEntry, index: number) =>
|
||||
! entryMap.has(entry.label) && entryMap.set(entry.label, true) && (
|
||||
!entryMap.has(entry.label) && entryMap.set(entry.label, true) && (
|
||||
<Command.Item onSelect={() => goToItem(`${entry.path}`)} key={`${entry.label}-${index}`} value={entry.label}><Icon sx={{color: accentColor}}>{entry.iconName}</Icon>{entry.label}</Command.Item>
|
||||
)
|
||||
)
|
||||
@ -311,29 +366,101 @@ const CommandMenu = ({metaData}: Props) =>
|
||||
);
|
||||
}
|
||||
|
||||
const containerElement = useRef(null)
|
||||
const containerElement = useRef(null);
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
function closeKeyboardHelp()
|
||||
{
|
||||
setKeyboardHelpOpen(false);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
function closeDotMenu()
|
||||
{
|
||||
setDotMenuOpen(false);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** filter function for cmd-k library
|
||||
**
|
||||
*******************************************************************************/
|
||||
function doFilter(value: string, search: string)
|
||||
{
|
||||
setSearchString(search);
|
||||
|
||||
/////////////////////
|
||||
// split on spaces //
|
||||
/////////////////////
|
||||
const searchParts = search.toLowerCase().split(" ");
|
||||
if (searchParts.length == 1)
|
||||
{
|
||||
//////////////////////////////////////////////
|
||||
// if only 1 word, just do an includes test //
|
||||
//////////////////////////////////////////////
|
||||
return (value.toLowerCase().includes(search.toLowerCase()) ? 1 : 0);
|
||||
}
|
||||
else
|
||||
{
|
||||
////////////////////////////////////////
|
||||
// else split the value on spaces too //
|
||||
////////////////////////////////////////
|
||||
const valueParts = value.toLowerCase().split(" ");
|
||||
if (searchParts.length > valueParts.length)
|
||||
{
|
||||
//////////////////////////////////////////////////////////////////////////////////
|
||||
// if there are more words in the search than in the value, then it can't match //
|
||||
// e.g. "order c" can't ever match, say "order" //
|
||||
//////////////////////////////////////////////////////////////////////////////////
|
||||
return (0);
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// iterate over the search parts - if any don't match the corresponding value parts, then it's a non-match //
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
let valueIndex = 0;
|
||||
for (let i = 0; i < searchParts.length; i++)
|
||||
{
|
||||
let foundMatch = false;
|
||||
for (; valueIndex < valueParts.length; valueIndex++)
|
||||
{
|
||||
if (valueParts[valueIndex].includes(searchParts[i]))
|
||||
{
|
||||
foundMatch = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!foundMatch)
|
||||
{
|
||||
return (0);
|
||||
}
|
||||
}
|
||||
|
||||
/////////////////////////////////
|
||||
// if no failure, return a hit //
|
||||
/////////////////////////////////
|
||||
return (1);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<Box ref={containerElement} className="raycast" sx={{position: "relative", zIndex: 10_000}}>
|
||||
{
|
||||
<Dialog open={dotMenuOpen} onClose={closeDotMenu}>
|
||||
<Command.Dialog open={dotMenuOpen} onOpenChange={setDotMenuOpen} container={containerElement.current} label="Test Global Command Menu">
|
||||
<Command.Dialog open={dotMenuOpen} onOpenChange={setDotMenuOpen} container={containerElement.current} filter={(value, search) => doFilter(value, search)}>
|
||||
<Box sx={{display: "flex"}}>
|
||||
<Command.Input placeholder="Search for Tables, Actions, or Recently Viewed Items..."/>
|
||||
<Command.Input placeholder="Search for Tables, Actions, or Recently Viewed Items..." />
|
||||
<Button onClick={closeDotMenu}><Icon>close</Icon></Button>
|
||||
</Box>
|
||||
<Command.Loading />
|
||||
<Command.Loading />
|
||||
<Command.Separator />
|
||||
<Command.List>
|
||||
<Command.Empty>No results found.</Command.Empty>
|
||||
@ -381,6 +508,6 @@ const CommandMenu = ({metaData}: Props) =>
|
||||
</Dialog>
|
||||
}
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
export default CommandMenu;
|
||||
|
@ -134,17 +134,36 @@ public class MaterialDashboardTableMetaData extends QSupplementalTableMetaData
|
||||
|
||||
for(FieldRule fieldRule : CollectionUtils.nonNullList(fieldRules))
|
||||
{
|
||||
qInstanceValidator.assertCondition(fieldRule.getTrigger() != null, prefix + "has a fieldRule without a trigger");
|
||||
qInstanceValidator.assertCondition(fieldRule.getAction() != null, prefix + "has a fieldRule without an action");
|
||||
validateFieldRule(qInstance, tableMetaData, qInstanceValidator, fieldRule, prefix);
|
||||
}
|
||||
}
|
||||
|
||||
if(qInstanceValidator.assertCondition(StringUtils.hasContent(fieldRule.getSourceField()), prefix + "has a fieldRule without a sourceField"))
|
||||
{
|
||||
qInstanceValidator.assertNoException(() -> tableMetaData.getField(fieldRule.getSourceField()), prefix + "has a fieldRule with an unrecognized sourceField: " + fieldRule.getSourceField());
|
||||
}
|
||||
|
||||
if(qInstanceValidator.assertCondition(StringUtils.hasContent(fieldRule.getTargetField()), prefix + "has a fieldRule without a targetField"))
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
static void validateFieldRule(QInstance qInstance, QTableMetaData tableMetaData, QInstanceValidator qInstanceValidator, FieldRule fieldRule, String prefix)
|
||||
{
|
||||
qInstanceValidator.assertCondition(fieldRule.getTrigger() != null, prefix + "has a fieldRule without a trigger");
|
||||
qInstanceValidator.assertCondition(fieldRule.getAction() != null, prefix + "has a fieldRule without an action");
|
||||
|
||||
if(qInstanceValidator.assertCondition(StringUtils.hasContent(fieldRule.getSourceField()), prefix + "has a fieldRule without a sourceField"))
|
||||
{
|
||||
qInstanceValidator.assertNoException(() -> tableMetaData.getField(fieldRule.getSourceField()), prefix + "has a fieldRule with an unrecognized sourceField: " + fieldRule.getSourceField());
|
||||
}
|
||||
|
||||
if(StringUtils.hasContent(fieldRule.getTargetField()))
|
||||
{
|
||||
qInstanceValidator.assertNoException(() -> tableMetaData.getField(fieldRule.getTargetField()), prefix + "has a fieldRule with an unrecognized targetField: " + fieldRule.getTargetField());
|
||||
}
|
||||
|
||||
if(StringUtils.hasContent(fieldRule.getTargetWidget()))
|
||||
{
|
||||
if(qInstanceValidator.assertCondition(qInstance.getWidget(fieldRule.getTargetWidget()) != null, prefix + "has a widgetRule with an unrecognized targetWidget: " + fieldRule.getTargetWidget()))
|
||||
{
|
||||
qInstanceValidator.assertNoException(() -> tableMetaData.getField(fieldRule.getTargetField()), prefix + "has a fieldRule with an unrecognized targetField: " + fieldRule.getTargetField());
|
||||
qInstanceValidator.assertCondition(CollectionUtils.nonNullList(tableMetaData.getSections()).stream().anyMatch(s -> fieldRule.getTargetWidget().equals(s.getWidgetName())),
|
||||
prefix + "has a widgetRule with a targetWidget which is not used in any sections on the table");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -38,6 +38,8 @@ public class FieldRule implements Serializable
|
||||
private FieldRuleAction action;
|
||||
private String targetField;
|
||||
|
||||
private String targetWidget;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@ -162,4 +164,35 @@ public class FieldRule implements Serializable
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for targetWidget
|
||||
*******************************************************************************/
|
||||
public String getTargetWidget()
|
||||
{
|
||||
return (this.targetWidget);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for targetWidget
|
||||
*******************************************************************************/
|
||||
public void setTargetWidget(String targetWidget)
|
||||
{
|
||||
this.targetWidget = targetWidget;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for targetWidget
|
||||
*******************************************************************************/
|
||||
public FieldRule withTargetWidget(String targetWidget)
|
||||
{
|
||||
this.targetWidget = targetWidget;
|
||||
return (this);
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -27,5 +27,6 @@ package com.kingsrook.qqq.frontend.materialdashboard.model.metadata.fieldrules;
|
||||
*******************************************************************************/
|
||||
public enum FieldRuleAction
|
||||
{
|
||||
CLEAR_TARGET_FIELD
|
||||
CLEAR_TARGET_FIELD,
|
||||
RELOAD_WIDGET
|
||||
}
|
||||
|
@ -174,7 +174,8 @@ function QDynamicForm({formData, formLabel, bulkEditMode, bulkEditSwitchChangeHa
|
||||
<DynamicSelect
|
||||
tableName={field.possibleValueProps.tableName}
|
||||
processName={field.possibleValueProps.processName}
|
||||
fieldName={fieldName}
|
||||
possibleValueSourceName={field.possibleValueProps.possibleValueSourceName}
|
||||
fieldName={field.possibleValueProps.fieldName}
|
||||
isEditable={field.isEditable}
|
||||
fieldLabel=""
|
||||
initialValue={values[fieldName]}
|
||||
|
@ -19,16 +19,17 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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 React, {useState} from "react";
|
||||
import DynamicFormUtils from "qqq/components/forms/DynamicFormUtils";
|
||||
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
|
||||
interface Props
|
||||
@ -85,6 +86,51 @@ function QDynamicFormField({
|
||||
}
|
||||
};
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////
|
||||
// check the field meta data for behavior that says to do toUpperCase or toLowerCase //
|
||||
///////////////////////////////////////////////////////////////////////////////////////
|
||||
let isToUpperCase = useMemo(() => DynamicFormUtils.isToUpperCase(formFieldObject?.fieldMetaData), [formFieldObject]);
|
||||
let isToLowerCase = useMemo(() => DynamicFormUtils.isToLowerCase(formFieldObject?.fieldMetaData), [formFieldObject]);
|
||||
|
||||
////////////////////////////////////////////////////////////////////////
|
||||
// if the field has a toUpperCase or toLowerCase behavior on it, then //
|
||||
// apply that rule. But also, to avoid the cursor always jumping to //
|
||||
// the end of the input, do some manipulation of the selection. //
|
||||
// See: https://giacomocerquone.com/blog/keep-input-cursor-still //
|
||||
// Note, we only want an onChange handle if we're doing one of these //
|
||||
// behaviors, (because teh flushSync is potentially slow). hence, we //
|
||||
// put the onChange in an object and assign it with a spread //
|
||||
////////////////////////////////////////////////////////////////////////
|
||||
let onChange: any = {};
|
||||
if (isToUpperCase || isToLowerCase)
|
||||
{
|
||||
onChange.onChange = (e: any) =>
|
||||
{
|
||||
const beforeStart = e.target.selectionStart;
|
||||
const beforeEnd = e.target.selectionEnd;
|
||||
|
||||
flushSync(() =>
|
||||
{
|
||||
let newValue = e.currentTarget.value;
|
||||
if (isToUpperCase)
|
||||
{
|
||||
newValue = newValue.toUpperCase();
|
||||
}
|
||||
if (isToLowerCase)
|
||||
{
|
||||
newValue = newValue.toLowerCase();
|
||||
}
|
||||
setFieldValue(name, newValue);
|
||||
});
|
||||
|
||||
const input = document.getElementById(name) as HTMLInputElement;
|
||||
if (input)
|
||||
{
|
||||
input.setSelectionRange(beforeStart, beforeEnd);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
let field;
|
||||
let getsBulkEditHtmlLabel = true;
|
||||
if (type === "checkbox")
|
||||
@ -102,7 +148,7 @@ function QDynamicFormField({
|
||||
else if (type === "ace")
|
||||
{
|
||||
let mode = "text";
|
||||
if(formFieldObject && formFieldObject.languageMode)
|
||||
if (formFieldObject && formFieldObject.languageMode)
|
||||
{
|
||||
mode = formFieldObject.languageMode;
|
||||
}
|
||||
@ -133,7 +179,7 @@ function QDynamicFormField({
|
||||
{
|
||||
field = (
|
||||
<>
|
||||
<Field {...rest} onWheel={handleOnWheel} name={name} type={type} as={MDInput} variant="outlined" label={label} InputLabelProps={inputLabelProps} InputProps={inputProps} fullWidth disabled={isDisabled}
|
||||
<Field {...rest} {...onChange} onWheel={handleOnWheel} name={name} type={type} as={MDInput} variant="outlined" label={label} InputLabelProps={inputLabelProps} InputProps={inputProps} fullWidth disabled={isDisabled}
|
||||
onKeyPress={(e: any) =>
|
||||
{
|
||||
if (e.key === "Enter")
|
||||
@ -173,7 +219,8 @@ function QDynamicFormField({
|
||||
id={`bulkEditSwitch-${name}`}
|
||||
checked={switchChecked}
|
||||
onClick={bulkEditSwitchChanged}
|
||||
sx={{top: "-4px",
|
||||
sx={{
|
||||
top: "-4px",
|
||||
"& .MuiSwitch-track": {
|
||||
height: 20,
|
||||
borderRadius: 10,
|
||||
|
@ -172,6 +172,17 @@ class DynamicFormUtils
|
||||
{
|
||||
isPossibleValue: true,
|
||||
tableName: tableName,
|
||||
fieldName: field.name,
|
||||
initialDisplayValue: initialDisplayValue,
|
||||
};
|
||||
}
|
||||
else if (processName)
|
||||
{
|
||||
dynamicFormFields[field.name].possibleValueProps =
|
||||
{
|
||||
isPossibleValue: true,
|
||||
processName: processName,
|
||||
fieldName: field.name,
|
||||
initialDisplayValue: initialDisplayValue,
|
||||
};
|
||||
}
|
||||
@ -180,8 +191,9 @@ class DynamicFormUtils
|
||||
dynamicFormFields[field.name].possibleValueProps =
|
||||
{
|
||||
isPossibleValue: true,
|
||||
processName: processName,
|
||||
initialDisplayValue: initialDisplayValue,
|
||||
fieldName: field.name,
|
||||
possibleValueSourceName: field.possibleValueSourceName
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -202,7 +214,7 @@ class DynamicFormUtils
|
||||
|
||||
if (Array.isArray(disabledFields))
|
||||
{
|
||||
return (disabledFields.indexOf(fieldName) > -1)
|
||||
return (disabledFields.indexOf(fieldName) > -1);
|
||||
}
|
||||
else
|
||||
{
|
||||
@ -210,6 +222,44 @@ class DynamicFormUtils
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* check if a field has the TO_UPPER_CASE behavior on it.
|
||||
***************************************************************************/
|
||||
public static isToUpperCase(fieldMetaData: QFieldMetaData): boolean
|
||||
{
|
||||
return this.hasBehavior(fieldMetaData, "TO_UPPER_CASE");
|
||||
}
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* check if a field has the TO_LOWER_CASE behavior on it.
|
||||
***************************************************************************/
|
||||
public static isToLowerCase(fieldMetaData: QFieldMetaData): boolean
|
||||
{
|
||||
return this.hasBehavior(fieldMetaData, "TO_LOWER_CASE");
|
||||
}
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* check if a field has a specific behavior name on it.
|
||||
***************************************************************************/
|
||||
private static hasBehavior(fieldMetaData: QFieldMetaData, behaviorName: string): boolean
|
||||
{
|
||||
if (fieldMetaData && fieldMetaData.behaviors)
|
||||
{
|
||||
for (let i = 0; i < fieldMetaData.behaviors.length; i++)
|
||||
{
|
||||
if (fieldMetaData.behaviors[i] == behaviorName)
|
||||
{
|
||||
return (true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (false);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default DynamicFormUtils;
|
||||
|
@ -97,7 +97,7 @@ export const getAutocompleteOutlinedStyle = (isDisabled: boolean) =>
|
||||
borderColor: inputBorderColor
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const qController = Client.getInstance();
|
||||
@ -108,40 +108,36 @@ function DynamicSelect({tableName, processName, fieldName, possibleValueSourceNa
|
||||
const [options, setOptions] = useState<readonly QPossibleValue[]>([]);
|
||||
const [searchTerm, setSearchTerm] = useState(null);
|
||||
const [firstRender, setFirstRender] = useState(true);
|
||||
const [otherValuesWhenResultsWereLoaded, setOtherValuesWhenResultsWereLoaded] = useState(JSON.stringify(Object.fromEntries((otherValues))))
|
||||
const [otherValuesWhenResultsWereLoaded, setOtherValuesWhenResultsWereLoaded] = useState(JSON.stringify(Object.fromEntries((otherValues))));
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
if(tableName && processName)
|
||||
if (tableName && processName)
|
||||
{
|
||||
console.log("DynamicSelect - you may not provide both a tableName and a processName")
|
||||
console.log("DynamicSelect - you may not provide both a tableName and a processName");
|
||||
}
|
||||
if(tableName && !fieldName)
|
||||
if (tableName && !fieldName)
|
||||
{
|
||||
console.log("DynamicSelect - if you provide a tableName, you must also provide a fieldName");
|
||||
}
|
||||
if(processName && !fieldName)
|
||||
if (processName && !fieldName)
|
||||
{
|
||||
console.log("DynamicSelect - if you provide a processName, you must also provide a fieldName");
|
||||
}
|
||||
if(fieldName && possibleValueSourceName)
|
||||
{
|
||||
console.log("DynamicSelect - if you provide a fieldName and a possibleValueSourceName, the possibleValueSourceName will be ignored");
|
||||
}
|
||||
if(!fieldName && !possibleValueSourceName)
|
||||
if (!fieldName && !possibleValueSourceName)
|
||||
{
|
||||
console.log("DynamicSelect - you must provide either a fieldName (and a tableName or processName) or a possibleValueSourceName");
|
||||
}
|
||||
if(fieldName)
|
||||
if (fieldName && !possibleValueSourceName)
|
||||
{
|
||||
if(!tableName || !processName)
|
||||
if (!tableName || !processName)
|
||||
{
|
||||
console.log("DynamicSelect - if you provide a fieldName, you must also provide a tableName or processName");
|
||||
console.log("DynamicSelect - if you provide a fieldName and not a possibleValueSourceName, then you must also provide a tableName or processName");
|
||||
}
|
||||
}
|
||||
if(possibleValueSourceName)
|
||||
if (possibleValueSourceName)
|
||||
{
|
||||
if(tableName || processName)
|
||||
if (tableName || processName)
|
||||
{
|
||||
console.log("DynamicSelect - if you provide a possibleValueSourceName, you should not also provide a tableName or processName");
|
||||
}
|
||||
@ -177,7 +173,7 @@ function DynamicSelect({tableName, processName, fieldName, possibleValueSourceNa
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
if(firstRender)
|
||||
if (firstRender)
|
||||
{
|
||||
// console.log("First render, so not searching...");
|
||||
setFirstRender(false);
|
||||
@ -198,9 +194,9 @@ function DynamicSelect({tableName, processName, fieldName, possibleValueSourceNa
|
||||
(async () =>
|
||||
{
|
||||
// console.log(`doing a search with ${searchTerm}`);
|
||||
const results: QPossibleValue[] = await qController.possibleValues(tableName, processName, fieldName ?? possibleValueSourceName, searchTerm ?? "", null, otherValues);
|
||||
const results: QPossibleValue[] = await qController.possibleValues(tableName, processName, possibleValueSourceName ?? fieldName, searchTerm ?? "", null, otherValues);
|
||||
|
||||
if(tableMetaData == null && tableName)
|
||||
if (tableMetaData == null && tableName)
|
||||
{
|
||||
let tableMetaData: QTableMetaData = await qController.loadTableMetaData(tableName);
|
||||
setTableMetaData(tableMetaData);
|
||||
@ -211,7 +207,7 @@ function DynamicSelect({tableName, processName, fieldName, possibleValueSourceNa
|
||||
// console.log(`${results}`);
|
||||
if (active)
|
||||
{
|
||||
setOptions([ ...results ]);
|
||||
setOptions([...results]);
|
||||
}
|
||||
})();
|
||||
|
||||
@ -219,30 +215,30 @@ function DynamicSelect({tableName, processName, fieldName, possibleValueSourceNa
|
||||
{
|
||||
active = false;
|
||||
};
|
||||
}, [ searchTerm ]);
|
||||
}, [searchTerm]);
|
||||
|
||||
// todo - finish... call it in onOpen?
|
||||
const reloadIfOtherValuesAreChanged = () =>
|
||||
{
|
||||
if(JSON.stringify(Object.fromEntries(otherValues)) != otherValuesWhenResultsWereLoaded)
|
||||
if (JSON.stringify(Object.fromEntries(otherValues)) != otherValuesWhenResultsWereLoaded)
|
||||
{
|
||||
(async () =>
|
||||
{
|
||||
setLoading(true);
|
||||
setOptions([]);
|
||||
console.log("Refreshing possible values...");
|
||||
const results: QPossibleValue[] = await qController.possibleValues(tableName, processName, fieldName ?? possibleValueSourceName, searchTerm ?? "", null, otherValues);
|
||||
const results: QPossibleValue[] = await qController.possibleValues(tableName, processName, possibleValueSourceName ?? fieldName, searchTerm ?? "", null, otherValues);
|
||||
setLoading(false);
|
||||
setOptions([ ...results ]);
|
||||
setOptions([...results]);
|
||||
setOtherValuesWhenResultsWereLoaded(JSON.stringify(Object.fromEntries(otherValues)));
|
||||
})();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const inputChanged = (event: React.SyntheticEvent, value: string, reason: string) =>
|
||||
{
|
||||
// console.log(`input changed. Reason: ${reason}, setting search term to ${value}`);
|
||||
if(reason !== "reset")
|
||||
if (reason !== "reset")
|
||||
{
|
||||
// console.log(` -> setting search term to ${value}`);
|
||||
setSearchTerm(value);
|
||||
@ -252,7 +248,7 @@ function DynamicSelect({tableName, processName, fieldName, possibleValueSourceNa
|
||||
const handleBlur = (x: any) =>
|
||||
{
|
||||
setSearchTerm(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChanged = (event: React.SyntheticEvent, value: any | any[], reason: string, details?: string) =>
|
||||
{
|
||||
@ -260,9 +256,9 @@ function DynamicSelect({tableName, processName, fieldName, possibleValueSourceNa
|
||||
// console.log(value);
|
||||
setSearchTerm(null);
|
||||
|
||||
if(onChange)
|
||||
if (onChange)
|
||||
{
|
||||
if(isMultiple)
|
||||
if (isMultiple)
|
||||
{
|
||||
onChange(value);
|
||||
}
|
||||
@ -271,7 +267,7 @@ function DynamicSelect({tableName, processName, fieldName, possibleValueSourceNa
|
||||
onChange(value ? new QPossibleValue(value) : null);
|
||||
}
|
||||
}
|
||||
else if(setFieldValueRef && fieldName)
|
||||
else if (setFieldValueRef && fieldName)
|
||||
{
|
||||
setFieldValueRef(fieldName, value ? value.id : null);
|
||||
}
|
||||
@ -284,7 +280,7 @@ function DynamicSelect({tableName, processName, fieldName, possibleValueSourceNa
|
||||
// get options whose text/label matches the input (e.g., not ids that match) //
|
||||
/////////////////////////////////////////////////////////////////////////////////
|
||||
return (options);
|
||||
}
|
||||
};
|
||||
|
||||
// @ts-ignore
|
||||
const renderOption = (props: Object, option: any, {selected}) =>
|
||||
@ -293,23 +289,24 @@ function DynamicSelect({tableName, processName, fieldName, possibleValueSourceNa
|
||||
|
||||
try
|
||||
{
|
||||
const field = tableMetaData?.fields.get(fieldName)
|
||||
if(field)
|
||||
const field = tableMetaData?.fields.get(fieldName);
|
||||
if (field)
|
||||
{
|
||||
const adornment = field.getAdornment(AdornmentType.CHIP);
|
||||
if(adornment)
|
||||
if (adornment)
|
||||
{
|
||||
const color = adornment.getValue("color." + option.id) ?? "default"
|
||||
const color = adornment.getValue("color." + option.id) ?? "default";
|
||||
const iconName = adornment.getValue("icon." + option.id) ?? null;
|
||||
const iconElement = iconName ? <Icon>{iconName}</Icon> : null;
|
||||
content = (<Chip label={option.label} color={color} icon={iconElement} size="small" variant="outlined" sx={{fontWeight: 500}} />);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch(e)
|
||||
{ }
|
||||
catch (e)
|
||||
{
|
||||
}
|
||||
|
||||
if(isMultiple)
|
||||
if (isMultiple)
|
||||
{
|
||||
content = (
|
||||
<>
|
||||
@ -331,7 +328,7 @@ function DynamicSelect({tableName, processName, fieldName, possibleValueSourceNa
|
||||
{content}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const bulkEditSwitchChanged = () =>
|
||||
{
|
||||
@ -361,7 +358,7 @@ function DynamicSelect({tableName, processName, fieldName, possibleValueSourceNa
|
||||
{
|
||||
setOpen(true);
|
||||
// console.log("setting open...");
|
||||
if(options.length == 0)
|
||||
if (options.length == 0)
|
||||
{
|
||||
// console.log("no options yet, so setting search term to ''...");
|
||||
setSearchTerm("");
|
||||
@ -374,19 +371,19 @@ function DynamicSelect({tableName, processName, fieldName, possibleValueSourceNa
|
||||
isOptionEqualToValue={(option, value) => value !== null && value !== undefined && option.id === value.id}
|
||||
getOptionLabel={(option) =>
|
||||
{
|
||||
if(option === null || option === undefined)
|
||||
if (option === null || option === undefined)
|
||||
{
|
||||
return ("");
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
if(option && option.length)
|
||||
if (option && option.length)
|
||||
{
|
||||
// @ts-ignore
|
||||
option = option[0];
|
||||
}
|
||||
// @ts-ignore
|
||||
return option.label
|
||||
return option.label;
|
||||
}}
|
||||
options={options}
|
||||
loading={loading}
|
||||
@ -450,7 +447,8 @@ function DynamicSelect({tableName, processName, fieldName, possibleValueSourceNa
|
||||
id={`bulkEditSwitch-${fieldName}`}
|
||||
checked={switchChecked}
|
||||
onClick={bulkEditSwitchChanged}
|
||||
sx={{top: "-4px",
|
||||
sx={{
|
||||
top: "-4px",
|
||||
"& .MuiSwitch-track": {
|
||||
height: 20,
|
||||
borderRadius: 10,
|
||||
@ -469,7 +467,7 @@ function DynamicSelect({tableName, processName, fieldName, possibleValueSourceNa
|
||||
else
|
||||
{
|
||||
return (
|
||||
<Box mb={1.5}>
|
||||
<Box>
|
||||
{autocomplete}
|
||||
</Box>
|
||||
);
|
||||
|
@ -43,9 +43,10 @@ import DynamicFormUtils from "qqq/components/forms/DynamicFormUtils";
|
||||
import MDTypography from "qqq/components/legacy/MDTypography";
|
||||
import HelpContent from "qqq/components/misc/HelpContent";
|
||||
import QRecordSidebar from "qqq/components/misc/RecordSidebar";
|
||||
import DynamicFormWidget from "qqq/components/widgets/misc/DynamicFormWidget";
|
||||
import FilterAndColumnsSetupWidget from "qqq/components/widgets/misc/FilterAndColumnsSetupWidget";
|
||||
import PivotTableSetupWidget from "qqq/components/widgets/misc/PivotTableSetupWidget";
|
||||
import RecordGridWidget, {ChildRecordListData} from "qqq/components/widgets/misc/RecordGridWidget";
|
||||
import ReportSetupWidget from "qqq/components/widgets/misc/ReportSetupWidget";
|
||||
import {FieldRule, FieldRuleAction, FieldRuleTrigger} from "qqq/models/fields/FieldRules";
|
||||
import HtmlUtils from "qqq/utils/HtmlUtils";
|
||||
import Client from "qqq/utils/qqq/Client";
|
||||
@ -87,7 +88,7 @@ EntityForm.defaultProps = {
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
let formikSetFieldValueFunction = (field: string, value: any, shouldValidate?: boolean): void =>
|
||||
{
|
||||
}
|
||||
};
|
||||
|
||||
function EntityForm(props: Props): JSX.Element
|
||||
{
|
||||
@ -118,11 +119,12 @@ function EntityForm(props: Props): JSX.Element
|
||||
const [, forceUpdate] = useReducer((x) => x + 1, 0);
|
||||
|
||||
const [showEditChildForm, setShowEditChildForm] = useState(null as any);
|
||||
const [modalDataChangedCounter, setModalDataChangedCount] = useState(0);
|
||||
|
||||
const [notAllowedError, setNotAllowedError] = useState(null as string);
|
||||
|
||||
const [formValuesJSON, setFormValuesJSON] = useState("");
|
||||
const [formValues, setFormValues] = useState({} as {[name: string]: any});
|
||||
const [formValues, setFormValues] = useState({} as { [name: string]: any });
|
||||
|
||||
const {pageHeader, setPageHeader} = useContext(QContext);
|
||||
|
||||
@ -203,7 +205,7 @@ function EntityForm(props: Props): JSX.Element
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
const deleteChildRecord = (name: string, widgetData: any, rowIndex: number) =>
|
||||
function deleteChildRecord(name: string, widgetData: any, rowIndex: number)
|
||||
{
|
||||
updateChildRecordList(name, "delete", rowIndex);
|
||||
};
|
||||
@ -281,6 +283,8 @@ function EntityForm(props: Props): JSX.Element
|
||||
setRenderedWidgetSections(newRenderedWidgetSections);
|
||||
forceUpdate();
|
||||
|
||||
setModalDataChangedCount(modalDataChangedCounter + 1);
|
||||
|
||||
setShowEditChildForm(null);
|
||||
}
|
||||
|
||||
@ -290,7 +294,7 @@ function EntityForm(props: Props): JSX.Element
|
||||
*******************************************************************************/
|
||||
useEffect(() =>
|
||||
{
|
||||
const newRenderedWidgetSections: {[name: string]: JSX.Element} = {};
|
||||
const newRenderedWidgetSections: { [name: string]: JSX.Element } = {};
|
||||
for (let widgetName in renderedWidgetSections)
|
||||
{
|
||||
const widgetMetaData = metaData.widgets.get(widgetName);
|
||||
@ -350,12 +354,11 @@ function EntityForm(props: Props): JSX.Element
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** if we have a widget that wants to set form-field values, they can take this
|
||||
** function in as a callback, and then call it with their values.
|
||||
*******************************************************************************/
|
||||
function setFormFieldValuesFromWidget(values: {[name: string]: any})
|
||||
function setFormFieldValuesFromWidget(values: { [name: string]: any })
|
||||
{
|
||||
for (let key in values)
|
||||
{
|
||||
@ -369,13 +372,13 @@ function EntityForm(props: Props): JSX.Element
|
||||
*******************************************************************************/
|
||||
function getWidgetSection(widgetMetaData: QWidgetMetaData, widgetData: any): JSX.Element
|
||||
{
|
||||
if(widgetMetaData.type == "childRecordList")
|
||||
if (widgetMetaData.type == "childRecordList")
|
||||
{
|
||||
widgetData.viewAllLink = null;
|
||||
widgetMetaData.showExportButton = false;
|
||||
|
||||
return <RecordGridWidget
|
||||
key={new Date().getTime()} // added so that editing values actually re-renders...
|
||||
return Object.keys(childListWidgetData).length > 0 && (<RecordGridWidget
|
||||
key={`${formValues["tableName"]}-${modalDataChangedCounter}`}
|
||||
widgetMetaData={widgetMetaData}
|
||||
data={widgetData}
|
||||
disableRowClick
|
||||
@ -384,21 +387,31 @@ function EntityForm(props: Props): JSX.Element
|
||||
addNewRecordCallback={() => openAddChildRecord(widgetMetaData.name, widgetData)}
|
||||
editRecordCallback={(rowIndex) => openEditChildRecord(widgetMetaData.name, widgetData, rowIndex)}
|
||||
deleteRecordCallback={(rowIndex) => deleteChildRecord(widgetMetaData.name, widgetData, rowIndex)}
|
||||
/>;
|
||||
/>);
|
||||
}
|
||||
|
||||
if(widgetMetaData.type == "reportSetup")
|
||||
if (widgetMetaData.type == "filterAndColumnsSetup")
|
||||
{
|
||||
return <ReportSetupWidget
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// if the widget metadata specifies a table name, set form values to that so widget knows which to use //
|
||||
// (for the case when it is not being specified by a separate field in the record) //
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
if (widgetData?.tableName)
|
||||
{
|
||||
formValues["tableName"] = widgetData?.tableName;
|
||||
}
|
||||
|
||||
return <FilterAndColumnsSetupWidget
|
||||
key={formValues["tableName"]} // todo, is this good? it was added so that editing values actually re-renders...
|
||||
isEditable={true}
|
||||
widgetMetaData={widgetMetaData}
|
||||
widgetData={widgetData}
|
||||
recordValues={formValues}
|
||||
onSaveCallback={setFormFieldValuesFromWidget}
|
||||
/>
|
||||
/>;
|
||||
}
|
||||
|
||||
if(widgetMetaData.type == "pivotTableSetup")
|
||||
if (widgetMetaData.type == "pivotTableSetup")
|
||||
{
|
||||
return <PivotTableSetupWidget
|
||||
key={formValues["tableName"]} // todo, is this good? it was added so that editing values actually re-renders...
|
||||
@ -406,10 +419,23 @@ function EntityForm(props: Props): JSX.Element
|
||||
widgetMetaData={widgetMetaData}
|
||||
recordValues={formValues}
|
||||
onSaveCallback={setFormFieldValuesFromWidget}
|
||||
/>
|
||||
/>;
|
||||
}
|
||||
|
||||
return (<Box>Unsupported widget type: {widgetMetaData.type}</Box>)
|
||||
if (widgetMetaData.type == "dynamicForm")
|
||||
{
|
||||
return <DynamicFormWidget
|
||||
key={formValues["savedReportId"]} // todo - pull this from the metaData (could do so above too...)
|
||||
isEditable={true}
|
||||
widgetMetaData={widgetMetaData}
|
||||
widgetData={widgetData}
|
||||
recordValues={formValues}
|
||||
record={record}
|
||||
onSaveCallback={setFormFieldValuesFromWidget}
|
||||
/>;
|
||||
}
|
||||
|
||||
return (<Box>Unsupported widget type: {widgetMetaData.type}</Box>);
|
||||
}
|
||||
|
||||
|
||||
@ -435,12 +461,12 @@ function EntityForm(props: Props): JSX.Element
|
||||
function setupFieldRules(tableMetaData: QTableMetaData)
|
||||
{
|
||||
const mdbMetaData = tableMetaData?.supplementalTableMetaData?.get("materialDashboard");
|
||||
if(!mdbMetaData)
|
||||
if (!mdbMetaData)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if(mdbMetaData.fieldRules)
|
||||
if (mdbMetaData.fieldRules)
|
||||
{
|
||||
const newFieldRules: FieldRule[] = [];
|
||||
for (let i = 0; i < mdbMetaData.fieldRules.length; i++)
|
||||
@ -455,83 +481,164 @@ function EntityForm(props: Props): JSX.Element
|
||||
//////////////////
|
||||
// initial load //
|
||||
//////////////////
|
||||
if (!asyncLoadInited)
|
||||
useEffect(() =>
|
||||
{
|
||||
setAsyncLoadInited(true);
|
||||
(async () =>
|
||||
if (!asyncLoadInited)
|
||||
{
|
||||
const tableMetaData = await qController.loadTableMetaData(tableName);
|
||||
setTableMetaData(tableMetaData);
|
||||
recordAnalytics({location: window.location, title: (props.isCopy ? "Copy" : props.id ? "Edit" : "New") + ": " + tableMetaData.label});
|
||||
|
||||
setupFieldRules(tableMetaData);
|
||||
|
||||
const metaData = await qController.loadMetaData();
|
||||
setMetaData(metaData);
|
||||
|
||||
/////////////////////////////////////////////////
|
||||
// define the sections, e.g., for the left-bar //
|
||||
/////////////////////////////////////////////////
|
||||
const tableSections = TableUtils.getSectionsForRecordSidebar(tableMetaData, [...tableMetaData.fields.keys()], (section: QTableSection) =>
|
||||
setAsyncLoadInited(true);
|
||||
(async () =>
|
||||
{
|
||||
const widget = metaData.widgets.get(section.widgetName);
|
||||
if(widget)
|
||||
const tableMetaData = await qController.loadTableMetaData(tableName);
|
||||
setTableMetaData(tableMetaData);
|
||||
recordAnalytics({location: window.location, title: (props.isCopy ? "Copy" : props.id ? "Edit" : "New") + ": " + tableMetaData.label});
|
||||
|
||||
setupFieldRules(tableMetaData);
|
||||
|
||||
const metaData = await qController.loadMetaData();
|
||||
setMetaData(metaData);
|
||||
|
||||
/////////////////////////////////////////////////
|
||||
// define the sections, e.g., for the left-bar //
|
||||
/////////////////////////////////////////////////
|
||||
const tableSections = TableUtils.getSectionsForRecordSidebar(tableMetaData, [...tableMetaData.fields.keys()], (section: QTableSection) =>
|
||||
{
|
||||
if(widget.type == "childRecordList" && widget.defaultValues?.has("manageAssociationName"))
|
||||
const widget = metaData?.widgets.get(section.widgetName);
|
||||
if (widget)
|
||||
{
|
||||
return (true);
|
||||
if (widget.type == "childRecordList" && widget.defaultValues?.has("manageAssociationName"))
|
||||
{
|
||||
return (true);
|
||||
}
|
||||
|
||||
if (widget.type == "filterAndColumnsSetup" || widget.type == "pivotTableSetup" || widget.type == "dynamicForm")
|
||||
{
|
||||
return (true);
|
||||
}
|
||||
}
|
||||
|
||||
if(widget.type == "reportSetup" || widget.type == "pivotTableSetup")
|
||||
{
|
||||
return (true);
|
||||
}
|
||||
}
|
||||
return (false);
|
||||
});
|
||||
setTableSections(tableSections);
|
||||
|
||||
return (false);
|
||||
});
|
||||
setTableSections(tableSections);
|
||||
|
||||
const fieldArray = [] as QFieldMetaData[];
|
||||
const sortedKeys = [...tableMetaData.fields.keys()].sort();
|
||||
sortedKeys.forEach((key) =>
|
||||
{
|
||||
const fieldMetaData = tableMetaData.fields.get(key);
|
||||
fieldArray.push(fieldMetaData);
|
||||
});
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////////////
|
||||
// if doing an edit or copy, fetch the record and pre-populate the form values from it //
|
||||
/////////////////////////////////////////////////////////////////////////////////////////
|
||||
let record: QRecord = null;
|
||||
let defaultDisplayValues = new Map<string, string>();
|
||||
if (props.id !== null)
|
||||
{
|
||||
record = await qController.get(tableName, props.id);
|
||||
setRecord(record);
|
||||
recordAnalytics({category: "tableEvents", action: props.isCopy ? "copy" : "edit", label: tableMetaData?.label + " / " + record?.recordLabel});
|
||||
|
||||
const titleVerb = props.isCopy ? "Copy" : "Edit";
|
||||
setFormTitle(`${titleVerb} ${tableMetaData?.label}: ${record?.recordLabel}`);
|
||||
|
||||
if (!props.isModal)
|
||||
const fieldArray = [] as QFieldMetaData[];
|
||||
const sortedKeys = [...tableMetaData.fields.keys()].sort();
|
||||
sortedKeys.forEach((key) =>
|
||||
{
|
||||
setPageHeader(`${titleVerb} ${tableMetaData?.label}: ${record?.recordLabel}`);
|
||||
}
|
||||
|
||||
tableMetaData.fields.forEach((fieldMetaData, key) =>
|
||||
{
|
||||
if (props.isCopy && fieldMetaData.name == tableMetaData.primaryKeyField)
|
||||
{
|
||||
return;
|
||||
}
|
||||
initialValues[key] = record.values.get(key);
|
||||
const fieldMetaData = tableMetaData.fields.get(key);
|
||||
fieldArray.push(fieldMetaData);
|
||||
});
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// these checks are only for updating records, if copying, it is actually an insert, which is checked after this block //
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
if (!props.isCopy)
|
||||
/////////////////////////////////////////////////////////////////////////////////////////
|
||||
// if doing an edit or copy, fetch the record and pre-populate the form values from it //
|
||||
/////////////////////////////////////////////////////////////////////////////////////////
|
||||
let record: QRecord = null;
|
||||
let defaultDisplayValues = new Map<string, string>();
|
||||
if (props.id !== null)
|
||||
{
|
||||
record = await qController.get(tableName, props.id);
|
||||
setRecord(record);
|
||||
recordAnalytics({category: "tableEvents", action: props.isCopy ? "copy" : "edit", label: tableMetaData?.label + " / " + record?.recordLabel});
|
||||
|
||||
const titleVerb = props.isCopy ? "Copy" : "Edit";
|
||||
setFormTitle(`${titleVerb} ${tableMetaData?.label}: ${record?.recordLabel}`);
|
||||
|
||||
if (!props.isModal)
|
||||
{
|
||||
setPageHeader(`${titleVerb} ${tableMetaData?.label}: ${record?.recordLabel}`);
|
||||
}
|
||||
|
||||
tableMetaData.fields.forEach((fieldMetaData, key) =>
|
||||
{
|
||||
if (props.isCopy && fieldMetaData.name == tableMetaData.primaryKeyField)
|
||||
{
|
||||
return;
|
||||
}
|
||||
initialValues[key] = record.values.get(key);
|
||||
});
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// these checks are only for updating records, if copying, it is actually an insert, which is checked after this block //
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
if (!props.isCopy)
|
||||
{
|
||||
if (!tableMetaData.capabilities.has(Capability.TABLE_UPDATE))
|
||||
{
|
||||
setNotAllowedError("Records may not be edited in this table");
|
||||
}
|
||||
else if (!tableMetaData.editPermission)
|
||||
{
|
||||
setNotAllowedError(`You do not have permission to edit ${tableMetaData.label} records`);
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
///////////////////////////////////////////
|
||||
// else handle preparing to do an insert //
|
||||
///////////////////////////////////////////
|
||||
setFormTitle(`Creating New ${tableMetaData?.label}`);
|
||||
recordAnalytics({category: "tableEvents", action: "new", label: tableMetaData?.label});
|
||||
|
||||
if (!props.isModal)
|
||||
{
|
||||
setPageHeader(`Creating New ${tableMetaData?.label}`);
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// if default values were supplied for a new record, then populate initialValues, for formik. //
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
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)
|
||||
{
|
||||
initialValues[fieldName] = defaultValue;
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////////
|
||||
// we need to set the initialDisplayValue for possible value fields with a default value //
|
||||
// so, look them up here now if needed //
|
||||
///////////////////////////////////////////////////////////////////////////////////////////
|
||||
if (fieldMetaData.possibleValueSourceName)
|
||||
{
|
||||
const results: QPossibleValue[] = await qController.possibleValues(tableName, null, fieldName, null, [initialValues[fieldName]]);
|
||||
if (results && results.length > 0)
|
||||
{
|
||||
defaultDisplayValues.set(fieldName, results[0].label);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////
|
||||
// if an override heading was passed in, use it. //
|
||||
///////////////////////////////////////////////////
|
||||
if (props.overrideHeading)
|
||||
{
|
||||
setFormTitle(props.overrideHeading);
|
||||
if (!props.isModal)
|
||||
{
|
||||
setPageHeader(props.overrideHeading);
|
||||
}
|
||||
}
|
||||
|
||||
//////////////////////////////////////
|
||||
// check capabilities & permissions //
|
||||
//////////////////////////////////////
|
||||
if (props.isCopy || !props.id)
|
||||
{
|
||||
if (!tableMetaData.capabilities.has(Capability.TABLE_INSERT))
|
||||
{
|
||||
setNotAllowedError("Records may not be created in this table");
|
||||
}
|
||||
else if (!tableMetaData.insertPermission)
|
||||
{
|
||||
setNotAllowedError(`You do not have permission to create ${tableMetaData.label} records`);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!tableMetaData.capabilities.has(Capability.TABLE_UPDATE))
|
||||
{
|
||||
@ -542,200 +649,123 @@ function EntityForm(props: Props): JSX.Element
|
||||
setNotAllowedError(`You do not have permission to edit ${tableMetaData.label} records`);
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
///////////////////////////////////////////
|
||||
// else handle preparing to do an insert //
|
||||
///////////////////////////////////////////
|
||||
setFormTitle(`Creating New ${tableMetaData?.label}`);
|
||||
recordAnalytics({category: "tableEvents", action: "new", label: tableMetaData?.label});
|
||||
|
||||
if (!props.isModal)
|
||||
{
|
||||
setPageHeader(`Creating New ${tableMetaData?.label}`);
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// if default values were supplied for a new record, then populate initialValues, for formik. //
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
/////////////////////////////////////////////////////////////////////
|
||||
// make sure all initialValues are properly formatted for the form //
|
||||
/////////////////////////////////////////////////////////////////////
|
||||
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)
|
||||
if (fieldMetaData.type == QFieldType.DATE_TIME && initialValues[fieldMetaData.name])
|
||||
{
|
||||
initialValues[fieldName] = defaultValue;
|
||||
initialValues[fieldMetaData.name] = ValueUtils.formatDateTimeValueForForm(initialValues[fieldMetaData.name]);
|
||||
}
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////////
|
||||
// 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)
|
||||
setInitialValues(initialValues);
|
||||
|
||||
/////////////////////////////////////////////////////////
|
||||
// get formField and formValidation objects for Formik //
|
||||
/////////////////////////////////////////////////////////
|
||||
const {
|
||||
dynamicFormFields,
|
||||
formValidations,
|
||||
} = DynamicFormUtils.getFormData(fieldArray, disabledFields);
|
||||
DynamicFormUtils.addPossibleValueProps(dynamicFormFields, fieldArray, tableName, null, record ? record.displayValues : defaultDisplayValues);
|
||||
|
||||
/////////////////////////////////////
|
||||
// group the formFields by section //
|
||||
/////////////////////////////////////
|
||||
const dynamicFormFieldsBySection = new Map<string, any>();
|
||||
let t1sectionName;
|
||||
let t1section;
|
||||
const nonT1Sections: QTableSection[] = [];
|
||||
const newRenderedWidgetSections: { [name: string]: JSX.Element } = {};
|
||||
const newChildListWidgetData: { [name: string]: ChildRecordListData } = {};
|
||||
|
||||
for (let i = 0; i < tableSections.length; i++)
|
||||
{
|
||||
const section = tableSections[i];
|
||||
const sectionDynamicFormFields: any[] = [];
|
||||
|
||||
if (section.isHidden)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
const hasFields = section.fieldNames && section.fieldNames.length > 0;
|
||||
if (hasFields)
|
||||
{
|
||||
for (let j = 0; j < section.fieldNames.length; j++)
|
||||
{
|
||||
const results: QPossibleValue[] = await qController.possibleValues(tableName, null, fieldName, null, [initialValues[fieldName]]);
|
||||
if (results && results.length > 0)
|
||||
const fieldName = section.fieldNames[j];
|
||||
const field = tableMetaData.fields.get(fieldName);
|
||||
|
||||
if (!field)
|
||||
{
|
||||
defaultDisplayValues.set(fieldName, results[0].label);
|
||||
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. //
|
||||
// || (or) we're on the insert screen in which case, only show editable fields. //
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
if ((props.id !== null && !props.isCopy) || field.isEditable)
|
||||
{
|
||||
sectionDynamicFormFields.push(dynamicFormFields[fieldName]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////
|
||||
// if an override heading was passed in, use it. //
|
||||
///////////////////////////////////////////////////
|
||||
if (props.overrideHeading)
|
||||
{
|
||||
setFormTitle(props.overrideHeading);
|
||||
if (!props.isModal)
|
||||
{
|
||||
setPageHeader(props.overrideHeading);
|
||||
}
|
||||
}
|
||||
|
||||
//////////////////////////////////////
|
||||
// check capabilities & permissions //
|
||||
//////////////////////////////////////
|
||||
if (props.isCopy || !props.id)
|
||||
{
|
||||
if (!tableMetaData.capabilities.has(Capability.TABLE_INSERT))
|
||||
{
|
||||
setNotAllowedError("Records may not be created in this table");
|
||||
}
|
||||
else if (!tableMetaData.insertPermission)
|
||||
{
|
||||
setNotAllowedError(`You do not have permission to create ${tableMetaData.label} records`);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!tableMetaData.capabilities.has(Capability.TABLE_UPDATE))
|
||||
{
|
||||
setNotAllowedError("Records may not be edited in this table");
|
||||
}
|
||||
else if (!tableMetaData.editPermission)
|
||||
{
|
||||
setNotAllowedError(`You do not have permission to edit ${tableMetaData.label} records`);
|
||||
}
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////////////////
|
||||
// make sure all initialValues are properly formatted for the form //
|
||||
/////////////////////////////////////////////////////////////////////
|
||||
for (let i = 0; i < fieldArray.length; i++)
|
||||
{
|
||||
const fieldMetaData = fieldArray[i];
|
||||
if (fieldMetaData.type == QFieldType.DATE_TIME && initialValues[fieldMetaData.name])
|
||||
{
|
||||
initialValues[fieldMetaData.name] = ValueUtils.formatDateTimeValueForForm(initialValues[fieldMetaData.name]);
|
||||
}
|
||||
}
|
||||
|
||||
setInitialValues(initialValues);
|
||||
|
||||
/////////////////////////////////////////////////////////
|
||||
// get formField and formValidation objects for Formik //
|
||||
/////////////////////////////////////////////////////////
|
||||
const {
|
||||
dynamicFormFields,
|
||||
formValidations,
|
||||
} = DynamicFormUtils.getFormData(fieldArray, disabledFields);
|
||||
DynamicFormUtils.addPossibleValueProps(dynamicFormFields, fieldArray, tableName, null, record ? record.displayValues : defaultDisplayValues);
|
||||
|
||||
/////////////////////////////////////
|
||||
// group the formFields by section //
|
||||
/////////////////////////////////////
|
||||
const dynamicFormFieldsBySection = new Map<string, any>();
|
||||
let t1sectionName;
|
||||
let t1section;
|
||||
const nonT1Sections: QTableSection[] = [];
|
||||
const newRenderedWidgetSections: { [name: string]: JSX.Element } = {};
|
||||
const newChildListWidgetData: { [name: string]: ChildRecordListData } = {};
|
||||
|
||||
for (let i = 0; i < tableSections.length; i++)
|
||||
{
|
||||
const section = tableSections[i];
|
||||
const sectionDynamicFormFields: any[] = [];
|
||||
|
||||
if (section.isHidden)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
const hasFields = section.fieldNames && section.fieldNames.length > 0;
|
||||
if(hasFields)
|
||||
{
|
||||
for (let j = 0; j < section.fieldNames.length; j++)
|
||||
{
|
||||
const fieldName = section.fieldNames[j];
|
||||
const field = tableMetaData.fields.get(fieldName);
|
||||
|
||||
if (!field)
|
||||
if (sectionDynamicFormFields.length === 0)
|
||||
{
|
||||
console.log(`Omitting un-found field ${fieldName} from form`);
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// in case there are no active fields in this section, remove it from the tableSections array //
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
tableSections.splice(i, 1);
|
||||
i--;
|
||||
continue;
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// 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. //
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
if ((props.id !== null && !props.isCopy) || field.isEditable)
|
||||
else
|
||||
{
|
||||
sectionDynamicFormFields.push(dynamicFormFields[fieldName]);
|
||||
dynamicFormFieldsBySection.set(section.name, sectionDynamicFormFields);
|
||||
}
|
||||
}
|
||||
|
||||
if (sectionDynamicFormFields.length === 0)
|
||||
{
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// in case there are no active fields in this section, remove it from the tableSections array //
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
tableSections.splice(i, 1);
|
||||
i--;
|
||||
continue;
|
||||
}
|
||||
else
|
||||
{
|
||||
dynamicFormFieldsBySection.set(section.name, sectionDynamicFormFields);
|
||||
const widgetMetaData = metaData?.widgets.get(section.widgetName);
|
||||
const widgetData = await qController.widget(widgetMetaData.name, makeQueryStringWithIdAndObject(tableMetaData, defaultValues));
|
||||
|
||||
newRenderedWidgetSections[section.widgetName] = getWidgetSection(widgetMetaData, widgetData);
|
||||
newChildListWidgetData[section.widgetName] = widgetData;
|
||||
}
|
||||
|
||||
//////////////////////////////////////
|
||||
// capture the tier1 section's name //
|
||||
//////////////////////////////////////
|
||||
if (section.tier === "T1")
|
||||
{
|
||||
t1sectionName = section.name;
|
||||
t1section = section;
|
||||
}
|
||||
else
|
||||
{
|
||||
nonT1Sections.push(section);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
const widgetMetaData = metaData.widgets.get(section.widgetName);
|
||||
const widgetData = await qController.widget(widgetMetaData.name, props.id ? `${tableMetaData.primaryKeyField}=${props.id}` : "");
|
||||
newRenderedWidgetSections[section.widgetName] = getWidgetSection(widgetMetaData, widgetData);
|
||||
newChildListWidgetData[section.widgetName] = widgetData;
|
||||
}
|
||||
|
||||
//////////////////////////////////////
|
||||
// capture the tier1 section's name //
|
||||
//////////////////////////////////////
|
||||
if (section.tier === "T1")
|
||||
{
|
||||
t1sectionName = section.name;
|
||||
t1section = section;
|
||||
}
|
||||
else
|
||||
{
|
||||
nonT1Sections.push(section);
|
||||
}
|
||||
}
|
||||
setT1SectionName(t1sectionName);
|
||||
setT1Section(t1section);
|
||||
setNonT1Sections(nonT1Sections);
|
||||
setFormFields(dynamicFormFieldsBySection);
|
||||
setValidations(Yup.object().shape(formValidations));
|
||||
setRenderedWidgetSections(newRenderedWidgetSections);
|
||||
setChildListWidgetData(newChildListWidgetData);
|
||||
|
||||
setT1SectionName(t1sectionName);
|
||||
setT1Section(t1section);
|
||||
setNonT1Sections(nonT1Sections);
|
||||
setFormFields(dynamicFormFieldsBySection);
|
||||
setValidations(Yup.object().shape(formValidations));
|
||||
setRenderedWidgetSections(newRenderedWidgetSections);
|
||||
setChildListWidgetData(newChildListWidgetData);
|
||||
|
||||
forceUpdate();
|
||||
})();
|
||||
}
|
||||
forceUpdate();
|
||||
})();
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
//////////////////////////////////////////////////////////////////
|
||||
@ -855,16 +885,28 @@ function EntityForm(props: Props): JSX.Element
|
||||
let haveAssociationsToPost = false;
|
||||
for (let name of Object.keys(childListWidgetData))
|
||||
{
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// if cannot find association name, continue loop, since cannot tell backend which association this is for //
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
const manageAssociationName = metaData.widgets.get(name)?.defaultValues?.get("manageAssociationName");
|
||||
if (!manageAssociationName)
|
||||
{
|
||||
console.log(`Cannot send association data to backend - missing a manageAssociationName defaultValue in widget meta data for widget name ${name}`);
|
||||
continue;
|
||||
}
|
||||
associationsToPost[manageAssociationName] = [];
|
||||
haveAssociationsToPost = true;
|
||||
for (let i = 0; i < childListWidgetData[name].queryOutput?.records?.length; i++)
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// if the records array exists, add to associations to post - note: even if empty list, the backend will expect this //
|
||||
// association name to be present if it is to act on it (for the case when all associations have been deleted) //
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
if (childListWidgetData[name].queryOutput.records)
|
||||
{
|
||||
associationsToPost[manageAssociationName].push(childListWidgetData[name].queryOutput.records[i].values);
|
||||
associationsToPost[manageAssociationName] = [];
|
||||
haveAssociationsToPost = true;
|
||||
for (let i = 0; i < childListWidgetData[name].queryOutput?.records?.length; i++)
|
||||
{
|
||||
associationsToPost[manageAssociationName].push(childListWidgetData[name].queryOutput.records[i].values);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (haveAssociationsToPost)
|
||||
@ -912,7 +954,7 @@ function EntityForm(props: Props): JSX.Element
|
||||
else
|
||||
{
|
||||
setAlertContent(error.message);
|
||||
HtmlUtils.autoScroll(0);
|
||||
scrollToTopToShowAlert();
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -958,7 +1000,7 @@ function EntityForm(props: Props): JSX.Element
|
||||
else
|
||||
{
|
||||
setAlertContent(error.message);
|
||||
HtmlUtils.autoScroll(0);
|
||||
scrollToTopToShowAlert();
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -966,14 +1008,75 @@ function EntityForm(props: Props): JSX.Element
|
||||
};
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
function scrollToTopToShowAlert()
|
||||
{
|
||||
if (props.isModal)
|
||||
{
|
||||
document.getElementById("modalTopReference")?.scrollIntoView();
|
||||
}
|
||||
else
|
||||
{
|
||||
HtmlUtils.autoScroll(0);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
function makeQueryStringWithIdAndObject(tableMetaData: QTableMetaData, object: { [key: string]: any })
|
||||
{
|
||||
const queryParamsArray: string[] = [];
|
||||
if (props.id)
|
||||
{
|
||||
queryParamsArray.push(`${tableMetaData.primaryKeyField}=${encodeURIComponent(props.id)}`);
|
||||
}
|
||||
|
||||
if (object)
|
||||
{
|
||||
for (let key in object)
|
||||
{
|
||||
queryParamsArray.push(`${key}=${encodeURIComponent(object[key])}`);
|
||||
}
|
||||
}
|
||||
|
||||
return (queryParamsArray.join("&"));
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
async function reloadWidget(widgetName: string, additionalQueryParamsForWidget: { [key: string]: any })
|
||||
{
|
||||
const widgetData = await qController.widget(widgetName, makeQueryStringWithIdAndObject(tableMetaData, additionalQueryParamsForWidget));
|
||||
const widgetMetaData = metaData.widgets.get(widgetName);
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// todo - rename this - it holds all widget dta, not just child-lists. also, the type is wrong... //
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
const newChildListWidgetData: { [name: string]: ChildRecordListData } = Object.assign({}, childListWidgetData);
|
||||
newChildListWidgetData[widgetName] = widgetData;
|
||||
setChildListWidgetData(newChildListWidgetData);
|
||||
|
||||
const newRenderedWidgetSections = Object.assign({}, renderedWidgetSections);
|
||||
newRenderedWidgetSections[widgetName] = getWidgetSection(widgetMetaData, widgetData);
|
||||
setRenderedWidgetSections(newRenderedWidgetSections);
|
||||
forceUpdate();
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** process a form-field having a changed value (e.g., apply field rules).
|
||||
*******************************************************************************/
|
||||
function handleChangedFieldValue(fieldName: string, oldValue: any, newValue: any, valueChangesToMake: {[fieldName: string]: any})
|
||||
function handleChangedFieldValue(fieldName: string, oldValue: any, newValue: any, valueChangesToMake: { [fieldName: string]: any })
|
||||
{
|
||||
for (let fieldRule of fieldRules)
|
||||
{
|
||||
if(fieldRule.trigger == FieldRuleTrigger.ON_CHANGE && fieldRule.sourceField == fieldName)
|
||||
if (fieldRule.trigger == FieldRuleTrigger.ON_CHANGE && fieldRule.sourceField == fieldName)
|
||||
{
|
||||
switch (fieldRule.action)
|
||||
{
|
||||
@ -981,6 +1084,10 @@ function EntityForm(props: Props): JSX.Element
|
||||
console.log(`Clearing value from [${fieldRule.targetField}] due to change in [${fieldName}]`);
|
||||
valueChangesToMake[fieldRule.targetField] = null;
|
||||
break;
|
||||
case FieldRuleAction.RELOAD_WIDGET:
|
||||
const additionalQueryParamsForWidget: { [key: string]: any } = {};
|
||||
additionalQueryParamsForWidget[fieldRule.sourceField] = newValue;
|
||||
reloadWidget(fieldRule.targetWidget, additionalQueryParamsForWidget);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1068,21 +1175,21 @@ function EntityForm(props: Props): JSX.Element
|
||||
/////////////////////////////////////////////////
|
||||
// if we have values from formik, look at them //
|
||||
/////////////////////////////////////////////////
|
||||
if(values)
|
||||
if (values)
|
||||
{
|
||||
////////////////////////////////////////////////////////////////////////
|
||||
// use stringified values as cheap/easy way to see if any are changed //
|
||||
////////////////////////////////////////////////////////////////////////
|
||||
const newFormValuesJSON = JSON.stringify(values);
|
||||
if(formValuesJSON != newFormValuesJSON)
|
||||
if (formValuesJSON != newFormValuesJSON)
|
||||
{
|
||||
const valueChangesToMake: {[fieldName: string]: any} = {};
|
||||
const valueChangesToMake: { [fieldName: string]: any } = {};
|
||||
|
||||
////////////////////////////////////////////////////////////////////
|
||||
// if the form is dirty (e.g., we're not doing the initial load), //
|
||||
// then process rules for any changed fields //
|
||||
////////////////////////////////////////////////////////////////////
|
||||
if(dirty)
|
||||
if (dirty)
|
||||
{
|
||||
for (let fieldName in values)
|
||||
{
|
||||
@ -1114,7 +1221,7 @@ function EntityForm(props: Props): JSX.Element
|
||||
setFieldValue(fieldName, valueChangesToMake[fieldName], false);
|
||||
}
|
||||
|
||||
setFormValues(formValues)
|
||||
setFormValues(formValues);
|
||||
setFormValuesJSON(JSON.stringify(values));
|
||||
}
|
||||
}
|
||||
@ -1201,6 +1308,7 @@ function EntityForm(props: Props): JSX.Element
|
||||
return (
|
||||
<Box sx={{position: "absolute", overflowY: "auto", maxHeight: "100%", width: "100%"}}>
|
||||
<Card sx={{my: 5, mx: "auto", p: 6, pb: 0, maxWidth: "1024px"}}>
|
||||
<span id="modalTopReference"></span>
|
||||
{body}
|
||||
</Card>
|
||||
</Box>
|
||||
|
@ -63,7 +63,7 @@ function QBreadcrumbs({icon, title, route, light}: Props): JSX.Element
|
||||
///////////////////////////////////////////////////////////////////////
|
||||
// strip away empty elements of the route (e.g., trailing slash(es)) //
|
||||
///////////////////////////////////////////////////////////////////////
|
||||
if(route.length)
|
||||
if (route.length)
|
||||
{
|
||||
// @ts-ignore
|
||||
route = route.filter(r => r != "");
|
||||
@ -74,18 +74,18 @@ function QBreadcrumbs({icon, title, route, light}: Props): JSX.Element
|
||||
|
||||
const fullPathToLabel = (fullPath: string, route: string): string =>
|
||||
{
|
||||
if(fullPath.endsWith("/"))
|
||||
if (fullPath.endsWith("/"))
|
||||
{
|
||||
fullPath = fullPath.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
if(pathToLabelMap && pathToLabelMap[fullPath])
|
||||
if (pathToLabelMap && pathToLabelMap[fullPath])
|
||||
{
|
||||
return pathToLabelMap[fullPath];
|
||||
}
|
||||
|
||||
return (routeToLabel(route));
|
||||
}
|
||||
};
|
||||
|
||||
let pageTitle = branding?.appName ?? "";
|
||||
const fullRoutes: string[] = [];
|
||||
@ -94,21 +94,24 @@ function QBreadcrumbs({icon, title, route, light}: Props): JSX.Element
|
||||
{
|
||||
////////////////////////////////////////////////////////
|
||||
// avoid showing "saved view" as a breadcrumb element //
|
||||
// e.g., if at /app/table/savedView/1 //
|
||||
////////////////////////////////////////////////////////
|
||||
if(routes[i] === "savedView")
|
||||
if (routes[i] === "savedView" && i == routes.length - 1)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////
|
||||
// avoid showing the table name if it's the element before savedView //
|
||||
// e.g., when at /app/table/savedView/1 (so where i==1) //
|
||||
// we want to just be showing "App" //
|
||||
///////////////////////////////////////////////////////////////////////
|
||||
if(i < routes.length - 1 && routes[i+1] == "savedView")
|
||||
if (i < routes.length - 1 && routes[i + 1] == "savedView" && i == 1)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if(routes[i] === "")
|
||||
if (routes[i] === "")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
@ -19,16 +19,15 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {Popper, InputAdornment} from "@mui/material";
|
||||
import {Popper, InputAdornment, Box} from "@mui/material";
|
||||
import AppBar from "@mui/material/AppBar";
|
||||
import Autocomplete from "@mui/material/Autocomplete";
|
||||
import Box from "@mui/material/Box";
|
||||
import Icon from "@mui/material/Icon";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import ListItemIcon from "@mui/material/ListItemIcon";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import Toolbar from "@mui/material/Toolbar";
|
||||
import React, {useContext, useEffect, useState} from "react";
|
||||
import React, {useContext, useEffect, useRef, useState} from "react";
|
||||
import {useLocation, useNavigate} from "react-router-dom";
|
||||
import QContext from "QContext";
|
||||
import QBreadcrumbs, {routeToLabel} from "qqq/components/horseshoe/Breadcrumbs";
|
||||
@ -45,7 +44,8 @@ interface Props
|
||||
isMini?: boolean;
|
||||
}
|
||||
|
||||
interface HistoryEntry {
|
||||
interface HistoryEntry
|
||||
{
|
||||
id: number;
|
||||
path: string;
|
||||
label: string;
|
||||
@ -64,7 +64,7 @@ function NavBar({absolute, light, isMini}: Props): JSX.Element
|
||||
const route = useLocation().pathname.split("/").slice(1);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const {pageHeader} = useContext(QContext);
|
||||
const {pageHeader, setDotMenuOpen} = useContext(QContext);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
@ -99,7 +99,7 @@ function NavBar({absolute, light, isMini}: Props): JSX.Element
|
||||
const options = [] as any;
|
||||
history.entries.reverse().forEach((entry, index) =>
|
||||
options.push({label: `${entry.label} index`, id: index, key: index, path: entry.path, iconName: entry.iconName})
|
||||
)
|
||||
);
|
||||
setHistory(options);
|
||||
|
||||
// Remove event listener on cleanup
|
||||
@ -111,7 +111,7 @@ function NavBar({absolute, light, isMini}: Props): JSX.Element
|
||||
const goToHistory = (path: string) =>
|
||||
{
|
||||
navigate(path);
|
||||
}
|
||||
};
|
||||
|
||||
function buildHistoryEntries()
|
||||
{
|
||||
@ -119,7 +119,7 @@ function NavBar({absolute, light, isMini}: Props): JSX.Element
|
||||
const options = [] as any;
|
||||
history.entries.reverse().forEach((entry, index) =>
|
||||
options.push({label: entry.label, id: index, key: index, path: entry.path, iconName: entry.iconName})
|
||||
)
|
||||
);
|
||||
setHistory(options);
|
||||
}
|
||||
|
||||
@ -133,12 +133,12 @@ function NavBar({absolute, light, isMini}: Props): JSX.Element
|
||||
|
||||
const handleAutocompleteOnChange = (event: any, value: any, reason: any, details: any) =>
|
||||
{
|
||||
if(value)
|
||||
if (value)
|
||||
{
|
||||
goToHistory(value.path);
|
||||
}
|
||||
setAutocompleteValue(null);
|
||||
}
|
||||
};
|
||||
|
||||
const CustomPopper = function (props: any)
|
||||
{
|
||||
@ -146,8 +146,8 @@ function NavBar({absolute, light, isMini}: Props): JSX.Element
|
||||
{...props}
|
||||
style={{whiteSpace: "nowrap", width: "auto"}}
|
||||
placement="bottom-end"
|
||||
/>)
|
||||
}
|
||||
/>);
|
||||
};
|
||||
|
||||
const renderHistory = () =>
|
||||
{
|
||||
@ -166,7 +166,7 @@ function NavBar({absolute, light, isMini}: Props): JSX.Element
|
||||
PopperComponent={CustomPopper}
|
||||
isOptionEqualToValue={(option, value) => option.id === value.id}
|
||||
sx={recentlyViewedMenu}
|
||||
renderInput={(params) => <TextField {...params} label="Recently Viewed Records" InputProps={{
|
||||
renderInput={(params) => <TextField {...params} label="Recently Viewed Records" InputProps={{
|
||||
...params.InputProps,
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
@ -184,7 +184,7 @@ function NavBar({absolute, light, isMini}: Props): JSX.Element
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Styles for the navbar icons
|
||||
const iconsStyle = ({
|
||||
@ -210,18 +210,18 @@ function NavBar({absolute, light, isMini}: Props): JSX.Element
|
||||
const {pathToLabelMap} = useContext(QContext);
|
||||
const fullPathToLabel = (fullPath: string, route: string): string =>
|
||||
{
|
||||
if(fullPath.endsWith("/"))
|
||||
if (fullPath.endsWith("/"))
|
||||
{
|
||||
fullPath = fullPath.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
if(pathToLabelMap && pathToLabelMap[fullPath])
|
||||
if (pathToLabelMap && pathToLabelMap[fullPath])
|
||||
{
|
||||
return pathToLabelMap[fullPath];
|
||||
}
|
||||
|
||||
return (routeToLabel(route));
|
||||
}
|
||||
};
|
||||
|
||||
const breadcrumbTitle = fullPathToLabel(fullPath, route[route.length - 1]);
|
||||
|
||||
@ -242,9 +242,14 @@ function NavBar({absolute, light, isMini}: Props): JSX.Element
|
||||
</Box>
|
||||
{isMini ? null : (
|
||||
<Box sx={(theme) => navbarRow(theme, {isMini})}>
|
||||
<Box pr={0} mr={-2}>
|
||||
<Box mt={"-0.25rem"} pb={"0.75rem"} pr={2} mr={-2} sx={{"& *": {cursor: "pointer !important"}}}>
|
||||
{renderHistory()}
|
||||
</Box>
|
||||
<Box mt={"-1rem"}>
|
||||
<IconButton size="small" disableRipple color="inherit" onClick={() => setDotMenuOpen(true)}>
|
||||
<Icon sx={iconsStyle} fontSize="small">search</Icon>
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Toolbar>
|
||||
|
74
src/qqq/components/misc/ErrorBoundary.tsx
Normal file
74
src/qqq/components/misc/ErrorBoundary.tsx
Normal file
@ -0,0 +1,74 @@
|
||||
/*
|
||||
* 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 React, {Component, ErrorInfo} from "react";
|
||||
|
||||
interface Props
|
||||
{
|
||||
errorElement?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
interface State
|
||||
{
|
||||
hasError: boolean;
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Component that you can wrap around other components that might throw an error,
|
||||
** to give some isolation, rather than breaking a whole page.
|
||||
** Credit: https://medium.com/@bobjunior542/how-to-use-error-boundaries-in-react-js-with-typescript-ee90ec814bf1
|
||||
*******************************************************************************/
|
||||
class ErrorBoundary extends Component<Props, State>
|
||||
{
|
||||
/***************************************************************************
|
||||
*
|
||||
***************************************************************************/
|
||||
constructor(props: Props)
|
||||
{
|
||||
super(props);
|
||||
this.state = {hasError: false};
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
*
|
||||
***************************************************************************/
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo)
|
||||
{
|
||||
console.error("ErrorBoundary caught an error: ", error, errorInfo);
|
||||
this.setState({hasError: true});
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
*
|
||||
***************************************************************************/
|
||||
render()
|
||||
{
|
||||
if (this.state.hasError)
|
||||
{
|
||||
return this.props.errorElement ?? <span>(Error)</span>;
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export default ErrorBoundary;
|
@ -35,6 +35,7 @@ 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";
|
||||
@ -71,7 +72,12 @@ function hasGotoFieldNames(tableMetaData: QTableMetaData): boolean
|
||||
|
||||
function GotoRecordDialog(props: Props): JSX.Element
|
||||
{
|
||||
const fields: QFieldMetaData[] = [];
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// this is an array of array of fields. //
|
||||
// that is - each entry in the top-level array is a set of fields that can be used together to goto a record //
|
||||
// such as (pkey), (ukey-field1,ukey-field2). //
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
const options: QFieldMetaData[][] = [];
|
||||
|
||||
let pkey = props?.tableMetaData?.fields.get(props?.tableMetaData?.primaryKeyField);
|
||||
let addedPkey = false;
|
||||
@ -82,31 +88,38 @@ function GotoRecordDialog(props: Props): JSX.Element
|
||||
{
|
||||
for (let i = 0; i < mdbMetaData.gotoFieldNames.length; i++)
|
||||
{
|
||||
// todo - multi-field keys!!
|
||||
let fieldName = mdbMetaData.gotoFieldNames[i][0];
|
||||
let field = props.tableMetaData.fields.get(fieldName);
|
||||
if (field)
|
||||
const option: QFieldMetaData[] = [];
|
||||
options.push(option);
|
||||
for (let j = 0; j < mdbMetaData.gotoFieldNames[i].length; j++)
|
||||
{
|
||||
fields.push(field);
|
||||
|
||||
if (field.name == pkey.name)
|
||||
let fieldName = mdbMetaData.gotoFieldNames[i][j];
|
||||
let field = props.tableMetaData.fields.get(fieldName);
|
||||
if (field)
|
||||
{
|
||||
addedPkey = true;
|
||||
option.push(field);
|
||||
|
||||
if (pkey != null && field.name == pkey.name)
|
||||
{
|
||||
addedPkey = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////
|
||||
// if pkey wasn't in the gotoField options meta-data, go ahead add it as an option here //
|
||||
//////////////////////////////////////////////////////////////////////////////////////////
|
||||
if (pkey && !addedPkey)
|
||||
{
|
||||
fields.unshift(pkey);
|
||||
options.unshift([pkey]);
|
||||
}
|
||||
|
||||
const makeInitialValues = () =>
|
||||
{
|
||||
const rs = {} as { [field: string]: string };
|
||||
fields.forEach((field) => rs[field.name] = "");
|
||||
options.forEach((option) => option.forEach((field) => rs[field.name] = ""));
|
||||
return (rs);
|
||||
};
|
||||
|
||||
@ -141,11 +154,16 @@ function GotoRecordDialog(props: Props): JSX.Element
|
||||
}
|
||||
else if (e.key == "Enter" && targetId?.startsWith("gotoInput-"))
|
||||
{
|
||||
const index = targetId?.replaceAll("gotoInput-", "");
|
||||
const parts = targetId?.split(/-/);
|
||||
const index = parts[1];
|
||||
document.getElementById("gotoButton-" + index).click();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** event handler for close button
|
||||
***************************************************************************/
|
||||
const closeRequested = () =>
|
||||
{
|
||||
if (props.mayClose)
|
||||
@ -154,10 +172,47 @@ function GotoRecordDialog(props: Props): JSX.Element
|
||||
}
|
||||
};
|
||||
|
||||
const goClicked = async (fieldName: string) =>
|
||||
|
||||
/*******************************************************************************
|
||||
** function to say if an option's submit button should be disabled
|
||||
*******************************************************************************/
|
||||
const isOptionSubmitButtonDisabled = (optionIndex: number) =>
|
||||
{
|
||||
let anyFieldsInThisOptionHaveAValue = false;
|
||||
|
||||
options[optionIndex].forEach((field) =>
|
||||
{
|
||||
if(values[field.name])
|
||||
{
|
||||
anyFieldsInThisOptionHaveAValue = true;
|
||||
}
|
||||
})
|
||||
|
||||
if(!anyFieldsInThisOptionHaveAValue)
|
||||
{
|
||||
return (true);
|
||||
}
|
||||
return (false);
|
||||
}
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** event handler for clicking an 'option's go/submit button
|
||||
***************************************************************************/
|
||||
const optionGoClicked = async (optionIndex: number) =>
|
||||
{
|
||||
setError("");
|
||||
const filter = new QQueryFilter([new QFilterCriteria(fieldName, QCriteriaOperator.EQUALS, [values[fieldName]])], null, null, "AND", null, 10);
|
||||
|
||||
const criteria: QFilterCriteria[] = [];
|
||||
const queryStringParts: string[] = [];
|
||||
options[optionIndex].forEach((field) =>
|
||||
{
|
||||
criteria.push(new QFilterCriteria(field.name, QCriteriaOperator.EQUALS, [values[field.name]]))
|
||||
queryStringParts.push(`${field.name}=${encodeURIComponent(values[field.name])}`)
|
||||
})
|
||||
|
||||
const filter = new QQueryFilter(criteria, null, null, "AND", null, 10);
|
||||
|
||||
try
|
||||
{
|
||||
const queryResult = await qController.query(props.tableMetaData.name, filter, null, props.tableVariant);
|
||||
@ -168,12 +223,26 @@ function GotoRecordDialog(props: Props): JSX.Element
|
||||
}
|
||||
else if (queryResult.length == 1)
|
||||
{
|
||||
navigate(`${props.metaData.getTablePathByName(props.tableMetaData.name)}/${encodeURIComponent(queryResult[0].values.get(props.tableMetaData.primaryKeyField))}`);
|
||||
if(options[optionIndex].length == 1 && options[optionIndex][0].name == pkey?.name)
|
||||
{
|
||||
/////////////////////////////////////////////////
|
||||
// navigate by pkey, if that's how we searched //
|
||||
/////////////////////////////////////////////////
|
||||
navigate(`${props.metaData.getTablePathByName(props.tableMetaData.name)}/${encodeURIComponent(queryResult[0].values.get(props.tableMetaData.primaryKeyField))}`);
|
||||
}
|
||||
else
|
||||
{
|
||||
/////////////////////////////////
|
||||
// else navigate by unique-key //
|
||||
/////////////////////////////////
|
||||
navigate(`${props.metaData.getTablePathByName(props.tableMetaData.name)}/key/?${queryStringParts.join("&")}`);
|
||||
}
|
||||
|
||||
close();
|
||||
}
|
||||
else
|
||||
{
|
||||
setError("More than 1 record found...");
|
||||
setError("More than 1 record was found...");
|
||||
setTimeout(() => setError(""), 3000);
|
||||
}
|
||||
}
|
||||
@ -187,7 +256,7 @@ function GotoRecordDialog(props: Props): JSX.Element
|
||||
|
||||
if (props.tableMetaData)
|
||||
{
|
||||
if (fields.length == 0 && !error)
|
||||
if (options.length == 0 && !error)
|
||||
{
|
||||
setError("This table is not configured for this feature.");
|
||||
}
|
||||
@ -200,31 +269,38 @@ function GotoRecordDialog(props: Props): JSX.Element
|
||||
<DialogContent>
|
||||
{props.subHeader}
|
||||
{
|
||||
fields.map((field, index) =>
|
||||
(
|
||||
<Grid key={field.name} container alignItems="center" py={1}>
|
||||
<Grid item xs={3} textAlign="right" pr={2}>
|
||||
{field.label}
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<TextField
|
||||
id={`gotoInput-${index}`}
|
||||
autoFocus={index == 0}
|
||||
autoComplete="off"
|
||||
inputProps={{width: "100%"}}
|
||||
onChange={(e) => handleChange(field.name, e.target.value)}
|
||||
value={values[field.name]}
|
||||
sx={{width: "100%"}}
|
||||
onFocus={event => event.target.select()}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={1} pl={2}>
|
||||
<MDButton id={`gotoButton-${index}`} type="submit" variant="gradient" color="info" size="small" onClick={() => goClicked(field.name)} fullWidth startIcon={<Icon>double_arrow</Icon>} disabled={`${values[field.name]}`.length == 0}>
|
||||
Go
|
||||
</MDButton>
|
||||
</Grid>
|
||||
</Grid>
|
||||
))
|
||||
options.map((option, optionIndex) =>
|
||||
<Box key={optionIndex}>
|
||||
{
|
||||
option.map((field, index) =>
|
||||
(
|
||||
<Grid key={field.name} container alignItems="center" py={1}>
|
||||
<Grid item xs={3} textAlign="right" pr={2}>
|
||||
{field.label}
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<TextField
|
||||
id={`gotoInput-${optionIndex}-${index}`}
|
||||
autoFocus={optionIndex == 0 && index == 0}
|
||||
autoComplete="off"
|
||||
inputProps={{width: "100%"}}
|
||||
onChange={(e) => handleChange(field.name, e.target.value)}
|
||||
value={values[field.name]}
|
||||
sx={{width: "100%"}}
|
||||
onFocus={event => event.target.select()}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={1} pl={2}>
|
||||
{
|
||||
(index == option.length - 1) &&
|
||||
<MDButton id={`gotoButton-${optionIndex}`} type="submit" variant="gradient" color="info" size="small" onClick={() => optionGoClicked(optionIndex)} fullWidth startIcon={<Icon>double_arrow</Icon>} disabled={isOptionSubmitButtonDisabled(optionIndex)}>Go</MDButton>
|
||||
}
|
||||
</Grid>
|
||||
</Grid>
|
||||
))
|
||||
}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
{
|
||||
error &&
|
||||
@ -282,7 +358,7 @@ export function GotoRecordButton(props: GotoRecordButtonProps): JSX.Element
|
||||
return (
|
||||
<React.Fragment>
|
||||
{
|
||||
props.buttonVisible && hasGotoFieldNames(props.tableMetaData) && <Button onClick={openGoto}>Go To...</Button>
|
||||
props.buttonVisible && hasGotoFieldNames(props.tableMetaData) && <Button onClick={openGoto} sx={{whiteSpace: "nowrap"}}>Go To...</Button>
|
||||
}
|
||||
<GotoRecordDialog metaData={props.metaData} tableMetaData={props.tableMetaData} tableVariant={props.tableVariant} isOpen={gotoIsOpen} closeHandler={closeGoto} mayClose={props.mayClose} subHeader={props.subHeader} />
|
||||
</React.Fragment>
|
||||
|
@ -22,6 +22,7 @@
|
||||
import {QHelpContent} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QHelpContent";
|
||||
import Box from "@mui/material/Box";
|
||||
import parse from "html-react-parser";
|
||||
import ErrorBoundary from "qqq/components/misc/ErrorBoundary";
|
||||
import React, {useContext} from "react";
|
||||
import Markdown from "react-markdown";
|
||||
import QContext from "QContext";
|
||||
@ -128,6 +129,7 @@ function HelpContent({helpContents, roles, heading, helpContentKey}: Props): JSX
|
||||
let selectedHelpContent = getMatchingHelpContent(helpContentsArray, roles);
|
||||
|
||||
let content = null;
|
||||
let errorContent = "Error rendering help content.";
|
||||
if (helpHelpActive)
|
||||
{
|
||||
if (!selectedHelpContent)
|
||||
@ -135,6 +137,7 @@ function HelpContent({helpContents, roles, heading, helpContentKey}: Props): JSX
|
||||
selectedHelpContent = new QHelpContent({content: ""});
|
||||
}
|
||||
content = selectedHelpContent.content + ` [${helpContentKey ?? "?"}]`;
|
||||
errorContent += ` [${helpContentKey ?? "?"}]`;
|
||||
}
|
||||
else if(selectedHelpContent)
|
||||
{
|
||||
@ -148,7 +151,9 @@ function HelpContent({helpContents, roles, heading, helpContentKey}: Props): JSX
|
||||
{
|
||||
return <Box display="inline" className="helpContent">
|
||||
{heading && <span className="header">{heading}</span>}
|
||||
{formatHelpContent(content, selectedHelpContent.format)}
|
||||
<ErrorBoundary errorElement={<i>{errorContent}</i>}>
|
||||
{formatHelpContent(content, selectedHelpContent.format)}
|
||||
</ErrorBoundary>
|
||||
</Box>;
|
||||
}
|
||||
|
||||
|
@ -22,7 +22,7 @@
|
||||
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
|
||||
import {QTableSection} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableSection";
|
||||
import {QWidgetMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QWidgetMetaData";
|
||||
import Box from "@mui/material/Box";
|
||||
import {Box} from "@mui/material";
|
||||
import Card from "@mui/material/Card";
|
||||
import Icon from "@mui/material/Icon";
|
||||
import {Theme} from "@mui/material/styles";
|
||||
@ -76,12 +76,12 @@ function QRecordSidebar({tableSections, widgetMetaDataList, light, stickyTop}: P
|
||||
|
||||
|
||||
return (
|
||||
<Card sx={{borderRadius: "0.75rem", position: "sticky", top: stickyTop, overflow: "auto", maxHeight: "calc(100vh - 2rem)"}}>
|
||||
<Box component="ul" display="flex" flexDirection="column" p={2} m={0} sx={{listStyle: "none"}}>
|
||||
<Card sx={{borderRadius: "0.75rem", position: "sticky", top: stickyTop, overflow: "hidden", maxHeight: "calc(100vh - 2rem)"}}>
|
||||
<Box component="ul" display="flex" flexDirection="column" p={2} m={0} sx={{listStyle: "none", overflow: "auto", height: "100%"}}>
|
||||
{
|
||||
sidebarEntries ? sidebarEntries.map((entry: SidebarEntry, key: number) => (
|
||||
|
||||
<HashLink key={`section-link-${entry.name}`} to={`#${entry.name}`}>
|
||||
<Box key={`section-link-${entry.name}`} onClick={() => document.getElementById(entry.name).scrollIntoView()} sx={{cursor: "pointer"}}>
|
||||
<Box key={`section-${entry.name}`} component="li" pt={key === 0 ? 0 : 1}>
|
||||
<MDTypography
|
||||
variant="button"
|
||||
@ -112,7 +112,7 @@ function QRecordSidebar({tableSections, widgetMetaDataList, light, stickyTop}: P
|
||||
|
||||
</MDTypography>
|
||||
</Box>
|
||||
</HashLink>
|
||||
</Box>
|
||||
)) : null
|
||||
}
|
||||
</Box>
|
||||
|
@ -25,7 +25,8 @@ import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QT
|
||||
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, Box, Button} from "@mui/material";
|
||||
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";
|
||||
@ -94,12 +95,12 @@ function SavedViews({qController, metaData, tableMetaData, currentSavedView, tab
|
||||
|
||||
const {accentColor, accentColorLight, userId: currentUserId} = useContext(QContext);
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////////////
|
||||
// this component is used by <RecordQuery> - but that component has different usages - //
|
||||
// e.g., the full-fledged query screen, but also, within other screens (e.g., a modal //
|
||||
// under the ReportSetupWidget). So, there are some behaviors we only want when we're //
|
||||
// on the full-fledged query screen, such as changing the URL with saved view ids. //
|
||||
/////////////////////////////////////////////////////////////////////////////////////////
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// this component is used by <RecordQuery> - but that component has different usages - //
|
||||
// e.g., the full-fledged query screen, but also, within other screens (e.g., a modal //
|
||||
// under the FilterAndColumnsSetupWidget). So, there are some behaviors we only want when //
|
||||
// we're on the full-fledged query screen, such as changing the URL with saved view ids. //
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////
|
||||
const isQueryScreen = queryScreenUsage == "queryScreen";
|
||||
|
||||
const openSavedViewsMenu = (event: any) => setSavedViewsMenu(event.currentTarget);
|
||||
|
66
src/qqq/components/query/AssignFilterVariable.tsx
Normal file
66
src/qqq/components/query/AssignFilterVariable.tsx
Normal file
@ -0,0 +1,66 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2023. 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 {FilterVariableExpression} from "@kingsrook/qqq-frontend-core/lib/model/query/FilterVariableExpression";
|
||||
import Box from "@mui/material/Box";
|
||||
import Icon from "@mui/material/Icon";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
import CriteriaDateField from "qqq/components/query/CriteriaDateField";
|
||||
import React, {SyntheticEvent, useState} from "react";
|
||||
|
||||
|
||||
export type Expression = FilterVariableExpression;
|
||||
|
||||
|
||||
interface AssignFilterButtonProps
|
||||
{
|
||||
valueIndex: number;
|
||||
field: QFieldMetaData;
|
||||
valueChangeHandler: (event: React.ChangeEvent | SyntheticEvent, valueIndex?: number | "all", newValue?: any) => void;
|
||||
}
|
||||
|
||||
CriteriaDateField.defaultProps = {
|
||||
valueIndex: 0,
|
||||
label: "Value",
|
||||
idPrefix: "value-"
|
||||
};
|
||||
|
||||
export default function AssignFilterVariable({valueIndex, field, valueChangeHandler}: AssignFilterButtonProps): JSX.Element
|
||||
{
|
||||
const [isValueAVariable, setIsValueAVariable] = useState(false);
|
||||
|
||||
const handleVariableButtonOnClick = () =>
|
||||
{
|
||||
setIsValueAVariable(!isValueAVariable);
|
||||
const expression = new FilterVariableExpression({fieldName: field.name, valueIndex: valueIndex});
|
||||
valueChangeHandler(null, valueIndex, expression);
|
||||
};
|
||||
|
||||
return <Box display="flex" alignItems="flex-end">
|
||||
<Box>
|
||||
<Tooltip title={`Use a variable as the value for the ${field.label} field`} placement="bottom">
|
||||
<Icon fontSize="small" color="info" sx={{mx: 0.25, cursor: "pointer", position: "relative", top: "2px"}} onClick={handleVariableButtonOnClick}>functions</Icon>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>;
|
||||
}
|
||||
|
@ -79,6 +79,8 @@ interface BasicAndAdvancedQueryControlsProps
|
||||
|
||||
queryScreenUsage: QueryScreenUsage;
|
||||
|
||||
allowVariables?: boolean;
|
||||
|
||||
mode: string;
|
||||
setMode: (mode: string) => void;
|
||||
}
|
||||
@ -114,7 +116,7 @@ export function getCurrentSortIndicator(queryFilter: QQueryFilter, tableMetaData
|
||||
*******************************************************************************/
|
||||
const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryControlsProps, ref) =>
|
||||
{
|
||||
const {metaData, tableMetaData, savedViewsComponent, columnMenuComponent, quickFilterFieldNames, setQuickFilterFieldNames, setQueryFilter, queryFilter, gridApiRef, queryFilterJSON, mode, setMode} = props;
|
||||
const {metaData, tableMetaData, savedViewsComponent, columnMenuComponent, quickFilterFieldNames, setQuickFilterFieldNames, setQueryFilter, queryFilter, gridApiRef, queryFilterJSON, mode, setMode, queryScreenUsage} = props;
|
||||
|
||||
/////////////////////
|
||||
// state variables //
|
||||
@ -676,12 +678,14 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo
|
||||
|
||||
return (<QuickFilter
|
||||
key={fieldName}
|
||||
allowVariables={props.allowVariables}
|
||||
fullFieldName={fieldName}
|
||||
tableMetaData={tableMetaData}
|
||||
updateCriteria={updateQuickCriteria}
|
||||
criteriaParam={getQuickCriteriaParam(fieldName)}
|
||||
fieldMetaData={field}
|
||||
defaultOperator={defaultOperator}
|
||||
queryScreenUsage={queryScreenUsage}
|
||||
handleRemoveQuickFilterField={null} />);
|
||||
})
|
||||
}
|
||||
@ -700,7 +704,9 @@ const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryCo
|
||||
updateCriteria={updateQuickCriteria}
|
||||
criteriaParam={getQuickCriteriaParam(fieldName)}
|
||||
fieldMetaData={field}
|
||||
allowVariables={props.allowVariables}
|
||||
defaultOperator={defaultOperator}
|
||||
queryScreenUsage={queryScreenUsage}
|
||||
handleRemoveQuickFilterField={handleRemoveQuickFilterField} />);
|
||||
})
|
||||
}
|
||||
|
@ -21,6 +21,7 @@
|
||||
|
||||
import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
|
||||
import {QFieldType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldType";
|
||||
import {FilterVariableExpression} from "@kingsrook/qqq-frontend-core/lib/model/query/FilterVariableExpression";
|
||||
import {NowExpression} from "@kingsrook/qqq-frontend-core/lib/model/query/NowExpression";
|
||||
import {NowWithOffsetExpression, NowWithOffsetOperator, NowWithOffsetUnit} from "@kingsrook/qqq-frontend-core/lib/model/query/NowWithOffsetExpression";
|
||||
import {ThisOrLastPeriodExpression, ThisOrLastPeriodOperator, ThisOrLastPeriodUnit} from "@kingsrook/qqq-frontend-core/lib/model/query/ThisOrLastPeriodExpression";
|
||||
@ -34,14 +35,14 @@ import MenuItem from "@mui/material/MenuItem";
|
||||
import {styled} from "@mui/material/styles";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import Tooltip, {tooltipClasses, TooltipProps} from "@mui/material/Tooltip";
|
||||
import React, {SyntheticEvent, useEffect, useReducer, useState} from "react";
|
||||
import AdvancedDateTimeFilterValues from "qqq/components/query/AdvancedDateTimeFilterValues";
|
||||
import {QFilterCriteriaWithId} from "qqq/components/query/CustomFilterPanel";
|
||||
import {EvaluatedExpression} from "qqq/components/query/EvaluatedExpression";
|
||||
import {makeTextField} from "qqq/components/query/FilterCriteriaRowValues";
|
||||
import React, {SyntheticEvent, useReducer, useState} from "react";
|
||||
|
||||
|
||||
export type Expression = NowWithOffsetExpression | ThisOrLastPeriodExpression | NowExpression;
|
||||
export type Expression = NowWithOffsetExpression | ThisOrLastPeriodExpression | NowExpression | FilterVariableExpression;
|
||||
|
||||
|
||||
interface CriteriaDateFieldProps
|
||||
@ -52,6 +53,7 @@ interface CriteriaDateFieldProps
|
||||
field: QFieldMetaData;
|
||||
criteria: QFilterCriteriaWithId;
|
||||
valueChangeHandler: (event: React.ChangeEvent | SyntheticEvent, valueIndex?: number | "all", newValue?: any) => void;
|
||||
allowVariables?: boolean;
|
||||
}
|
||||
|
||||
CriteriaDateField.defaultProps = {
|
||||
@ -60,19 +62,30 @@ CriteriaDateField.defaultProps = {
|
||||
idPrefix: "value-"
|
||||
};
|
||||
|
||||
export default function CriteriaDateField({valueIndex, label, idPrefix, field, criteria, valueChangeHandler}: CriteriaDateFieldProps): JSX.Element
|
||||
export const NoWrapTooltip = styled(({className, children, ...props}: TooltipProps) => (
|
||||
<Tooltip {...props} classes={{popper: className}}>{children}</Tooltip>
|
||||
))({
|
||||
[`& .${tooltipClasses.tooltip}`]: {
|
||||
whiteSpace: "nowrap"
|
||||
},
|
||||
});
|
||||
|
||||
export default function CriteriaDateField({valueIndex, label, idPrefix, field, criteria, valueChangeHandler, allowVariables}: CriteriaDateFieldProps): JSX.Element
|
||||
{
|
||||
const [relativeDateTimeOpen, setRelativeDateTimeOpen] = useState(false);
|
||||
const [relativeDateTimeMenuAnchorElement, setRelativeDateTimeMenuAnchorElement] = useState(null);
|
||||
const [forceAdvancedDateTimeDialogOpen, setForceAdvancedDateTimeDialogOpen] = useState(false)
|
||||
const [forceAdvancedDateTimeDialogOpen, setForceAdvancedDateTimeDialogOpen] = useState(false);
|
||||
const [, forceUpdate] = useReducer((x) => x + 1, 0);
|
||||
|
||||
const openRelativeDateTimeMenu = (event: React.MouseEvent<HTMLElement>) =>
|
||||
{
|
||||
setRelativeDateTimeOpen(true);
|
||||
setRelativeDateTimeMenuAnchorElement(event.currentTarget);
|
||||
};
|
||||
|
||||
const closeRelativeDateTimeMenu = () =>
|
||||
{
|
||||
setRelativeDateTimeOpen(false);
|
||||
setRelativeDateTimeMenuAnchorElement(null);
|
||||
};
|
||||
|
||||
@ -137,20 +150,12 @@ export default function CriteriaDateField({valueIndex, label, idPrefix, field, c
|
||||
const isExpression = criteria.values && criteria.values[valueIndex] && criteria.values[valueIndex].type;
|
||||
const currentExpression = isExpression ? criteria.values[valueIndex] : null;
|
||||
|
||||
const NoWrapTooltip = styled(({className, children, ...props}: TooltipProps) => (
|
||||
<Tooltip {...props} classes={{popper: className}}>{children}</Tooltip>
|
||||
))({
|
||||
[`& .${tooltipClasses.tooltip}`]: {
|
||||
whiteSpace: "nowrap"
|
||||
},
|
||||
});
|
||||
|
||||
const tooltipMenuItemFromExpression = (valueIndex: number, tooltipPlacement: "left" | "right", expression: Expression) =>
|
||||
{
|
||||
let startOfPrefix = "";
|
||||
if(expression.type == "ThisOrLastPeriod")
|
||||
if (expression.type == "ThisOrLastPeriod")
|
||||
{
|
||||
if(field.type == QFieldType.DATE_TIME || expression.timeUnit != "DAYS")
|
||||
if (field.type == QFieldType.DATE_TIME || expression.timeUnit != "DAYS")
|
||||
{
|
||||
startOfPrefix = "start of ";
|
||||
}
|
||||
@ -191,84 +196,120 @@ export default function CriteriaDateField({valueIndex, label, idPrefix, field, c
|
||||
setTimeout(() => setForceAdvancedDateTimeDialogOpen(false), 100);
|
||||
}
|
||||
|
||||
const makeFilterVariableTextField = (expression: FilterVariableExpression, valueIndex: number = 0, label = "Value", idPrefix = "value-") =>
|
||||
{
|
||||
const clearValue = (event: React.MouseEvent<HTMLAnchorElement> | React.MouseEvent<HTMLButtonElement>, index: number) =>
|
||||
{
|
||||
valueChangeHandler(event, index, "");
|
||||
document.getElementById(`${idPrefix}${criteria.id}`).focus();
|
||||
};
|
||||
|
||||
const inputProps2: any = {};
|
||||
inputProps2.endAdornment = (
|
||||
<InputAdornment position="end">
|
||||
<IconButton sx={{visibility: expression ? "visible" : "hidden"}} onClick={(event) => clearValue(event, valueIndex)}>
|
||||
<Icon>closer</Icon>
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
);
|
||||
|
||||
return <NoWrapTooltip title={<EvaluatedExpression field={field} expression={expression} />} placement="bottom" enterDelay={1000} sx={{marginLeft: "-75px !important", marginTop: "-8px !important"}}><TextField
|
||||
id={`${idPrefix}${criteria.id}`}
|
||||
label={label}
|
||||
variant="standard"
|
||||
autoComplete="off"
|
||||
InputProps={{disabled: true, readOnly: true, unselectable: "off", ...inputProps2}}
|
||||
InputLabelProps={{shrink: true}}
|
||||
value="${VARIABLE}"
|
||||
fullWidth
|
||||
/></NoWrapTooltip>;
|
||||
};
|
||||
|
||||
|
||||
return <Box display="flex" alignItems="flex-end">
|
||||
{
|
||||
isExpression ? makeDateTimeExpressionTextField(criteria.values[valueIndex], valueIndex, label, idPrefix)
|
||||
: makeTextField(field, criteria, valueChangeHandler, valueIndex, label, idPrefix)
|
||||
isExpression ?
|
||||
currentExpression?.type == "FilterVariableExpression" ? (
|
||||
makeFilterVariableTextField(criteria.values[valueIndex], valueIndex, label, idPrefix)
|
||||
) : (
|
||||
makeDateTimeExpressionTextField(criteria.values[valueIndex], valueIndex, label, idPrefix)
|
||||
)
|
||||
: makeTextField(field, criteria, valueChangeHandler, valueIndex, label, idPrefix, allowVariables)
|
||||
}
|
||||
<Box>
|
||||
<Tooltip title={`Choose a common relative ${field.type == QFieldType.DATE ? "date" : "date-time"} expression`} placement="bottom">
|
||||
<Icon fontSize="small" color="info" sx={{mx: 0.25, cursor: "pointer", position: "relative", top: "2px"}} onClick={openRelativeDateTimeMenu}>date_range</Icon>
|
||||
</Tooltip>
|
||||
<Menu
|
||||
open={relativeDateTimeMenuAnchorElement}
|
||||
anchorEl={relativeDateTimeMenuAnchorElement}
|
||||
transformOrigin={{horizontal: "left", vertical: "top"}}
|
||||
onClose={closeRelativeDateTimeMenu}
|
||||
>
|
||||
{
|
||||
field.type == QFieldType.DATE ?
|
||||
<Box display="flex">
|
||||
<Box>
|
||||
{tooltipMenuItemFromExpression(valueIndex, "left", newNowWithOffsetExpression("MINUS", 7, "DAYS"))}
|
||||
{tooltipMenuItemFromExpression(valueIndex, "left", newNowWithOffsetExpression("MINUS", 14, "DAYS"))}
|
||||
{tooltipMenuItemFromExpression(valueIndex, "left", newNowWithOffsetExpression("MINUS", 30, "DAYS"))}
|
||||
{tooltipMenuItemFromExpression(valueIndex, "left", newNowWithOffsetExpression("MINUS", 90, "DAYS"))}
|
||||
{tooltipMenuItemFromExpression(valueIndex, "left", newNowWithOffsetExpression("MINUS", 180, "DAYS"))}
|
||||
{tooltipMenuItemFromExpression(valueIndex, "left", newNowWithOffsetExpression("MINUS", 1, "YEARS"))}
|
||||
<Divider />
|
||||
<Tooltip title="Define a custom expression" placement="left">
|
||||
<MenuItem onClick={doForceAdvancedDateTimeDialogOpen}>Custom</MenuItem>
|
||||
</Tooltip>
|
||||
{
|
||||
(!isExpression || currentExpression?.type != "FilterVariableExpression") && (
|
||||
<><Box>
|
||||
<Tooltip title={`Choose a common relative ${field.type == QFieldType.DATE ? "date" : "date-time"} expression`} placement="bottom">
|
||||
<Icon fontSize="small" color="info" sx={{mx: 0.25, cursor: "pointer", position: "relative", top: "2px"}} onClick={openRelativeDateTimeMenu}>date_range</Icon>
|
||||
</Tooltip>
|
||||
<Menu
|
||||
open={relativeDateTimeOpen}
|
||||
anchorEl={relativeDateTimeMenuAnchorElement}
|
||||
transformOrigin={{horizontal: "left", vertical: "top"}}
|
||||
onClose={closeRelativeDateTimeMenu}
|
||||
>
|
||||
{field.type == QFieldType.DATE ?
|
||||
<Box display="flex">
|
||||
<Box>
|
||||
{tooltipMenuItemFromExpression(valueIndex, "left", newNowWithOffsetExpression("MINUS", 7, "DAYS"))}
|
||||
{tooltipMenuItemFromExpression(valueIndex, "left", newNowWithOffsetExpression("MINUS", 14, "DAYS"))}
|
||||
{tooltipMenuItemFromExpression(valueIndex, "left", newNowWithOffsetExpression("MINUS", 30, "DAYS"))}
|
||||
{tooltipMenuItemFromExpression(valueIndex, "left", newNowWithOffsetExpression("MINUS", 90, "DAYS"))}
|
||||
{tooltipMenuItemFromExpression(valueIndex, "left", newNowWithOffsetExpression("MINUS", 180, "DAYS"))}
|
||||
{tooltipMenuItemFromExpression(valueIndex, "left", newNowWithOffsetExpression("MINUS", 1, "YEARS"))}
|
||||
<Divider />
|
||||
<Tooltip title="Define a custom expression" placement="left">
|
||||
<MenuItem onClick={doForceAdvancedDateTimeDialogOpen}>Custom</MenuItem>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<Box>
|
||||
{tooltipMenuItemFromExpression(valueIndex, "right", newThisOrLastPeriodExpression("THIS", "DAYS"))}
|
||||
{tooltipMenuItemFromExpression(valueIndex, "right", newThisOrLastPeriodExpression("LAST", "DAYS"))}
|
||||
{tooltipMenuItemFromExpression(valueIndex, "right", newThisOrLastPeriodExpression("THIS", "WEEKS"))}
|
||||
{tooltipMenuItemFromExpression(valueIndex, "right", newThisOrLastPeriodExpression("LAST", "WEEKS"))}
|
||||
{tooltipMenuItemFromExpression(valueIndex, "right", newThisOrLastPeriodExpression("THIS", "MONTHS"))}
|
||||
{tooltipMenuItemFromExpression(valueIndex, "right", newThisOrLastPeriodExpression("LAST", "MONTHS"))}
|
||||
{tooltipMenuItemFromExpression(valueIndex, "right", newThisOrLastPeriodExpression("THIS", "YEARS"))}
|
||||
{tooltipMenuItemFromExpression(valueIndex, "right", newThisOrLastPeriodExpression("LAST", "YEARS"))}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box>
|
||||
{tooltipMenuItemFromExpression(valueIndex, "right", newThisOrLastPeriodExpression("THIS", "DAYS"))}
|
||||
{tooltipMenuItemFromExpression(valueIndex, "right", newThisOrLastPeriodExpression("LAST", "DAYS"))}
|
||||
{tooltipMenuItemFromExpression(valueIndex, "right", newThisOrLastPeriodExpression("THIS", "WEEKS"))}
|
||||
{tooltipMenuItemFromExpression(valueIndex, "right", newThisOrLastPeriodExpression("LAST", "WEEKS"))}
|
||||
{tooltipMenuItemFromExpression(valueIndex, "right", newThisOrLastPeriodExpression("THIS", "MONTHS"))}
|
||||
{tooltipMenuItemFromExpression(valueIndex, "right", newThisOrLastPeriodExpression("LAST", "MONTHS"))}
|
||||
{tooltipMenuItemFromExpression(valueIndex, "right", newThisOrLastPeriodExpression("THIS", "YEARS"))}
|
||||
{tooltipMenuItemFromExpression(valueIndex, "right", newThisOrLastPeriodExpression("LAST", "YEARS"))}
|
||||
</Box>
|
||||
</Box>
|
||||
:
|
||||
<Box display="flex">
|
||||
<Box>
|
||||
{tooltipMenuItemFromExpression(valueIndex, "left", newNowWithOffsetExpression("MINUS", 1, "HOURS"))}
|
||||
{tooltipMenuItemFromExpression(valueIndex, "left", newNowWithOffsetExpression("MINUS", 12, "HOURS"))}
|
||||
{tooltipMenuItemFromExpression(valueIndex, "left", newNowWithOffsetExpression("MINUS", 24, "HOURS"))}
|
||||
{tooltipMenuItemFromExpression(valueIndex, "left", newNowWithOffsetExpression("MINUS", 7, "DAYS"))}
|
||||
{tooltipMenuItemFromExpression(valueIndex, "left", newNowWithOffsetExpression("MINUS", 14, "DAYS"))}
|
||||
{tooltipMenuItemFromExpression(valueIndex, "left", newNowWithOffsetExpression("MINUS", 30, "DAYS"))}
|
||||
{tooltipMenuItemFromExpression(valueIndex, "left", newNowWithOffsetExpression("MINUS", 90, "DAYS"))}
|
||||
{tooltipMenuItemFromExpression(valueIndex, "left", newNowWithOffsetExpression("MINUS", 180, "DAYS"))}
|
||||
{tooltipMenuItemFromExpression(valueIndex, "left", newNowWithOffsetExpression("MINUS", 1, "YEARS"))}
|
||||
<Divider />
|
||||
<Tooltip title="Define a custom expression" placement="left">
|
||||
<MenuItem onClick={doForceAdvancedDateTimeDialogOpen}>Custom</MenuItem>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<Box>
|
||||
{tooltipMenuItemFromExpression(valueIndex, "right", newNowExpression())}
|
||||
{tooltipMenuItemFromExpression(valueIndex, "right", newThisOrLastPeriodExpression("THIS", "HOURS"))}
|
||||
{tooltipMenuItemFromExpression(valueIndex, "right", newThisOrLastPeriodExpression("LAST", "HOURS"))}
|
||||
{tooltipMenuItemFromExpression(valueIndex, "right", newThisOrLastPeriodExpression("THIS", "DAYS"))}
|
||||
{tooltipMenuItemFromExpression(valueIndex, "right", newThisOrLastPeriodExpression("LAST", "DAYS"))}
|
||||
{tooltipMenuItemFromExpression(valueIndex, "right", newThisOrLastPeriodExpression("THIS", "WEEKS"))}
|
||||
{tooltipMenuItemFromExpression(valueIndex, "right", newThisOrLastPeriodExpression("LAST", "WEEKS"))}
|
||||
{tooltipMenuItemFromExpression(valueIndex, "right", newThisOrLastPeriodExpression("THIS", "MONTHS"))}
|
||||
{tooltipMenuItemFromExpression(valueIndex, "right", newThisOrLastPeriodExpression("LAST", "MONTHS"))}
|
||||
{tooltipMenuItemFromExpression(valueIndex, "right", newThisOrLastPeriodExpression("THIS", "YEARS"))}
|
||||
{tooltipMenuItemFromExpression(valueIndex, "right", newThisOrLastPeriodExpression("LAST", "YEARS"))}
|
||||
</Box>
|
||||
</Box>
|
||||
}
|
||||
</Menu>
|
||||
</Box>
|
||||
<Box>
|
||||
<AdvancedDateTimeFilterValues type={field.type} expression={currentExpression} onSave={(expression: any) => saveNewDateTimeExpression(valueIndex, expression)} forcedOpen={forceAdvancedDateTimeDialogOpen} />
|
||||
</Box>
|
||||
:
|
||||
<Box display="flex">
|
||||
<Box>
|
||||
{tooltipMenuItemFromExpression(valueIndex, "left", newNowWithOffsetExpression("MINUS", 1, "HOURS"))}
|
||||
{tooltipMenuItemFromExpression(valueIndex, "left", newNowWithOffsetExpression("MINUS", 12, "HOURS"))}
|
||||
{tooltipMenuItemFromExpression(valueIndex, "left", newNowWithOffsetExpression("MINUS", 24, "HOURS"))}
|
||||
{tooltipMenuItemFromExpression(valueIndex, "left", newNowWithOffsetExpression("MINUS", 7, "DAYS"))}
|
||||
{tooltipMenuItemFromExpression(valueIndex, "left", newNowWithOffsetExpression("MINUS", 14, "DAYS"))}
|
||||
{tooltipMenuItemFromExpression(valueIndex, "left", newNowWithOffsetExpression("MINUS", 30, "DAYS"))}
|
||||
{tooltipMenuItemFromExpression(valueIndex, "left", newNowWithOffsetExpression("MINUS", 90, "DAYS"))}
|
||||
{tooltipMenuItemFromExpression(valueIndex, "left", newNowWithOffsetExpression("MINUS", 180, "DAYS"))}
|
||||
{tooltipMenuItemFromExpression(valueIndex, "left", newNowWithOffsetExpression("MINUS", 1, "YEARS"))}
|
||||
<Divider />
|
||||
<Tooltip title="Define a custom expression" placement="left">
|
||||
<MenuItem onClick={doForceAdvancedDateTimeDialogOpen}>Custom</MenuItem>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<Box>
|
||||
{tooltipMenuItemFromExpression(valueIndex, "right", newNowExpression())}
|
||||
{tooltipMenuItemFromExpression(valueIndex, "right", newThisOrLastPeriodExpression("THIS", "HOURS"))}
|
||||
{tooltipMenuItemFromExpression(valueIndex, "right", newThisOrLastPeriodExpression("LAST", "HOURS"))}
|
||||
{tooltipMenuItemFromExpression(valueIndex, "right", newThisOrLastPeriodExpression("THIS", "DAYS"))}
|
||||
{tooltipMenuItemFromExpression(valueIndex, "right", newThisOrLastPeriodExpression("LAST", "DAYS"))}
|
||||
{tooltipMenuItemFromExpression(valueIndex, "right", newThisOrLastPeriodExpression("THIS", "WEEKS"))}
|
||||
{tooltipMenuItemFromExpression(valueIndex, "right", newThisOrLastPeriodExpression("LAST", "WEEKS"))}
|
||||
{tooltipMenuItemFromExpression(valueIndex, "right", newThisOrLastPeriodExpression("THIS", "MONTHS"))}
|
||||
{tooltipMenuItemFromExpression(valueIndex, "right", newThisOrLastPeriodExpression("LAST", "MONTHS"))}
|
||||
{tooltipMenuItemFromExpression(valueIndex, "right", newThisOrLastPeriodExpression("THIS", "YEARS"))}
|
||||
{tooltipMenuItemFromExpression(valueIndex, "right", newThisOrLastPeriodExpression("LAST", "YEARS"))}
|
||||
</Box>
|
||||
</Box>}
|
||||
</Menu>
|
||||
</Box><Box>
|
||||
<AdvancedDateTimeFilterValues type={field.type} expression={currentExpression} onSave={(expression: any) => saveNewDateTimeExpression(valueIndex, expression)} forcedOpen={forceAdvancedDateTimeDialogOpen} />
|
||||
</Box></>
|
||||
)
|
||||
}
|
||||
</Box>;
|
||||
}
|
||||
|
||||
|
@ -21,7 +21,9 @@
|
||||
|
||||
import {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstance";
|
||||
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
|
||||
import {Box, FormControlLabel, FormGroup} from "@mui/material";
|
||||
|
||||
import {FormControlLabel, FormGroup} from "@mui/material";
|
||||
import Box from "@mui/material/Box";
|
||||
import Button from "@mui/material/Button";
|
||||
import Icon from "@mui/material/Icon";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
@ -56,7 +58,7 @@ export const CustomColumnsPanel = forwardRef<any, GridColumnsPanelProps>(
|
||||
const someRef = createRef();
|
||||
|
||||
const textRef = useRef(null);
|
||||
const [didInitialFocus, setDidInitialFocus] = useState(false)
|
||||
const [didInitialFocus, setDidInitialFocus] = useState(false);
|
||||
|
||||
const [openGroups, setOpenGroups] = useState(props.initialOpenedGroups || {});
|
||||
const openGroupsBecauseOfFilter = {} as { [name: string]: boolean };
|
||||
@ -71,9 +73,9 @@ export const CustomColumnsPanel = forwardRef<any, GridColumnsPanelProps>(
|
||||
|
||||
console.log(`Open groups: ${JSON.stringify(openGroups)}`);
|
||||
|
||||
if(!didInitialFocus)
|
||||
if (!didInitialFocus)
|
||||
{
|
||||
if(textRef.current)
|
||||
if (textRef.current)
|
||||
{
|
||||
textRef.current.select();
|
||||
setDidInitialFocus(true);
|
||||
@ -189,11 +191,11 @@ export const CustomColumnsPanel = forwardRef<any, GridColumnsPanelProps>(
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// always sort columns by label. note, in future may offer different sorts - here's where to do it. //
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
const sortedColumns = [... columns];
|
||||
const sortedColumns = [...columns];
|
||||
sortedColumns.sort((a, b): number =>
|
||||
{
|
||||
return a.headerName.localeCompare(b.headerName);
|
||||
})
|
||||
});
|
||||
|
||||
for (let i = 0; i < sortedColumns.length; i++)
|
||||
{
|
||||
@ -361,7 +363,7 @@ export const CustomColumnsPanel = forwardRef<any, GridColumnsPanelProps>(
|
||||
const changeFilterText = (newValue: string) =>
|
||||
{
|
||||
setFilterText(newValue);
|
||||
props.filterTextChanger(newValue)
|
||||
props.filterTextChanger(newValue);
|
||||
};
|
||||
|
||||
const filterTextChanged = (event: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>) =>
|
||||
|
@ -28,8 +28,8 @@ import Box from "@mui/material/Box";
|
||||
import Button from "@mui/material/Button/Button";
|
||||
import Icon from "@mui/material/Icon/Icon";
|
||||
import {GridFilterPanelProps, GridSlotsComponentsProps} from "@mui/x-data-grid-pro";
|
||||
import React, {forwardRef, useReducer} from "react";
|
||||
import {FilterCriteriaRow, getDefaultCriteriaValue} from "qqq/components/query/FilterCriteriaRow";
|
||||
import React, {forwardRef, useReducer} from "react";
|
||||
|
||||
|
||||
declare module "@mui/x-data-grid"
|
||||
@ -49,7 +49,7 @@ declare module "@mui/x-data-grid"
|
||||
|
||||
export class QFilterCriteriaWithId extends QFilterCriteria
|
||||
{
|
||||
id: number
|
||||
id: number;
|
||||
}
|
||||
|
||||
|
||||
@ -62,6 +62,7 @@ export const CustomFilterPanel = forwardRef<any, GridFilterPanelProps>(
|
||||
const [, forceUpdate] = useReducer((x) => x + 1, 0);
|
||||
|
||||
const queryFilter = props.queryFilter;
|
||||
|
||||
// console.log(`CustomFilterPanel: filter: ${JSON.stringify(queryFilter)}`);
|
||||
|
||||
function focusLastField()
|
||||
@ -124,7 +125,7 @@ export const CustomFilterPanel = forwardRef<any, GridFilterPanelProps>(
|
||||
}
|
||||
}
|
||||
|
||||
if(queryFilter.criteria.length == 1 && !queryFilter.criteria[0].fieldName)
|
||||
if (queryFilter.criteria.length == 1 && !queryFilter.criteria[0].fieldName)
|
||||
{
|
||||
focusLastField();
|
||||
}
|
||||
@ -142,7 +143,7 @@ export const CustomFilterPanel = forwardRef<any, GridFilterPanelProps>(
|
||||
{
|
||||
queryFilter.criteria[index] = newCriteria;
|
||||
|
||||
clearTimeout(debounceTimeout)
|
||||
clearTimeout(debounceTimeout);
|
||||
debounceTimeout = setTimeout(() => props.updateFilter(queryFilter), needDebounce ? 500 : 1);
|
||||
|
||||
forceUpdate();
|
||||
@ -178,6 +179,8 @@ export const CustomFilterPanel = forwardRef<any, GridFilterPanelProps>(
|
||||
updateCriteria={(newCriteria, needDebounce) => updateCriteria(newCriteria, index, needDebounce)}
|
||||
removeCriteria={() => removeCriteria(index)}
|
||||
updateBooleanOperator={(newValue) => updateBooleanOperator(newValue)}
|
||||
allowVariables={props.allowVariables}
|
||||
queryScreenUsage={props.queryScreenUsage}
|
||||
/>
|
||||
{/*JSON.stringify(criteria)*/}
|
||||
</Box>
|
||||
|
@ -21,9 +21,9 @@
|
||||
|
||||
import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
|
||||
import {QFieldType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldType";
|
||||
import React, {useEffect, useState} from "react";
|
||||
import {Expression} from "qqq/components/query/CriteriaDateField";
|
||||
import ValueUtils from "qqq/utils/qqq/ValueUtils";
|
||||
import React, {useEffect, useState} from "react";
|
||||
|
||||
/*******************************************************************************
|
||||
** Helper component to show value inside tooltips that ticks up every second.
|
||||
@ -57,6 +57,11 @@ const HOUR_MS = 60 * 60 * 1000;
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const evaluateExpression = (time: Date, field: QFieldMetaData, expression: Expression): string =>
|
||||
{
|
||||
if (expression.type == "FilterVariableExpression")
|
||||
{
|
||||
return (expression.toString());
|
||||
}
|
||||
|
||||
let rs: Date = null;
|
||||
if (expression.type == "NowWithOffset")
|
||||
{
|
||||
|
@ -35,6 +35,7 @@ import TextField from "@mui/material/TextField";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
import FieldAutoComplete from "qqq/components/misc/FieldAutoComplete";
|
||||
import FilterCriteriaRowValues from "qqq/components/query/FilterCriteriaRowValues";
|
||||
import {QueryScreenUsage} from "qqq/pages/records/query/RecordQuery";
|
||||
import FilterUtils from "qqq/utils/qqq/FilterUtils";
|
||||
import React, {ReactNode, SyntheticEvent, useState} from "react";
|
||||
|
||||
@ -72,7 +73,7 @@ export const getValueModeRequiredCount = (valueMode: ValueMode): number =>
|
||||
case ValueMode.PVS_MULTI:
|
||||
return (null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export interface OperatorOption
|
||||
{
|
||||
@ -183,7 +184,7 @@ export const getOperatorOptions = (tableMetaData: QTableMetaData, fieldName: str
|
||||
}
|
||||
|
||||
return (operatorOptions);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
interface FilterCriteriaRowProps
|
||||
@ -197,13 +198,14 @@ interface FilterCriteriaRowProps
|
||||
updateCriteria: (newCriteria: QFilterCriteria, needDebounce: boolean) => void;
|
||||
removeCriteria: () => void;
|
||||
updateBooleanOperator: (newValue: string) => void;
|
||||
queryScreenUsage?: QueryScreenUsage;
|
||||
allowVariables?: boolean;
|
||||
}
|
||||
|
||||
FilterCriteriaRow.defaultProps =
|
||||
{
|
||||
};
|
||||
{};
|
||||
|
||||
export function validateCriteria(criteria: QFilterCriteria, operatorSelectedValue?: OperatorOption): {criteriaIsValid: boolean, criteriaStatusTooltip: string}
|
||||
export function validateCriteria(criteria: QFilterCriteria, operatorSelectedValue?: OperatorOption): { criteriaIsValid: boolean, criteriaStatusTooltip: string }
|
||||
{
|
||||
let criteriaIsValid = true;
|
||||
let criteriaStatusTooltip = "This condition is fully defined and is part of your filter.";
|
||||
@ -213,7 +215,7 @@ export function validateCriteria(criteria: QFilterCriteria, operatorSelectedValu
|
||||
return (value === null || value == undefined || String(value).trim() === "");
|
||||
}
|
||||
|
||||
if(!criteria)
|
||||
if (!criteria)
|
||||
{
|
||||
criteriaIsValid = false;
|
||||
criteriaStatusTooltip = "This condition is not defined.";
|
||||
@ -266,7 +268,7 @@ export function validateCriteria(criteria: QFilterCriteria, operatorSelectedValu
|
||||
return {criteriaIsValid, criteriaStatusTooltip};
|
||||
}
|
||||
|
||||
export function FilterCriteriaRow({id, index, tableMetaData, metaData, criteria, booleanOperator, updateCriteria, removeCriteria, updateBooleanOperator}: FilterCriteriaRowProps): JSX.Element
|
||||
export function FilterCriteriaRow({id, index, tableMetaData, metaData, criteria, booleanOperator, updateCriteria, removeCriteria, updateBooleanOperator, queryScreenUsage, allowVariables}: FilterCriteriaRowProps): JSX.Element
|
||||
{
|
||||
// console.log(`FilterCriteriaRow: criteria: ${JSON.stringify(criteria)}`);
|
||||
const [operatorSelectedValue, setOperatorSelectedValue] = useState(null as OperatorOption);
|
||||
@ -284,7 +286,7 @@ export function FilterCriteriaRow({id, index, tableMetaData, metaData, criteria,
|
||||
let defaultFieldValue;
|
||||
let field = null;
|
||||
let fieldTable = null;
|
||||
if(criteria && criteria.fieldName)
|
||||
if (criteria && criteria.fieldName)
|
||||
{
|
||||
[field, fieldTable] = FilterUtils.getField(tableMetaData, criteria.fieldName);
|
||||
if (field && fieldTable)
|
||||
@ -303,9 +305,9 @@ export function FilterCriteriaRow({id, index, tableMetaData, metaData, criteria,
|
||||
|
||||
let newOperatorSelectedValue = operatorOptions.filter(option =>
|
||||
{
|
||||
if(option.value == criteria.operator)
|
||||
if (option.value == criteria.operator)
|
||||
{
|
||||
if(option.implicitValues)
|
||||
if (option.implicitValues)
|
||||
{
|
||||
return (JSON.stringify(option.implicitValues) == JSON.stringify(criteria.values));
|
||||
}
|
||||
@ -316,7 +318,7 @@ export function FilterCriteriaRow({id, index, tableMetaData, metaData, criteria,
|
||||
}
|
||||
return (false);
|
||||
})[0];
|
||||
if(newOperatorSelectedValue?.label !== operatorSelectedValue?.label)
|
||||
if (newOperatorSelectedValue?.label !== operatorSelectedValue?.label)
|
||||
{
|
||||
setOperatorSelectedValue(newOperatorSelectedValue);
|
||||
setOperatorInputValue(newOperatorSelectedValue?.label);
|
||||
@ -379,12 +381,12 @@ export function FilterCriteriaRow({id, index, tableMetaData, metaData, criteria,
|
||||
{
|
||||
criteria.operator = newValue ? newValue.value : null;
|
||||
|
||||
if(newValue)
|
||||
if (newValue)
|
||||
{
|
||||
setOperatorSelectedValue(newValue);
|
||||
setOperatorInputValue(newValue.label);
|
||||
|
||||
if(newValue.implicitValues)
|
||||
if (newValue.implicitValues)
|
||||
{
|
||||
criteria.values = newValue.implicitValues;
|
||||
}
|
||||
@ -393,15 +395,15 @@ export function FilterCriteriaRow({id, index, tableMetaData, metaData, criteria,
|
||||
// we've seen cases where switching operators can sometimes put a null in as the first value... //
|
||||
// that just causes a bad time (e.g., null pointers in Autocomplete), so, get rid of that. //
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
if(criteria.values && criteria.values.length == 1 && criteria.values[0] == null)
|
||||
if (criteria.values && criteria.values.length == 1 && criteria.values[0] == null)
|
||||
{
|
||||
criteria.values = [];
|
||||
}
|
||||
|
||||
if(newValue.valueMode && !newValue.implicitValues)
|
||||
if (newValue.valueMode && !newValue.implicitValues)
|
||||
{
|
||||
const requiredValueCount = getValueModeRequiredCount(newValue.valueMode);
|
||||
if(requiredValueCount != null && criteria.values.length > requiredValueCount)
|
||||
if (requiredValueCount != null && criteria.values.length > requiredValueCount)
|
||||
{
|
||||
criteria.values.splice(requiredValueCount);
|
||||
}
|
||||
@ -424,12 +426,12 @@ export function FilterCriteriaRow({id, index, tableMetaData, metaData, criteria,
|
||||
// @ts-ignore
|
||||
const value = newValue !== undefined ? newValue : event ? event.target.value : null;
|
||||
|
||||
if(!criteria.values)
|
||||
if (!criteria.values)
|
||||
{
|
||||
criteria.values = [];
|
||||
}
|
||||
|
||||
if(valueIndex == "all")
|
||||
if (valueIndex == "all")
|
||||
{
|
||||
criteria.values = value;
|
||||
}
|
||||
@ -514,6 +516,8 @@ export function FilterCriteriaRow({id, index, tableMetaData, metaData, criteria,
|
||||
field={field}
|
||||
table={fieldTable}
|
||||
valueChangeHandler={(event, valueIndex, newValue) => handleValueChange(event, valueIndex, newValue)}
|
||||
queryScreenUsage={queryScreenUsage}
|
||||
allowVariables={allowVariables}
|
||||
/>
|
||||
</Box>
|
||||
<Box display="inline-block">
|
||||
|
@ -23,19 +23,25 @@
|
||||
import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
|
||||
import {QFieldType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldType";
|
||||
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
|
||||
import {FilterVariableExpression} from "@kingsrook/qqq-frontend-core/lib/model/query/FilterVariableExpression";
|
||||
import Autocomplete from "@mui/material/Autocomplete";
|
||||
import Box from "@mui/material/Box";
|
||||
import Icon from "@mui/material/Icon";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import InputAdornment from "@mui/material/InputAdornment/InputAdornment";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import React, {SyntheticEvent, useReducer} from "react";
|
||||
import DynamicFormUtils from "qqq/components/forms/DynamicFormUtils";
|
||||
import DynamicSelect from "qqq/components/forms/DynamicSelect";
|
||||
import CriteriaDateField from "qqq/components/query/CriteriaDateField";
|
||||
import AssignFilterVariable from "qqq/components/query/AssignFilterVariable";
|
||||
import CriteriaDateField, {NoWrapTooltip} from "qqq/components/query/CriteriaDateField";
|
||||
import {QFilterCriteriaWithId} from "qqq/components/query/CustomFilterPanel";
|
||||
import {EvaluatedExpression} from "qqq/components/query/EvaluatedExpression";
|
||||
import FilterCriteriaPaster from "qqq/components/query/FilterCriteriaPaster";
|
||||
import {OperatorOption, ValueMode} from "qqq/components/query/FilterCriteriaRow";
|
||||
import {QueryScreenUsage} from "qqq/pages/records/query/RecordQuery";
|
||||
import ValueUtils from "qqq/utils/qqq/ValueUtils";
|
||||
import React, {SyntheticEvent, useReducer} from "react";
|
||||
import {flushSync} from "react-dom";
|
||||
|
||||
interface Props
|
||||
{
|
||||
@ -44,7 +50,9 @@ interface Props
|
||||
field: QFieldMetaData;
|
||||
table: QTableMetaData;
|
||||
valueChangeHandler: (event: React.ChangeEvent | SyntheticEvent, valueIndex?: number | "all", newValue?: any) => void;
|
||||
initiallyOpenMultiValuePvs?: boolean
|
||||
initiallyOpenMultiValuePvs?: boolean;
|
||||
queryScreenUsage?: QueryScreenUsage;
|
||||
allowVariables?: boolean;
|
||||
}
|
||||
|
||||
FilterCriteriaRowValues.defaultProps =
|
||||
@ -52,6 +60,10 @@ FilterCriteriaRowValues.defaultProps =
|
||||
initiallyOpenMultiValuePvs: false
|
||||
};
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* get the type to use for an <input> from a QFieldMetaData
|
||||
***************************************************************************/
|
||||
export const getTypeForTextField = (field: QFieldMetaData): string =>
|
||||
{
|
||||
let type = "search";
|
||||
@ -72,8 +84,15 @@ export const getTypeForTextField = (field: QFieldMetaData): string =>
|
||||
return (type);
|
||||
};
|
||||
|
||||
export const makeTextField = (field: QFieldMetaData, criteria: QFilterCriteriaWithId, valueChangeHandler?: (event: (React.ChangeEvent | React.SyntheticEvent), valueIndex?: (number | "all"), newValue?: any) => void, valueIndex: number = 0, label = "Value", idPrefix = "value-") =>
|
||||
|
||||
/***************************************************************************
|
||||
* Make an <input type=text> (actually, might be a different type, but that's
|
||||
* the gist of it), for a field.
|
||||
***************************************************************************/
|
||||
export const makeTextField = (field: QFieldMetaData, criteria: QFilterCriteriaWithId, valueChangeHandler?: (event: (React.ChangeEvent | React.SyntheticEvent), valueIndex?: (number | "all"), newValue?: any) => void, valueIndex: number = 0, label = "Value", idPrefix = "value-", allowVariables = false) =>
|
||||
{
|
||||
const isExpression = criteria.values && criteria.values[valueIndex] && criteria.values[valueIndex].type;
|
||||
const inputId = `${idPrefix}${criteria.id}`;
|
||||
let type = getTypeForTextField(field);
|
||||
const inputLabelProps: any = {};
|
||||
|
||||
@ -88,14 +107,16 @@ export const makeTextField = (field: QFieldMetaData, criteria: QFilterCriteriaWi
|
||||
value = ValueUtils.formatDateTimeValueForForm(value);
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
* Event handler for the clear 'x'.
|
||||
***************************************************************************/
|
||||
const clearValue = (event: React.MouseEvent<HTMLAnchorElement> | React.MouseEvent<HTMLButtonElement>, index: number) =>
|
||||
{
|
||||
valueChangeHandler(event, index, "");
|
||||
document.getElementById(`${idPrefix}${criteria.id}`).focus();
|
||||
document.getElementById(inputId).focus();
|
||||
};
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Event handler for key-down events - specifically added here, to stop pressing
|
||||
** 'tab' in a date or date-time from closing the quick-filter...
|
||||
@ -104,7 +125,7 @@ export const makeTextField = (field: QFieldMetaData, criteria: QFilterCriteriaWi
|
||||
{
|
||||
if (field.type == QFieldType.DATE || field.type == QFieldType.DATE_TIME)
|
||||
{
|
||||
if(e.code == "Tab")
|
||||
if (e.code == "Tab")
|
||||
{
|
||||
console.log("Tab on date or date-time - don't close me, just move to the next sub-field!...");
|
||||
e.stopPropagation();
|
||||
@ -112,6 +133,44 @@ export const makeTextField = (field: QFieldMetaData, criteria: QFilterCriteriaWi
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* make a version of the text field for when the criteria's value is set to
|
||||
* be a "variable"
|
||||
***************************************************************************/
|
||||
const makeFilterVariableTextField = (expression: FilterVariableExpression, valueIndex: number = 0, label = "Value", idPrefix = "value-") =>
|
||||
{
|
||||
const clearValue = (event: React.MouseEvent<HTMLAnchorElement> | React.MouseEvent<HTMLButtonElement>, index: number) =>
|
||||
{
|
||||
valueChangeHandler(event, index, "");
|
||||
document.getElementById(`${idPrefix}${criteria.id}`).focus();
|
||||
};
|
||||
|
||||
const inputProps2: any = {};
|
||||
inputProps2.endAdornment = (
|
||||
<InputAdornment position="end">
|
||||
<IconButton sx={{visibility: expression ? "visible" : "hidden"}} onClick={(event) => clearValue(event, valueIndex)}>
|
||||
<Icon>closer</Icon>
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
);
|
||||
|
||||
return <NoWrapTooltip title={<EvaluatedExpression field={field} expression={expression} />} placement="bottom" enterDelay={1000} sx={{marginLeft: "-75px !important", marginTop: "-8px !important"}}><TextField
|
||||
id={`${idPrefix}${criteria.id}`}
|
||||
label={label}
|
||||
variant="standard"
|
||||
autoComplete="off"
|
||||
InputProps={{disabled: true, readOnly: true, unselectable: "off", ...inputProps2}}
|
||||
InputLabelProps={{shrink: true}}
|
||||
value="${VARIABLE}"
|
||||
fullWidth
|
||||
/></NoWrapTooltip>;
|
||||
};
|
||||
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
// set up an 'x' icon as an end-adornment, to clear value from the field //
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
const inputProps: any = {};
|
||||
inputProps.endAdornment = (
|
||||
<InputAdornment position="end">
|
||||
@ -121,23 +180,87 @@ export const makeTextField = (field: QFieldMetaData, criteria: QFilterCriteriaWi
|
||||
</InputAdornment>
|
||||
);
|
||||
|
||||
return <TextField
|
||||
id={`${idPrefix}${criteria.id}`}
|
||||
label={label}
|
||||
variant="standard"
|
||||
autoComplete="off"
|
||||
type={type}
|
||||
onChange={(event) => valueChangeHandler(event, valueIndex)}
|
||||
onKeyDown={handleKeyDown}
|
||||
value={value}
|
||||
InputLabelProps={inputLabelProps}
|
||||
InputProps={inputProps}
|
||||
fullWidth
|
||||
autoFocus={true}
|
||||
/>;
|
||||
|
||||
/***************************************************************************
|
||||
* onChange event handler. deals with, if the field has a to upper/lower
|
||||
* case rule on it, to apply that transform, and adjust the cursor.
|
||||
* See: https://giacomocerquone.com/blog/keep-input-cursor-still
|
||||
***************************************************************************/
|
||||
function onChange(event: any)
|
||||
{
|
||||
const beforeStart = event.target.selectionStart;
|
||||
const beforeEnd = event.target.selectionEnd;
|
||||
|
||||
let isToUpperCase = DynamicFormUtils.isToUpperCase(field);
|
||||
let isToLowerCase = DynamicFormUtils.isToLowerCase(field);
|
||||
|
||||
if (isToUpperCase || isToLowerCase)
|
||||
{
|
||||
flushSync(() =>
|
||||
{
|
||||
let newValue = event.currentTarget.value;
|
||||
|
||||
if (isToUpperCase)
|
||||
{
|
||||
newValue = newValue.toUpperCase();
|
||||
}
|
||||
if (isToLowerCase)
|
||||
{
|
||||
newValue = newValue.toLowerCase();
|
||||
}
|
||||
|
||||
event.currentTarget.value = newValue;
|
||||
});
|
||||
|
||||
const input = document.getElementById(inputId);
|
||||
if (input)
|
||||
{
|
||||
// @ts-ignore
|
||||
input.setSelectionRange(beforeStart, beforeEnd);
|
||||
}
|
||||
}
|
||||
|
||||
valueChangeHandler(event, valueIndex);
|
||||
}
|
||||
|
||||
////////////////////////
|
||||
// return the element //
|
||||
////////////////////////
|
||||
return <Box sx={{margin: 0, padding: 0, display: "flex"}}>
|
||||
{
|
||||
isExpression ? (
|
||||
makeFilterVariableTextField(criteria.values[valueIndex], valueIndex, label, idPrefix)
|
||||
) : (
|
||||
<TextField
|
||||
id={inputId}
|
||||
label={label}
|
||||
variant="standard"
|
||||
autoComplete="off"
|
||||
type={type}
|
||||
onChange={onChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
value={value}
|
||||
InputLabelProps={inputLabelProps}
|
||||
InputProps={inputProps}
|
||||
fullWidth
|
||||
autoFocus={true}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
allowVariables && (
|
||||
<AssignFilterVariable field={field} valueChangeHandler={valueChangeHandler} valueIndex={valueIndex} />
|
||||
)
|
||||
}
|
||||
</Box>;
|
||||
};
|
||||
|
||||
function FilterCriteriaRowValues({operatorOption, criteria, field, table, valueChangeHandler, initiallyOpenMultiValuePvs}: Props): JSX.Element
|
||||
|
||||
/***************************************************************************
|
||||
* Component that is the "values" portion of a FilterCriteria Row in the
|
||||
* advanced query filter editor.
|
||||
***************************************************************************/
|
||||
function FilterCriteriaRowValues({operatorOption, criteria, field, table, valueChangeHandler, initiallyOpenMultiValuePvs, queryScreenUsage, allowVariables}: Props): JSX.Element
|
||||
{
|
||||
const [, forceUpdate] = useReducer((x) => x + 1, 0);
|
||||
|
||||
@ -146,6 +269,10 @@ function FilterCriteriaRowValues({operatorOption, criteria, field, table, valueC
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
* Callback for the Save button from the paste-values modal
|
||||
***************************************************************************/
|
||||
function saveNewPasterValues(newValues: any[])
|
||||
{
|
||||
if (criteria.values)
|
||||
@ -169,33 +296,38 @@ function FilterCriteriaRowValues({operatorOption, criteria, field, table, valueC
|
||||
forceUpdate();
|
||||
}
|
||||
|
||||
const isExpression = criteria.values && criteria.values[0] && criteria.values[0].type;
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////
|
||||
// render different form element9s) based on operator option's "value mode" //
|
||||
//////////////////////////////////////////////////////////////////////////////
|
||||
switch (operatorOption.valueMode)
|
||||
{
|
||||
case ValueMode.NONE:
|
||||
return null;
|
||||
case ValueMode.SINGLE:
|
||||
return makeTextField(field, criteria, valueChangeHandler);
|
||||
return makeTextField(field, criteria, valueChangeHandler, 0, undefined, undefined, allowVariables);
|
||||
case ValueMode.SINGLE_DATE:
|
||||
return <CriteriaDateField field={field} valueChangeHandler={valueChangeHandler} criteria={criteria} />;
|
||||
return <CriteriaDateField field={field} valueChangeHandler={valueChangeHandler} criteria={criteria} allowVariables={allowVariables} />;
|
||||
case ValueMode.DOUBLE_DATE:
|
||||
return <Box>
|
||||
<CriteriaDateField field={field} valueChangeHandler={valueChangeHandler} criteria={criteria} valueIndex={0} label="From" idPrefix="from-" />
|
||||
<CriteriaDateField field={field} valueChangeHandler={valueChangeHandler} criteria={criteria} valueIndex={1} label="To" idPrefix="to-" />
|
||||
<CriteriaDateField field={field} valueChangeHandler={valueChangeHandler} criteria={criteria} valueIndex={0} label="From" idPrefix="from-" allowVariables={allowVariables} />
|
||||
<CriteriaDateField field={field} valueChangeHandler={valueChangeHandler} criteria={criteria} valueIndex={1} label="To" idPrefix="to-" allowVariables={allowVariables} />
|
||||
</Box>;
|
||||
case ValueMode.SINGLE_DATE_TIME:
|
||||
return <CriteriaDateField field={field} valueChangeHandler={valueChangeHandler} criteria={criteria} />;
|
||||
return <CriteriaDateField field={field} valueChangeHandler={valueChangeHandler} criteria={criteria} allowVariables={allowVariables} />;
|
||||
case ValueMode.DOUBLE_DATE_TIME:
|
||||
return <Box>
|
||||
<CriteriaDateField field={field} valueChangeHandler={valueChangeHandler} criteria={criteria} valueIndex={0} label="From" idPrefix="from-" />
|
||||
<CriteriaDateField field={field} valueChangeHandler={valueChangeHandler} criteria={criteria} valueIndex={1} label="To" idPrefix="to-" />
|
||||
<CriteriaDateField field={field} valueChangeHandler={valueChangeHandler} criteria={criteria} valueIndex={0} label="From" idPrefix="from-" allowVariables={allowVariables} />
|
||||
<CriteriaDateField field={field} valueChangeHandler={valueChangeHandler} criteria={criteria} valueIndex={1} label="To" idPrefix="to-" allowVariables={allowVariables} />
|
||||
</Box>;
|
||||
case ValueMode.DOUBLE:
|
||||
return <Box>
|
||||
<Box width="50%" display="inline-block">
|
||||
{makeTextField(field, criteria, valueChangeHandler, 0, "From", "from-")}
|
||||
{makeTextField(field, criteria, valueChangeHandler, 0, "From", "from-", allowVariables)}
|
||||
</Box>
|
||||
<Box width="50%" display="inline-block">
|
||||
{makeTextField(field, criteria, valueChangeHandler, 1, "To", "to-")}
|
||||
{makeTextField(field, criteria, valueChangeHandler, 1, "To", "to-", allowVariables)}
|
||||
</Box>
|
||||
</Box>;
|
||||
case ValueMode.MULTI:
|
||||
@ -228,19 +360,30 @@ function FilterCriteriaRowValues({operatorOption, criteria, field, table, valueC
|
||||
{
|
||||
selectedPossibleValue = criteria.values[0];
|
||||
}
|
||||
return <Box mb={-1.5}>
|
||||
<DynamicSelect
|
||||
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"
|
||||
/>
|
||||
return <Box display="flex">
|
||||
{
|
||||
isExpression ? (
|
||||
makeTextField(field, criteria, valueChangeHandler, 0, undefined, undefined, allowVariables)
|
||||
) : (
|
||||
<Box width={"100%"}>
|
||||
<DynamicSelect
|
||||
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"
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
{
|
||||
allowVariables && !isExpression && <Box mt={2.0}><AssignFilterVariable field={field} valueChangeHandler={valueChangeHandler} valueIndex={0} /></Box>
|
||||
}
|
||||
</Box>;
|
||||
case ValueMode.PVS_MULTI:
|
||||
console.log("Doing pvs multi: " + criteria.values);
|
||||
@ -256,7 +399,7 @@ function FilterCriteriaRowValues({operatorOption, criteria, field, table, valueC
|
||||
initialValues = criteria.values;
|
||||
}
|
||||
}
|
||||
return <Box mb={-1.5}>
|
||||
return <Box>
|
||||
<DynamicSelect
|
||||
tableName={table.name}
|
||||
fieldName={field.name}
|
||||
@ -276,4 +419,4 @@ function FilterCriteriaRowValues({operatorOption, criteria, field, table, valueC
|
||||
return (<br />);
|
||||
}
|
||||
|
||||
export default FilterCriteriaRowValues;
|
||||
export default FilterCriteriaRowValues;
|
||||
|
@ -30,14 +30,15 @@ import Box from "@mui/material/Box";
|
||||
import Button from "@mui/material/Button";
|
||||
import Menu from "@mui/material/Menu";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import React, {SyntheticEvent, useContext, useReducer, useState} from "react";
|
||||
import QContext from "QContext";
|
||||
import {QFilterCriteriaWithId} from "qqq/components/query/CustomFilterPanel";
|
||||
import {getDefaultCriteriaValue, getOperatorOptions, getValueModeRequiredCount, OperatorOption, validateCriteria} from "qqq/components/query/FilterCriteriaRow";
|
||||
import FilterCriteriaRowValues from "qqq/components/query/FilterCriteriaRowValues";
|
||||
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, useReducer, useState} from "react";
|
||||
|
||||
export type CriteriaParamType = QFilterCriteriaWithId | null | "tooComplex";
|
||||
|
||||
@ -50,6 +51,8 @@ interface QuickFilterProps
|
||||
updateCriteria: (newCriteria: QFilterCriteria, needDebounce: boolean, doRemoveCriteria: boolean) => void;
|
||||
defaultOperator?: QCriteriaOperator;
|
||||
handleRemoveQuickFilterField?: (fieldName: string) => void;
|
||||
queryScreenUsage?: QueryScreenUsage;
|
||||
allowVariables?: boolean;
|
||||
}
|
||||
|
||||
QuickFilter.defaultProps =
|
||||
@ -71,7 +74,7 @@ export const quickFilterButtonStyles = {
|
||||
minHeight: "auto",
|
||||
padding: "0.375rem 0.625rem", whiteSpace: "nowrap",
|
||||
marginBottom: "0.5rem"
|
||||
}
|
||||
};
|
||||
|
||||
/*******************************************************************************
|
||||
** Test if a CriteriaParamType represents an actual query criteria - or, if it's
|
||||
@ -89,11 +92,11 @@ const criteriaParamIsCriteria = (param: CriteriaParamType): boolean =>
|
||||
*******************************************************************************/
|
||||
const doesOperatorOptionEqualCriteria = (operatorOption: OperatorOption, criteria: QFilterCriteriaWithId): boolean =>
|
||||
{
|
||||
if(operatorOption.value == criteria.operator)
|
||||
if (operatorOption.value == criteria.operator)
|
||||
{
|
||||
if(operatorOption.implicitValues)
|
||||
if (operatorOption.implicitValues)
|
||||
{
|
||||
if(JSON.stringify(operatorOption.implicitValues) == JSON.stringify(criteria.values))
|
||||
if (JSON.stringify(operatorOption.implicitValues) == JSON.stringify(criteria.values))
|
||||
{
|
||||
return (true);
|
||||
}
|
||||
@ -107,7 +110,7 @@ const doesOperatorOptionEqualCriteria = (operatorOption: OperatorOption, criteri
|
||||
}
|
||||
|
||||
return (false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@ -117,29 +120,29 @@ const doesOperatorOptionEqualCriteria = (operatorOption: OperatorOption, criteri
|
||||
*******************************************************************************/
|
||||
const getOperatorSelectedValue = (operatorOptions: OperatorOption[], criteria: QFilterCriteriaWithId, defaultOperator: QCriteriaOperator): OperatorOption =>
|
||||
{
|
||||
if(criteria)
|
||||
if (criteria)
|
||||
{
|
||||
const filteredOptions = operatorOptions.filter(o => doesOperatorOptionEqualCriteria(o, criteria));
|
||||
if(filteredOptions.length > 0)
|
||||
if (filteredOptions.length > 0)
|
||||
{
|
||||
return (filteredOptions[0]);
|
||||
}
|
||||
}
|
||||
|
||||
const filteredOptions = operatorOptions.filter(o => o.value == defaultOperator);
|
||||
if(filteredOptions.length > 0)
|
||||
if (filteredOptions.length > 0)
|
||||
{
|
||||
return (filteredOptions[0]);
|
||||
}
|
||||
|
||||
return (null);
|
||||
}
|
||||
};
|
||||
|
||||
/*******************************************************************************
|
||||
** Component to render a QuickFilter - that is - a button, with a Menu under it,
|
||||
** with Operator and Value controls.
|
||||
*******************************************************************************/
|
||||
export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData, criteriaParam, updateCriteria, defaultOperator, handleRemoveQuickFilterField}: QuickFilterProps): JSX.Element
|
||||
export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData, criteriaParam, updateCriteria, defaultOperator, handleRemoveQuickFilterField, queryScreenUsage, allowVariables}: QuickFilterProps): JSX.Element
|
||||
{
|
||||
const operatorOptions = fieldMetaData ? getOperatorOptions(tableMetaData, fullFieldName) : [];
|
||||
const [_, tableForField] = TableUtils.getFieldAndTable(tableMetaData, fullFieldName);
|
||||
@ -190,7 +193,7 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
if (criteriaParamIsCriteria(criteriaParam) && JSON.stringify(criteriaParam) !== JSON.stringify(criteria))
|
||||
{
|
||||
if(isOpen)
|
||||
if (isOpen)
|
||||
{
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
// this was firing too-often for case where: there was a criteria originally //
|
||||
@ -217,12 +220,12 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData
|
||||
*******************************************************************************/
|
||||
const criteriaNeedsReset = (): boolean =>
|
||||
{
|
||||
if(criteria != null && criteriaParam == null)
|
||||
if (criteria != null && criteriaParam == null)
|
||||
{
|
||||
const defaultOperatorOption = operatorOptions.filter(o => o.value == defaultOperator)[0];
|
||||
if(criteria.operator !== defaultOperatorOption?.value || JSON.stringify(criteria.values) !== JSON.stringify(getDefaultCriteriaValue()))
|
||||
if (criteria.operator !== defaultOperatorOption?.value || JSON.stringify(criteria.values) !== JSON.stringify(getDefaultCriteriaValue()))
|
||||
{
|
||||
if(isOpen)
|
||||
if (isOpen)
|
||||
{
|
||||
//////////////////////////////////////////////////////////////////////////////////
|
||||
// this was firing too-often for case where: there was no criteria originally, //
|
||||
@ -237,7 +240,7 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData
|
||||
}
|
||||
|
||||
return (false);
|
||||
}
|
||||
};
|
||||
|
||||
/*******************************************************************************
|
||||
** Construct a new criteria object - resetting the values tied to the operator
|
||||
@ -251,8 +254,8 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData
|
||||
setOperatorSelectedValue(operatorOption);
|
||||
setOperatorInputValue(operatorOption?.label);
|
||||
setCriteria(criteria);
|
||||
return(criteria);
|
||||
}
|
||||
return (criteria);
|
||||
};
|
||||
|
||||
/*******************************************************************************
|
||||
** event handler to open the menu in response to the button being clicked.
|
||||
@ -266,7 +269,7 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData
|
||||
{
|
||||
const element = document.getElementById("value-" + criteria.id);
|
||||
element?.focus();
|
||||
})
|
||||
});
|
||||
};
|
||||
|
||||
/*******************************************************************************
|
||||
@ -304,15 +307,15 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData
|
||||
// we've seen cases where switching operators can sometimes put a null in as the first value... //
|
||||
// that just causes a bad time (e.g., null pointers in Autocomplete), so, get rid of that. //
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
if(criteria.values && criteria.values.length == 1 && criteria.values[0] == null)
|
||||
if (criteria.values && criteria.values.length == 1 && criteria.values[0] == null)
|
||||
{
|
||||
criteria.values = [];
|
||||
}
|
||||
|
||||
if(newValue.valueMode && !newValue.implicitValues)
|
||||
if (newValue.valueMode && !newValue.implicitValues)
|
||||
{
|
||||
const requiredValueCount = getValueModeRequiredCount(newValue.valueMode);
|
||||
if(requiredValueCount != null && criteria.values.length > requiredValueCount)
|
||||
if (requiredValueCount != null && criteria.values.length > requiredValueCount)
|
||||
{
|
||||
criteria.values.splice(requiredValueCount);
|
||||
}
|
||||
@ -345,6 +348,7 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData
|
||||
// @ts-ignore
|
||||
const value = newValue !== undefined ? newValue : event ? event.target.value : null;
|
||||
|
||||
console.log("IN HERE");
|
||||
if (!criteria.values)
|
||||
{
|
||||
criteria.values = [];
|
||||
@ -376,13 +380,13 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData
|
||||
*******************************************************************************/
|
||||
const resetCriteria = (e: React.MouseEvent<HTMLSpanElement>) =>
|
||||
{
|
||||
if(criteriaIsValid)
|
||||
if (criteriaIsValid)
|
||||
{
|
||||
e.stopPropagation();
|
||||
const newCriteria = makeNewCriteria();
|
||||
updateCriteria(newCriteria, false, true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/*******************************************************************************
|
||||
** event handler for clicking the (x) icon that turns off this quick filter field.
|
||||
@ -390,17 +394,17 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData
|
||||
*******************************************************************************/
|
||||
const handleTurningOffQuickFilterField = () =>
|
||||
{
|
||||
closeMenu()
|
||||
if(handleRemoveQuickFilterField)
|
||||
closeMenu();
|
||||
if (handleRemoveQuickFilterField)
|
||||
{
|
||||
handleRemoveQuickFilterField(criteria?.fieldName);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////
|
||||
// if no field was input (e.g., record-query is still loading), return null early //
|
||||
////////////////////////////////////////////////////////////////////////////////////
|
||||
if(!fieldMetaData)
|
||||
if (!fieldMetaData)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
@ -410,10 +414,10 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData
|
||||
// from the last selected one, then set the state vars that control that autocomplete //
|
||||
//////////////////////////////////////////////////////////////////////////////////////////
|
||||
const maybeNewOperatorSelectedValue = getOperatorSelectedValue(operatorOptions, criteria, defaultOperator);
|
||||
if(JSON.stringify(maybeNewOperatorSelectedValue) !== JSON.stringify(operatorSelectedValue))
|
||||
if (JSON.stringify(maybeNewOperatorSelectedValue) !== JSON.stringify(operatorSelectedValue))
|
||||
{
|
||||
setOperatorSelectedValue(maybeNewOperatorSelectedValue)
|
||||
setOperatorInputValue(maybeNewOperatorSelectedValue?.label)
|
||||
setOperatorSelectedValue(maybeNewOperatorSelectedValue);
|
||||
setOperatorInputValue(maybeNewOperatorSelectedValue?.label);
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////////
|
||||
@ -431,7 +435,7 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData
|
||||
const tooltipEnterDelay = 500;
|
||||
|
||||
let buttonAdditionalStyles: any = {};
|
||||
let buttonContent = <span>{tableForField?.name != tableMetaData.name ? `${tableForField.label}: ` : ""}{fieldMetaData.label}</span>
|
||||
let buttonContent = <span>{tableForField?.name != tableMetaData.name ? `${tableForField.label}: ` : ""}{fieldMetaData.label}</span>;
|
||||
let buttonClassName = "filterNotActive";
|
||||
if (criteriaIsValid)
|
||||
{
|
||||
@ -446,9 +450,9 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData
|
||||
// don't show the Equals or In operators //
|
||||
///////////////////////////////////////////
|
||||
let operatorString = (<>{operatorSelectedValue.label} </>);
|
||||
if(operatorSelectedValue.value == QCriteriaOperator.EQUALS || operatorSelectedValue.value == QCriteriaOperator.IN)
|
||||
if (operatorSelectedValue.value == QCriteriaOperator.EQUALS || operatorSelectedValue.value == QCriteriaOperator.IN)
|
||||
{
|
||||
operatorString = (<></>)
|
||||
operatorString = (<></>);
|
||||
}
|
||||
|
||||
buttonContent = (<><span style={{fontWeight: 700}}>{buttonContent}:</span> <span style={{fontWeight: 400}}>{operatorString}{valuesString}</span></>);
|
||||
@ -491,7 +495,7 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData
|
||||
const xClicked = (e: React.MouseEvent<HTMLSpanElement>) =>
|
||||
{
|
||||
e.stopPropagation();
|
||||
if(criteriaIsValid)
|
||||
if (criteriaIsValid)
|
||||
{
|
||||
resetCriteria(e);
|
||||
}
|
||||
@ -499,12 +503,12 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData
|
||||
{
|
||||
handleTurningOffQuickFilterField();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
//////////////////////////////
|
||||
// return the button & menu //
|
||||
//////////////////////////////
|
||||
const widthAndMaxWidth = fieldMetaData?.type == QFieldType.DATE_TIME ? 275 : 250
|
||||
const widthAndMaxWidth = (fieldMetaData?.type == QFieldType.DATE_TIME) ? 315 : 250;
|
||||
return (
|
||||
<>
|
||||
{button}
|
||||
@ -541,10 +545,12 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData
|
||||
</Box>
|
||||
<Box width={widthAndMaxWidth} maxWidth={widthAndMaxWidth} className="quickFilter filterValuesColumn">
|
||||
<FilterCriteriaRowValues
|
||||
queryScreenUsage={queryScreenUsage}
|
||||
operatorOption={operatorSelectedValue}
|
||||
criteria={criteria}
|
||||
field={fieldMetaData}
|
||||
table={tableForField}
|
||||
allowVariables={allowVariables}
|
||||
valueChangeHandler={(event, valueIndex, newValue) => handleValueChange(event, valueIndex, newValue)}
|
||||
initiallyOpenMultiValuePvs={true} // todo - maybe not?
|
||||
/>
|
||||
|
@ -24,9 +24,8 @@ import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QT
|
||||
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} from "@mui/material";
|
||||
import {Alert, Box} from "@mui/material";
|
||||
import Autocomplete from "@mui/material/Autocomplete";
|
||||
import Box from "@mui/material/Box";
|
||||
import Button from "@mui/material/Button";
|
||||
import Card from "@mui/material/Card";
|
||||
import Icon from "@mui/material/Icon";
|
||||
@ -370,15 +369,15 @@ export default function ShareModal({open, onClose, tableMetaData, record}: Share
|
||||
<Card sx={{my: 5, mx: "auto", p: 3}}>
|
||||
|
||||
{/* header */}
|
||||
<Box display="flex" flexDirection="row" justifyContent="space-between" alignItems="flex-start">
|
||||
<Box display="flex" flexDirection="row" justifyContent="space-between" alignItems="flex-start" maxWidth="590px">
|
||||
<Typography variant="h4" pb={1} fontWeight="600">
|
||||
Share {tableMetaData.label}: {record?.recordLabel ?? record?.values?.get(tableMetaData.primaryKeyField) ?? "Unknown"}
|
||||
<Box color={colors.gray.main} pb={"0.5rem"} fontSize={"0.875rem"} fontWeight="400" maxWidth="590px">
|
||||
<Box color={colors.gray.main} pb={"0.5rem"} fontSize={"0.875rem"} fontWeight="400">
|
||||
{/* todo move to helpContent (what do we attach the meta-data too??) */}
|
||||
Select a user or a group to share this record with.
|
||||
You can choose if they should only be able to Read the record, or also make Edits to it.
|
||||
{/*You can choose if they should only be able to Read the record, or also make Edits to it.*/}
|
||||
</Box>
|
||||
<Box fontSize={14} maxWidth="590px" pb={1} fontWeight="300">
|
||||
<Box fontSize={14} pb={1} fontWeight="300">
|
||||
{alert && <Alert color="error" onClose={() => setAlert(null)}>{alert}</Alert>}
|
||||
{statusString}
|
||||
{!alert && !statusString && (<> </>)}
|
||||
@ -390,7 +389,7 @@ export default function ShareModal({open, onClose, tableMetaData, record}: Share
|
||||
<Box pb={3} display="flex" flexDirection="column">
|
||||
{/* row for adding a new share */}
|
||||
<Box display="flex" flexDirection="row" alignItems="center">
|
||||
<Box width="350px" pr={2} mb={-1.5}>
|
||||
<Box width="550px" pr={2} mb={-1.5}>
|
||||
<DynamicSelect
|
||||
possibleValueSourceName={shareableTableMetaData.audiencePossibleValueSourceName}
|
||||
fieldLabel="User or Group" // todo should come from shareableTableMetaData
|
||||
@ -400,9 +399,12 @@ export default function ShareModal({open, onClose, tableMetaData, record}: Share
|
||||
onChange={handleAudienceChange}
|
||||
/>
|
||||
</Box>
|
||||
<Box width="180px" pr={2}>
|
||||
{renderScopeDropdown("new-share-scope", defaultScope, handleScopeChange)}
|
||||
</Box>
|
||||
{/*
|
||||
when turning scope back on, change width of audience box to 350px
|
||||
<Box width="180px" pr={2}>
|
||||
{renderScopeDropdown("new-share-scope", defaultScope, handleScopeChange)}
|
||||
</Box>
|
||||
*/}
|
||||
<Box>
|
||||
<Tooltip title={selectedAudienceId == null ? "Select a user or group to share with." : null}>
|
||||
<span>
|
||||
@ -429,8 +431,11 @@ export default function ShareModal({open, onClose, tableMetaData, record}: Share
|
||||
currentShares.map((share) => (
|
||||
<Box key={share.shareId} display="flex" justifyContent="space-between" alignItems="center" p="0.25rem" pb="0.75rem" fontSize="1rem">
|
||||
<Box display="flex" alignItems="center">
|
||||
<Box width="310px" pl="1rem">{share.audienceLabel}</Box>
|
||||
<Box width="160px">{renderScopeDropdown(`scope-${share.shareId}`, getScopeOption(share.scopeId), (event: React.SyntheticEvent, value: any | any[], reason: string) => editingExistingShareScope(share.shareId, value))}</Box>
|
||||
<Box width="490px" pl="1rem">{share.audienceLabel}</Box>
|
||||
{/*
|
||||
when turning scope back on, change width of audience box to 310px
|
||||
<Box width="160px">{renderScopeDropdown(`scope-${share.shareId}`, getScopeOption(share.scopeId), (event: React.SyntheticEvent, value: any | any[], reason: string) => editingExistingShareScope(share.shareId, value))}</Box>
|
||||
*/}
|
||||
</Box>
|
||||
<Box pr="1rem">
|
||||
<Button sx={{...iconButtonSX, ...redIconButton}} onClick={() => removeShare(share.shareId)}><Icon>clear</Icon></Button>
|
||||
|
@ -22,16 +22,16 @@
|
||||
|
||||
import {QWidgetMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QWidgetMetaData";
|
||||
import {Box, Skeleton} from "@mui/material";
|
||||
import React from "react";
|
||||
import {BlockData} from "qqq/components/widgets/blocks/BlockModels";
|
||||
import WidgetBlock from "qqq/components/widgets/WidgetBlock";
|
||||
import React from "react";
|
||||
|
||||
|
||||
interface CompositeData
|
||||
{
|
||||
blocks: BlockData[];
|
||||
styleOverrides?: any;
|
||||
layout?: string
|
||||
layout?: string;
|
||||
}
|
||||
|
||||
|
||||
@ -57,7 +57,14 @@ export default function CompositeWidget({widgetMetaData, data}: CompositeWidgetP
|
||||
////////////////////////////////////////////////////////////////////////////////////
|
||||
let layout = data?.layout;
|
||||
let boxStyle: any = {};
|
||||
if (layout == "FLEX_ROW_WRAPPED")
|
||||
if (layout == "FLEX_COLUMN")
|
||||
{
|
||||
boxStyle.display = "flex";
|
||||
boxStyle.flexDirection = "column";
|
||||
boxStyle.flexWrap = "wrap";
|
||||
boxStyle.gap = "0.5rem";
|
||||
}
|
||||
else if (layout == "FLEX_ROW_WRAPPED")
|
||||
{
|
||||
boxStyle.display = "flex";
|
||||
boxStyle.flexDirection = "row";
|
||||
@ -68,7 +75,7 @@ export default function CompositeWidget({widgetMetaData, data}: CompositeWidgetP
|
||||
{
|
||||
boxStyle.display = "flex";
|
||||
boxStyle.flexDirection = "row";
|
||||
boxStyle.justifyContent = "space-between"
|
||||
boxStyle.justifyContent = "space-between";
|
||||
boxStyle.gap = "0.25rem";
|
||||
}
|
||||
else if (layout == "TABLE_SUB_ROW_DETAILS")
|
||||
|
@ -38,18 +38,20 @@ import StackedBarChart from "qqq/components/widgets/charts/StackedBarChart";
|
||||
import CompositeWidget from "qqq/components/widgets/CompositeWidget";
|
||||
import DataBagViewer from "qqq/components/widgets/misc/DataBagViewer";
|
||||
import DividerWidget from "qqq/components/widgets/misc/Divider";
|
||||
import DynamicFormWidget from "qqq/components/widgets/misc/DynamicFormWidget";
|
||||
import FieldValueListWidget from "qqq/components/widgets/misc/FieldValueListWidget";
|
||||
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 from "qqq/components/widgets/misc/RecordGridWidget";
|
||||
import ReportSetupWidget from "qqq/components/widgets/misc/ReportSetupWidget";
|
||||
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";
|
||||
import Widget, {HeaderIcon, LabelComponent, WIDGET_DROPDOWN_SELECTION_LOCAL_STORAGE_KEY_ROOT} from "qqq/components/widgets/Widget";
|
||||
import Widget, {HeaderIcon, LabelComponent, WIDGET_DROPDOWN_SELECTION_LOCAL_STORAGE_KEY_ROOT, WidgetData} from "qqq/components/widgets/Widget";
|
||||
import WidgetBlock from "qqq/components/widgets/WidgetBlock";
|
||||
import ProcessRun from "qqq/pages/processes/ProcessRun";
|
||||
import Client from "qqq/utils/qqq/Client";
|
||||
@ -257,11 +259,11 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, reco
|
||||
** helper function, to convert values from a QRecord values map to a regular old
|
||||
** js object
|
||||
*******************************************************************************/
|
||||
function convertQRecordValuesFromMapToObject(record: QRecord): {[name: string]: any}
|
||||
function convertQRecordValuesFromMapToObject(record: QRecord): { [name: string]: any }
|
||||
{
|
||||
const rs: {[name: string]: any} = {};
|
||||
const rs: { [name: string]: any } = {};
|
||||
|
||||
if(record.values)
|
||||
if (record && record.values)
|
||||
{
|
||||
record.values.forEach((value, key) => rs[key] = value);
|
||||
}
|
||||
@ -292,7 +294,7 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, reco
|
||||
}
|
||||
|
||||
return (
|
||||
<Box key={`${widgetMetaData.name}-${i}`} sx={{alignItems: "stretch", flexGrow: 1, display: "flex", marginTop: "0px", paddingTop: "0px", width: "100%", height: "100%"}}>
|
||||
<Box key={`${widgetMetaData.name}-${i}`} sx={{alignItems: "stretch", flexGrow: 1, display: "flex", marginTop: "0px", paddingTop: "0px", width: "100%", height: "100%", flexDirection: widgetMetaData.type == "multiTable" ? "column" : "row"}}>
|
||||
{
|
||||
haveLoadedParams && widgetMetaData.type === "parentWidget" && (
|
||||
<ParentWidget
|
||||
@ -342,6 +344,20 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, reco
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
widgetMetaData.type === "multiTable" && (
|
||||
widgetData[i]?.tableDataList?.map((tableData: WidgetData, index: number) =>
|
||||
<Box pb={3} key={`${widgetMetaData.type}-${index}`}>
|
||||
<TableWidget
|
||||
widgetMetaData={widgetMetaData}
|
||||
widgetData={tableData}
|
||||
reloadWidgetCallback={(data) => reloadWidget(i, data)}
|
||||
isChild={areChildren}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
)
|
||||
}
|
||||
{
|
||||
widgetMetaData.type === "stackedBarChart" && (
|
||||
<Widget
|
||||
@ -566,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 &&
|
||||
@ -583,17 +607,25 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, reco
|
||||
)
|
||||
}
|
||||
{
|
||||
widgetMetaData.type === "reportSetup" && (
|
||||
widgetData && widgetData[i] && widgetData[i].queryParams &&
|
||||
<ReportSetupWidget isEditable={false} widgetMetaData={widgetMetaData} recordValues={convertQRecordValuesFromMapToObject(record)} onSaveCallback={() =>
|
||||
{}} />
|
||||
widgetMetaData.type === "filterAndColumnsSetup" && (
|
||||
widgetData && widgetData[i] &&
|
||||
<FilterAndColumnsSetupWidget isEditable={false} widgetMetaData={widgetMetaData} widgetData={widgetData[i]} recordValues={convertQRecordValuesFromMapToObject(record)} onSaveCallback={() =>
|
||||
{
|
||||
}} />
|
||||
)
|
||||
}
|
||||
{
|
||||
widgetMetaData.type === "pivotTableSetup" && (
|
||||
widgetData && widgetData[i] && widgetData[i].queryParams &&
|
||||
<PivotTableSetupWidget isEditable={false} widgetMetaData={widgetMetaData} recordValues={convertQRecordValuesFromMapToObject(record)} onSaveCallback={() =>
|
||||
{}} />
|
||||
{
|
||||
}} />
|
||||
)
|
||||
}
|
||||
{
|
||||
widgetMetaData.type === "dynamicForm" && (
|
||||
widgetData && widgetData[i] &&
|
||||
<DynamicFormWidget isEditable={false} widgetMetaData={widgetMetaData} widgetData={widgetData[i]} record={record} recordValues={convertQRecordValuesFromMapToObject(record)} />
|
||||
)
|
||||
}
|
||||
</Box>
|
||||
|
@ -21,8 +21,7 @@
|
||||
|
||||
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
|
||||
import {QWidgetMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QWidgetMetaData";
|
||||
import {InputLabel} from "@mui/material";
|
||||
import Box from "@mui/material/Box";
|
||||
import {Box, InputLabel} from "@mui/material";
|
||||
import Button from "@mui/material/Button";
|
||||
import Card from "@mui/material/Card";
|
||||
import Icon from "@mui/material/Icon";
|
||||
@ -196,8 +195,6 @@ export function HeaderLinkButtonComponent({label, onClickCallback, disabled, dis
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@ -220,7 +217,7 @@ export function HeaderToggleComponent({label, getValue, onClickCallback, disable
|
||||
const onClick = () =>
|
||||
{
|
||||
onClickCallback();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box alignItems="baseline" mr="-0.75rem">
|
||||
@ -236,7 +233,6 @@ export function HeaderToggleComponent({label, getValue, onClickCallback, disable
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@ -698,7 +694,7 @@ function Widget(props: React.PropsWithChildren<Props>): JSX.Element
|
||||
);
|
||||
|
||||
let sublabelElement = (
|
||||
<Box height="20px">
|
||||
<Box key="sublabel" height="20px">
|
||||
<Typography sx={{position: "relative", top: "-18px"}} variant="caption">
|
||||
{props.widgetData?.sublabel}
|
||||
</Typography>
|
||||
@ -785,7 +781,7 @@ function Widget(props: React.PropsWithChildren<Props>): JSX.Element
|
||||
}
|
||||
{localLabelAdditionalElementsLeft}
|
||||
</Box>
|
||||
<Box display="flex">
|
||||
<Box key="sublabelContainer" display="flex">
|
||||
{
|
||||
hasPermission && props.widgetData?.sublabel && (sublabelElement)
|
||||
}
|
||||
|
@ -21,14 +21,16 @@
|
||||
|
||||
|
||||
import {QWidgetMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QWidgetMetaData";
|
||||
import Box from "@mui/material/Box";
|
||||
import Button from "@mui/material/Button";
|
||||
import Icon from "@mui/material/Icon";
|
||||
import Tooltip from "@mui/material/Tooltip/Tooltip";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import React from "react";
|
||||
import colors from "qqq/assets/theme/base/colors";
|
||||
import {WidgetData} from "qqq/components/widgets/Widget";
|
||||
import ValueUtils from "qqq/utils/qqq/ValueUtils";
|
||||
import React from "react";
|
||||
import {Link} from "react-router-dom";
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class used by Widgets
|
||||
@ -51,6 +53,17 @@ export class WidgetUtils
|
||||
};
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public static generateLabelLink = (linkText: string, linkURL: string): JSX.Element =>
|
||||
{
|
||||
return (<Box key={1} fontSize="1rem" pl={1} display="inline" position="relative">
|
||||
(<Link to={linkURL}>{linkText}</Link>)
|
||||
</Box>);
|
||||
};
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@ -97,4 +110,4 @@ export class WidgetUtils
|
||||
return (fileName);
|
||||
};
|
||||
|
||||
}
|
||||
}
|
||||
|
@ -41,7 +41,7 @@ export default function NumberIconBadgeBlock({widgetMetaData, data}: StandardBlo
|
||||
{
|
||||
data.values.iconName &&
|
||||
<BlockElementWrapper metaData={widgetMetaData} data={data} slot="icon">
|
||||
<Icon style={{color: data.styles.color, fontSize: "1rem", position: "relative", top: "3px"}}>{data.values.iconName}</Icon>
|
||||
<Icon style={{color: data.styles.color, fontSize: "1rem", marginLeft: "2px", position: "relative", top: "4px"}}>{data.values.iconName}</Icon>
|
||||
</BlockElementWrapper>
|
||||
}
|
||||
</div>);
|
||||
|
265
src/qqq/components/widgets/misc/DynamicFormWidget.tsx
Normal file
265
src/qqq/components/widgets/misc/DynamicFormWidget.tsx
Normal file
@ -0,0 +1,265 @@
|
||||
/*
|
||||
* 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 {QWidgetMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QWidgetMetaData";
|
||||
import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord";
|
||||
import Box from "@mui/material/Box";
|
||||
import {FormikContextType, useFormikContext} from "formik";
|
||||
import QDynamicForm from "qqq/components/forms/DynamicForm";
|
||||
import DynamicFormUtils from "qqq/components/forms/DynamicFormUtils";
|
||||
import Widget from "qqq/components/widgets/Widget";
|
||||
import {renderSectionOfFields} from "qqq/pages/records/view/RecordView";
|
||||
import Client from "qqq/utils/qqq/Client";
|
||||
import React, {useEffect, useState} from "react";
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** component props
|
||||
*******************************************************************************/
|
||||
interface DynamicFormWidgetProps
|
||||
{
|
||||
isEditable: boolean;
|
||||
widgetMetaData: QWidgetMetaData;
|
||||
widgetData: any;
|
||||
record: QRecord;
|
||||
recordValues: { [name: string]: any };
|
||||
onSaveCallback?: (values: { [name: string]: any }) => void;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** default values for props
|
||||
*******************************************************************************/
|
||||
DynamicFormWidget.defaultProps = {
|
||||
onSaveCallback: null
|
||||
};
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Component to display a dynamic form - e.g., on a record edit or view screen,
|
||||
** or even within a process.
|
||||
*******************************************************************************/
|
||||
export default function DynamicFormWidget({isEditable, widgetMetaData, widgetData, record, recordValues, onSaveCallback}: DynamicFormWidgetProps): JSX.Element
|
||||
{
|
||||
const [fields, setFields] = useState([] as QFieldMetaData[]);
|
||||
|
||||
const [effectiveIsEditable, setEffectiveIsEditable] = useState(isEditable);
|
||||
if(widgetMetaData.defaultValues.has("isEditable"))
|
||||
{
|
||||
const defaultIsEditableValue = widgetMetaData.defaultValues.get("isEditable")
|
||||
if(defaultIsEditableValue != effectiveIsEditable)
|
||||
{
|
||||
setEffectiveIsEditable(defaultIsEditableValue);
|
||||
}
|
||||
}
|
||||
|
||||
const [dynamicFormFields, setDynamicFormFields] = useState(null as any);
|
||||
const [formValidations, setFormValidations] = useState(null as any);
|
||||
|
||||
const [lastKnowFormValues, setLastKnowFormValues] = useState({} as {[name: string]: any});
|
||||
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////
|
||||
// on initial load, and any time widgetData changes (e.g., if widget gets re-rendered), //
|
||||
// figure out what our form fields are //
|
||||
//////////////////////////////////////////////////////////////////////////////////////////
|
||||
useEffect(() =>
|
||||
{
|
||||
setDynamicFormFields({})
|
||||
setFormValidations({})
|
||||
|
||||
if(widgetData && widgetData.fieldList)
|
||||
{
|
||||
const newFields: QFieldMetaData[] = [];
|
||||
for (let i = 0; i < widgetData.fieldList.length; i++)
|
||||
{
|
||||
newFields.push(new QFieldMetaData(widgetData.fieldList[i]));
|
||||
}
|
||||
setFields(newFields);
|
||||
|
||||
if(newFields.length > 0)
|
||||
{
|
||||
const recordOfFieldValues = widgetData.recordOfFieldValues ? new QRecord(widgetData.recordOfFieldValues) : null;
|
||||
const {dynamicFormFields: newDynamicFormFields, formValidations: newFormValidations} = DynamicFormUtils.getFormData(newFields);
|
||||
const defaultDisplayValues = new Map<string,string>(); // todo - seems not right?
|
||||
DynamicFormUtils.addPossibleValueProps(newDynamicFormFields, newFields, recordValues.tableName, null, recordOfFieldValues ? recordOfFieldValues.displayValues : defaultDisplayValues);
|
||||
setDynamicFormFields(newDynamicFormFields)
|
||||
setFormValidations(newFormValidations)
|
||||
}
|
||||
|
||||
setLastKnowFormValues({});
|
||||
}
|
||||
else
|
||||
{
|
||||
setFields([])
|
||||
}
|
||||
}, [widgetData]);
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
function checkForFormValueChanges(formikProps: FormikContextType<any>)
|
||||
{
|
||||
if(!fields || !fields.length)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
let anyChanged = false;
|
||||
for (let i = 0; i < fields.length; i++)
|
||||
{
|
||||
const name = fields[i].name;
|
||||
if(formikProps.values[name] != lastKnowFormValues[name])
|
||||
{
|
||||
anyChanged = true;
|
||||
lastKnowFormValues[name] = formikProps.values[name];
|
||||
}
|
||||
}
|
||||
|
||||
if(anyChanged)
|
||||
{
|
||||
const mergedDynamicFormValuesIntoFieldName = widgetData.mergedDynamicFormValuesIntoFieldName;
|
||||
if(mergedDynamicFormValuesIntoFieldName && onSaveCallback)
|
||||
{
|
||||
const onSaveCallbackParam: {[name: string]: any} = {};
|
||||
onSaveCallbackParam[mergedDynamicFormValuesIntoFieldName] = JSON.stringify(lastKnowFormValues);
|
||||
onSaveCallback(onSaveCallbackParam);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
function getInitialValue(fieldName: string)
|
||||
{
|
||||
for (let i = 0; i < fields?.length; i++)
|
||||
{
|
||||
if(fields[i].name == fieldName && fields[i].defaultValue)
|
||||
{
|
||||
return (fields[i].defaultValue)
|
||||
}
|
||||
}
|
||||
|
||||
return (null);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
function renderEditForm()
|
||||
{
|
||||
const formikProps = useFormikContext();
|
||||
if(!fields || !fields.length)
|
||||
{
|
||||
return (
|
||||
<Box>
|
||||
<Box fontSize="1rem">{widgetData && widgetData.noFieldsMessage}</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const formData: any = {};
|
||||
formData.values = formikProps.values;
|
||||
formData.touched = formikProps.touched;
|
||||
formData.errors = formikProps.errors;
|
||||
formData.formFields = {};
|
||||
|
||||
// todo - merge the formValidations object with formik's - maybe in the useEffect where we build it
|
||||
// setValidations(Yup.object().shape(formValidations));
|
||||
// formikProps.validationSchema.
|
||||
|
||||
for (let key of Object.keys(dynamicFormFields))
|
||||
{
|
||||
const dynamicFormField = dynamicFormFields[key];
|
||||
formData.formFields[dynamicFormField.name] = dynamicFormField;
|
||||
|
||||
const initialValue = getInitialValue(dynamicFormField.name);
|
||||
if(initialValue != null)
|
||||
{
|
||||
console.log(`@dk trying to set an initial value [${dynamicFormField.name}] to [${initialValue}]`);
|
||||
// @ts-ignore some any
|
||||
formikProps.initialValues[dynamicFormField.name] = initialValue;
|
||||
}
|
||||
}
|
||||
|
||||
if(formData.values)
|
||||
{
|
||||
checkForFormValueChanges(formikProps);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<QDynamicForm formData={formData} record={record} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
function renderViewForm()
|
||||
{
|
||||
const fieldNames: string[] = [];
|
||||
const fieldMap: {[name: string]: QFieldMetaData} = {};
|
||||
const fakeRecord = new QRecord(widgetData.recordOfFieldValues ?? {});
|
||||
|
||||
const mergedDynamicFormValuesIntoFieldName = widgetData.mergedDynamicFormValuesIntoFieldName;
|
||||
|
||||
for (let i = 0; i < fields?.length; i++)
|
||||
{
|
||||
const fieldName = fields[i].name;
|
||||
fieldNames.push(fieldName);
|
||||
fieldMap[fieldName] = fields[i];
|
||||
|
||||
if(mergedDynamicFormValuesIntoFieldName && recordValues[mergedDynamicFormValuesIntoFieldName])
|
||||
{
|
||||
fakeRecord.values.set(fieldName, recordValues[mergedDynamicFormValuesIntoFieldName][fieldName]);
|
||||
}
|
||||
}
|
||||
|
||||
const section = renderSectionOfFields(`dynamicFormWidget:${widgetMetaData.name}`, fieldNames, null, false, fakeRecord, fieldMap);
|
||||
|
||||
return (<Box>
|
||||
{section}
|
||||
</Box>);
|
||||
}
|
||||
|
||||
|
||||
////////////
|
||||
// render //
|
||||
////////////
|
||||
return (<Widget widgetMetaData={widgetMetaData}>
|
||||
{
|
||||
<React.Fragment>
|
||||
{effectiveIsEditable ? renderEditForm() : renderViewForm()}
|
||||
</React.Fragment>
|
||||
}
|
||||
</Widget>);
|
||||
}
|
||||
|
@ -22,6 +22,8 @@
|
||||
|
||||
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
|
||||
import {QWidgetMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QWidgetMetaData";
|
||||
import {QCriteriaOperator} from "@kingsrook/qqq-frontend-core/lib/model/query/QCriteriaOperator";
|
||||
import {QFilterCriteria} from "@kingsrook/qqq-frontend-core/lib/model/query/QFilterCriteria";
|
||||
import {QQueryFilter} from "@kingsrook/qqq-frontend-core/lib/model/query/QQueryFilter";
|
||||
import {Alert, Collapse} from "@mui/material";
|
||||
import Box from "@mui/material/Box";
|
||||
@ -42,15 +44,16 @@ import Client from "qqq/utils/qqq/Client";
|
||||
import FilterUtils from "qqq/utils/qqq/FilterUtils";
|
||||
import React, {useContext, useEffect, useRef, useState} from "react";
|
||||
|
||||
interface ReportSetupWidgetProps
|
||||
interface FilterAndColumnsSetupWidgetProps
|
||||
{
|
||||
isEditable: boolean;
|
||||
widgetMetaData: QWidgetMetaData;
|
||||
recordValues: {[name: string]: any};
|
||||
onSaveCallback?: (values: {[name: string]: any}) => void;
|
||||
widgetData: any;
|
||||
recordValues: { [name: string]: any };
|
||||
onSaveCallback?: (values: { [name: string]: any }) => void;
|
||||
}
|
||||
|
||||
ReportSetupWidget.defaultProps = {
|
||||
FilterAndColumnsSetupWidget.defaultProps = {
|
||||
onSaveCallback: null
|
||||
};
|
||||
|
||||
@ -80,9 +83,10 @@ const qController = Client.getInstance();
|
||||
/*******************************************************************************
|
||||
** Component for editing the main setup of a report - that is: filter & columns
|
||||
*******************************************************************************/
|
||||
export default function ReportSetupWidget({isEditable, widgetMetaData, recordValues, onSaveCallback}: ReportSetupWidgetProps): JSX.Element
|
||||
export default function FilterAndColumnsSetupWidget({isEditable, widgetMetaData, widgetData, recordValues, onSaveCallback}: FilterAndColumnsSetupWidgetProps): JSX.Element
|
||||
{
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [hideColumns, setHideColumns] = useState(widgetData?.hideColumns);
|
||||
const [tableMetaData, setTableMetaData] = useState(null as QTableMetaData);
|
||||
|
||||
const [alertContent, setAlertContent] = useState(null as string);
|
||||
@ -101,16 +105,43 @@ export default function ReportSetupWidget({isEditable, widgetMetaData, recordVal
|
||||
/////////////////////////////
|
||||
// load values from record //
|
||||
/////////////////////////////
|
||||
let queryFilter = recordValues["queryFilterJson"] && JSON.parse(recordValues["queryFilterJson"]) as QQueryFilter;
|
||||
let columns: QQueryColumns = null;
|
||||
let usingDefaultEmptyFilter = false;
|
||||
if(!queryFilter)
|
||||
let queryFilter = recordValues["queryFilterJson"] && JSON.parse(recordValues["queryFilterJson"]) as QQueryFilter;
|
||||
const defaultFilterFields = widgetData?.filterDefaultFieldNames;
|
||||
if (!queryFilter)
|
||||
{
|
||||
queryFilter = new QQueryFilter();
|
||||
usingDefaultEmptyFilter = true;
|
||||
if (defaultFilterFields?.length == 0)
|
||||
{
|
||||
usingDefaultEmptyFilter = true;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
queryFilter = Object.assign(new QQueryFilter(), queryFilter);
|
||||
}
|
||||
|
||||
let columns: QQueryColumns = null;
|
||||
if(recordValues["columnsJson"])
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// if there are default fields from which a query should be seeded, add/update the filter with them //
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
if (defaultFilterFields?.length > 0)
|
||||
{
|
||||
defaultFilterFields.forEach((fieldName: string) =>
|
||||
{
|
||||
////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// if a value for the default field exists, remove the criteria for it in our query first //
|
||||
////////////////////////////////////////////////////////////////////////////////////////////
|
||||
queryFilter.criteria = queryFilter.criteria?.filter(c => c.fieldName != fieldName);
|
||||
|
||||
if (recordValues[fieldName])
|
||||
{
|
||||
queryFilter.addCriteria(new QFilterCriteria(fieldName, QCriteriaOperator.EQUALS, [recordValues[fieldName]]));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (recordValues["columnsJson"])
|
||||
{
|
||||
columns = QQueryColumns.buildFromJSON(recordValues["columnsJson"]);
|
||||
}
|
||||
@ -120,19 +151,28 @@ export default function ReportSetupWidget({isEditable, widgetMetaData, recordVal
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
useEffect(() =>
|
||||
{
|
||||
if (recordValues["tableName"] && (tableMetaData == null || tableMetaData.name != recordValues["tableName"]))
|
||||
////////////////////////////////////////////////////////////////////////////////////////
|
||||
// if a default table name specified, use it, otherwise use it from the record values //
|
||||
////////////////////////////////////////////////////////////////////////////////////////
|
||||
let tableName = widgetData?.tableName;
|
||||
if (!tableName && recordValues["tableName"] && (tableMetaData == null || tableMetaData.name != recordValues["tableName"]))
|
||||
{
|
||||
tableName = recordValues["tableName"];
|
||||
}
|
||||
|
||||
if (tableName)
|
||||
{
|
||||
(async () =>
|
||||
{
|
||||
const tableMetaData = await qController.loadTableMetaData(recordValues["tableName"])
|
||||
const tableMetaData = await qController.loadTableMetaData(tableName);
|
||||
setTableMetaData(tableMetaData);
|
||||
|
||||
const queryFilterForFrontend = Object.assign({}, queryFilter);
|
||||
await FilterUtils.cleanupValuesInFilerFromQueryString(qController, tableMetaData, queryFilterForFrontend)
|
||||
setFrontendQueryFilter(queryFilterForFrontend)
|
||||
await FilterUtils.cleanupValuesInFilerFromQueryString(qController, tableMetaData, queryFilterForFrontend);
|
||||
setFrontendQueryFilter(queryFilterForFrontend);
|
||||
})();
|
||||
}
|
||||
}, [recordValues]);
|
||||
}, [JSON.stringify(recordValues)]);
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@ -140,8 +180,27 @@ export default function ReportSetupWidget({isEditable, widgetMetaData, recordVal
|
||||
*******************************************************************************/
|
||||
function openEditor()
|
||||
{
|
||||
if(recordValues["tableName"])
|
||||
let missingRequiredFields = [] as string[];
|
||||
widgetData?.filterDefaultFieldNames?.forEach((fieldName: string) =>
|
||||
{
|
||||
if (!recordValues[fieldName])
|
||||
{
|
||||
missingRequiredFields.push(tableMetaData.fields.get(fieldName).label);
|
||||
}
|
||||
});
|
||||
|
||||
////////////////////////////////////////////////////////////////////
|
||||
// display an alert and return if any required fields are missing //
|
||||
////////////////////////////////////////////////////////////////////
|
||||
if (missingRequiredFields.length > 0)
|
||||
{
|
||||
setAlertContent("The following fields must first be selected to edit the filter: '" + missingRequiredFields.join(", ") + "'");
|
||||
return;
|
||||
}
|
||||
|
||||
if (recordValues["tableName"])
|
||||
{
|
||||
setAlertContent(null);
|
||||
setModalOpen(true);
|
||||
}
|
||||
}
|
||||
@ -152,7 +211,7 @@ export default function ReportSetupWidget({isEditable, widgetMetaData, recordVal
|
||||
*******************************************************************************/
|
||||
function saveClicked()
|
||||
{
|
||||
if(!onSaveCallback)
|
||||
if (!onSaveCallback)
|
||||
{
|
||||
console.log("onSaveCallback was not defined");
|
||||
return;
|
||||
@ -181,7 +240,7 @@ export default function ReportSetupWidget({isEditable, widgetMetaData, recordVal
|
||||
*******************************************************************************/
|
||||
function closeEditor(event?: {}, reason?: "backdropClick" | "escapeKeyDown")
|
||||
{
|
||||
if(reason == "backdropClick" || reason == "escapeKeyDown")
|
||||
if (reason == "backdropClick" || reason == "escapeKeyDown")
|
||||
{
|
||||
return;
|
||||
}
|
||||
@ -195,9 +254,9 @@ export default function ReportSetupWidget({isEditable, widgetMetaData, recordVal
|
||||
*******************************************************************************/
|
||||
function renderColumn(column: Column): JSX.Element
|
||||
{
|
||||
const [field, table] = FilterUtils.getField(tableMetaData, column.name)
|
||||
const [field, table] = FilterUtils.getField(tableMetaData, column.name);
|
||||
|
||||
if(!column || !column.isVisible || column.name == "__check__" || !field)
|
||||
if (!column || !column.isVisible || column.name == "__check__" || !field)
|
||||
{
|
||||
return (<React.Fragment />);
|
||||
}
|
||||
@ -215,9 +274,9 @@ export default function ReportSetupWidget({isEditable, widgetMetaData, recordVal
|
||||
*******************************************************************************/
|
||||
function mayShowQueryPreview(): boolean
|
||||
{
|
||||
if(tableMetaData)
|
||||
if (tableMetaData)
|
||||
{
|
||||
if(frontendQueryFilter?.criteria?.length > 0 || frontendQueryFilter?.subFilters?.length > 0)
|
||||
if (frontendQueryFilter?.criteria?.length > 0 || frontendQueryFilter?.subFilters?.length > 0)
|
||||
{
|
||||
return (true);
|
||||
}
|
||||
@ -231,11 +290,11 @@ export default function ReportSetupWidget({isEditable, widgetMetaData, recordVal
|
||||
*******************************************************************************/
|
||||
function mayShowColumnsPreview(): boolean
|
||||
{
|
||||
if(tableMetaData)
|
||||
if (tableMetaData)
|
||||
{
|
||||
for(let i = 0; i<columns?.columns?.length; i++)
|
||||
for (let i = 0; i < columns?.columns?.length; i++)
|
||||
{
|
||||
if(columns.columns[i].isVisible && columns.columns[i].name != "__check__")
|
||||
if (columns.columns[i].isVisible && columns.columns[i].name != "__check__")
|
||||
{
|
||||
return (true);
|
||||
}
|
||||
@ -269,10 +328,17 @@ export default function ReportSetupWidget({isEditable, widgetMetaData, recordVal
|
||||
// add link to widget header for opening modal //
|
||||
/////////////////////////////////////////////////
|
||||
const selectTableFirstTooltipTitle = tableMetaData ? null : "You must select a table before you can set up your report filters and columns";
|
||||
const labelAdditionalElementsRight: JSX.Element[] = []
|
||||
if(isEditable)
|
||||
const labelAdditionalElementsRight: JSX.Element[] = [];
|
||||
if (isEditable)
|
||||
{
|
||||
labelAdditionalElementsRight.push(<HeaderLinkButtonComponent label="Edit Filters and Columns" onClickCallback={openEditor} disabled={tableMetaData == null} disabledTooltip={selectTableFirstTooltipTitle} />)
|
||||
if (!hideColumns)
|
||||
{
|
||||
labelAdditionalElementsRight.push(<HeaderLinkButtonComponent key="filterAndColumnsHeader" label="Edit Filters and Columns" onClickCallback={openEditor} disabled={tableMetaData == null} disabledTooltip={selectTableFirstTooltipTitle} />);
|
||||
}
|
||||
else
|
||||
{
|
||||
labelAdditionalElementsRight.push(<HeaderLinkButtonComponent key="filterAndColumnsHeader" label="Edit Filters" onClickCallback={openEditor} disabled={tableMetaData == null} disabledTooltip={selectTableFirstTooltipTitle} />);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -306,34 +372,36 @@ export default function ReportSetupWidget({isEditable, widgetMetaData, recordVal
|
||||
</Tooltip>
|
||||
}
|
||||
{
|
||||
!isEditable && <Box color={colors.gray.main}>Your report has no filters.</Box>
|
||||
!isEditable && <Box color={colors.gray.main}>No filters are configured.</Box>
|
||||
}
|
||||
</Box>
|
||||
}
|
||||
</Box>
|
||||
<Box pt="1rem">
|
||||
<h5>Columns</h5>
|
||||
<Box display="flex" flexWrap="wrap" fontSize="1rem">
|
||||
{
|
||||
mayShowColumnsPreview() &&
|
||||
columns.columns.map((column, i) => <React.Fragment key={i}>{renderColumn(column)}</React.Fragment>)
|
||||
}
|
||||
{
|
||||
!mayShowColumnsPreview() &&
|
||||
<Box width="100%" sx={{fontSize: "1rem", background: "#FFFFFF"}} minHeight={"2.375rem"} p={"0.5rem"} pb={"0.125rem"}>
|
||||
{
|
||||
isEditable &&
|
||||
<Tooltip title={selectTableFirstTooltipTitle}>
|
||||
<span><Button disabled={!recordValues["tableName"]} sx={unborderedButtonSX} onClick={openEditor}>+ Add Columns</Button></span>
|
||||
</Tooltip>
|
||||
}
|
||||
{
|
||||
!isEditable && <Box color={colors.gray.main}>Your report has no columns.</Box>
|
||||
}
|
||||
</Box>
|
||||
}
|
||||
{!hideColumns && (
|
||||
<Box pt="1rem">
|
||||
<h5>Columns</h5>
|
||||
<Box display="flex" flexWrap="wrap" fontSize="1rem">
|
||||
{
|
||||
mayShowColumnsPreview() &&
|
||||
columns.columns.map((column, i) => <React.Fragment key={`column-${i}`}>{renderColumn(column)}</React.Fragment>)
|
||||
}
|
||||
{
|
||||
!mayShowColumnsPreview() &&
|
||||
<Box width="100%" sx={{fontSize: "1rem", background: "#FFFFFF"}} minHeight={"2.375rem"} p={"0.5rem"} pb={"0.125rem"}>
|
||||
{
|
||||
isEditable &&
|
||||
<Tooltip title={selectTableFirstTooltipTitle}>
|
||||
<span><Button disabled={!recordValues["tableName"]} sx={unborderedButtonSX} onClick={openEditor}>+ Add Columns</Button></span>
|
||||
</Tooltip>
|
||||
}
|
||||
{
|
||||
!isEditable && <Box color={colors.gray.main}>No columns are selected.</Box>
|
||||
}
|
||||
</Box>
|
||||
}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
{
|
||||
modalOpen &&
|
||||
<Modal open={modalOpen} onClose={(event, reason) => closeEditor(event, reason)}>
|
||||
@ -349,6 +417,7 @@ export default function ReportSetupWidget({isEditable, widgetMetaData, recordVal
|
||||
}
|
||||
{
|
||||
tableMetaData && <RecordQuery
|
||||
allowVariables={widgetData?.allowVariables}
|
||||
ref={recordQueryRef}
|
||||
table={tableMetaData}
|
||||
usage="reportSetup"
|
@ -39,9 +39,9 @@ import colors from "qqq/assets/theme/base/colors";
|
||||
import {QCancelButton, QSaveButton} from "qqq/components/buttons/DefaultButtons";
|
||||
import FieldAutoComplete from "qqq/components/misc/FieldAutoComplete";
|
||||
import HelpContent, {hasHelpContent} from "qqq/components/misc/HelpContent";
|
||||
import {buttonSX, unborderedButtonSX} from "qqq/components/widgets/misc/FilterAndColumnsSetupWidget";
|
||||
import {PivotTableGroupByElement} from "qqq/components/widgets/misc/PivotTableGroupByElement";
|
||||
import {PivotTableValueElement} from "qqq/components/widgets/misc/PivotTableValueElement";
|
||||
import {buttonSX, unborderedButtonSX} from "qqq/components/widgets/misc/ReportSetupWidget";
|
||||
import Widget, {HeaderToggleComponent} from "qqq/components/widgets/Widget";
|
||||
import {PivotObjectKey, PivotTableDefinition, PivotTableFunction, pivotTableFunctionLabels, PivotTableGroupBy, PivotTableValue} from "qqq/models/misc/PivotTableDefinitionModels";
|
||||
import QQueryColumns from "qqq/models/query/QQueryColumns";
|
||||
@ -280,7 +280,7 @@ export default function PivotTableSetupWidget({isEditable, widgetMetaData, recor
|
||||
}
|
||||
|
||||
modalPivotTableDefinition[rowsOrColumns].push(new PivotTableGroupBy());
|
||||
validateForm()
|
||||
validateForm();
|
||||
forceUpdate();
|
||||
}
|
||||
|
||||
@ -292,7 +292,7 @@ export default function PivotTableSetupWidget({isEditable, widgetMetaData, recor
|
||||
{
|
||||
updateUsedGroupByFieldNames(modalPivotTableDefinition);
|
||||
updateUsedValueFieldNames(modalPivotTableDefinition);
|
||||
validateForm()
|
||||
validateForm();
|
||||
forceUpdate();
|
||||
}
|
||||
|
||||
@ -308,7 +308,7 @@ export default function PivotTableSetupWidget({isEditable, widgetMetaData, recor
|
||||
}
|
||||
|
||||
modalPivotTableDefinition.values.push(new PivotTableValue());
|
||||
validateForm()
|
||||
validateForm();
|
||||
forceUpdate();
|
||||
}
|
||||
|
||||
@ -319,7 +319,7 @@ export default function PivotTableSetupWidget({isEditable, widgetMetaData, recor
|
||||
function removeValue(index: number)
|
||||
{
|
||||
modalPivotTableDefinition.values.splice(index, 1);
|
||||
validateForm()
|
||||
validateForm();
|
||||
forceUpdate();
|
||||
}
|
||||
|
||||
@ -503,7 +503,7 @@ export default function PivotTableSetupWidget({isEditable, widgetMetaData, recor
|
||||
const labelAdditionalElementsRight: JSX.Element[] = [];
|
||||
if (isEditable)
|
||||
{
|
||||
labelAdditionalElementsRight.push(<HeaderToggleComponent disabled={editPopupDisabled} disabledTooltip={selectTableFirstTooltipTitle ?? selectColumnsFirstTooltipTitle} label="Use Pivot Table?" getValue={() => enabled} onClickCallback={toggleEnabled} />);
|
||||
labelAdditionalElementsRight.push(<HeaderToggleComponent key="pivotTableHeader" disabled={editPopupDisabled} disabledTooltip={selectTableFirstTooltipTitle ?? selectColumnsFirstTooltipTitle} label="Use Pivot Table?" getValue={() => enabled} onClickCallback={toggleEnabled} />);
|
||||
}
|
||||
|
||||
|
||||
@ -659,7 +659,7 @@ export default function PivotTableSetupWidget({isEditable, widgetMetaData, recor
|
||||
// if this isn't a call from the on-submit handler, and we haven't previously attempted a submit, then return w/o setting any alerts //
|
||||
// this is like a version of considering "touched"... //
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
if(!submitting && !attemptedSubmit)
|
||||
if (!submitting && !attemptedSubmit)
|
||||
{
|
||||
return;
|
||||
}
|
||||
@ -703,7 +703,7 @@ export default function PivotTableSetupWidget({isEditable, widgetMetaData, recor
|
||||
// now they've fixed 'em - so go back to a 'clean' state - so if they add more //
|
||||
// boxes, they won't immediately show errors, until a re-submit //
|
||||
////////////////////////////////////////////////////////////////////////////////////
|
||||
if(attemptedSubmit)
|
||||
if (attemptedSubmit)
|
||||
{
|
||||
setAttemptedSubmit(false);
|
||||
}
|
||||
|
@ -40,14 +40,14 @@ import {Link, useNavigate} from "react-router-dom";
|
||||
export interface ChildRecordListData extends WidgetData
|
||||
{
|
||||
title: string;
|
||||
queryOutput: {records: {values: any}[]}
|
||||
queryOutput: { records: { values: any }[] };
|
||||
childTableMetaData: QTableMetaData;
|
||||
tablePath: string;
|
||||
viewAllLink: string;
|
||||
totalRows: number;
|
||||
canAddChildRecord: boolean;
|
||||
defaultValuesForNewChildRecords: {[fieldName: string]: any};
|
||||
disabledFieldsForNewChildRecords: {[fieldName: string]: any};
|
||||
defaultValuesForNewChildRecords: { [fieldName: string]: any };
|
||||
disabledFieldsForNewChildRecords: { [fieldName: string]: any };
|
||||
}
|
||||
|
||||
interface Props
|
||||
@ -75,9 +75,9 @@ function RecordGridWidget({widgetMetaData, data, addNewRecordCallback, disableRo
|
||||
{
|
||||
const instance = useRef({timer: null});
|
||||
const [rows, setRows] = useState([]);
|
||||
const [records, setRecords] = useState([] as QRecord[])
|
||||
const [records, setRecords] = useState([] as QRecord[]);
|
||||
const [columns, setColumns] = useState([]);
|
||||
const [allColumns, setAllColumns] = useState([])
|
||||
const [allColumns, setAllColumns] = useState([]);
|
||||
const [csv, setCsv] = useState(null as string);
|
||||
const [fileName, setFileName] = useState(null as string);
|
||||
const [gridMouseDownX, setGridMouseDownX] = useState(0);
|
||||
@ -110,20 +110,20 @@ function RecordGridWidget({widgetMetaData, data, addNewRecordCallback, disableRo
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// capture all-columns to use for the export (before we might splice some away from the on-screen display) //
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
const allColumns = [... columns];
|
||||
const allColumns = [...columns];
|
||||
setAllColumns(JSON.parse(JSON.stringify(columns)));
|
||||
|
||||
////////////////////////////////////////////////////////////////
|
||||
// do not not show the foreign-key column of the parent table //
|
||||
////////////////////////////////////////////////////////////////
|
||||
if(data.defaultValuesForNewChildRecords)
|
||||
if (data.defaultValuesForNewChildRecords)
|
||||
{
|
||||
for (let i = 0; i < columns.length; i++)
|
||||
{
|
||||
if(data.defaultValuesForNewChildRecords[columns[i].field])
|
||||
if (data.defaultValuesForNewChildRecords[columns[i].field])
|
||||
{
|
||||
columns.splice(i, 1);
|
||||
i--
|
||||
i--;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -131,7 +131,7 @@ function RecordGridWidget({widgetMetaData, data, addNewRecordCallback, disableRo
|
||||
////////////////////////////////////
|
||||
// add actions cell, if available //
|
||||
////////////////////////////////////
|
||||
if(allowRecordEdit || allowRecordDelete)
|
||||
if (allowRecordEdit || allowRecordDelete)
|
||||
{
|
||||
columns.unshift({
|
||||
field: "_actions",
|
||||
@ -145,19 +145,19 @@ function RecordGridWidget({widgetMetaData, data, addNewRecordCallback, disableRo
|
||||
return <Box>
|
||||
{allowRecordEdit && <IconButton onClick={() => editRecordCallback(params.row.__rowIndex)}><Icon>edit</Icon></IconButton>}
|
||||
{allowRecordDelete && <IconButton onClick={() => deleteRecordCallback(params.row.__rowIndex)}><Icon>delete</Icon></IconButton>}
|
||||
</Box>
|
||||
</Box>;
|
||||
})
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
setRows(rows);
|
||||
setRecords(records)
|
||||
setRecords(records);
|
||||
setColumns(columns);
|
||||
|
||||
let csv = "";
|
||||
for (let i = 0; i < allColumns.length; i++)
|
||||
{
|
||||
csv += `${i > 0 ? "," : ""}"${ValueUtils.cleanForCsv(allColumns[i].headerName)}"`
|
||||
csv += `${i > 0 ? "," : ""}"${ValueUtils.cleanForCsv(allColumns[i].headerName)}"`;
|
||||
}
|
||||
csv += "\n";
|
||||
|
||||
@ -165,8 +165,8 @@ function RecordGridWidget({widgetMetaData, data, addNewRecordCallback, disableRo
|
||||
{
|
||||
for (let j = 0; j < allColumns.length; j++)
|
||||
{
|
||||
const value = records[i].displayValues.get(allColumns[j].field) ?? records[i].values.get(allColumns[j].field)
|
||||
csv += `${j > 0 ? "," : ""}"${ValueUtils.cleanForCsv(value)}"`
|
||||
const value = records[i].displayValues.get(allColumns[j].field) ?? records[i].values.get(allColumns[j].field);
|
||||
csv += `${j > 0 ? "," : ""}"${ValueUtils.cleanForCsv(value)}"`;
|
||||
}
|
||||
csv += "\n";
|
||||
}
|
||||
@ -182,13 +182,13 @@ function RecordGridWidget({widgetMetaData, data, addNewRecordCallback, disableRo
|
||||
// view all link //
|
||||
///////////////////
|
||||
const labelAdditionalElementsLeft: JSX.Element[] = [];
|
||||
if(data && data.viewAllLink)
|
||||
if (data && data.viewAllLink)
|
||||
{
|
||||
labelAdditionalElementsLeft.push(
|
||||
<Typography variant="body2" p={2} display="inline" fontSize=".875rem" pt="0" position="relative">
|
||||
<Typography key={"viewAllLink"} variant="body2" p={2} display="inline" fontSize=".875rem" pt="0" position="relative">
|
||||
<Link to={data.viewAllLink}>View All</Link>
|
||||
</Typography>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
///////////////////
|
||||
@ -200,10 +200,10 @@ function RecordGridWidget({widgetMetaData, data, addNewRecordCallback, disableRo
|
||||
{
|
||||
isExportDisabled = false;
|
||||
|
||||
if(data.totalRows && data.queryOutput.records.length < data.totalRows)
|
||||
if (data.totalRows && data.queryOutput.records.length < data.totalRows)
|
||||
{
|
||||
tooltipTitle = "Export these " + data.queryOutput.records.length + " records."
|
||||
if(data.viewAllLink)
|
||||
tooltipTitle = "Export these " + data.queryOutput.records.length + " records.";
|
||||
if (data.viewAllLink)
|
||||
{
|
||||
tooltipTitle += "\nClick View All to export all records.";
|
||||
}
|
||||
@ -212,21 +212,21 @@ function RecordGridWidget({widgetMetaData, data, addNewRecordCallback, disableRo
|
||||
|
||||
const onExportClick = () =>
|
||||
{
|
||||
if(csv)
|
||||
if (csv)
|
||||
{
|
||||
HtmlUtils.download(fileName, csv);
|
||||
}
|
||||
else
|
||||
{
|
||||
alert("There is no data available to export.")
|
||||
alert("There is no data available to export.");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if(widgetMetaData?.showExportButton)
|
||||
if (widgetMetaData?.showExportButton)
|
||||
{
|
||||
labelAdditionalElementsLeft.push(
|
||||
<Typography key={1} variant="body2" px={0} display="inline" position="relative">
|
||||
<Tooltip title={tooltipTitle}><Button sx={{px: 1, py: 0, minWidth: "initial"}} onClick={onExportClick} disabled={isExportDisabled}><Icon sx={{color: "#757575", fontSize: 1.25}}>save_alt</Icon></Button></Tooltip>
|
||||
<Typography key={"exportButton"} variant="body2" px={0} display="inline" position="relative">
|
||||
<Tooltip title={tooltipTitle}><span><Button sx={{px: 1, py: 0, minWidth: "initial"}} onClick={onExportClick} disabled={isExportDisabled}><Icon sx={{color: "#757575", fontSize: 1.25}}>save_alt</Icon></Button></span></Tooltip>
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
@ -234,15 +234,15 @@ function RecordGridWidget({widgetMetaData, data, addNewRecordCallback, disableRo
|
||||
////////////////////
|
||||
// add new button //
|
||||
////////////////////
|
||||
const labelAdditionalComponentsRight: LabelComponent[] = []
|
||||
if(data && data.canAddChildRecord)
|
||||
const labelAdditionalComponentsRight: LabelComponent[] = [];
|
||||
if (data && data.canAddChildRecord)
|
||||
{
|
||||
let disabledFields = data.disabledFieldsForNewChildRecords;
|
||||
if(!disabledFields)
|
||||
if (!disabledFields)
|
||||
{
|
||||
disabledFields = data.defaultValuesForNewChildRecords;
|
||||
}
|
||||
labelAdditionalComponentsRight.push(new AddNewRecordButton(data.childTableMetaData, data.defaultValuesForNewChildRecords, "Add new", disabledFields, addNewRecordCallback))
|
||||
labelAdditionalComponentsRight.push(new AddNewRecordButton(data.childTableMetaData, data.defaultValuesForNewChildRecords, "Add new", disabledFields, addNewRecordCallback));
|
||||
}
|
||||
|
||||
|
||||
@ -251,16 +251,16 @@ function RecordGridWidget({widgetMetaData, data, addNewRecordCallback, disableRo
|
||||
/////////////////////////////////////////////////////////////////
|
||||
const handleRowClick = (params: GridRowParams, event: MuiEvent<React.MouseEvent>, details: GridCallbackDetails) =>
|
||||
{
|
||||
if(disableRowClick)
|
||||
if (disableRowClick)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
(async () =>
|
||||
{
|
||||
const qInstance = await qController.loadMetaData()
|
||||
let tablePath = qInstance.getTablePathByName(data.childTableMetaData.name)
|
||||
if(tablePath)
|
||||
const qInstance = await qController.loadMetaData();
|
||||
let tablePath = qInstance.getTablePathByName(data.childTableMetaData.name);
|
||||
if (tablePath)
|
||||
{
|
||||
tablePath = `${tablePath}/${params.row[data.childTableMetaData.primaryKeyField]}`;
|
||||
DataGridUtils.handleRowClick(tablePath, event, gridMouseDownX, gridMouseDownY, navigate, instance);
|
||||
@ -276,7 +276,7 @@ function RecordGridWidget({widgetMetaData, data, addNewRecordCallback, disableRo
|
||||
*******************************************************************************/
|
||||
function CustomToolbar()
|
||||
{
|
||||
const handleMouseDown: GridEventListener<"cellMouseDown"> = ( params, event, details ) =>
|
||||
const handleMouseDown: GridEventListener<"cellMouseDown"> = (params, event, details) =>
|
||||
{
|
||||
setGridMouseDownX(event.clientX);
|
||||
setGridMouseDownY(event.clientY);
|
||||
@ -304,49 +304,51 @@ function RecordGridWidget({widgetMetaData, data, addNewRecordCallback, disableRo
|
||||
labelAdditionalComponentsRight={labelAdditionalComponentsRight}
|
||||
labelBoxAdditionalSx={{position: "relative", top: "-0.375rem"}}
|
||||
>
|
||||
<Box mx={-2} mb={-3}>
|
||||
<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 mx={-3} mb={-3}>
|
||||
<Box>
|
||||
<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>
|
||||
);
|
||||
|
383
src/qqq/components/widgets/misc/WorkflowViewer.tsx
Normal file
383
src/qqq/components/widgets/misc/WorkflowViewer.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -30,8 +30,6 @@ 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 React, {useEffect, useMemo, useState} from "react";
|
||||
import {useAsyncDebounce, useExpanded, useGlobalFilter, usePagination, useSortBy, useTable} from "react-table";
|
||||
import colors from "qqq/assets/theme/base/colors";
|
||||
import MDInput from "qqq/components/legacy/MDInput";
|
||||
import MDPagination from "qqq/components/legacy/MDPagination";
|
||||
@ -43,6 +41,8 @@ import DefaultCell from "qqq/components/widgets/tables/cells/DefaultCell";
|
||||
import ImageCell from "qqq/components/widgets/tables/cells/ImageCell";
|
||||
import {TableDataInput} from "qqq/components/widgets/tables/TableCard";
|
||||
import WidgetBlock from "qqq/components/widgets/WidgetBlock";
|
||||
import React, {useEffect, useMemo, useState} from "react";
|
||||
import {useAsyncDebounce, useExpanded, useGlobalFilter, usePagination, useSortBy, useTable} from "react-table";
|
||||
|
||||
interface Props
|
||||
{
|
||||
@ -106,17 +106,17 @@ function DataTable({
|
||||
entries = entriesPerPageOptions ? entriesPerPageOptions : ["10", "25", "50", "100"];
|
||||
|
||||
let widths = [];
|
||||
for(let i = 0; i<table.columns.length; i++)
|
||||
for (let i = 0; i < table.columns.length; i++)
|
||||
{
|
||||
const column = table.columns[i];
|
||||
if(column.type !== "hidden")
|
||||
if (column.type !== "hidden")
|
||||
{
|
||||
widths.push(table.columns[i].width ?? "1fr");
|
||||
}
|
||||
}
|
||||
|
||||
let showExpandColumn = false;
|
||||
if(table.rows)
|
||||
if (table.rows)
|
||||
{
|
||||
for (let i = 0; i < table.rows.length; i++)
|
||||
{
|
||||
@ -129,7 +129,7 @@ function DataTable({
|
||||
}
|
||||
|
||||
const columnsToMemo = [...table.columns];
|
||||
if(showExpandColumn)
|
||||
if (showExpandColumn)
|
||||
{
|
||||
widths.push("60px");
|
||||
columnsToMemo.push(
|
||||
@ -173,11 +173,11 @@ function DataTable({
|
||||
);
|
||||
}
|
||||
|
||||
if(table.columnHeaderTooltips)
|
||||
if (table.columnHeaderTooltips)
|
||||
{
|
||||
for (let column of columnsToMemo)
|
||||
{
|
||||
if(table.columnHeaderTooltips[column.accessor])
|
||||
if (table.columnHeaderTooltips[column.accessor])
|
||||
{
|
||||
column.tooltip = table.columnHeaderTooltips[column.accessor];
|
||||
}
|
||||
@ -297,7 +297,7 @@ function DataTable({
|
||||
}
|
||||
|
||||
let visibleFooterRows = 1;
|
||||
if(expanded && expanded[`${table.rows.length-1}`])
|
||||
if (expanded && expanded[`${table.rows.length - 1}`])
|
||||
{
|
||||
//////////////////////////////////////////////////
|
||||
// todo - should count how many are expanded... //
|
||||
@ -308,7 +308,7 @@ function DataTable({
|
||||
function getTable(includeHead: boolean, rows: any, isFooter: boolean)
|
||||
{
|
||||
let boxStyle = {};
|
||||
if(fixedStickyLastRow)
|
||||
if (fixedStickyLastRow)
|
||||
{
|
||||
boxStyle = isFooter
|
||||
? {borderTop: `0.0625rem solid ${colors.grayLines.main};`, backgroundColor: "#EEEEEE"}
|
||||
@ -316,7 +316,7 @@ function DataTable({
|
||||
}
|
||||
|
||||
let innerBoxStyle = {};
|
||||
if(fixedStickyLastRow && isFooter)
|
||||
if (fixedStickyLastRow && isFooter)
|
||||
{
|
||||
innerBoxStyle = {overflowY: "auto", scrollbarGutter: "stable"};
|
||||
}
|
||||
@ -327,7 +327,7 @@ function DataTable({
|
||||
includeHead && (
|
||||
<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", gridTemplateColumns: gridTemplateColumns}}>
|
||||
<TableRow key={i} {...headerGroup.getHeaderGroupProps()} sx={{display: "grid", alignItems: "flex-end", gridTemplateColumns: gridTemplateColumns}}>
|
||||
{headerGroup.headers.map((column: any) => (
|
||||
column.type !== "hidden" && (
|
||||
<DataTableHeadCell
|
||||
@ -356,10 +356,10 @@ function DataTable({
|
||||
//////////////////////////////////////////////////////////////////////////////////
|
||||
// don't do an end-border on nested rows - unless they're the last one in a set //
|
||||
//////////////////////////////////////////////////////////////////////////////////
|
||||
if(row.depth > 0)
|
||||
if (row.depth > 0)
|
||||
{
|
||||
overrideNoEndBorder = true;
|
||||
if(key + 1 < rows.length && rows[key + 1].depth == 0)
|
||||
if (key + 1 < rows.length && rows[key + 1].depth == 0)
|
||||
{
|
||||
overrideNoEndBorder = false;
|
||||
}
|
||||
@ -368,17 +368,17 @@ function DataTable({
|
||||
///////////////////////////////////////
|
||||
// don't do end-border on the footer //
|
||||
///////////////////////////////////////
|
||||
if(isFooter)
|
||||
if (isFooter)
|
||||
{
|
||||
overrideNoEndBorder = true;
|
||||
}
|
||||
|
||||
let background = "initial";
|
||||
if(isFooter)
|
||||
if (isFooter)
|
||||
{
|
||||
background = "#EEEEEE";
|
||||
}
|
||||
else if(row.depth > 0 || row.isExpanded)
|
||||
else if (row.depth > 0 || row.isExpanded)
|
||||
{
|
||||
background = "#FAFAFA";
|
||||
}
|
||||
@ -453,7 +453,7 @@ function DataTable({
|
||||
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Box></Box>
|
||||
</Box></Box>;
|
||||
}
|
||||
|
||||
return (
|
||||
|
@ -28,13 +28,13 @@ import TableBody from "@mui/material/TableBody";
|
||||
import TableContainer from "@mui/material/TableContainer";
|
||||
import TableRow from "@mui/material/TableRow";
|
||||
import parse from "html-react-parser";
|
||||
import React, {useEffect, useState} from "react";
|
||||
import MDTypography from "qqq/components/legacy/MDTypography";
|
||||
import DataTableBodyCell from "qqq/components/widgets/tables/cells/DataTableBodyCell";
|
||||
import DataTableHeadCell from "qqq/components/widgets/tables/cells/DataTableHeadCell";
|
||||
import DefaultCell from "qqq/components/widgets/tables/cells/DefaultCell";
|
||||
import DataTable from "qqq/components/widgets/tables/DataTable";
|
||||
import Client from "qqq/utils/qqq/Client";
|
||||
import React, {useEffect, useState} from "react";
|
||||
|
||||
|
||||
//////////////////////////////////////
|
||||
@ -43,7 +43,7 @@ import Client from "qqq/utils/qqq/Client";
|
||||
export interface TableDataInput
|
||||
{
|
||||
columns: { [key: string]: any }[];
|
||||
columnHeaderTooltips?: { [columnName: string]: string | JSX.Element }
|
||||
columnHeaderTooltips?: { [columnName: string]: string | JSX.Element };
|
||||
rows: { [key: string]: any }[];
|
||||
}
|
||||
|
||||
@ -63,6 +63,7 @@ interface Props
|
||||
}
|
||||
|
||||
const qController = Client.getInstance();
|
||||
|
||||
function TableCard({noRowsFoundHTML, data, rowsPerPage, hidePaginationDropdown, fixedStickyLastRow, fixedHeight, widgetMetaData}: Props): JSX.Element
|
||||
{
|
||||
const [qInstance, setQInstance] = useState(null as QInstance);
|
||||
@ -108,7 +109,7 @@ function TableCard({noRowsFoundHTML, data, rowsPerPage, hidePaginationDropdown,
|
||||
<TableContainer sx={{boxShadow: "none"}}>
|
||||
<Table>
|
||||
<Box component="thead">
|
||||
<TableRow key="header">
|
||||
<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%" />
|
||||
|
@ -23,7 +23,6 @@
|
||||
import {QWidgetMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QWidgetMetaData";
|
||||
// @ts-ignore
|
||||
import {htmlToText} from "html-to-text";
|
||||
import React, {useContext, useEffect, useState} from "react";
|
||||
import QContext from "QContext";
|
||||
import HelpContent, {hasHelpContent} from "qqq/components/misc/HelpContent";
|
||||
import TableCard from "qqq/components/widgets/tables/TableCard";
|
||||
@ -31,6 +30,7 @@ import Widget, {WidgetData} from "qqq/components/widgets/Widget";
|
||||
import {WidgetUtils} from "qqq/components/widgets/WidgetUtils";
|
||||
import HtmlUtils from "qqq/utils/HtmlUtils";
|
||||
import ValueUtils from "qqq/utils/qqq/ValueUtils";
|
||||
import React, {useContext, useEffect, useState} from "react";
|
||||
|
||||
interface Props
|
||||
{
|
||||
@ -40,8 +40,7 @@ interface Props
|
||||
isChild?: boolean;
|
||||
}
|
||||
|
||||
TableWidget.defaultProps = {
|
||||
};
|
||||
TableWidget.defaultProps = {};
|
||||
|
||||
function TableWidget(props: Props): JSX.Element
|
||||
{
|
||||
@ -86,7 +85,7 @@ function TableWidget(props: Props): JSX.Element
|
||||
|
||||
const cell = rows[i][columns[j].accessor];
|
||||
let text = cell;
|
||||
if(columns[j].type != "default")
|
||||
if (columns[j].type != "default")
|
||||
{
|
||||
text = htmlToText(cell,
|
||||
{
|
||||
@ -105,7 +104,7 @@ function TableWidget(props: Props): JSX.Element
|
||||
setCsv(csv);
|
||||
|
||||
const fileName = WidgetUtils.makeExportFileName(props.widgetData, props.widgetMetaData);
|
||||
setFileName(fileName)
|
||||
setFileName(fileName);
|
||||
|
||||
console.log(`useEffect, setting fileName ${fileName}`);
|
||||
}
|
||||
@ -114,24 +113,28 @@ function TableWidget(props: Props): JSX.Element
|
||||
|
||||
const onExportClick = () =>
|
||||
{
|
||||
if(props.widgetData?.csvData)
|
||||
if (props.widgetData?.csvData)
|
||||
{
|
||||
const csv = WidgetUtils.widgetCsvDataToString(props.widgetData);
|
||||
const fileName = WidgetUtils.makeExportFileName(props.widgetData, props.widgetMetaData);
|
||||
HtmlUtils.download(fileName, csv);
|
||||
}
|
||||
else if(csv)
|
||||
else if (csv)
|
||||
{
|
||||
HtmlUtils.download(fileName, csv);
|
||||
}
|
||||
else
|
||||
{
|
||||
alert("There is no data available to export.")
|
||||
alert("There is no data available to export.");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const labelAdditionalElementsLeft: JSX.Element[] = [];
|
||||
if(props.widgetMetaData?.showExportButton)
|
||||
if (props.widgetData?.linkText && props.widgetData?.linkURL)
|
||||
{
|
||||
labelAdditionalElementsLeft.push(WidgetUtils.generateLabelLink(props.widgetData?.linkText, props.widgetData?.linkURL));
|
||||
}
|
||||
if (props.widgetMetaData?.showExportButton)
|
||||
{
|
||||
labelAdditionalElementsLeft.push(WidgetUtils.generateExportButton(onExportClick));
|
||||
}
|
||||
@ -139,14 +142,14 @@ function TableWidget(props: Props): JSX.Element
|
||||
//////////////////////////////////////////////////////
|
||||
// look for column-header tooltips from helpContent //
|
||||
//////////////////////////////////////////////////////
|
||||
const columnHeaderTooltips: {[columnName: string]: JSX.Element} = {}
|
||||
const columnHeaderTooltips: { [columnName: string]: JSX.Element } = {};
|
||||
for (let column of props.widgetData?.columns ?? [])
|
||||
{
|
||||
const helpRoles = ["ALL_SCREENS"]
|
||||
const helpRoles = ["ALL_SCREENS"];
|
||||
const slotName = `columnHeader=${column.accessor}`;
|
||||
const showHelp = helpHelpActive || hasHelpContent(props.widgetMetaData?.helpContent?.get(slotName), helpRoles);
|
||||
|
||||
if(showHelp)
|
||||
if (showHelp)
|
||||
{
|
||||
const formattedHelpContent = <HelpContent helpContents={props.widgetMetaData?.helpContent?.get(slotName)} roles={helpRoles} helpContentKey={`widget:${props.widgetMetaData?.name};slot:${slotName}`} />;
|
||||
columnHeaderTooltips[column.accessor] = formattedHelpContent;
|
||||
|
21
src/qqq/components/workflows/RootEditor.tsx
Normal file
21
src/qqq/components/workflows/RootEditor.tsx
Normal 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.
|
||||
</>
|
||||
);
|
||||
}
|
69
src/qqq/components/workflows/StepEditor.tsx
Normal file
69
src/qqq/components/workflows/StepEditor.tsx
Normal 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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
214
src/qqq/components/workflows/StepUtils.ts
Normal file
214
src/qqq/components/workflows/StepUtils.ts
Normal 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
|
||||
};
|
||||
}
|
187
src/qqq/components/workflows/WorkflowEditor.tsx
Normal file
187
src/qqq/components/workflows/WorkflowEditor.tsx
Normal 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;
|
199
src/qqq/components/workflows/WorkflowPreview.tsx
Normal file
199
src/qqq/components/workflows/WorkflowPreview.tsx
Normal 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;
|
75
src/qqq/components/workflows/model.ts
Normal file
75
src/qqq/components/workflows/model.ts
Normal 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)[];
|
||||
}
|
@ -29,6 +29,7 @@ export interface FieldRule
|
||||
sourceField: string;
|
||||
action: FieldRuleAction;
|
||||
targetField: string;
|
||||
targetWidget: string;
|
||||
}
|
||||
|
||||
|
||||
@ -46,5 +47,6 @@ export enum FieldRuleTrigger
|
||||
*******************************************************************************/
|
||||
export enum FieldRuleAction
|
||||
{
|
||||
CLEAR_TARGET_FIELD = "CLEAR_TARGET_FIELD"
|
||||
CLEAR_TARGET_FIELD = "CLEAR_TARGET_FIELD",
|
||||
RELOAD_WIDGET = "RELOAD_WIDGET"
|
||||
}
|
||||
|
@ -33,10 +33,10 @@ import {QJobComplete} from "@kingsrook/qqq-frontend-core/lib/model/processes/QJo
|
||||
import {QJobError} from "@kingsrook/qqq-frontend-core/lib/model/processes/QJobError";
|
||||
import {QJobRunning} from "@kingsrook/qqq-frontend-core/lib/model/processes/QJobRunning";
|
||||
import {QJobStarted} from "@kingsrook/qqq-frontend-core/lib/model/processes/QJobStarted";
|
||||
import {QPossibleValue} from "@kingsrook/qqq-frontend-core/lib/model/QPossibleValue";
|
||||
import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord";
|
||||
import {QQueryFilter} from "@kingsrook/qqq-frontend-core/lib/model/query/QQueryFilter";
|
||||
import {Alert, Button, CircularProgress, Icon, TablePagination} from "@mui/material";
|
||||
import Box from "@mui/material/Box";
|
||||
import {Alert, Box, Button, CircularProgress, Icon, TablePagination} from "@mui/material";
|
||||
import Card from "@mui/material/Card";
|
||||
import Grid from "@mui/material/Grid";
|
||||
import Step from "@mui/material/Step";
|
||||
@ -48,16 +48,19 @@ import FormData from "form-data";
|
||||
import {Form, Formik} from "formik";
|
||||
import parse from "html-react-parser";
|
||||
import QContext from "QContext";
|
||||
import colors from "qqq/assets/theme/base/colors";
|
||||
import {QCancelButton, QSubmitButton} from "qqq/components/buttons/DefaultButtons";
|
||||
import QDynamicForm from "qqq/components/forms/DynamicForm";
|
||||
import DynamicFormUtils from "qqq/components/forms/DynamicFormUtils";
|
||||
import MDButton from "qqq/components/legacy/MDButton";
|
||||
import MDProgress from "qqq/components/legacy/MDProgress";
|
||||
import MDTypography from "qqq/components/legacy/MDTypography";
|
||||
import HelpContent, {hasHelpContent} from "qqq/components/misc/HelpContent";
|
||||
import QRecordSidebar from "qqq/components/misc/RecordSidebar";
|
||||
import {GoogleDriveFolderPickerWrapper} from "qqq/components/processes/GoogleDriveFolderPickerWrapper";
|
||||
import ProcessSummaryResults from "qqq/components/processes/ProcessSummaryResults";
|
||||
import ValidationReview from "qqq/components/processes/ValidationReview";
|
||||
import DashboardWidgets from "qqq/components/widgets/DashboardWidgets";
|
||||
import BaseLayout from "qqq/layouts/BaseLayout";
|
||||
import {TABLE_VARIANT_LOCAL_STORAGE_KEY_ROOT} from "qqq/pages/records/query/RecordQuery";
|
||||
import Client from "qqq/utils/qqq/Client";
|
||||
@ -86,6 +89,16 @@ const INITIAL_RETRY_MILLIS = 1_500;
|
||||
const RETRY_MAX_MILLIS = 12_000;
|
||||
const BACKOFF_AMOUNT = 1.5;
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
// define a function that we can make referenes to, which we'll overwrite //
|
||||
// with formik's setFieldValue function, once we're inside formik. //
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
let formikSetFieldValueFunction = (field: string, value: any, shouldValidate?: boolean): void =>
|
||||
{
|
||||
};
|
||||
|
||||
const cachedPossibleValueLabels: { [fieldName: string]: { [id: string | number]: string } } = {};
|
||||
|
||||
function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, isReport, recordIds, closeModalHandler, forceReInit, overrideLabel}: Props): JSX.Element
|
||||
{
|
||||
const processNameParam = useParams().processName;
|
||||
@ -124,7 +137,9 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
|
||||
const [showErrorDetail, setShowErrorDetail] = useState(false);
|
||||
const [showFullHelpText, setShowFullHelpText] = useState(false);
|
||||
|
||||
const {pageHeader, recordAnalytics, setPageHeader} = useContext(QContext);
|
||||
const [renderedWidgets, setRenderedWidgets] = useState({} as { [step: string]: { [widgetName: string]: any } });
|
||||
|
||||
const {pageHeader, recordAnalytics, setPageHeader, helpHelpActive} = useContext(QContext);
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// for setting the processError state - call this function, which will also set the isUserFacingError state //
|
||||
@ -226,15 +241,15 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
|
||||
setShowFullHelpText(!showFullHelpText);
|
||||
};
|
||||
|
||||
const download = (processValues: {[key: string]: string}) =>
|
||||
const download = (processValues: { [key: string]: string }) =>
|
||||
{
|
||||
let url;
|
||||
let fileName = processValues.downloadFileName;
|
||||
if(processValues.serverFilePath)
|
||||
if (processValues.serverFilePath)
|
||||
{
|
||||
url = `/download/${encodeURIComponent(processValues.downloadFileName)}?filePath=${encodeURIComponent(processValues.serverFilePath)}`;
|
||||
}
|
||||
else if(processValues.storageTableName && processValues.storageReference)
|
||||
else if (processValues.storageTableName && processValues.storageReference)
|
||||
{
|
||||
url = `/download/${encodeURIComponent(processValues.downloadFileName)}?storageTableName=${encodeURIComponent(processValues.storageTableName)}&storageReference=${encodeURIComponent(processValues.storageReference)}`;
|
||||
}
|
||||
@ -273,6 +288,42 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
function renderWidget(widgetName: string)
|
||||
{
|
||||
if (!renderedWidgets[activeStep.name])
|
||||
{
|
||||
renderedWidgets[activeStep.name] = {};
|
||||
setRenderedWidgets(renderedWidgets);
|
||||
}
|
||||
|
||||
if (renderedWidgets[activeStep.name][widgetName])
|
||||
{
|
||||
return renderedWidgets[activeStep.name][widgetName];
|
||||
}
|
||||
|
||||
const widgetMetaData = qInstance.widgets.get(widgetName);
|
||||
if (!widgetMetaData)
|
||||
{
|
||||
return (<Alert color="error">Unrecognized widget name: {widgetName}</Alert>);
|
||||
}
|
||||
|
||||
const queryStringParts: string[] = [];
|
||||
for (let name in processValues)
|
||||
{
|
||||
queryStringParts.push(`${name}=${encodeURIComponent(processValues[name])}`);
|
||||
}
|
||||
|
||||
const renderedWidget = (<Box m={-2}>
|
||||
<DashboardWidgets widgetMetaDataList={[widgetMetaData]} omitWrappingGridContainer={true} childUrlParams={queryStringParts.join("&")} />
|
||||
</Box>);
|
||||
renderedWidgets[activeStep.name][widgetName] = renderedWidget;
|
||||
return renderedWidget;
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////
|
||||
// generate the main form body content for a step //
|
||||
////////////////////////////////////////////////////
|
||||
@ -319,8 +370,8 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
|
||||
</MDTypography>
|
||||
<Box component="div" py={3}>
|
||||
<Grid container justifyContent="flex-end" spacing={3}>
|
||||
{isModal ? <QCancelButton onClickHandler={handleCancelClicked} disabled={false} label="Close" />
|
||||
: !isWidget && <QCancelButton onClickHandler={handleCancelClicked} disabled={false} />
|
||||
{isModal ? <QCancelButton onClickHandler={() => handleCancelClicked(true)} disabled={false} label="Close" />
|
||||
: !isWidget && <QCancelButton onClickHandler={() => handleCancelClicked(true)} disabled={false} />
|
||||
}
|
||||
</Grid>
|
||||
</Box>
|
||||
@ -395,7 +446,20 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
|
||||
{
|
||||
if (processValues[key])
|
||||
{
|
||||
formFields[key].possibleValueProps.initialDisplayValue = processValues[key];
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// if we have a cached possible-value label for this field name (key), then set it as the PV's initialDisplayValue //
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
if (cachedPossibleValueLabels[key] && cachedPossibleValueLabels[key][processValues[key]])
|
||||
{
|
||||
formFields[key].possibleValueProps.initialDisplayValue = cachedPossibleValueLabels[key][processValues[key]];
|
||||
}
|
||||
else
|
||||
{
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
// else (and i don't think this should happen?) at least set something... //
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
formFields[key].possibleValueProps.initialDisplayValue = processValues[key];
|
||||
}
|
||||
}
|
||||
|
||||
formFields[key].possibleValueProps.otherValues = formFields[key].possibleValueProps.otherValues ?? new Map<string, any>();
|
||||
@ -407,6 +471,13 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
|
||||
});
|
||||
}
|
||||
|
||||
/////////////////////////////////////
|
||||
// screen(step)-level help content //
|
||||
/////////////////////////////////////
|
||||
let helpRoles = ["PROCESS_SCREEN", "ALL_SCREENS"];
|
||||
const showHelp = helpHelpActive || hasHelpContent(step.helpContents, helpRoles);
|
||||
const formattedHelpContent = <HelpContent helpContents={step.helpContents} roles={helpRoles} helpContentKey={`process:${processName};step:${step?.name}`} />;
|
||||
|
||||
return (
|
||||
<>
|
||||
{
|
||||
@ -421,6 +492,13 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
|
||||
</MDTypography>
|
||||
}
|
||||
|
||||
{
|
||||
showHelp &&
|
||||
<Box fontSize={"0.875rem"} color={colors.blueGray.main} pb={2}>
|
||||
{formattedHelpContent}
|
||||
</Box>
|
||||
}
|
||||
|
||||
{
|
||||
//////////////////////////////////////////////////
|
||||
// render all of the components for this screen //
|
||||
@ -433,6 +511,23 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
|
||||
helpRoles = ["EDIT_SCREEN", "WRITE_SCREENS", "ALL_SCREENS"];
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
// if the component specifies a sub-set of field names to include, then //
|
||||
// edit the formData object to just include those. //
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
let formDataToUse = formData;
|
||||
if (component.values && component.values.includeFieldNames)
|
||||
{
|
||||
formDataToUse = Object.assign({}, formData);
|
||||
|
||||
formDataToUse.formFields = {};
|
||||
for (let i = 0; i < component.values.includeFieldNames.length; i++)
|
||||
{
|
||||
const fieldName = component.values.includeFieldNames[i];
|
||||
formDataToUse.formFields[fieldName] = formData.formFields[fieldName];
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={index}>
|
||||
{
|
||||
@ -528,9 +623,22 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
|
||||
)
|
||||
}
|
||||
{
|
||||
component.type === QComponentType.EDIT_FORM && (
|
||||
<QDynamicForm formData={formData} helpRoles={helpRoles} helpContentKeyPrefix={`process:${processName};`} />
|
||||
)
|
||||
component.type === QComponentType.EDIT_FORM &&
|
||||
<>
|
||||
{
|
||||
component.values?.sectionLabel ?
|
||||
<Box py={1.5}>
|
||||
<Card sx={{scrollMarginTop: "20px"}}>
|
||||
<MDTypography variant="h5" p={3} pl={2} pb={1}>
|
||||
{component.values?.sectionLabel}
|
||||
</MDTypography>
|
||||
<Box pt={0} p={2}>
|
||||
<QDynamicForm formData={formDataToUse} helpRoles={helpRoles} helpContentKeyPrefix={`process:${processName};`} />
|
||||
</Box>
|
||||
</Card>
|
||||
</Box> : <QDynamicForm formData={formDataToUse} helpRoles={helpRoles} helpContentKeyPrefix={`process:${processName};`} />
|
||||
}
|
||||
</>
|
||||
}
|
||||
{
|
||||
component.type === QComponentType.VIEW_FORM && step.viewFields && (
|
||||
@ -653,6 +761,12 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
{
|
||||
component.type === QComponentType.WIDGET && (
|
||||
component.values?.widgetName &&
|
||||
renderWidget(component.values?.widgetName)
|
||||
)
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}))
|
||||
@ -767,6 +881,12 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
|
||||
{
|
||||
dynamicFormFields[fieldName] = dynamicFormValue;
|
||||
initialValues[fieldName] = initialValue;
|
||||
|
||||
if (formikSetFieldValueFunction)
|
||||
{
|
||||
formikSetFieldValueFunction(fieldName, initialValue);
|
||||
}
|
||||
|
||||
formValidations[fieldName] = validation;
|
||||
};
|
||||
|
||||
@ -816,6 +936,11 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
|
||||
fullFieldList.forEach((field) =>
|
||||
{
|
||||
initialValues[field.name] = processValues[field.name];
|
||||
|
||||
if (formikSetFieldValueFunction)
|
||||
{
|
||||
formikSetFieldValueFunction(field.name, processValues[field.name]);
|
||||
}
|
||||
});
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////
|
||||
@ -915,7 +1040,7 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
|
||||
}
|
||||
});
|
||||
|
||||
DynamicFormUtils.addPossibleValueProps(newDynamicFormFields, fullFieldList, tableMetaData.name, null, null);
|
||||
DynamicFormUtils.addPossibleValueProps(newDynamicFormFields, fullFieldList, tableMetaData?.name, null, null);
|
||||
|
||||
setFormFields(newDynamicFormFields);
|
||||
setValidationScheme(Yup.object().shape(newFormValidations));
|
||||
@ -975,16 +1100,88 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
|
||||
|
||||
if (lastProcessResponse instanceof QJobComplete)
|
||||
{
|
||||
const qJobComplete = lastProcessResponse as QJobComplete;
|
||||
setJobUUID(null);
|
||||
setNewStep(qJobComplete.nextStep);
|
||||
setProcessValues(qJobComplete.values);
|
||||
setQJobRunning(null);
|
||||
|
||||
if (activeStep && activeStep.recordListFields)
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// run an async function here, in case we need to await looking up any possible-value labels //
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////
|
||||
(async () =>
|
||||
{
|
||||
setNeedRecords(true);
|
||||
}
|
||||
const qJobComplete = lastProcessResponse as QJobComplete;
|
||||
const newValues = qJobComplete.values;
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// if the process step sent a new frontend-step-list, then refresh what we have in state (constructing new full model objects) //
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
let frontendSteps = steps;
|
||||
const updatedFrontendStepList = qJobComplete.updatedFrontendStepList;
|
||||
if (updatedFrontendStepList)
|
||||
{
|
||||
setSteps(updatedFrontendStepList);
|
||||
frontendSteps = updatedFrontendStepList;
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////
|
||||
// if the next screen has any PVS fields - look up their labels (display values) //
|
||||
///////////////////////////////////////////////////////////////////////////////////
|
||||
const nextStepName = qJobComplete.nextStep;
|
||||
let nextStep: QFrontendStepMetaData | null = null;
|
||||
if (frontendSteps && nextStepName)
|
||||
{
|
||||
for (let i = 0; i < frontendSteps.length; i++)
|
||||
{
|
||||
if (frontendSteps[i].name === nextStepName)
|
||||
{
|
||||
nextStep = frontendSteps[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (nextStep && nextStep.formFields)
|
||||
{
|
||||
for (let i = 0; i < nextStep.formFields.length; i++)
|
||||
{
|
||||
const field = nextStep.formFields[i];
|
||||
const fieldName = field.name;
|
||||
if (field.possibleValueSourceName && newValues && newValues[fieldName])
|
||||
{
|
||||
const results: QPossibleValue[] = await Client.getInstance().possibleValues(null, processName, fieldName, null, [newValues[fieldName]]);
|
||||
if (results && results.length > 0)
|
||||
{
|
||||
if (!cachedPossibleValueLabels[fieldName])
|
||||
{
|
||||
cachedPossibleValueLabels[fieldName] = {};
|
||||
}
|
||||
cachedPossibleValueLabels[fieldName][newValues[fieldName]] = results[0].label;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setJobUUID(null);
|
||||
setNewStep(nextStepName);
|
||||
setProcessValues(newValues);
|
||||
setQJobRunning(null);
|
||||
|
||||
if (formikSetFieldValueFunction)
|
||||
{
|
||||
//////////////////////////////////
|
||||
// reset field values in formik //
|
||||
//////////////////////////////////
|
||||
for (let key in qJobComplete.values)
|
||||
{
|
||||
if (Object.hasOwn(formFields, key))
|
||||
{
|
||||
console.log(`(re)setting form field [${key}] to [${qJobComplete.values[key]}]`);
|
||||
formikSetFieldValueFunction(key, qJobComplete.values[key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (activeStep && activeStep.recordListFields)
|
||||
{
|
||||
setNeedRecords(true);
|
||||
}
|
||||
})();
|
||||
}
|
||||
else if (lastProcessResponse instanceof QJobStarted)
|
||||
{
|
||||
@ -1226,7 +1423,10 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
|
||||
const formData = new FormData();
|
||||
Object.keys(values).forEach((key) =>
|
||||
{
|
||||
formData.append(key, values[key]);
|
||||
if (values[key] !== undefined)
|
||||
{
|
||||
formData.append(key, values[key]);
|
||||
}
|
||||
});
|
||||
|
||||
if (tableVariantLocalStorageKey && localStorage.getItem(tableVariantLocalStorageKey))
|
||||
@ -1279,8 +1479,20 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
|
||||
});
|
||||
};
|
||||
|
||||
const handleCancelClicked = () =>
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
const handleCancelClicked = (isClose: boolean) =>
|
||||
{
|
||||
//////////////////////////////////////////////////////////////////
|
||||
// unless this is a 'close', then tell backend we're cancelling //
|
||||
//////////////////////////////////////////////////////////////////
|
||||
if (!isClose)
|
||||
{
|
||||
Client.getInstance().processCancel(processName, processUUID);
|
||||
}
|
||||
|
||||
if (isModal && closeModalHandler)
|
||||
{
|
||||
closeModalHandler(null, "cancelClicked");
|
||||
@ -1293,6 +1505,7 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
|
||||
navigate(path, {replace: true});
|
||||
};
|
||||
|
||||
|
||||
const mainCardStyles: any = {};
|
||||
const formStyles: any = {};
|
||||
mainCardStyles.minHeight = `calc(100vh - ${isModal ? 150 : 400}px)`;
|
||||
@ -1340,94 +1553,103 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
|
||||
>
|
||||
{({
|
||||
values, errors, touched, isSubmitting, setFieldValue,
|
||||
}) => (
|
||||
<Form style={formStyles} id={formId} autoComplete="off">
|
||||
<Card sx={mainCardStyles}>
|
||||
{
|
||||
!isWidget && (
|
||||
<Box mx={2} mt={-3}>
|
||||
<Stepper activeStep={activeStepIndex} alternativeLabel>
|
||||
{steps.map((step) => (
|
||||
<Step key={step.name}>
|
||||
<StepLabel>{step.label}</StepLabel>
|
||||
</Step>
|
||||
))}
|
||||
</Stepper>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
}) =>
|
||||
{
|
||||
///////////////////////////////////////////////////////////////////
|
||||
// once we're in the formik form, use its setFieldValue function //
|
||||
// over top of the default one we created globally //
|
||||
///////////////////////////////////////////////////////////////////
|
||||
formikSetFieldValueFunction = setFieldValue;
|
||||
|
||||
<Box p={3}>
|
||||
<Box pb={isWidget ? 6 : "initial"}>
|
||||
{/***************************************************************************
|
||||
** step content - e.g., the appropriate form or other screen for the step **
|
||||
***************************************************************************/}
|
||||
{getDynamicStepContent(
|
||||
activeStepIndex,
|
||||
activeStep,
|
||||
{
|
||||
values,
|
||||
touched,
|
||||
formFields,
|
||||
errors,
|
||||
},
|
||||
processError,
|
||||
processValues,
|
||||
recordConfig,
|
||||
setFieldValue,
|
||||
)}
|
||||
{/********************************
|
||||
** back &| next/submit buttons **
|
||||
********************************/}
|
||||
<Box mt={6} width="100%" display="flex" justifyContent="space-between" position={isWidget ? "absolute" : "initial"} bottom={isWidget ? "3rem" : "initial"} right={isWidget ? "1.5rem" : "initial"}>
|
||||
{true || activeStepIndex === 0 ? (
|
||||
<Box />
|
||||
) : (
|
||||
<MDButton variant="gradient" color="light" onClick={handleBack}>back</MDButton>
|
||||
)}
|
||||
{processError || qJobRunning || !activeStep ? (
|
||||
<Box />
|
||||
) : (
|
||||
<>
|
||||
{formError && (
|
||||
<MDTypography component="div" variant="caption" color="error" fontWeight="regular" align="right" fullWidth>
|
||||
{formError}
|
||||
</MDTypography>
|
||||
)}
|
||||
{
|
||||
noMoreSteps && <QCancelButton
|
||||
onClickHandler={handleCancelClicked}
|
||||
label={isModal ? "Close" : "Return"}
|
||||
iconName={isModal ? "cancel" : "arrow_back"}
|
||||
disabled={isSubmitting} />
|
||||
}
|
||||
{
|
||||
!noMoreSteps && (
|
||||
<Box component="div" py={3}>
|
||||
<Grid container justifyContent="flex-end" spacing={3}>
|
||||
{
|
||||
!isWidget && (
|
||||
<QCancelButton onClickHandler={handleCancelClicked} disabled={isSubmitting} />
|
||||
)
|
||||
}
|
||||
<QSubmitButton label={nextButtonLabel} iconName={nextButtonIcon} disabled={isSubmitting} />
|
||||
</Grid>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
</>
|
||||
return (
|
||||
<Form style={formStyles} id={formId} autoComplete="off">
|
||||
<Card sx={mainCardStyles}>
|
||||
{
|
||||
!isWidget && (
|
||||
<Box mx={2} mt={-3} sx={{"& .MuiStepper-horizontal": {minHeight: "5rem"}}}>
|
||||
<Stepper activeStep={activeStepIndex} alternativeLabel>
|
||||
{steps.map((step) => (
|
||||
<Step key={step.name}>
|
||||
<StepLabel>{step.label}</StepLabel>
|
||||
</Step>
|
||||
))}
|
||||
</Stepper>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
<Box p={3}>
|
||||
<Box pb={isWidget ? 6 : "initial"}>
|
||||
{/***************************************************************************
|
||||
** step content - e.g., the appropriate form or other screen for the step **
|
||||
***************************************************************************/}
|
||||
{getDynamicStepContent(
|
||||
activeStepIndex,
|
||||
activeStep,
|
||||
{
|
||||
values,
|
||||
touched,
|
||||
formFields,
|
||||
errors,
|
||||
},
|
||||
processError,
|
||||
processValues,
|
||||
recordConfig,
|
||||
setFieldValue,
|
||||
)}
|
||||
{/********************************
|
||||
** back &| next/submit buttons **
|
||||
********************************/}
|
||||
<Box mt={6} width="100%" display="flex" justifyContent="space-between" position={isWidget ? "absolute" : "initial"} bottom={isWidget ? "3rem" : "initial"} right={isWidget ? "1.5rem" : "initial"}>
|
||||
{true || activeStepIndex === 0 ? (
|
||||
<Box />
|
||||
) : (
|
||||
<MDButton variant="gradient" color="light" onClick={handleBack}>back</MDButton>
|
||||
)}
|
||||
{processError || qJobRunning || !activeStep ? (
|
||||
<Box />
|
||||
) : (
|
||||
<>
|
||||
{formError && (
|
||||
<MDTypography component="div" variant="caption" color="error" fontWeight="regular" align="right" fullWidth>
|
||||
{formError}
|
||||
</MDTypography>
|
||||
)}
|
||||
{
|
||||
noMoreSteps && <QCancelButton
|
||||
onClickHandler={() => handleCancelClicked(true)}
|
||||
label={isModal ? "Close" : "Return"}
|
||||
iconName={isModal ? "cancel" : "arrow_back"}
|
||||
disabled={isSubmitting} />
|
||||
}
|
||||
{
|
||||
!noMoreSteps && (
|
||||
<Box component="div" py={3}>
|
||||
<Grid container justifyContent="flex-end" spacing={3}>
|
||||
{
|
||||
!isWidget && (
|
||||
<QCancelButton onClickHandler={() => handleCancelClicked(false)} disabled={isSubmitting} />
|
||||
)
|
||||
}
|
||||
<QSubmitButton label={nextButtonLabel} iconName={nextButtonIcon} disabled={isSubmitting} />
|
||||
</Grid>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Card>
|
||||
</Form>
|
||||
)}
|
||||
</Card>
|
||||
</Form>
|
||||
);
|
||||
}}
|
||||
</Formik>
|
||||
);
|
||||
|
||||
const body = (
|
||||
<Box py={3} mb={20}>
|
||||
<Box py={3} mb={20} className="processRun">
|
||||
<Grid container justifyContent="center" alignItems="center" sx={{height: "100%", mt: 8}}>
|
||||
<Grid item xs={12} lg={10} xl={8}>
|
||||
{form}
|
||||
|
@ -94,6 +94,7 @@ interface Props
|
||||
isModal?: boolean;
|
||||
initialQueryFilter?: QQueryFilter;
|
||||
initialColumns?: QQueryColumns;
|
||||
allowVariables?: boolean;
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////
|
||||
@ -109,7 +110,7 @@ const qController = Client.getInstance();
|
||||
*******************************************************************************/
|
||||
const getLoadingScreen = (isModal: boolean) =>
|
||||
{
|
||||
if(isModal)
|
||||
if (isModal)
|
||||
{
|
||||
return (<Box> </Box>);
|
||||
}
|
||||
@ -125,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, initialQueryFilter, initialColumns}: Props, ref) =>
|
||||
const RecordQuery = forwardRef(({table, usage, isModal, allowVariables, initialQueryFilter, initialColumns}: Props, ref) =>
|
||||
{
|
||||
const tableName = table.name;
|
||||
const [searchParams] = useSearchParams();
|
||||
@ -151,7 +152,7 @@ const RecordQuery = forwardRef(({table, usage, isModal, initialQueryFilter, init
|
||||
*******************************************************************************/
|
||||
function localStorageSet(key: string, value: string)
|
||||
{
|
||||
if(mayWriteLocalStorage)
|
||||
if (mayWriteLocalStorage)
|
||||
{
|
||||
localStorage.setItem(key, value);
|
||||
}
|
||||
@ -163,7 +164,7 @@ const RecordQuery = forwardRef(({table, usage, isModal, initialQueryFilter, init
|
||||
*******************************************************************************/
|
||||
function localStorageRemove(key: string)
|
||||
{
|
||||
if(mayWriteLocalStorage)
|
||||
if (mayWriteLocalStorage)
|
||||
{
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
@ -176,7 +177,7 @@ const RecordQuery = forwardRef(({table, usage, isModal, initialQueryFilter, init
|
||||
{
|
||||
return view;
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
@ -256,7 +257,7 @@ const RecordQuery = forwardRef(({table, usage, isModal, initialQueryFilter, init
|
||||
defaultView.mode = defaultMode;
|
||||
}
|
||||
|
||||
if(firstRender)
|
||||
if (firstRender)
|
||||
{
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
// allow a caller to send in an initial filter & set of columns. //
|
||||
@ -408,7 +409,7 @@ const RecordQuery = forwardRef(({table, usage, isModal, initialQueryFilter, init
|
||||
//////////////////////////////////////////////////////////////////
|
||||
// we use our own header - so clear out the context page header //
|
||||
//////////////////////////////////////////////////////////////////
|
||||
if(!isModal)
|
||||
if (!isModal)
|
||||
{
|
||||
setPageHeader(null);
|
||||
}
|
||||
@ -486,7 +487,6 @@ const RecordQuery = forwardRef(({table, usage, isModal, initialQueryFilter, init
|
||||
};
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@ -631,7 +631,7 @@ const RecordQuery = forwardRef(({table, usage, isModal, initialQueryFilter, init
|
||||
const type = (e.target as any).type;
|
||||
const validType = (type !== "text" && type !== "textarea" && type !== "input" && type !== "search");
|
||||
|
||||
if (validType && !dotMenuOpen && !keyboardHelpOpen && !activeModalProcess)
|
||||
if (validType && !isModal && !dotMenuOpen && !keyboardHelpOpen && !activeModalProcess)
|
||||
{
|
||||
if (!e.metaKey && !e.ctrlKey && e.key === "n" && table.capabilities.has(Capability.TABLE_INSERT) && table.insertPermission)
|
||||
{
|
||||
@ -669,7 +669,7 @@ const RecordQuery = forwardRef(({table, usage, isModal, initialQueryFilter, init
|
||||
{
|
||||
document.removeEventListener("keydown", down);
|
||||
};
|
||||
}, [dotMenuOpen, keyboardHelpOpen, metaData, activeModalProcess]);
|
||||
}, [isModal, dotMenuOpen, keyboardHelpOpen, metaData, activeModalProcess]);
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@ -711,7 +711,7 @@ const RecordQuery = forwardRef(({table, usage, isModal, initialQueryFilter, init
|
||||
{
|
||||
if (localStorage.getItem(currentSavedViewLocalStorageKey))
|
||||
{
|
||||
if(usage == "queryScreen")
|
||||
if (usage == "queryScreen")
|
||||
{
|
||||
currentSavedViewId = Number.parseInt(localStorage.getItem(currentSavedViewLocalStorageKey));
|
||||
navigate(`${metaData.getTablePathByName(tableName)}/savedView/${currentSavedViewId}`);
|
||||
@ -750,13 +750,13 @@ const RecordQuery = forwardRef(({table, usage, isModal, initialQueryFilter, init
|
||||
const viewForLocalStorage: RecordQueryView = JSON.parse(viewAsJSON);
|
||||
if (viewForLocalStorage?.queryFilter?.criteria?.length > 0)
|
||||
{
|
||||
FilterUtils.stripAwayIncompleteCriteria(viewForLocalStorage.queryFilter)
|
||||
FilterUtils.stripAwayIncompleteCriteria(viewForLocalStorage.queryFilter);
|
||||
}
|
||||
localStorageSet(viewLocalStorageKey, JSON.stringify(viewForLocalStorage));
|
||||
}
|
||||
catch(e)
|
||||
catch (e)
|
||||
{
|
||||
console.log("Error storing view in local storage: " + e)
|
||||
console.log("Error storing view in local storage: " + e);
|
||||
}
|
||||
};
|
||||
|
||||
@ -868,7 +868,7 @@ const RecordQuery = forwardRef(({table, usage, isModal, initialQueryFilter, init
|
||||
for (let i = 0; i < queryFilter?.orderBys?.length; i++)
|
||||
{
|
||||
const fieldName = queryFilter.orderBys[i].fieldName;
|
||||
if (fieldName.indexOf(".") > -1)
|
||||
if (fieldName != null && fieldName.indexOf(".") > -1)
|
||||
{
|
||||
const joinTableName = fieldName.replaceAll(/\..*/g, "");
|
||||
if (!vjtToUse.has(joinTableName))
|
||||
@ -901,6 +901,26 @@ const RecordQuery = forwardRef(({table, usage, isModal, initialQueryFilter, init
|
||||
return;
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// if any values in the query are of type "FilterVariableExpression", display an error showing //
|
||||
// that a backend query cannot be made because of missing values for that expression //
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
setWarningAlert(null);
|
||||
for (var i = 0; i < queryFilter?.criteria?.length; i++)
|
||||
{
|
||||
for (var j = 0; j < queryFilter?.criteria[i]?.values?.length; j++)
|
||||
{
|
||||
const value = queryFilter.criteria[i].values[j];
|
||||
if (value?.type == "FilterVariableExpression")
|
||||
{
|
||||
setWarningAlert("Cannot perform query because of a missing value for a variable.");
|
||||
setLoading(false);
|
||||
setRows([]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
recordAnalytics({category: "tableEvents", action: "query", label: tableMetaData.label});
|
||||
|
||||
console.log(`In updateTable for ${reason} ${JSON.stringify(queryFilter)}`);
|
||||
@ -939,7 +959,7 @@ const RecordQuery = forwardRef(({table, usage, isModal, initialQueryFilter, init
|
||||
console.log(`Issuing query: ${thisQueryId}`);
|
||||
if (tableMetaData.capabilities.has(Capability.TABLE_COUNT))
|
||||
{
|
||||
if(clearOutCount)
|
||||
if (clearOutCount)
|
||||
{
|
||||
setTotalRecords(null);
|
||||
setDistinctRecords(null);
|
||||
@ -1437,7 +1457,6 @@ const RecordQuery = forwardRef(({table, usage, isModal, initialQueryFilter, init
|
||||
return (selectedIds.length);
|
||||
};
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** get a query-string to put on the url to indicate what records are going into
|
||||
** a process.
|
||||
@ -2527,7 +2546,7 @@ const RecordQuery = forwardRef(({table, usage, isModal, initialQueryFilter, init
|
||||
{
|
||||
const currentSavedViewId = Number.parseInt(localStorage.getItem(currentSavedViewLocalStorageKey));
|
||||
console.log(`returning to previously active saved view ${currentSavedViewId}`);
|
||||
if(usage == "queryScreen")
|
||||
if (usage == "queryScreen")
|
||||
{
|
||||
navigate(`${metaData.getTablePathByName(tableName)}/savedView/${currentSavedViewId}`);
|
||||
}
|
||||
@ -2770,7 +2789,7 @@ const RecordQuery = forwardRef(({table, usage, isModal, initialQueryFilter, init
|
||||
spaceAboveGrid += 60;
|
||||
}
|
||||
|
||||
if(isModal)
|
||||
if (isModal)
|
||||
{
|
||||
spaceAboveGrid += 130;
|
||||
}
|
||||
@ -2866,6 +2885,7 @@ const RecordQuery = forwardRef(({table, usage, isModal, initialQueryFilter, init
|
||||
gridApiRef={gridApiRef}
|
||||
mode={mode}
|
||||
queryScreenUsage={usage}
|
||||
allowVariables={allowVariables}
|
||||
setMode={doSetMode}
|
||||
savedViewsComponent={savedViewsComponent}
|
||||
columnMenuComponent={buildColumnMenu()}
|
||||
@ -2890,9 +2910,11 @@ const RecordQuery = forwardRef(({table, usage, isModal, initialQueryFilter, init
|
||||
filterPanel:
|
||||
{
|
||||
tableMetaData: tableMetaData,
|
||||
queryScreenUsage: usage,
|
||||
metaData: metaData,
|
||||
queryFilter: queryFilter,
|
||||
updateFilter: doSetQueryFilter,
|
||||
allowVariables: allowVariables
|
||||
}
|
||||
}}
|
||||
localeText={{
|
||||
@ -2976,15 +2998,15 @@ const RecordQuery = forwardRef(({table, usage, isModal, initialQueryFilter, init
|
||||
</React.Fragment>
|
||||
);
|
||||
|
||||
if(isModal)
|
||||
if (isModal)
|
||||
{
|
||||
return body;
|
||||
}
|
||||
|
||||
return (
|
||||
<BaseLayout>{body}</BaseLayout>
|
||||
)
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
RecordQuery.defaultProps = {
|
||||
|
@ -21,6 +21,7 @@
|
||||
|
||||
import {QException} from "@kingsrook/qqq-frontend-core/lib/exceptions/QException";
|
||||
import {Capability} from "@kingsrook/qqq-frontend-core/lib/model/metaData/Capability";
|
||||
import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
|
||||
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";
|
||||
@ -73,22 +74,94 @@ const qController = Client.getInstance();
|
||||
interface Props
|
||||
{
|
||||
table?: QTableMetaData;
|
||||
record?: QRecord;
|
||||
launchProcess?: QProcessMetaData;
|
||||
}
|
||||
|
||||
RecordView.defaultProps =
|
||||
{
|
||||
table: null,
|
||||
record: null,
|
||||
launchProcess: null,
|
||||
};
|
||||
|
||||
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} )
|
||||
{
|
||||
return <Box key={key} display="flex" flexDirection="column" py={1} pr={2}>
|
||||
{
|
||||
fieldNames.map((fieldName: string) =>
|
||||
{
|
||||
let [field, tableForField] = tableMetaData ? TableUtils.getFieldAndTable(tableMetaData, fieldName) : fieldMap ? [fieldMap[fieldName], null] : [null, null];
|
||||
|
||||
if (field != null)
|
||||
{
|
||||
let label = field.label;
|
||||
|
||||
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"}}>{label}:</Typography>;
|
||||
|
||||
return (
|
||||
<Box key={fieldName} flexDirection="row" pr={2}>
|
||||
<>
|
||||
{
|
||||
showHelp && formattedHelpContent ? <Tooltip title={formattedHelpContent}>{labelElement}</Tooltip> : labelElement
|
||||
}
|
||||
<div style={{display: "inline-block", width: 0}}> </div>
|
||||
<Typography variant="button" textTransform="none" fontWeight="regular" color="rgb(123, 128, 154)">
|
||||
{ValueUtils.getDisplayValue(field, record, "view", fieldName)}
|
||||
</Typography>
|
||||
</>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
})
|
||||
}
|
||||
</Box>;
|
||||
}
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
export function 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);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Record View Screen component.
|
||||
*******************************************************************************/
|
||||
function RecordView({table, launchProcess}: Props): JSX.Element
|
||||
function RecordView({table, record: overrideRecord, launchProcess}: Props): JSX.Element
|
||||
{
|
||||
const {id} = useParams();
|
||||
|
||||
@ -105,7 +178,7 @@ function RecordView({table, launchProcess}: Props): JSX.Element
|
||||
const [adornmentFieldsMap, setAdornmentFieldsMap] = useState(new Map<string, boolean>);
|
||||
const [deleteConfirmationOpen, setDeleteConfirmationOpen] = useState(false);
|
||||
const [metaData, setMetaData] = useState(null as QInstance);
|
||||
const [record, setRecord] = useState(null as QRecord);
|
||||
const [record, setRecord] = useState(overrideRecord ?? null as QRecord);
|
||||
const [tableSections, setTableSections] = useState([] as QTableSection[]);
|
||||
const [t1Section, setT1Section] = useState(null as QTableSection);
|
||||
const [t1SectionName, setT1SectionName] = useState(null as string);
|
||||
@ -339,31 +412,6 @@ function RecordView({table, launchProcess}: Props): JSX.Element
|
||||
reload();
|
||||
}, [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);
|
||||
};
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** get an element (or empty) to use as help content for a section
|
||||
@ -439,7 +487,18 @@ function RecordView({table, launchProcess}: Props): JSX.Element
|
||||
let record: QRecord;
|
||||
try
|
||||
{
|
||||
record = await qController.get(tableName, id, tableVariant, null, queryJoins);
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
// if the component took in a record object, then we don't need to GET it //
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
if(overrideRecord)
|
||||
{
|
||||
record = overrideRecord;
|
||||
}
|
||||
else
|
||||
{
|
||||
record = await qController.get(tableName, id, tableVariant, null, queryJoins);
|
||||
}
|
||||
|
||||
setRecord(record);
|
||||
recordAnalytics({category: "tableEvents", action: "view", label: tableMetaData?.label + " / " + record?.recordLabel});
|
||||
}
|
||||
@ -476,7 +535,7 @@ function RecordView({table, launchProcess}: Props): JSX.Element
|
||||
|
||||
setPageHeader(record.recordLabel);
|
||||
|
||||
if (!launchingProcess)
|
||||
if (!launchingProcess && !activeModalProcess)
|
||||
{
|
||||
try
|
||||
{
|
||||
@ -528,40 +587,7 @@ function RecordView({table, launchProcess}: Props): JSX.Element
|
||||
// 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 = (
|
||||
<Box key={section.name} display="flex" flexDirection="column" py={1} pr={2}>
|
||||
{
|
||||
section.fieldNames.map((fieldName: string) =>
|
||||
{
|
||||
let [field, tableForField] = TableUtils.getFieldAndTable(tableMetaData, fieldName);
|
||||
if (field != null)
|
||||
{
|
||||
let label = field.label;
|
||||
|
||||
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:${tableName};field:${fieldName}`} />;
|
||||
|
||||
const labelElement = <Typography variant="button" textTransform="none" fontWeight="bold" pr={1} color="rgb(52, 71, 103)" sx={{cursor: "default"}}>{label}:</Typography>;
|
||||
|
||||
return (
|
||||
<Box key={fieldName} flexDirection="row" pr={2}>
|
||||
<>
|
||||
{
|
||||
showHelp && formattedHelpContent ? <Tooltip title={formattedHelpContent}>{labelElement}</Tooltip> : labelElement
|
||||
}
|
||||
<div style={{display: "inline-block", width: 0}}> </div>
|
||||
<Typography variant="button" textTransform="none" fontWeight="regular" color="rgb(123, 128, 154)">
|
||||
{ValueUtils.getDisplayValue(field, record, "view", fieldName)}
|
||||
</Typography>
|
||||
</>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
})
|
||||
}
|
||||
</Box>
|
||||
);
|
||||
const fields = renderSectionOfFields(section.name, section.fieldNames, tableMetaData, helpHelpActive, record);
|
||||
|
||||
if (section.tier === "T1")
|
||||
{
|
||||
@ -1055,7 +1081,7 @@ function RecordView({table, launchProcess}: Props): JSX.Element
|
||||
|
||||
{
|
||||
showEditChildForm &&
|
||||
<Modal open={showEditChildForm as boolean} onClose={(event, reason) => closeEditChildForm(event, reason)}>
|
||||
<Modal open={showEditChildForm !== null} onClose={(event, reason) => closeEditChildForm(event, reason)}>
|
||||
<div className="modalEditForm">
|
||||
<EntityForm
|
||||
isModal={true}
|
||||
|
163
src/qqq/pages/records/view/RecordViewByUniqueKey.tsx
Normal file
163
src/qqq/pages/records/view/RecordViewByUniqueKey.tsx
Normal file
@ -0,0 +1,163 @@
|
||||
/*
|
||||
* 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 {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
|
||||
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 {QQueryFilter} from "@kingsrook/qqq-frontend-core/lib/model/query/QQueryFilter";
|
||||
import {QueryJoin} from "@kingsrook/qqq-frontend-core/lib/model/query/QueryJoin";
|
||||
import {Alert, Box} from "@mui/material";
|
||||
import Grid from "@mui/material/Grid";
|
||||
import BaseLayout from "qqq/layouts/BaseLayout";
|
||||
import RecordView, {getVisibleJoinTables} from "qqq/pages/records/view/RecordView";
|
||||
import Client from "qqq/utils/qqq/Client";
|
||||
import TableUtils from "qqq/utils/qqq/TableUtils";
|
||||
import React, {useEffect, useState} from "react";
|
||||
import {useSearchParams} from "react-router-dom";
|
||||
|
||||
interface RecordViewByUniqueKeyProps
|
||||
{
|
||||
table: QTableMetaData;
|
||||
}
|
||||
|
||||
RecordViewByUniqueKey.defaultProps = {};
|
||||
|
||||
const qController = Client.getInstance();
|
||||
|
||||
/***************************************************************************
|
||||
** Wrapper around RecordView, that reads a unique key from the query string,
|
||||
** looks for a record matching that key, and shows that record.
|
||||
***************************************************************************/
|
||||
export default function RecordViewByUniqueKey({table}: RecordViewByUniqueKeyProps): JSX.Element
|
||||
{
|
||||
const tableName = table.name;
|
||||
|
||||
const [asyncLoadInited, setAsyncLoadInited] = useState(false);
|
||||
const [tableMetaData, setTableMetaData] = useState(null as QTableMetaData);
|
||||
const [doneLoading, setDoneLoading] = useState(false);
|
||||
const [record, setRecord] = useState(null as QRecord);
|
||||
const [errorMessage, setErrorMessage] = useState(null as string);
|
||||
|
||||
const [queryParams] = useSearchParams();
|
||||
|
||||
if (!asyncLoadInited)
|
||||
{
|
||||
setAsyncLoadInited(true);
|
||||
|
||||
(async () =>
|
||||
{
|
||||
const tableMetaData = await qController.loadTableMetaData(tableName);
|
||||
setTableMetaData(tableMetaData);
|
||||
|
||||
const criteria: QFilterCriteria[] = [];
|
||||
for (let [name, value] of queryParams.entries())
|
||||
{
|
||||
criteria.push(new QFilterCriteria(name, QCriteriaOperator.EQUALS, [value]));
|
||||
if(!tableMetaData.fields.has(name))
|
||||
{
|
||||
setErrorMessage(`Query-string parameter [${name}] is not a defined field on the ${tableMetaData.label} table.`);
|
||||
setDoneLoading(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let queryJoins: QueryJoin[] = null;
|
||||
const visibleJoinTables = getVisibleJoinTables(tableMetaData);
|
||||
if (visibleJoinTables.size > 0)
|
||||
{
|
||||
queryJoins = TableUtils.getQueryJoins(tableMetaData, visibleJoinTables);
|
||||
}
|
||||
|
||||
const filter = new QQueryFilter(criteria, null, null, "AND", 0, 2);
|
||||
qController.query(tableName, filter, queryJoins)
|
||||
.then((queryResult) =>
|
||||
{
|
||||
setDoneLoading(true);
|
||||
if (queryResult.length == 1)
|
||||
{
|
||||
setRecord(queryResult[0]);
|
||||
}
|
||||
else if (queryResult.length == 0)
|
||||
{
|
||||
setErrorMessage(`No ${tableMetaData.label} record was found matching the given values.`);
|
||||
}
|
||||
else if (queryResult.length > 1)
|
||||
{
|
||||
setErrorMessage(`More than one ${tableMetaData.label} record was found matching the given values.`);
|
||||
}
|
||||
})
|
||||
.catch((error) =>
|
||||
{
|
||||
setDoneLoading(true);
|
||||
console.log(error);
|
||||
if (error && error.message)
|
||||
{
|
||||
setErrorMessage(error.message);
|
||||
}
|
||||
else if (error && error.response && error.response.data && error.response.data.error)
|
||||
{
|
||||
setErrorMessage(error.response.data.error);
|
||||
}
|
||||
else
|
||||
{
|
||||
setErrorMessage("Unexpected error running query");
|
||||
}
|
||||
});
|
||||
})();
|
||||
}
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
if (asyncLoadInited)
|
||||
{
|
||||
setAsyncLoadInited(false);
|
||||
setDoneLoading(false);
|
||||
setRecord(null);
|
||||
}
|
||||
}, [queryParams]);
|
||||
|
||||
if (!doneLoading)
|
||||
{
|
||||
return (<div>Loading...</div>);
|
||||
}
|
||||
else if (record)
|
||||
{
|
||||
return (<RecordView table={table} record={record} />);
|
||||
}
|
||||
else if (errorMessage)
|
||||
{
|
||||
return (<BaseLayout>
|
||||
<Box className="recordView">
|
||||
<Grid container>
|
||||
<Grid item xs={12}>
|
||||
<Box mb={3}>
|
||||
{
|
||||
<Alert color="error" sx={{mb: 3}}>{errorMessage}</Alert>
|
||||
}
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
</BaseLayout>);
|
||||
}
|
||||
}
|
@ -421,6 +421,14 @@ input[type="search"]::-webkit-search-results-decoration
|
||||
font-size: 2rem !important;
|
||||
}
|
||||
|
||||
.dashboard-table-actions-icon
|
||||
{
|
||||
font-size: 1.5rem !important;
|
||||
position: relative;
|
||||
top: -5px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.dashboard-schedule-icon
|
||||
{
|
||||
font-size: 1.1rem !important;
|
||||
@ -653,6 +661,11 @@ input[type="search"]::-webkit-search-results-decoration
|
||||
min-height: unset !important;
|
||||
}
|
||||
|
||||
.MuiDataGrid-columnHeaders
|
||||
{
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
/* new style for toggle buttons */
|
||||
.MuiToggleButtonGroup-root
|
||||
{
|
||||
@ -688,8 +701,590 @@ input[type="search"]::-webkit-search-results-decoration
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.recordView .widget
|
||||
{
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.entityForm .widget
|
||||
{
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.MuiPickersDay-root.Mui-selected, .MuiPickersDay-root.MuiPickersDay-dayWithMargin:hover
|
||||
{
|
||||
color: white;
|
||||
background-color: #0062FF !important;
|
||||
}
|
||||
|
||||
/* several styles below here for user-defined alert inside helpContent */
|
||||
.helpContentAlert
|
||||
{
|
||||
padding: 6px 16px;
|
||||
font-size: 1rem;
|
||||
font-weight: 300;
|
||||
line-height: 1.6;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.helpContentAlert .MuiAlert-icon
|
||||
{
|
||||
display: flex;
|
||||
margin-right: 12px;
|
||||
padding: 7px 0;
|
||||
font-size: 22px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.helpContentAlert .MuiAlert-icon .material-icons-round
|
||||
{
|
||||
display: inline-block;
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
}
|
||||
|
||||
.helpContentAlert .MuiAlert-message
|
||||
{
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.helpContentAlert.success
|
||||
{
|
||||
background-color: rgb(240, 248, 241);
|
||||
color: rgb(44, 76, 46);
|
||||
}
|
||||
|
||||
.helpContentAlert.success .MuiAlert-icon .material-icons-round
|
||||
{
|
||||
color: #4CAF50;
|
||||
}
|
||||
|
||||
.helpContentAlert.warning
|
||||
{
|
||||
background-color: rgb(254, 245, 234);
|
||||
color: rgb(100, 65, 20);
|
||||
}
|
||||
|
||||
.helpContentAlert.warning .MuiAlert-icon .material-icons-round
|
||||
{
|
||||
color: #fb8c00;
|
||||
}
|
||||
|
||||
.helpContentAlert.error
|
||||
{
|
||||
background-color: rgb(254, 239, 238);
|
||||
color: rgb(98, 41, 37);
|
||||
}
|
||||
|
||||
.helpContentAlert.error .MuiAlert-icon .material-icons-round
|
||||
{
|
||||
color: #F44335;
|
||||
}
|
||||
|
||||
/* the alert widget, was built with minimal (no?) margins, for embedding in
|
||||
a parent widget; but for using it on a process, give it some breathing room */
|
||||
.processRun .widget .MuiAlert-root
|
||||
{
|
||||
margin: 2rem 1rem;
|
||||
}
|
||||
|
||||
|
||||
.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;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.sqd-designer,
|
||||
.sqd-drag,
|
||||
.sqd-context-menu {
|
||||
font-size: 13px;
|
||||
line-height: 1em;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
@ -23,6 +23,7 @@ import {QController} from "@kingsrook/qqq-frontend-core/lib/controllers/QControl
|
||||
import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
|
||||
import {QFieldType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldType";
|
||||
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
|
||||
import {FilterVariableExpression} from "@kingsrook/qqq-frontend-core/lib/model/query/FilterVariableExpression";
|
||||
import {NowExpression} from "@kingsrook/qqq-frontend-core/lib/model/query/NowExpression";
|
||||
import {NowWithOffsetExpression} from "@kingsrook/qqq-frontend-core/lib/model/query/NowWithOffsetExpression";
|
||||
import {QCriteriaOperator} from "@kingsrook/qqq-frontend-core/lib/model/query/QCriteriaOperator";
|
||||
@ -108,6 +109,8 @@ class FilterUtils
|
||||
let [field, fieldTable] = TableUtils.getFieldAndTable(tableMetaData, criteria.fieldName);
|
||||
|
||||
let values = criteria.values;
|
||||
let hasFilterVariable = false;
|
||||
|
||||
if (field.possibleValueSourceName)
|
||||
{
|
||||
//////////////////////////////////////////////////////////////////////////////////
|
||||
@ -121,7 +124,17 @@ class FilterUtils
|
||||
//////////////////////////////////////////////////////////////////////////////////
|
||||
if (values && values.length > 0 && values[0] !== null && values[0] !== undefined && values[0] !== "")
|
||||
{
|
||||
values = await qController.possibleValues(fieldTable.name, null, field.name, "", values);
|
||||
////////////////////////////////////////////////////////////////////////
|
||||
// do not do this lookup if the field is a filter variable expression //
|
||||
////////////////////////////////////////////////////////////////////////
|
||||
if (values[0].type && values[0].type == "FilterVariableExpression")
|
||||
{
|
||||
hasFilterVariable = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
values = await qController.possibleValues(fieldTable.name, null, field.name, "", values);
|
||||
}
|
||||
}
|
||||
|
||||
////////////////////////////////////////////
|
||||
@ -232,6 +245,10 @@ class FilterUtils
|
||||
{
|
||||
return (new ThisOrLastPeriodExpression(value));
|
||||
}
|
||||
else if (value.type == "FilterVariableExpression")
|
||||
{
|
||||
return (new FilterVariableExpression(value));
|
||||
}
|
||||
}
|
||||
|
||||
return (null);
|
||||
@ -365,7 +382,12 @@ class FilterUtils
|
||||
for (let i = 0; i < maxLoops; i++)
|
||||
{
|
||||
const value = criteria.values[i];
|
||||
if (value.type == "NowWithOffset")
|
||||
if (value.type == "FilterVariableExpression")
|
||||
{
|
||||
const expression = new FilterVariableExpression(value);
|
||||
labels.push(expression.toString());
|
||||
}
|
||||
else if (value.type == "NowWithOffset")
|
||||
{
|
||||
const expression = new NowWithOffsetExpression(value);
|
||||
labels.push(expression.toString());
|
||||
@ -657,7 +679,7 @@ class FilterUtils
|
||||
|
||||
filterForBackend.subFilters = subFilters;
|
||||
|
||||
if(pageNumber !== undefined && rowsPerPage !== undefined)
|
||||
if (pageNumber !== undefined && rowsPerPage !== undefined)
|
||||
{
|
||||
filterForBackend.skip = pageNumber * rowsPerPage;
|
||||
filterForBackend.limit = rowsPerPage;
|
||||
|
@ -133,6 +133,11 @@ class TableUtils
|
||||
*******************************************************************************/
|
||||
public static getFieldAndTable(tableMetaData: QTableMetaData, fieldName: string): [QFieldMetaData, QTableMetaData]
|
||||
{
|
||||
if(!fieldName)
|
||||
{
|
||||
return [null, null];
|
||||
}
|
||||
|
||||
if (fieldName.indexOf(".") > -1)
|
||||
{
|
||||
const nameParts = fieldName.split(".", 2);
|
||||
|
@ -29,6 +29,9 @@ import com.kingsrook.qqq.backend.core.instances.QInstanceValidator;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.frontend.materialdashboard.junit.BaseTest;
|
||||
import com.kingsrook.qqq.frontend.materialdashboard.junit.TestUtils;
|
||||
import com.kingsrook.qqq.frontend.materialdashboard.model.metadata.fieldrules.FieldRule;
|
||||
import com.kingsrook.qqq.frontend.materialdashboard.model.metadata.fieldrules.FieldRuleAction;
|
||||
import com.kingsrook.qqq.frontend.materialdashboard.model.metadata.fieldrules.FieldRuleTrigger;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
@ -76,6 +79,37 @@ class MaterialDashboardTableMetaDataTest extends BaseTest
|
||||
|
||||
assertValidationFailureReasons(qInstance -> qInstance.getTable(TestUtils.TABLE_NAME_PERSON).withSupplementalMetaData(new MaterialDashboardTableMetaData().withDefaultQuickFilterFieldNames(List.of("firstName", "lastName", "firstName"))),
|
||||
"duplicated field name: firstName");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testValidateFieldRules()
|
||||
{
|
||||
assertValidationFailureReasons(qInstance -> qInstance.getTable(TestUtils.TABLE_NAME_PERSON).withSupplementalMetaData(new MaterialDashboardTableMetaData().withFieldRule(new FieldRule())),
|
||||
"without an action",
|
||||
"without a trigger",
|
||||
"without a sourceField");
|
||||
|
||||
assertValidationFailureReasons(qInstance -> qInstance.getTable(TestUtils.TABLE_NAME_PERSON).withSupplementalMetaData(new MaterialDashboardTableMetaData().withFieldRule(new FieldRule()
|
||||
.withTrigger(FieldRuleTrigger.ON_CHANGE)
|
||||
.withAction(FieldRuleAction.CLEAR_TARGET_FIELD)
|
||||
.withSourceField("notAField")
|
||||
.withTargetField("alsoNotAField")
|
||||
)),
|
||||
"unrecognized sourceField: notAField",
|
||||
"unrecognized targetField: alsoNotAField");
|
||||
|
||||
assertValidationFailureReasons(qInstance -> qInstance.getTable(TestUtils.TABLE_NAME_PERSON).withSupplementalMetaData(new MaterialDashboardTableMetaData().withFieldRule(new FieldRule()
|
||||
.withTrigger(FieldRuleTrigger.ON_CHANGE)
|
||||
.withAction(FieldRuleAction.RELOAD_WIDGET)
|
||||
.withSourceField("id")
|
||||
.withTargetWidget("notAWidget")
|
||||
)),
|
||||
"unrecognized targetWidget: notAWidget");
|
||||
|
||||
}
|
||||
|
||||
|
@ -33,6 +33,7 @@ import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.TestInfo;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.openqa.selenium.Dimension;
|
||||
import org.openqa.selenium.WebDriver;
|
||||
import org.openqa.selenium.chrome.ChromeDriver;
|
||||
@ -43,6 +44,7 @@ import static org.junit.jupiter.api.Assertions.fail;
|
||||
/*******************************************************************************
|
||||
** Base class for Selenium tests
|
||||
*******************************************************************************/
|
||||
@ExtendWith(SeleniumTestWatcher.class)
|
||||
public class QBaseSeleniumTest
|
||||
{
|
||||
protected static ChromeOptions chromeOptions;
|
||||
@ -93,6 +95,8 @@ public class QBaseSeleniumTest
|
||||
driver.manage().window().setSize(new Dimension(1700, 1300));
|
||||
qSeleniumLib = new QSeleniumLib(driver);
|
||||
|
||||
SeleniumTestWatcher.setCurrentSeleniumLib(qSeleniumLib);
|
||||
|
||||
if(useInternalJavalin())
|
||||
{
|
||||
qSeleniumJavalin = new QSeleniumJavalin();
|
||||
@ -197,10 +201,10 @@ public class QBaseSeleniumTest
|
||||
qSeleniumLib.takeScreenshotToFile(getClass().getSimpleName() + "/" + testInfo.getDisplayName());
|
||||
}
|
||||
|
||||
if(driver != null)
|
||||
{
|
||||
driver.quit();
|
||||
}
|
||||
////////////////////////////////////////////////////////////////////////////////////////
|
||||
// note - at one time we did a driver.quit here - but we're moving that into //
|
||||
// SeleniumTestWatcher, so it can dump logs if it wants to (it runs after the @After) //
|
||||
////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
if(qSeleniumJavalin != null)
|
||||
{
|
||||
|
@ -42,6 +42,8 @@ import org.openqa.selenium.StaleElementReferenceException;
|
||||
import org.openqa.selenium.WebDriver;
|
||||
import org.openqa.selenium.WebElement;
|
||||
import org.openqa.selenium.interactions.Actions;
|
||||
import org.openqa.selenium.logging.LogEntries;
|
||||
import org.openqa.selenium.logging.LogEntry;
|
||||
import org.openqa.selenium.support.ui.ExpectedConditions;
|
||||
import org.openqa.selenium.support.ui.WebDriverWait;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
@ -333,7 +335,7 @@ public class QSeleniumLib
|
||||
return;
|
||||
}
|
||||
|
||||
if(elements.stream().noneMatch(e -> e.getText().toLowerCase().contains(textContains)))
|
||||
if(elements.stream().noneMatch(e -> e.getText().toLowerCase().contains(textContains.toLowerCase())))
|
||||
{
|
||||
LOG.debug("Found non-existence of element(s) matching selector [" + cssSelector + "] containing text [" + textContains + "]");
|
||||
return;
|
||||
@ -343,7 +345,7 @@ public class QSeleniumLib
|
||||
}
|
||||
while(start + (1000 * WAIT_SECONDS) > System.currentTimeMillis());
|
||||
|
||||
fail("Failed for non-existence of element matching selector [" + cssSelector + "] after [" + WAIT_SECONDS + "] seconds.");
|
||||
fail("Failed for non-existence of element matching selector [" + cssSelector + "] containing text [" + textContains + "] after [" + WAIT_SECONDS + "] seconds.");
|
||||
}
|
||||
|
||||
|
||||
@ -735,4 +737,22 @@ public class QSeleniumLib
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public void dumpConsole()
|
||||
{
|
||||
Set<String> availableLogTypes = driver.manage().logs().getAvailableLogTypes();
|
||||
for(String logType : availableLogTypes)
|
||||
{
|
||||
LogEntries logEntries = driver.manage().logs().get(logType);
|
||||
for(LogEntry logEntry : logEntries)
|
||||
{
|
||||
System.out.println(logEntry.toJson());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -261,9 +261,7 @@ public class QueryScreenLib
|
||||
if(StringUtils.hasContent(value))
|
||||
{
|
||||
qSeleniumLib.waitForSelector(".filterValuesColumn INPUT").click();
|
||||
// todo - no, not in a listbox/LI here...
|
||||
qSeleniumLib.waitForSelectorContaining(".MuiAutocomplete-listbox LI", value).click();
|
||||
System.out.println(value);
|
||||
qSeleniumLib.waitForSelector(".filterValuesColumn INPUT").sendKeys(value);
|
||||
}
|
||||
|
||||
qSeleniumLib.clickBackdrop();
|
||||
@ -271,6 +269,21 @@ public class QueryScreenLib
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public void setBasicBooleanFilter(String fieldLabel, String operatorLabel)
|
||||
{
|
||||
qSeleniumLib.waitForSelectorContaining("BUTTON", fieldLabel).click();
|
||||
qSeleniumLib.waitForMillis(250);
|
||||
qSeleniumLib.waitForSelector("#criteriaOperator").click();
|
||||
qSeleniumLib.waitForSelectorContaining("LI", operatorLabel).click();
|
||||
|
||||
qSeleniumLib.clickBackdrop();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
|
@ -0,0 +1,126 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qqq.frontend.materialdashboard.selenium.lib;
|
||||
|
||||
|
||||
import java.util.Optional;
|
||||
import org.junit.jupiter.api.extension.ExtensionContext;
|
||||
import org.junit.jupiter.api.extension.TestWatcher;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public class SeleniumTestWatcher implements TestWatcher
|
||||
{
|
||||
private static QSeleniumLib qSeleniumLib;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public static void setCurrentSeleniumLib(QSeleniumLib qSeleniumLib)
|
||||
{
|
||||
SeleniumTestWatcher.qSeleniumLib = qSeleniumLib;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void testFailed(ExtensionContext context, Throwable cause)
|
||||
{
|
||||
if(qSeleniumLib != null)
|
||||
{
|
||||
System.out.println("Dumping browser console after failed test: " + context.getDisplayName());
|
||||
System.out.println("----------------------------------------------------------------------------");
|
||||
try
|
||||
{
|
||||
qSeleniumLib.dumpConsole();
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
System.out.println("Error dumping console:");
|
||||
e.printStackTrace();
|
||||
}
|
||||
System.out.println("----------------------------------------------------------------------------");
|
||||
}
|
||||
|
||||
tryToQuitSelenium();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private void tryToQuitSelenium()
|
||||
{
|
||||
if(qSeleniumLib != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
qSeleniumLib.driver.quit();
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
System.err.println("Error quiting selenium driver: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void testSuccessful(ExtensionContext context)
|
||||
{
|
||||
tryToQuitSelenium();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void testAborted(ExtensionContext context, Throwable cause)
|
||||
{
|
||||
tryToQuitSelenium();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void testDisabled(ExtensionContext context, Optional<String> reason)
|
||||
{
|
||||
tryToQuitSelenium();
|
||||
}
|
||||
}
|
@ -0,0 +1,172 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qqq.frontend.materialdashboard.selenium.tests;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.QBaseSeleniumTest;
|
||||
import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.QSeleniumLib;
|
||||
import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.QueryScreenLib;
|
||||
import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.javalin.QSeleniumJavalin;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.openqa.selenium.By;
|
||||
import org.openqa.selenium.Keys;
|
||||
import org.openqa.selenium.WebElement;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Test for Saved Report screen (table has some special behaviors)
|
||||
*******************************************************************************/
|
||||
public class SavedReportTest extends QBaseSeleniumTest
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected void addJavalinRoutes(QSeleniumJavalin qSeleniumJavalin)
|
||||
{
|
||||
super.addJavalinRoutes(qSeleniumJavalin);
|
||||
qSeleniumJavalin
|
||||
.withRouteToFile("/metaData/table/savedReport", "metaData/table/savedReport.json")
|
||||
.withRouteToFile("/widget/reportSetupWidget", "widget/reportSetupWidget.json")
|
||||
.withRouteToFile("/widget/pivotTableSetupWidget", "widget/pivotTableSetupWidget.json")
|
||||
.withRouteToFile("/data/savedReport/possibleValues/tableName", "data/savedReport/possibleValues/tableName.json")
|
||||
|
||||
.withRouteToFile("/data/person/count", "data/person/count.json")
|
||||
.withRouteToFile("/data/person/query", "data/person/index.json")
|
||||
;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testCreate()
|
||||
{
|
||||
qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/userCustomizations/savedReport/create", "Creating New Saved Report");
|
||||
|
||||
//////////////////////////////////////////////////////////////
|
||||
// make sure things are disabled before a table is selected //
|
||||
//////////////////////////////////////////////////////////////
|
||||
WebElement webElement = qSeleniumLib.waitForSelectorContaining("button", "Edit Filters and Columns");
|
||||
assertEquals("true", webElement.getAttribute("disabled"));
|
||||
|
||||
qSeleniumLib.waitForSelector("#label").click();
|
||||
qSeleniumLib.waitForSelector("#label").sendKeys("My Report");
|
||||
|
||||
qSeleniumLib.waitForSelector("#tableName").click();
|
||||
qSeleniumLib.waitForSelector("#tableName").sendKeys("Person" + Keys.DOWN + Keys.ENTER);
|
||||
|
||||
//////////////////////////////////
|
||||
// make sure things enabled now //
|
||||
//////////////////////////////////
|
||||
webElement = qSeleniumLib.waitForSelectorContaining("button", "Edit Filters and Columns");
|
||||
assertNull(webElement.getAttribute("disabled"));
|
||||
|
||||
////////////////////////////////////////////////////
|
||||
// open query-screen popup, wait for query to run //
|
||||
////////////////////////////////////////////////////
|
||||
qSeleniumJavalin.beginCapture();
|
||||
qSeleniumLib.waitForSelectorContaining("button", "Edit Filters and Columns").click();
|
||||
qSeleniumJavalin.waitForCapturedPath("/data/person/count");
|
||||
qSeleniumJavalin.waitForCapturedPath("/data/person/query");
|
||||
qSeleniumJavalin.endCapture();
|
||||
|
||||
QueryScreenLib queryScreenLib = new QueryScreenLib(qSeleniumLib);
|
||||
queryScreenLib.setBasicFilter("First Name", "contains", "Darin");
|
||||
|
||||
////////////////////////
|
||||
// close query screen //
|
||||
////////////////////////
|
||||
qSeleniumLib.waitForSelectorContaining("button", "OK").click();
|
||||
|
||||
//////////////////////////////////////////////////////
|
||||
// make sure query things appear on edit screen now //
|
||||
//////////////////////////////////////////////////////
|
||||
qSeleniumLib.waitForSelectorContaining(".advancedQueryString", "First Name");
|
||||
qSeleniumLib.waitForSelectorContaining(".advancedQueryString", "contains");
|
||||
qSeleniumLib.waitForSelectorContaining(".advancedQueryString", "Darin");
|
||||
List<WebElement> columns = qSeleniumLib.waitForSelectorContaining("h5", "Columns")
|
||||
.findElement(QSeleniumLib.PARENT)
|
||||
.findElements(By.cssSelector("DIV"));
|
||||
|
||||
assertThat(columns)
|
||||
.hasSizeGreaterThanOrEqualTo(5) // at least this many
|
||||
.anyMatch(we -> we.getText().equals("Home City")); // a few fields are found
|
||||
|
||||
///////////////////
|
||||
// turn on pivot //
|
||||
///////////////////
|
||||
qSeleniumLib.waitForSelectorContaining("label", "Use Pivot Table").click();
|
||||
qSeleniumLib.waitForSelectorContaining("button", "Edit Pivot Table").click();
|
||||
qSeleniumLib.waitForSelectorContaining("h3", "Edit Pivot Table");
|
||||
|
||||
///////////////
|
||||
// add a row //
|
||||
///////////////
|
||||
qSeleniumLib.waitForSelectorContaining(".MuiModal-root button", "Add new row").click();
|
||||
WebElement row0Input = qSeleniumLib.waitForSelector("#rows-0");
|
||||
row0Input.click();
|
||||
row0Input.sendKeys("Last Name" + Keys.ENTER);
|
||||
|
||||
//////////////////
|
||||
// add a column //
|
||||
//////////////////
|
||||
qSeleniumLib.waitForSelectorContaining(".MuiModal-root button", "Add new column").click();
|
||||
WebElement column0Input = qSeleniumLib.waitForSelector("#columns-0");
|
||||
column0Input.click();
|
||||
column0Input.sendKeys("Home City" + Keys.ENTER);
|
||||
|
||||
/////////////////
|
||||
// add a value //
|
||||
/////////////////
|
||||
qSeleniumLib.waitForSelectorContaining(".MuiModal-root button", "Add new value").click();
|
||||
WebElement value0Input = qSeleniumLib.waitForSelector("#values-field-0");
|
||||
value0Input.click();
|
||||
value0Input.sendKeys("Id" + Keys.ENTER);
|
||||
|
||||
/////////////////////////////////////////
|
||||
// try to submit - but expect an error //
|
||||
/////////////////////////////////////////
|
||||
qSeleniumLib.waitForSelectorContaining("button", "OK").click();
|
||||
qSeleniumLib.waitForSelectorContaining(".MuiAlert-standard", "Missing value in 1 field.").click();
|
||||
|
||||
///////////////////////////
|
||||
// now select a function //
|
||||
///////////////////////////
|
||||
WebElement function0Input = qSeleniumLib.waitForSelector("#values-function-0");
|
||||
function0Input.click();
|
||||
function0Input.sendKeys("Count" + Keys.ENTER);
|
||||
|
||||
qSeleniumLib.waitForSelectorContaining("button", "OK").click();
|
||||
qSeleniumLib.waitForSelectorContainingToNotExist("h3", "Edit Pivot Table");
|
||||
|
||||
// qSeleniumLib.waitForever();
|
||||
}
|
||||
|
||||
}
|
@ -153,16 +153,16 @@ public class QueryScreenTest extends QBaseSeleniumTest
|
||||
|
||||
queryScreenLib.addBasicFilter("Is Employed");
|
||||
|
||||
testBasicCriteria(queryScreenLib, "Is Employed", "equals yes", null, "(?s).*Is Employed:.*yes.*", """
|
||||
testBasicBooleanCriteria(queryScreenLib, "Is Employed", "equals yes", "(?s).*Is Employed:.*yes.*", """
|
||||
{"fieldName":"isEmployed","operator":"EQUALS","values":[true]}""");
|
||||
|
||||
testBasicCriteria(queryScreenLib, "Is Employed", "equals no", null, "(?s).*Is Employed:.*no.*", """
|
||||
testBasicBooleanCriteria(queryScreenLib, "Is Employed", "equals no", "(?s).*Is Employed:.*no.*", """
|
||||
{"fieldName":"isEmployed","operator":"EQUALS","values":[false]}""");
|
||||
|
||||
testBasicCriteria(queryScreenLib, "Is Employed", "is empty", null, "(?s).*Is Employed:.*is empty.*", """
|
||||
testBasicBooleanCriteria(queryScreenLib, "Is Employed", "is empty", "(?s).*Is Employed:.*is empty.*", """
|
||||
{"fieldName":"isEmployed","operator":"IS_BLANK","values":[]}""");
|
||||
|
||||
testBasicCriteria(queryScreenLib, "Is Employed", "is not empty", null, "(?s).*Is Employed:.*is not empty.*", """
|
||||
testBasicBooleanCriteria(queryScreenLib, "Is Employed", "is not empty", "(?s).*Is Employed:.*is not empty.*", """
|
||||
{"fieldName":"isEmployed","operator":"IS_NOT_BLANK","values":[]}""");
|
||||
}
|
||||
|
||||
@ -203,10 +203,10 @@ public class QueryScreenTest extends QBaseSeleniumTest
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private void testBasicCriteria(QueryScreenLib queryScreenLib, String fieldLabel, String operatorLabel, String value, String expectButtonStringRegex, String expectFilterJsonContains)
|
||||
private void testBasicBooleanCriteria(QueryScreenLib queryScreenLib, String fieldLabel, String operatorLabel, String expectButtonStringRegex, String expectFilterJsonContains)
|
||||
{
|
||||
qSeleniumJavalin.beginCapture();
|
||||
queryScreenLib.setBasicFilter(fieldLabel, operatorLabel, value);
|
||||
queryScreenLib.setBasicBooleanFilter(fieldLabel, operatorLabel);
|
||||
queryScreenLib.waitForBasicFilterButtonMatchingRegex(expectButtonStringRegex);
|
||||
qSeleniumJavalin.waitForCapturedPathWithBodyContaining("/data/person/query", expectFilterJsonContains);
|
||||
qSeleniumJavalin.endCapture();
|
||||
|
@ -0,0 +1,16 @@
|
||||
{
|
||||
"options": [
|
||||
{
|
||||
"id": "person",
|
||||
"label": "Person"
|
||||
},
|
||||
{
|
||||
"id": "city",
|
||||
"label": "City"
|
||||
},
|
||||
{
|
||||
"id": "savedReport",
|
||||
"label": "Saved Report"
|
||||
}
|
||||
]
|
||||
}
|
@ -189,6 +189,26 @@
|
||||
"insertPermission": true,
|
||||
"editPermission": true,
|
||||
"deletePermission": true
|
||||
},
|
||||
"savedReport": {
|
||||
"name": "savedReport",
|
||||
"label": "Saved Report",
|
||||
"isHidden": false,
|
||||
"iconName": "article",
|
||||
"capabilities": [
|
||||
"TABLE_COUNT",
|
||||
"TABLE_GET",
|
||||
"TABLE_QUERY",
|
||||
"QUERY_STATS",
|
||||
"TABLE_UPDATE",
|
||||
"TABLE_INSERT",
|
||||
"TABLE_DELETE"
|
||||
],
|
||||
"readPermission": true,
|
||||
"insertPermission": true,
|
||||
"editPermission": true,
|
||||
"deletePermission": true,
|
||||
"usesVariants": false
|
||||
}
|
||||
},
|
||||
"processes": {
|
||||
@ -420,6 +440,40 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"userCustomizations": {
|
||||
"name": "userCustomizations",
|
||||
"label": "User Customizations",
|
||||
"iconName": "article",
|
||||
"widgets": [],
|
||||
"children": [
|
||||
{
|
||||
"type": "TABLE",
|
||||
"name": "savedReport",
|
||||
"label": "Saved Report",
|
||||
"iconName": "article"
|
||||
}
|
||||
],
|
||||
"childMap": {
|
||||
"savedReport": {
|
||||
"type": "TABLE",
|
||||
"name": "savedReport",
|
||||
"label": "Saved Report",
|
||||
"iconName": "article"
|
||||
}
|
||||
},
|
||||
"sections": [
|
||||
{
|
||||
"name": "userCustomizations",
|
||||
"label": "User Customizations",
|
||||
"icon": {
|
||||
"name": "badge"
|
||||
},
|
||||
"tables": [
|
||||
"savedReport"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"miscellaneous": {
|
||||
"name": "miscellaneous",
|
||||
"label": "Miscellaneous",
|
||||
@ -730,6 +784,20 @@
|
||||
}
|
||||
],
|
||||
"iconName": "data_object"
|
||||
},
|
||||
{
|
||||
"type": "APP",
|
||||
"name": "userCustomizations",
|
||||
"label": "User Customizations",
|
||||
"children": [
|
||||
{
|
||||
"type": "TABLE",
|
||||
"name": "savedReport",
|
||||
"label": "Saved Report",
|
||||
"iconName": "article"
|
||||
}
|
||||
],
|
||||
"iconName": "data_object"
|
||||
}
|
||||
],
|
||||
"branding": {
|
||||
@ -750,6 +818,28 @@
|
||||
"isCard": true,
|
||||
"storeDropdownSelections": false,
|
||||
"hasPermission": true
|
||||
},
|
||||
"reportSetupWidget": {
|
||||
"name": "reportSetupWidget",
|
||||
"label": "Filters and Columns",
|
||||
"type": "filterAndColumnsSetup",
|
||||
"isCard": true,
|
||||
"storeDropdownSelections": false,
|
||||
"showReloadButton": true,
|
||||
"showExportButton": false,
|
||||
"defaultValues": {},
|
||||
"hasPermission": true
|
||||
},
|
||||
"pivotTableSetupWidget": {
|
||||
"name": "pivotTableSetupWidget",
|
||||
"label": "Pivot Table",
|
||||
"type": "pivotTableSetup",
|
||||
"isCard": true,
|
||||
"storeDropdownSelections": false,
|
||||
"showReloadButton": true,
|
||||
"showExportButton": false,
|
||||
"defaultValues": {},
|
||||
"hasPermission": true
|
||||
}
|
||||
},
|
||||
"environmentValues": {
|
||||
|
218
src/test/resources/fixtures/metaData/table/savedReport.json
Normal file
218
src/test/resources/fixtures/metaData/table/savedReport.json
Normal file
@ -0,0 +1,218 @@
|
||||
{
|
||||
"table": {
|
||||
"name": "savedReport",
|
||||
"label": "Saved Report",
|
||||
"isHidden": false,
|
||||
"primaryKeyField": "id",
|
||||
"iconName": "article",
|
||||
"fields": {
|
||||
"queryFilterJson": {
|
||||
"name": "queryFilterJson",
|
||||
"label": "Query Filter",
|
||||
"type": "STRING",
|
||||
"isRequired": false,
|
||||
"isEditable": true,
|
||||
"isHeavy": false,
|
||||
"displayFormat": "%s"
|
||||
},
|
||||
"columnsJson": {
|
||||
"name": "columnsJson",
|
||||
"label": "Columns",
|
||||
"type": "STRING",
|
||||
"isRequired": false,
|
||||
"isEditable": true,
|
||||
"isHeavy": false,
|
||||
"displayFormat": "%s"
|
||||
},
|
||||
"inputFieldsJson": {
|
||||
"name": "inputFieldsJson",
|
||||
"label": "Input Fields",
|
||||
"type": "STRING",
|
||||
"isRequired": false,
|
||||
"isEditable": true,
|
||||
"isHeavy": false,
|
||||
"displayFormat": "%s"
|
||||
},
|
||||
"pivotTableJson": {
|
||||
"name": "pivotTableJson",
|
||||
"label": "Pivot Table",
|
||||
"type": "STRING",
|
||||
"isRequired": false,
|
||||
"isEditable": true,
|
||||
"isHeavy": false,
|
||||
"displayFormat": "%s"
|
||||
},
|
||||
"modifyDate": {
|
||||
"name": "modifyDate",
|
||||
"label": "Modify Date",
|
||||
"type": "DATE_TIME",
|
||||
"isRequired": false,
|
||||
"isEditable": false,
|
||||
"isHeavy": false,
|
||||
"displayFormat": "%s"
|
||||
},
|
||||
"label": {
|
||||
"name": "label",
|
||||
"label": "Report Name",
|
||||
"type": "STRING",
|
||||
"isRequired": true,
|
||||
"isEditable": true,
|
||||
"isHeavy": false,
|
||||
"displayFormat": "%s"
|
||||
},
|
||||
"id": {
|
||||
"name": "id",
|
||||
"label": "Id",
|
||||
"type": "INTEGER",
|
||||
"isRequired": false,
|
||||
"isEditable": false,
|
||||
"isHeavy": false,
|
||||
"displayFormat": "%s"
|
||||
},
|
||||
"userId": {
|
||||
"name": "userId",
|
||||
"label": "User Id",
|
||||
"type": "STRING",
|
||||
"isRequired": false,
|
||||
"isEditable": true,
|
||||
"isHeavy": false,
|
||||
"displayFormat": "%s"
|
||||
},
|
||||
"tableName": {
|
||||
"name": "tableName",
|
||||
"label": "Table",
|
||||
"type": "STRING",
|
||||
"isRequired": true,
|
||||
"isEditable": true,
|
||||
"isHeavy": false,
|
||||
"possibleValueSourceName": "tables",
|
||||
"displayFormat": "%s"
|
||||
},
|
||||
"createDate": {
|
||||
"name": "createDate",
|
||||
"label": "Create Date",
|
||||
"type": "DATE_TIME",
|
||||
"isRequired": false,
|
||||
"isEditable": false,
|
||||
"isHeavy": false,
|
||||
"displayFormat": "%s"
|
||||
}
|
||||
},
|
||||
"sections": [
|
||||
{
|
||||
"name": "identity",
|
||||
"label": "Identity",
|
||||
"tier": "T1",
|
||||
"fieldNames": [
|
||||
"id",
|
||||
"label",
|
||||
"tableName"
|
||||
],
|
||||
"icon": {
|
||||
"name": "badge"
|
||||
},
|
||||
"isHidden": false
|
||||
},
|
||||
{
|
||||
"name": "filtersAndColumns",
|
||||
"label": "Filters and Columns",
|
||||
"tier": "T2",
|
||||
"widgetName": "reportSetupWidget",
|
||||
"icon": {
|
||||
"name": "table_chart"
|
||||
},
|
||||
"isHidden": false
|
||||
},
|
||||
{
|
||||
"name": "pivotTable",
|
||||
"label": "Pivot Table",
|
||||
"tier": "T2",
|
||||
"widgetName": "pivotTableSetupWidget",
|
||||
"icon": {
|
||||
"name": "pivot_table_chart"
|
||||
},
|
||||
"isHidden": false
|
||||
},
|
||||
{
|
||||
"name": "data",
|
||||
"label": "Data",
|
||||
"tier": "T2",
|
||||
"fieldNames": [
|
||||
"queryFilterJson",
|
||||
"columnsJson",
|
||||
"pivotTableJson"
|
||||
],
|
||||
"icon": {
|
||||
"name": "text_snippet"
|
||||
},
|
||||
"isHidden": true
|
||||
},
|
||||
{
|
||||
"name": "hidden",
|
||||
"label": "Hidden",
|
||||
"tier": "T2",
|
||||
"fieldNames": [
|
||||
"inputFieldsJson",
|
||||
"userId"
|
||||
],
|
||||
"icon": {
|
||||
"name": "text_snippet"
|
||||
},
|
||||
"isHidden": true
|
||||
},
|
||||
{
|
||||
"name": "dates",
|
||||
"label": "Dates",
|
||||
"tier": "T3",
|
||||
"fieldNames": [
|
||||
"createDate",
|
||||
"modifyDate"
|
||||
],
|
||||
"icon": {
|
||||
"name": "calendar_month"
|
||||
},
|
||||
"isHidden": false
|
||||
}
|
||||
],
|
||||
"exposedJoins": [],
|
||||
"supplementalTableMetaData": {
|
||||
"materialDashboard": {
|
||||
"fieldRules": [
|
||||
{
|
||||
"trigger": "ON_CHANGE",
|
||||
"sourceField": "tableName",
|
||||
"action": "CLEAR_TARGET_FIELD",
|
||||
"targetField": "queryFilterJson"
|
||||
},
|
||||
{
|
||||
"trigger": "ON_CHANGE",
|
||||
"sourceField": "tableName",
|
||||
"action": "CLEAR_TARGET_FIELD",
|
||||
"targetField": "columnsJson"
|
||||
},
|
||||
{
|
||||
"trigger": "ON_CHANGE",
|
||||
"sourceField": "tableName",
|
||||
"action": "CLEAR_TARGET_FIELD",
|
||||
"targetField": "pivotTableJson"
|
||||
}
|
||||
],
|
||||
"type": "materialDashboard"
|
||||
}
|
||||
},
|
||||
"capabilities": [
|
||||
"TABLE_COUNT",
|
||||
"TABLE_GET",
|
||||
"TABLE_QUERY",
|
||||
"QUERY_STATS",
|
||||
"TABLE_UPDATE",
|
||||
"TABLE_INSERT",
|
||||
"TABLE_DELETE"
|
||||
],
|
||||
"readPermission": true,
|
||||
"insertPermission": true,
|
||||
"editPermission": true,
|
||||
"deletePermission": true,
|
||||
"usesVariants": false
|
||||
}
|
||||
}
|
@ -0,0 +1 @@
|
||||
{"type":"pivotTableSetup"}
|
@ -0,0 +1 @@
|
||||
{"type":"reportSetup"}
|
Reference in New Issue
Block a user