Compare commits

...

67 Commits

Author SHA1 Message Date
3adb8ab4ba CE-1727 - Improve (fix?) next/submit handling for validation screen and linear flow. 2024-09-20 12:17:30 -05:00
98a02cda96 CE-1727 - Add support for ad-hoc, composite/block widgets; support processFlow (e.g., non-linear); some mobile-style adjustments 2024-09-20 11:07:22 -05:00
aee4becda5 CE-1727 - Initial checkin 2024-09-20 11:06:29 -05:00
f13c2c276f CE-1727 - mobile style adjust 2024-09-20 11:06:14 -05:00
a99272767b CE-1727 - Update qfc to 1.0.107 (add process.stepFlow) 2024-09-20 11:05:54 -05:00
a3236b426e CE-1727 - minor mobile placement adjustments 2024-09-20 10:44:41 -05:00
597fde977f CE-1727 - in Yup required message, show 'This field' instead of 'undefined' if field doesn't have a label 2024-09-20 10:44:26 -05:00
e303ed0b43 CE-1727 - Initial checkin 2024-09-20 10:43:57 -05:00
2b057768b3 CE-1727 - Style adjustments re: mobile processes 2024-09-20 10:43:37 -05:00
504a43d9c3 CE-1727 - Add layout FLEX_ROW_CENTER; Add actionCallback 2024-09-20 10:43:19 -05:00
33e56f823d CE-1727 - Add BlockData.conditional attribute; Add actionCallback to StandardBlockComponentProps 2024-09-20 10:42:55 -05:00
dc8fdb33dc CE-1727 - New blocks and styles for (mobile-style) widgets in processes, plus callbacks and contextValues 2024-09-20 10:42:22 -05:00
efa67da7f9 CE-1727 - Switch from use of updatedFrontendStepList to processMetaDataAdjustment, which can include updatedFields (specifically adding to change a dynamic possible value source, but, seems more flexible than just that) 2024-09-09 16:04:16 -05:00
3dc92aec88 CE-1727 - Add inlinePossibleValueSources option to fields; pass more data into DynamicSelect in a named object, so it's a little less loosely defined. 2024-09-09 16:01:30 -05:00
d2705c3aed Avoid null-pointer in TableUtils.getSectionsForRecordSidebar callback, if instance has no widgets 2024-09-05 19:05:40 -05:00
1d965bcdee Merge tag 'version-0.22.0' into dev
Tag release
2024-09-04 20:15:14 -05:00
894a9c2afc Merge branch 'release/0.22.0' 2024-09-04 20:15:14 -05:00
d25f124d87 Update for next development version 2024-09-04 20:00:14 -05:00
fd5055e502 Update versions for release 2024-09-04 20:00:10 -05:00
326367fbe0 Merge pull request #72 from Kingsrook/feature/CE-1647-update-filter-widget-to-show-preview
CE-1647: added preview to query filter widget
2024-09-04 16:17:40 -05:00
bb6f818457 Merge pull request #71 from Kingsrook/feature/CE-1646-possible-value-filter-bug
Feature/ce 1646 possible value filter bug
2024-09-04 16:17:25 -05:00
1cd6e07907 Merge pull request #70 from Kingsrook/feature/CE-1643-query-date-bugs
Feature/ce 1643 query date bugs
2024-09-04 16:16:59 -05:00
e839da6123 CE-1405 - Put margin-left on this, so if its used in a big number block, and it wraps, it does right, i think 2024-08-29 16:03:26 -05:00
34a4fc19b4 CE-1647: added preview to query filter widget 2024-08-27 17:52:34 -05:00
2cc7e9ebe1 CE-1643 Add a timezone conversion to the formatDate function for the case where it took a string rather than a Date as input, in which case, the new Date() call would be appying a timezone, and making us off-by-one (for some side of the prime merdian i think) 2024-08-23 15:13:44 -05:00
128a748b63 CE-1643 Add fontVariantNumeric: "tabular-nums" to the thing with numbers that count up, so it's awesome. 2024-08-23 15:11:52 -05:00
1284e3a22c CE-1643 change default operator for DATEs to be equals 2024-08-23 15:11:26 -05:00
ae358b9067 Merge tag 'version-0.21.0' into dev
Tag release
2024-08-23 14:42:25 -05:00
dc20c3d5ec Turn off postReleaseGoals=install in gitflow-maven-plugin 2024-08-23 14:41:55 -05:00
71a9c6470a Merge branch 'release/0.21.0' 2024-08-23 14:39:48 -05:00
765d40aef1 Add skipTestProject to gitflow-maven-plugin 2024-08-23 14:39:39 -05:00
d9f1642f0a Update for next development version 2024-08-23 14:10:54 -05:00
858540427d Update versions for release 2024-08-23 14:10:51 -05:00
eecb2d4489 Update qqq-backend-core dep to 0.21.0 2024-08-23 14:10:29 -05:00
5a6293cfdf Merged dev into feature/CE-1646-possible-value-filter-bug 2024-08-23 10:27:45 -05:00
868022408c Merge pull request #68 from Kingsrook/feature/CE-1555-ops-overview-fix-accordion
Feature/ce 1555 ops overview fix accordion
2024-08-23 10:26:53 -05:00
d090a665ff Merge pull request #69 from Kingsrook/feature/CE-1556-ops-overview-enhanced-tooltips
Feature/ce 1556 ops overview enhanced tooltips
2024-08-23 10:24:15 -05:00
f112cf5543 Remove sold border 2024-08-23 10:24:00 -05:00
0c2dcb1215 Update qqq-frontend-core to 1.0.105 2024-08-23 10:07:18 -05:00
418f7957a2 CE-1646 pass useCase (filter or form) into DynamicSelect and down to possibleValue backend calls 2024-08-23 08:51:24 -05:00
8be8bf367a CE-1405 / CE-1479 - Add missing ? 2024-08-21 08:52:57 -05:00
1ca1313a25 CE-1405 / CE-1479 - Let widget meta data default values set more grid cols per size classes 2024-08-21 08:35:35 -05:00
4533815535 CE-1554: added ability to overlay html over a block widget 2024-08-20 15:42:58 -05:00
4230f34b15 Only output Link if link has an href (else page blows up) 2024-08-20 10:07:45 -05:00
e08e37222b CE-1556: updated to try to use composite block data within tooltips 2024-08-13 16:22:12 -05:00
0ffada6aec CE-1555: updates 'accordian' behavior of tables 2024-08-12 12:09:29 -05:00
9f04d897a1 Merge branch 'feature/style-cleanups-20240725' into feature/CE-1555-ops-overview-fix-accordion 2024-08-09 13:44:52 -05:00
e604f47231 Fixes for data table css redo (a z-index on headers, and use background color (as sx prop) in body cells 2024-07-26 10:34:18 -05:00
93f5bb688c Merge pull request #66 from Kingsrook/feature/CE-1460-export-and-join-bugs
Feature/ce 1460 export and join bugs
2024-07-25 11:53:35 -05:00
3fa017e8b9 Update selector and widths per css change 2024-07-25 09:29:17 -05:00
9d5af539b9 Re-do css on table skeleton, per changes in the included DataTable*Cell components 2024-07-25 09:29:13 -05:00
97bab57974 Re-do css on tables, to do the whole table as divs with display: grid 2024-07-25 08:37:37 -05:00
d9de96ea7f Make whole top-right bar display:none at under md breakpoints 2024-07-25 08:36:13 -05:00
ff839d85fd Merged dev into feature/CE-1460-export-and-join-bugs 2024-07-09 11:36:32 -05:00
d31215f6c0 Added dev for build, not test 2024-07-09 10:09:25 -05:00
262855b9c0 Increase version to 0.21.0-SNAPSHOT 2024-07-09 10:08:32 -05:00
4d082c3c57 Update revision to 0.20.0, to publish that release version 2024-07-05 20:38:36 -05:00
45b6b42836 Add a ? in case no valueCount records came back - which, can happen for a join-field where there were no matching join records. 2024-07-05 12:42:42 -05:00
47fb7cc2e3 Merge pull request #65 from Kingsrook/feature/CE-1402-field-case-change-behaviors
Feature/ce 1402 field case change behaviors
2024-07-03 16:29:05 -05:00
647c63f5a3 Add ErrorBoundary, and wrap HelpContent with it 2024-06-25 13:32:13 -05:00
f545649882 CE-1402 Make consistent naming 'behaviors', not 'fieldBehaviors' 2024-06-25 08:40:30 -05:00
4d4610801f Add "&& npm dedupe --force" to clean-and-install 2024-06-25 07:58:29 -05:00
3ec43fbbd3 CE-1402 Only do flushSync and setSelectionRange after a toUpper/Lower and add a try-catch, just in case (specifically, because failed on input type=number) 2024-06-25 07:58:12 -05:00
28bc07cce4 CE-1402 Look for toUpperCase/toLowerCase behaviors on fields, and apply those transforms (plus cursor adjustments) in input fields and filter criteria values 2024-06-24 20:44:15 -05:00
c7d31fa39e Better matching for multi-word search terms ("one th" now matches "one two three") 2024-06-19 16:43:26 -05:00
69f1cfe92f Merge pull request #64 from Kingsrook/feature/fix-process-pvs-display-value
Fix to fetch possible-values when switching screens, to display label…
2024-06-19 16:32:38 -05:00
2ed95ff77a Update to not submit 'undefined' values to backend 2024-06-14 11:38:55 -05:00
47 changed files with 1687 additions and 376 deletions

View File

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

View File

@ -6,7 +6,7 @@
"@auth0/auth0-react": "1.10.2", "@auth0/auth0-react": "1.10.2",
"@emotion/react": "11.7.1", "@emotion/react": "11.7.1",
"@emotion/styled": "11.6.0", "@emotion/styled": "11.6.0",
"@kingsrook/qqq-frontend-core": "1.0.102", "@kingsrook/qqq-frontend-core": "1.0.108",
"@mui/icons-material": "5.4.1", "@mui/icons-material": "5.4.1",
"@mui/material": "5.11.1", "@mui/material": "5.11.1",
"@mui/styles": "5.11.1", "@mui/styles": "5.11.1",
@ -59,7 +59,7 @@
"build": "react-scripts build", "build": "react-scripts build",
"clean": "rm -rf node_modules package-lock.json lib", "clean": "rm -rf node_modules package-lock.json lib",
"eject": "react-scripts eject", "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", "npm-install": "npm install --legacy-peer-deps",
"prepublishOnly": "tsc -p ./ --outDir lib/", "prepublishOnly": "tsc -p ./ --outDir lib/",
"start": "BROWSER=none react-scripts --max-http-header-size=65535 start", "start": "BROWSER=none react-scripts --max-http-header-size=65535 start",

View File

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

View File

@ -72,7 +72,7 @@ const CommandMenu = ({metaData}: Props) =>
const navigate = useNavigate(); const navigate = useNavigate();
const pathParts = location.pathname.replace(/\/+$/, "").split("/"); 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(); const classes = useStyles();
@ -87,6 +87,7 @@ const CommandMenu = ({metaData}: Props) =>
if (e.key === "." && !keyboardHelpOpen) if (e.key === "." && !keyboardHelpOpen)
{ {
e.preventDefault(); e.preventDefault();
recordAnalytics({category: "globalEvents", action: "dotMenuKeyboardShortcut"});
setDotMenuOpen(true); setDotMenuOpen(true);
} }
else if (e.key === "?" && !dotMenuOpen) else if (e.key === "?" && !dotMenuOpen)
@ -423,9 +424,20 @@ const CommandMenu = ({metaData}: Props) =>
///////////////////////////////////////////////////////////////////////////////////////////////////////////// /////////////////////////////////////////////////////////////////////////////////////////////////////////////
// iterate over the search parts - if any don't match the corresponding value parts, then it's a non-match // // 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++) for (let i = 0; i < searchParts.length; i++)
{ {
if (!valueParts[i].includes(searchParts[i])) let foundMatch = false;
for (; valueIndex < valueParts.length; valueIndex++)
{
if (valueParts[valueIndex].includes(searchParts[i]))
{
foundMatch = true;
break;
}
}
if (!foundMatch)
{ {
return (0); return (0);
} }

View File

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

View File

@ -172,17 +172,14 @@ function QDynamicForm({formData, formLabel, bulkEditMode, bulkEditSwitchChangeHa
<Grid item xs={12} sm={6} key={fieldName}> <Grid item xs={12} sm={6} key={fieldName}>
{labelElement} {labelElement}
<DynamicSelect <DynamicSelect
tableName={field.possibleValueProps.tableName} fieldPossibleValueProps={field.possibleValueProps}
processName={field.possibleValueProps.processName}
possibleValueSourceName={field.possibleValueProps.possibleValueSourceName}
fieldName={field.possibleValueProps.fieldName}
isEditable={field.isEditable} isEditable={field.isEditable}
fieldLabel="" fieldLabel=""
initialValue={values[fieldName]} initialValue={values[fieldName]}
initialDisplayValue={field.possibleValueProps.initialDisplayValue}
bulkEditMode={bulkEditMode} bulkEditMode={bulkEditMode}
bulkEditSwitchChangeHandler={bulkEditSwitchChanged} bulkEditSwitchChangeHandler={bulkEditSwitchChanged}
otherValues={otherValuesMap} otherValues={otherValuesMap}
useCase="form"
/> />
{formattedHelpContent} {formattedHelpContent}
</Grid> </Grid>

View File

@ -19,16 +19,17 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import {InputAdornment, InputLabel} from "@mui/material"; import {Box, InputAdornment, InputLabel} from "@mui/material";
import Box from "@mui/material/Box";
import Switch from "@mui/material/Switch"; import Switch from "@mui/material/Switch";
import {ErrorMessage, Field, useFormikContext} from "formik"; 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 AceEditor from "react-ace";
import colors from "qqq/assets/theme/base/colors"; import colors from "qqq/assets/theme/base/colors";
import BooleanFieldSwitch from "qqq/components/forms/BooleanFieldSwitch"; import BooleanFieldSwitch from "qqq/components/forms/BooleanFieldSwitch";
import MDInput from "qqq/components/legacy/MDInput"; import MDInput from "qqq/components/legacy/MDInput";
import MDTypography from "qqq/components/legacy/MDTypography"; import MDTypography from "qqq/components/legacy/MDTypography";
import {flushSync} from "react-dom";
// Declaring props types for FormField // Declaring props types for FormField
interface Props 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 field;
let getsBulkEditHtmlLabel = true; let getsBulkEditHtmlLabel = true;
if (type === "checkbox") if (type === "checkbox")
@ -102,7 +148,7 @@ function QDynamicFormField({
else if (type === "ace") else if (type === "ace")
{ {
let mode = "text"; let mode = "text";
if(formFieldObject && formFieldObject.languageMode) if (formFieldObject && formFieldObject.languageMode)
{ {
mode = formFieldObject.languageMode; mode = formFieldObject.languageMode;
} }
@ -133,7 +179,7 @@ function QDynamicFormField({
{ {
field = ( 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) => onKeyPress={(e: any) =>
{ {
if (e.key === "Enter") if (e.key === "Enter")
@ -173,7 +219,8 @@ function QDynamicFormField({
id={`bulkEditSwitch-${name}`} id={`bulkEditSwitch-${name}`}
checked={switchChecked} checked={switchChecked}
onClick={bulkEditSwitchChanged} onClick={bulkEditSwitchChanged}
sx={{top: "-4px", sx={{
top: "-4px",
"& .MuiSwitch-track": { "& .MuiSwitch-track": {
height: 20, height: 20,
borderRadius: 10, borderRadius: 10,

View File

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

View File

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

View File

@ -502,7 +502,7 @@ function EntityForm(props: Props): JSX.Element
///////////////////////////////////////////////// /////////////////////////////////////////////////
const tableSections = TableUtils.getSectionsForRecordSidebar(tableMetaData, [...tableMetaData.fields.keys()], (section: QTableSection) => const tableSections = TableUtils.getSectionsForRecordSidebar(tableMetaData, [...tableMetaData.fields.keys()], (section: QTableSection) =>
{ {
const widget = metaData?.widgets.get(section.widgetName); const widget = metaData?.widgets?.get(section.widgetName);
if (widget) if (widget)
{ {
if (widget.type == "childRecordList" && widget.defaultValues?.has("manageAssociationName")) if (widget.type == "childRecordList" && widget.defaultValues?.has("manageAssociationName"))
@ -1152,11 +1152,11 @@ function EntityForm(props: Props): JSX.Element
<Grid container spacing={3}> <Grid container spacing={3}>
{ {
!props.isModal && !props.isModal &&
<Grid item xs={12} lg={3}> <Grid item xs={12} lg={3} className="recordSidebar">
<QRecordSidebar tableSections={tableSections} /> <QRecordSidebar tableSections={tableSections} />
</Grid> </Grid>
} }
<Grid item xs={12} lg={props.isModal ? 12 : 9}> <Grid item xs={12} lg={props.isModal ? 12 : 9} className={props.isModal ? "" : "recordWithSidebar"}>
<Formik <Formik
initialValues={initialValues} initialValues={initialValues}

View File

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

View File

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

View 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;

View File

@ -22,6 +22,7 @@
import {QHelpContent} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QHelpContent"; import {QHelpContent} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QHelpContent";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import parse from "html-react-parser"; import parse from "html-react-parser";
import ErrorBoundary from "qqq/components/misc/ErrorBoundary";
import React, {useContext} from "react"; import React, {useContext} from "react";
import Markdown from "react-markdown"; import Markdown from "react-markdown";
import QContext from "QContext"; import QContext from "QContext";
@ -128,6 +129,7 @@ function HelpContent({helpContents, roles, heading, helpContentKey}: Props): JSX
let selectedHelpContent = getMatchingHelpContent(helpContentsArray, roles); let selectedHelpContent = getMatchingHelpContent(helpContentsArray, roles);
let content = null; let content = null;
let errorContent = "Error rendering help content.";
if (helpHelpActive) if (helpHelpActive)
{ {
if (!selectedHelpContent) if (!selectedHelpContent)
@ -135,6 +137,7 @@ function HelpContent({helpContents, roles, heading, helpContentKey}: Props): JSX
selectedHelpContent = new QHelpContent({content: ""}); selectedHelpContent = new QHelpContent({content: ""});
} }
content = selectedHelpContent.content + ` [${helpContentKey ?? "?"}]`; content = selectedHelpContent.content + ` [${helpContentKey ?? "?"}]`;
errorContent += ` [${helpContentKey ?? "?"}]`;
} }
else if(selectedHelpContent) else if(selectedHelpContent)
{ {
@ -148,7 +151,9 @@ function HelpContent({helpContents, roles, heading, helpContentKey}: Props): JSX
{ {
return <Box display="inline" className="helpContent"> return <Box display="inline" className="helpContent">
{heading && <span className="header">{heading}</span>} {heading && <span className="header">{heading}</span>}
{formatHelpContent(content, selectedHelpContent.format)} <ErrorBoundary errorElement={<i>{errorContent}</i>}>
{formatHelpContent(content, selectedHelpContent.format)}
</ErrorBoundary>
</Box>; </Box>;
} }

View File

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

View File

@ -273,7 +273,7 @@ function ValidationReview({
); );
return ( return (
<Box m={3}> <Box m={{xs: 0, md: 3}} mt={"3rem!important"}>
<Grid container spacing={2}> <Grid container spacing={2}>
<Grid item xs={12} lg={6}> <Grid item xs={12} lg={6}>
<MDTypography color="body" variant="button"> <MDTypography color="body" variant="button">

View File

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

View File

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

View File

@ -30,6 +30,7 @@ import Icon from "@mui/material/Icon";
import IconButton from "@mui/material/IconButton"; import IconButton from "@mui/material/IconButton";
import InputAdornment from "@mui/material/InputAdornment/InputAdornment"; import InputAdornment from "@mui/material/InputAdornment/InputAdornment";
import TextField from "@mui/material/TextField"; import TextField from "@mui/material/TextField";
import DynamicFormUtils from "qqq/components/forms/DynamicFormUtils";
import DynamicSelect from "qqq/components/forms/DynamicSelect"; import DynamicSelect from "qqq/components/forms/DynamicSelect";
import AssignFilterVariable from "qqq/components/query/AssignFilterVariable"; import AssignFilterVariable from "qqq/components/query/AssignFilterVariable";
import CriteriaDateField, {NoWrapTooltip} from "qqq/components/query/CriteriaDateField"; import CriteriaDateField, {NoWrapTooltip} from "qqq/components/query/CriteriaDateField";
@ -40,6 +41,7 @@ import {OperatorOption, ValueMode} from "qqq/components/query/FilterCriteriaRow"
import {QueryScreenUsage} from "qqq/pages/records/query/RecordQuery"; import {QueryScreenUsage} from "qqq/pages/records/query/RecordQuery";
import ValueUtils from "qqq/utils/qqq/ValueUtils"; import ValueUtils from "qqq/utils/qqq/ValueUtils";
import React, {SyntheticEvent, useReducer} from "react"; import React, {SyntheticEvent, useReducer} from "react";
import {flushSync} from "react-dom";
interface Props interface Props
{ {
@ -58,6 +60,10 @@ FilterCriteriaRowValues.defaultProps =
initiallyOpenMultiValuePvs: false initiallyOpenMultiValuePvs: false
}; };
/***************************************************************************
* get the type to use for an <input> from a QFieldMetaData
***************************************************************************/
export const getTypeForTextField = (field: QFieldMetaData): string => export const getTypeForTextField = (field: QFieldMetaData): string =>
{ {
let type = "search"; let type = "search";
@ -78,10 +84,15 @@ export const getTypeForTextField = (field: QFieldMetaData): string =>
return (type); return (type);
}; };
/***************************************************************************
* 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) => 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 isExpression = criteria.values && criteria.values[valueIndex] && criteria.values[valueIndex].type;
const inputId = `${idPrefix}${criteria.id}`;
let type = getTypeForTextField(field); let type = getTypeForTextField(field);
const inputLabelProps: any = {}; const inputLabelProps: any = {};
@ -96,10 +107,13 @@ export const makeTextField = (field: QFieldMetaData, criteria: QFilterCriteriaWi
value = ValueUtils.formatDateTimeValueForForm(value); value = ValueUtils.formatDateTimeValueForForm(value);
} }
/***************************************************************************
* Event handler for the clear 'x'.
***************************************************************************/
const clearValue = (event: React.MouseEvent<HTMLAnchorElement> | React.MouseEvent<HTMLButtonElement>, index: number) => const clearValue = (event: React.MouseEvent<HTMLAnchorElement> | React.MouseEvent<HTMLButtonElement>, index: number) =>
{ {
valueChangeHandler(event, index, ""); valueChangeHandler(event, index, "");
document.getElementById(`${idPrefix}${criteria.id}`).focus(); document.getElementById(inputId).focus();
}; };
@ -120,6 +134,10 @@ 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 makeFilterVariableTextField = (expression: FilterVariableExpression, valueIndex: number = 0, label = "Value", idPrefix = "value-") =>
{ {
const clearValue = (event: React.MouseEvent<HTMLAnchorElement> | React.MouseEvent<HTMLButtonElement>, index: number) => const clearValue = (event: React.MouseEvent<HTMLAnchorElement> | React.MouseEvent<HTMLButtonElement>, index: number) =>
@ -149,6 +167,10 @@ export const makeTextField = (field: QFieldMetaData, criteria: QFilterCriteriaWi
/></NoWrapTooltip>; /></NoWrapTooltip>;
}; };
///////////////////////////////////////////////////////////////////////////
// set up an 'x' icon as an end-adornment, to clear value from the field //
///////////////////////////////////////////////////////////////////////////
const inputProps: any = {}; const inputProps: any = {};
inputProps.endAdornment = ( inputProps.endAdornment = (
<InputAdornment position="end"> <InputAdornment position="end">
@ -158,18 +180,64 @@ export const makeTextField = (field: QFieldMetaData, criteria: QFilterCriteriaWi
</InputAdornment> </InputAdornment>
); );
/***************************************************************************
* 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"}}> return <Box sx={{margin: 0, padding: 0, display: "flex"}}>
{ {
isExpression ? ( isExpression ? (
makeFilterVariableTextField(criteria.values[valueIndex], valueIndex, label, idPrefix) makeFilterVariableTextField(criteria.values[valueIndex], valueIndex, label, idPrefix)
) : ( ) : (
<TextField <TextField
id={`${idPrefix}${criteria.id}`} id={inputId}
label={label} label={label}
variant="standard" variant="standard"
autoComplete="off" autoComplete="off"
type={type} type={type}
onChange={(event) => valueChangeHandler(event, valueIndex)} onChange={onChange}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
value={value} value={value}
InputLabelProps={inputLabelProps} InputLabelProps={inputLabelProps}
@ -188,6 +256,10 @@ export const makeTextField = (field: QFieldMetaData, criteria: QFilterCriteriaWi
}; };
/***************************************************************************
* 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 function FilterCriteriaRowValues({operatorOption, criteria, field, table, valueChangeHandler, initiallyOpenMultiValuePvs, queryScreenUsage, allowVariables}: Props): JSX.Element
{ {
const [, forceUpdate] = useReducer((x) => x + 1, 0); const [, forceUpdate] = useReducer((x) => x + 1, 0);
@ -197,6 +269,10 @@ function FilterCriteriaRowValues({operatorOption, criteria, field, table, valueC
return null; return null;
} }
/***************************************************************************
* Callback for the Save button from the paste-values modal
***************************************************************************/
function saveNewPasterValues(newValues: any[]) function saveNewPasterValues(newValues: any[])
{ {
if (criteria.values) if (criteria.values)
@ -222,6 +298,9 @@ function FilterCriteriaRowValues({operatorOption, criteria, field, table, valueC
const isExpression = criteria.values && criteria.values[0] && criteria.values[0].type; 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) switch (operatorOption.valueMode)
{ {
case ValueMode.NONE: case ValueMode.NONE:
@ -288,16 +367,15 @@ function FilterCriteriaRowValues({operatorOption, criteria, field, table, valueC
) : ( ) : (
<Box width={"100%"}> <Box width={"100%"}>
<DynamicSelect <DynamicSelect
tableName={table.name} fieldPossibleValueProps={{tableName: table.name, fieldName: field.name, initialDisplayValue: selectedPossibleValue?.label}}
fieldName={field.name}
overrideId={field.name + "-single-" + criteria.id} overrideId={field.name + "-single-" + criteria.id}
key={field.name + "-single-" + criteria.id} key={field.name + "-single-" + criteria.id}
fieldLabel="Value" fieldLabel="Value"
initialValue={selectedPossibleValue?.id} initialValue={selectedPossibleValue?.id}
initialDisplayValue={selectedPossibleValue?.label}
inForm={false} inForm={false}
onChange={(value: any) => valueChangeHandler(null, 0, value)} onChange={(value: any) => valueChangeHandler(null, 0, value)}
variant="standard" variant="standard"
useCase="filter"
/> />
</Box> </Box>
) )
@ -322,8 +400,7 @@ function FilterCriteriaRowValues({operatorOption, criteria, field, table, valueC
} }
return <Box> return <Box>
<DynamicSelect <DynamicSelect
tableName={table.name} fieldPossibleValueProps={{tableName: table.name, fieldName: field.name, initialDisplayValue: null}}
fieldName={field.name}
overrideId={field.name + "-multi-" + criteria.id} overrideId={field.name + "-multi-" + criteria.id}
key={field.name + "-multi-" + criteria.id} key={field.name + "-multi-" + criteria.id}
isMultiple isMultiple
@ -333,6 +410,7 @@ function FilterCriteriaRowValues({operatorOption, criteria, field, table, valueC
inForm={false} inForm={false}
onChange={(value: any) => valueChangeHandler(null, "all", value)} onChange={(value: any) => valueChangeHandler(null, "all", value)}
variant="standard" variant="standard"
useCase="filter"
/> />
</Box>; </Box>;
} }

View File

@ -440,10 +440,10 @@ function ScriptEditor({title, scriptId, scriptRevisionRecord, closeCallback, tab
<Box sx={{height: openTool ? "45%" : "100%"}}> <Box sx={{height: openTool ? "45%" : "100%"}}>
<Grid container alignItems="flex-end"> <Grid container alignItems="flex-end">
<Box maxWidth={"50%"} minWidth={300}> <Box maxWidth={"50%"} minWidth={300}>
<DynamicSelect fieldName={"apiName"} initialValue={apiName} initialDisplayValue={apiNameLabel} fieldLabel={"API Name *"} tableName={"scriptRevision"} inForm={false} onChange={changeApiName} /> <DynamicSelect fieldPossibleValueProps={{tableName: "scriptRevision", fieldName: "apiName", initialDisplayValue: apiNameLabel}} initialValue={apiName} fieldLabel={"API Name *"} inForm={false} onChange={changeApiName} useCase="form" />
</Box> </Box>
<Box maxWidth={"50%"} minWidth={300} pl={2}> <Box maxWidth={"50%"} minWidth={300} pl={2}>
<DynamicSelect fieldName={"apiVersion"} initialValue={apiVersion} initialDisplayValue={apiVersionLabel} fieldLabel={"API Version *"} tableName={"scriptRevision"} inForm={false} onChange={changeApiVersion} /> <DynamicSelect fieldPossibleValueProps={{tableName: "scriptRevision", fieldName: "apiVersion", initialDisplayValue: apiVersionLabel}} initialValue={apiVersion} fieldLabel={"API Version *"} inForm={false} onChange={changeApiVersion} useCase="form" />
</Box> </Box>
</Grid> </Grid>
<Box display="flex" sx={{height: "100%"}}> <Box display="flex" sx={{height: "100%"}}>

View File

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

View File

@ -22,16 +22,19 @@
import {QWidgetMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QWidgetMetaData"; import {QWidgetMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QWidgetMetaData";
import {Box, Skeleton} from "@mui/material"; import {Box, Skeleton} from "@mui/material";
import parse from "html-react-parser";
import {BlockData} from "qqq/components/widgets/blocks/BlockModels"; import {BlockData} from "qqq/components/widgets/blocks/BlockModels";
import WidgetBlock from "qqq/components/widgets/WidgetBlock"; import WidgetBlock from "qqq/components/widgets/WidgetBlock";
import React from "react"; import React from "react";
interface CompositeData export interface CompositeData
{ {
blocks: BlockData[]; blocks: BlockData[];
styleOverrides?: any; styleOverrides?: any;
layout?: string; layout?: string;
overlayHtml?: string;
overlayStyleOverrides?: any;
} }
@ -39,13 +42,14 @@ interface CompositeWidgetProps
{ {
widgetMetaData: QWidgetMetaData; widgetMetaData: QWidgetMetaData;
data: CompositeData; data: CompositeData;
actionCallback?: (blockData: BlockData) => boolean;
} }
/******************************************************************************* /*******************************************************************************
** Widget which is a list of Blocks. ** Widget which is a list of Blocks.
*******************************************************************************/ *******************************************************************************/
export default function CompositeWidget({widgetMetaData, data}: CompositeWidgetProps): JSX.Element export default function CompositeWidget({widgetMetaData, data, actionCallback}: CompositeWidgetProps): JSX.Element
{ {
if (!data || !data.blocks) if (!data || !data.blocks)
{ {
@ -78,6 +82,14 @@ export default function CompositeWidget({widgetMetaData, data}: CompositeWidgetP
boxStyle.justifyContent = "space-between"; boxStyle.justifyContent = "space-between";
boxStyle.gap = "0.25rem"; boxStyle.gap = "0.25rem";
} }
else if (layout == "FLEX_ROW_CENTER")
{
boxStyle.display = "flex";
boxStyle.flexDirection = "row";
boxStyle.justifyContent = "center";
boxStyle.gap = "0.25rem";
boxStyle.flexWrap = "wrap";
}
else if (layout == "TABLE_SUB_ROW_DETAILS") else if (layout == "TABLE_SUB_ROW_DETAILS")
{ {
boxStyle.display = "flex"; boxStyle.display = "flex";
@ -97,20 +109,34 @@ export default function CompositeWidget({widgetMetaData, data}: CompositeWidgetP
boxStyle.borderRadius = "0.5rem"; boxStyle.borderRadius = "0.5rem";
boxStyle.background = "#FFFFFF"; boxStyle.background = "#FFFFFF";
} }
if (data?.styleOverrides) if (data?.styleOverrides)
{ {
boxStyle = {...boxStyle, ...data.styleOverrides}; boxStyle = {...boxStyle, ...data.styleOverrides};
} }
return (<Box sx={boxStyle} className="compositeWidget"> let overlayStyle: any = {};
{
data.blocks.map((block: BlockData, index) => ( if (data?.overlayStyleOverrides)
<React.Fragment key={index}> {
<WidgetBlock widgetMetaData={widgetMetaData} block={block} /> overlayStyle = {...overlayStyle, ...data.overlayStyleOverrides};
</React.Fragment> }
))
} return (
</Box>); <>
{
data?.overlayHtml &&
<Box sx={overlayStyle} className="blockWidgetOverlay">{parse(data.overlayHtml)}</Box>
}
<Box sx={boxStyle} className="compositeWidget">
{
data.blocks.map((block: BlockData, index) => (
<React.Fragment key={index}>
<WidgetBlock widgetMetaData={widgetMetaData} block={block} actionCallback={actionCallback} />
</React.Fragment>
))
}
</Box>
</>
);
} }

View File

@ -638,8 +638,28 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, reco
if (!omitWrappingGridContainer) if (!omitWrappingGridContainer)
{ {
// @ts-ignore const gridProps: {[key: string]: any} = {};
renderedWidget = (<Grid id={widgetMetaData.name} item xxl={widgetMetaData.gridColumns ? widgetMetaData.gridColumns : 12} xs={12} sx={{display: "flex", alignItems: "stretch", scrollMarginTop: "100px"}}>
for(let size of ["xs", "sm", "md", "lg", "xl", "xxl"])
{
const key = `gridCols:sizeClass:${size}`
if(widgetMetaData?.defaultValues?.has(key))
{
gridProps[size] = widgetMetaData?.defaultValues.get(key);
}
}
if(!gridProps["xxl"])
{
gridProps["xxl"] = widgetMetaData.gridColumns ? widgetMetaData.gridColumns : 12;
}
if(!gridProps["xs"])
{
gridProps["xs"] = 12;
}
renderedWidget = (<Grid id={widgetMetaData.name} item {...gridProps} sx={{display: "flex", alignItems: "stretch", scrollMarginTop: "100px"}}>
{renderedWidget} {renderedWidget}
</Grid>); </Grid>);
} }

View File

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

View File

@ -0,0 +1,60 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import Box from "@mui/material/Box";
import Icon from "@mui/material/Icon";
import {standardWidth} from "qqq/components/buttons/DefaultButtons";
import MDButton from "qqq/components/legacy/MDButton";
import BlockElementWrapper from "qqq/components/widgets/blocks/BlockElementWrapper";
import {StandardBlockComponentProps} from "qqq/components/widgets/blocks/BlockModels";
import React from "react";
/*******************************************************************************
** Block that renders ... an action button...
**
*******************************************************************************/
export default function ActionButtonBlock({widgetMetaData, data, actionCallback}: StandardBlockComponentProps): JSX.Element
{
const icon = data.values.iconName ? <Icon>{data.values.iconName}</Icon> : null;
function onClick()
{
if(actionCallback)
{
actionCallback(data, {actionCode: data.values?.actionCode})
}
else
{
console.log("ActionButtonBlock onClick with no actionCallback present, so, noop");
}
}
return (
<BlockElementWrapper metaData={widgetMetaData} data={data} slot="">
<Box mx={1} my={1} minWidth={standardWidth}>
<MDButton type="button" variant="gradient" color="dark" size="small" fullWidth startIcon={icon} onClick={onClick}>
{data.values.label ?? "Action"}
</MDButton>
</Box>
</BlockElementWrapper>
);
}

View File

@ -0,0 +1,40 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import Box from "@mui/material/Box";
import BlockElementWrapper from "qqq/components/widgets/blocks/BlockElementWrapper";
import {StandardBlockComponentProps} from "qqq/components/widgets/blocks/BlockModels";
import DumpJsonBox from "qqq/utils/DumpJsonBox";
import React from "react";
/*******************************************************************************
** Block that renders ... an audio tag
**
** <audio src=${path} ${autoPlay} ${showControls} />
*******************************************************************************/
export default function AudioBlock({widgetMetaData, data}: StandardBlockComponentProps): JSX.Element
{
return (
<BlockElementWrapper metaData={widgetMetaData} data={data} slot="">
<audio src={data.values?.path} autoPlay={data.values?.autoPlay} controls={data.values?.showControls} />
</BlockElementWrapper>
);
}

View File

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

View File

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

View File

@ -0,0 +1,59 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import Box from "@mui/material/Box";
import BlockElementWrapper from "qqq/components/widgets/blocks/BlockElementWrapper";
import {StandardBlockComponentProps} from "qqq/components/widgets/blocks/BlockModels";
import DumpJsonBox from "qqq/utils/DumpJsonBox";
import React from "react";
/*******************************************************************************
** Block that renders ... an image tag
**
** <audio src=${path} ${autoPlay} ${showControls} />
*******************************************************************************/
export default function AudioBlock({widgetMetaData, data}: StandardBlockComponentProps): JSX.Element
{
let imageStyle: any = {};
if(data.styles?.width)
{
imageStyle.width = data.styles?.width;
}
if(data.styles?.height)
{
imageStyle.height = data.styles?.height;
}
if(data.styles?.bordered)
{
imageStyle.border = "1px solid #C0C0C0";
imageStyle.borderRadius = "0.5rem";
}
return (
<BlockElementWrapper metaData={widgetMetaData} data={data} slot="">
<img src={data.values?.path} alt={data.values?.alt} style={imageStyle} />
</BlockElementWrapper>
);
}

View File

@ -0,0 +1,128 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
import Box from "@mui/material/Box";
import QDynamicFormField from "qqq/components/forms/DynamicFormField";
import DynamicFormUtils from "qqq/components/forms/DynamicFormUtils";
import BlockElementWrapper from "qqq/components/widgets/blocks/BlockElementWrapper";
import {StandardBlockComponentProps} from "qqq/components/widgets/blocks/BlockModels";
import React, {SyntheticEvent, useState} from "react";
/*******************************************************************************
** Block that renders ... a text input
**
*******************************************************************************/
export default function InputFieldBlock({widgetMetaData, data, actionCallback}: StandardBlockComponentProps): JSX.Element
{
const [blurCount, setBlurCount] = useState(0)
const fieldMetaData = new QFieldMetaData(data.values.fieldMetaData);
const dynamicField = DynamicFormUtils.getDynamicField(fieldMetaData);
let autoFocus = data.values.autoFocus as boolean
let value = data.values.value;
if(value == null || value == undefined)
{
value = "";
}
////////////////////////////////////////////////////////////////////////////////
// for an autoFocus field... //
// we're finding that if we blur it when clicking an action button, that //
// an un-desirable "now it's been touched, so show an error" happens. //
// so let us remove the default blur handler, for the first (auto) focus/blur //
// cycle, and we seem to have a better time. //
////////////////////////////////////////////////////////////////////////////////
let onBlurRest: {onBlur?: any} = {}
if(autoFocus && blurCount == 0)
{
onBlurRest.onBlur = (event: React.SyntheticEvent) =>
{
event.stopPropagation();
event.preventDefault();
setBlurCount(blurCount + 1);
}
}
/***************************************************************************
**
***************************************************************************/
function eventHandler(event: KeyboardEvent)
{
if(data.values.submitOnEnter && event.key == "Enter")
{
// @ts-ignore target.value...
const inputValue = event.target.value?.trim()
// todo - make this behavior opt-in for inputBlocks?
if(inputValue && `${inputValue}`.startsWith("->"))
{
const actionCode = inputValue.substring(2);
if(actionCallback)
{
actionCallback(data, {actionCode: actionCode, _fieldToClearIfError: fieldMetaData.name});
///////////////////////////////////////////////////////
// return, so we don't submit the actionCode as text //
///////////////////////////////////////////////////////
return;
}
}
if(fieldMetaData.isRequired && inputValue == "")
{
console.log("input field is required, but missing value, so not submitting");
return;
}
if(actionCallback)
{
console.log("InputFieldBlock calling actionCallback for submitOnEnter");
let values: {[name: string]: any} = {};
values[fieldMetaData.name] = inputValue;
actionCallback(data, values);
}
else
{
console.log("InputFieldBlock was set as submitOnEnter, but no actionCallback was present, so, noop");
}
}
}
const labelElement = <Box fontSize="1rem" fontWeight="500" marginBottom="0.25rem">
<label htmlFor={fieldMetaData.name}>{fieldMetaData.label}</label>
</Box>
return (
<Box mt="0.5rem">
<BlockElementWrapper metaData={widgetMetaData} data={data} slot="">
<>
{labelElement}
<QDynamicFormField name={fieldMetaData.name} displayFormat={null} label="" formFieldObject={dynamicField} type={fieldMetaData.type} value={value} autoFocus={autoFocus} onKeyUp={eventHandler} {...onBlurRest} />
</>
</BlockElementWrapper>
</Box>
);
}

View File

@ -19,8 +19,10 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import Box from "@mui/material/Box";
import BlockElementWrapper from "qqq/components/widgets/blocks/BlockElementWrapper"; import BlockElementWrapper from "qqq/components/widgets/blocks/BlockElementWrapper";
import {StandardBlockComponentProps} from "qqq/components/widgets/blocks/BlockModels"; import {StandardBlockComponentProps} from "qqq/components/widgets/blocks/BlockModels";
import DumpJsonBox from "qqq/utils/DumpJsonBox";
/******************************************************************************* /*******************************************************************************
** Block that renders ... just some text. ** Block that renders ... just some text.
@ -29,9 +31,54 @@ import {StandardBlockComponentProps} from "qqq/components/widgets/blocks/BlockMo
*******************************************************************************/ *******************************************************************************/
export default function TextBlock({widgetMetaData, data}: StandardBlockComponentProps): JSX.Element export default function TextBlock({widgetMetaData, data}: StandardBlockComponentProps): JSX.Element
{ {
let color = "rgba(0, 0, 0, 0.87)";
if (data.styles?.standardColor)
{
switch (data.styles?.standardColor)
{
case "SUCCESS":
color = "#2BA83F";
break;
case "WARNING":
color = "#FBA132";
break;
case "ERROR":
color = "#FB4141";
break;
case "INFO":
color = "#458CFF";
break;
case "MUTED":
color = "#7b809a";
break;
}
}
let boxStyle = {};
if (data.styles?.isAlert)
{
boxStyle =
{
border: `1px solid ${color}`,
background: `${color}40`,
padding: "0.5rem",
borderRadius: "0.5rem",
};
}
const text = data.values.interpolatedText ?? data.values.text;
const lines = text.split("\n");
return ( return (
<BlockElementWrapper metaData={widgetMetaData} data={data} slot=""> <BlockElementWrapper metaData={widgetMetaData} data={data} slot="">
<span style={{fontSize: "1.000rem"}}>{data.values.text}</span> <Box display="inline-block" lineHeight="1.2" sx={boxStyle}>
<span style={{fontSize: "1rem", color: color}}>
{lines.map((line: string, index: number) =>
(
<div key={index}>{line}</div>
))
}</span>
</Box>
</BlockElementWrapper> </BlockElementWrapper>
); );
} }

View File

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

View File

@ -87,6 +87,7 @@ export default function FilterAndColumnsSetupWidget({isEditable, widgetMetaData,
{ {
const [modalOpen, setModalOpen] = useState(false); const [modalOpen, setModalOpen] = useState(false);
const [hideColumns, setHideColumns] = useState(widgetData?.hideColumns); const [hideColumns, setHideColumns] = useState(widgetData?.hideColumns);
const [hidePreview, setHidePreview] = useState(widgetData?.hidePreview);
const [tableMetaData, setTableMetaData] = useState(null as QTableMetaData); const [tableMetaData, setTableMetaData] = useState(null as QTableMetaData);
const [alertContent, setAlertContent] = useState(null as string); const [alertContent, setAlertContent] = useState(null as string);
@ -272,7 +273,7 @@ export default function FilterAndColumnsSetupWidget({isEditable, widgetMetaData,
/******************************************************************************* /*******************************************************************************
** **
*******************************************************************************/ *******************************************************************************/
function mayShowQueryPreview(): boolean function mayShowQuery(): boolean
{ {
if (tableMetaData) if (tableMetaData)
{ {
@ -288,7 +289,7 @@ export default function FilterAndColumnsSetupWidget({isEditable, widgetMetaData,
/******************************************************************************* /*******************************************************************************
** **
*******************************************************************************/ *******************************************************************************/
function mayShowColumnsPreview(): boolean function mayShowColumns(): boolean
{ {
if (tableMetaData) if (tableMetaData)
{ {
@ -356,14 +357,14 @@ export default function FilterAndColumnsSetupWidget({isEditable, widgetMetaData,
<Box pt="0.5rem"> <Box pt="0.5rem">
<Box display="flex" justifyContent="space-between" alignItems="center"> <Box display="flex" justifyContent="space-between" alignItems="center">
<h5>Query Filter</h5> <h5>Query Filter</h5>
<Box fontSize="0.75rem" fontWeight="700">{mayShowQueryPreview() && getCurrentSortIndicator(frontendQueryFilter, tableMetaData, null)}</Box> <Box fontSize="0.75rem" fontWeight="700">{mayShowQuery() && getCurrentSortIndicator(frontendQueryFilter, tableMetaData, null)}</Box>
</Box> </Box>
{ {
mayShowQueryPreview() && mayShowQuery() &&
<AdvancedQueryPreview tableMetaData={tableMetaData} queryFilter={frontendQueryFilter} isEditable={false} isQueryTooComplex={frontendQueryFilter.subFilters?.length > 0} removeCriteriaByIndexCallback={null} /> <AdvancedQueryPreview tableMetaData={tableMetaData} queryFilter={frontendQueryFilter} isEditable={false} isQueryTooComplex={frontendQueryFilter.subFilters?.length > 0} removeCriteriaByIndexCallback={null} />
} }
{ {
!mayShowQueryPreview() && !mayShowQuery() &&
<Box width="100%" sx={{fontSize: "1rem", background: "#FFFFFF"}} minHeight={"2.5rem"} p={"0.5rem"} pb={"0.125rem"} borderRadius="0.75rem" border={`1px solid ${colors.grayLines.main}`}> <Box width="100%" sx={{fontSize: "1rem", background: "#FFFFFF"}} minHeight={"2.5rem"} p={"0.5rem"} pb={"0.125rem"} borderRadius="0.75rem" border={`1px solid ${colors.grayLines.main}`}>
{ {
isEditable && isEditable &&
@ -382,11 +383,11 @@ export default function FilterAndColumnsSetupWidget({isEditable, widgetMetaData,
<h5>Columns</h5> <h5>Columns</h5>
<Box display="flex" flexWrap="wrap" fontSize="1rem"> <Box display="flex" flexWrap="wrap" fontSize="1rem">
{ {
mayShowColumnsPreview() && mayShowColumns() && columns &&
columns.columns.map((column, i) => <React.Fragment key={`column-${i}`}>{renderColumn(column)}</React.Fragment>) columns.columns.map((column, i) => <React.Fragment key={`column-${i}`}>{renderColumn(column)}</React.Fragment>)
} }
{ {
!mayShowColumnsPreview() && !mayShowColumns() &&
<Box width="100%" sx={{fontSize: "1rem", background: "#FFFFFF"}} minHeight={"2.375rem"} p={"0.5rem"} pb={"0.125rem"}> <Box width="100%" sx={{fontSize: "1rem", background: "#FFFFFF"}} minHeight={"2.375rem"} p={"0.5rem"} pb={"0.125rem"}>
{ {
isEditable && isEditable &&
@ -402,6 +403,21 @@ export default function FilterAndColumnsSetupWidget({isEditable, widgetMetaData,
</Box> </Box>
</Box> </Box>
)} )}
{!hidePreview && !isEditable && frontendQueryFilter && tableMetaData && (
<Box pt="1rem">
<h5>Preview</h5>
<RecordQuery
allowVariables={widgetData?.allowVariables}
ref={recordQueryRef}
table={tableMetaData}
isPreview={true}
usage="reportSetup"
isModal={true}
initialQueryFilter={frontendQueryFilter}
initialColumns={columns}
/>
</Box>
)}
{ {
modalOpen && modalOpen &&
<Modal open={modalOpen} onClose={(event, reason) => closeEditor(event, reason)}> <Modal open={modalOpen} onClose={(event, reason) => closeEditor(event, reason)}>

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,38 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {QPossibleValue} from "@kingsrook/qqq-frontend-core/lib/model/QPossibleValue";
/*******************************************************************************
** Properties attached to a (formik?) form field, to denote how it behaves as
** as related to a possible value source.
*******************************************************************************/
export interface FieldPossibleValueProps
{
isPossibleValue?: boolean;
possibleValues?: QPossibleValue[];
initialDisplayValue: string | null;
fieldName?: string;
tableName?: string;
processName?: string;
possibleValueSourceName?: string;
}

View File

@ -29,6 +29,7 @@ import {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstan
import {QProcessMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QProcessMetaData"; import {QProcessMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QProcessMetaData";
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData"; import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
import {QTableSection} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableSection"; import {QTableSection} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableSection";
import {QWidgetMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QWidgetMetaData";
import {QJobComplete} from "@kingsrook/qqq-frontend-core/lib/model/processes/QJobComplete"; import {QJobComplete} from "@kingsrook/qqq-frontend-core/lib/model/processes/QJobComplete";
import {QJobError} from "@kingsrook/qqq-frontend-core/lib/model/processes/QJobError"; import {QJobError} from "@kingsrook/qqq-frontend-core/lib/model/processes/QJobError";
import {QJobRunning} from "@kingsrook/qqq-frontend-core/lib/model/processes/QJobRunning"; import {QJobRunning} from "@kingsrook/qqq-frontend-core/lib/model/processes/QJobRunning";
@ -36,12 +37,14 @@ import {QJobStarted} from "@kingsrook/qqq-frontend-core/lib/model/processes/QJob
import {QPossibleValue} from "@kingsrook/qqq-frontend-core/lib/model/QPossibleValue"; import {QPossibleValue} from "@kingsrook/qqq-frontend-core/lib/model/QPossibleValue";
import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord"; import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord";
import {QQueryFilter} from "@kingsrook/qqq-frontend-core/lib/model/query/QQueryFilter"; import {QQueryFilter} from "@kingsrook/qqq-frontend-core/lib/model/query/QQueryFilter";
import {Alert, Box, Button, CircularProgress, Icon, TablePagination} from "@mui/material"; import {Alert, Button, CircularProgress, Icon, TablePagination} from "@mui/material";
import Box from "@mui/material/Box";
import Card from "@mui/material/Card"; import Card from "@mui/material/Card";
import Grid from "@mui/material/Grid"; import Grid from "@mui/material/Grid";
import Step from "@mui/material/Step"; import Step from "@mui/material/Step";
import StepLabel from "@mui/material/StepLabel"; import StepLabel from "@mui/material/StepLabel";
import Stepper from "@mui/material/Stepper"; import Stepper from "@mui/material/Stepper";
import {Theme} from "@mui/material/styles";
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
import {DataGridPro, GridColDef} from "@mui/x-data-grid-pro"; import {DataGridPro, GridColDef} from "@mui/x-data-grid-pro";
import FormData from "form-data"; import FormData from "form-data";
@ -60,8 +63,11 @@ import QRecordSidebar from "qqq/components/misc/RecordSidebar";
import {GoogleDriveFolderPickerWrapper} from "qqq/components/processes/GoogleDriveFolderPickerWrapper"; import {GoogleDriveFolderPickerWrapper} from "qqq/components/processes/GoogleDriveFolderPickerWrapper";
import ProcessSummaryResults from "qqq/components/processes/ProcessSummaryResults"; import ProcessSummaryResults from "qqq/components/processes/ProcessSummaryResults";
import ValidationReview from "qqq/components/processes/ValidationReview"; import ValidationReview from "qqq/components/processes/ValidationReview";
import {BlockData} from "qqq/components/widgets/blocks/BlockModels";
import CompositeWidget, {CompositeData} from "qqq/components/widgets/CompositeWidget";
import DashboardWidgets from "qqq/components/widgets/DashboardWidgets"; import DashboardWidgets from "qqq/components/widgets/DashboardWidgets";
import BaseLayout from "qqq/layouts/BaseLayout"; import BaseLayout from "qqq/layouts/BaseLayout";
import ProcessWidgetBlockUtils from "qqq/pages/processes/ProcessWidgetBlockUtils";
import {TABLE_VARIANT_LOCAL_STORAGE_KEY_ROOT} from "qqq/pages/records/query/RecordQuery"; import {TABLE_VARIANT_LOCAL_STORAGE_KEY_ROOT} from "qqq/pages/records/query/RecordQuery";
import Client from "qqq/utils/qqq/Client"; import Client from "qqq/utils/qqq/Client";
import TableUtils from "qqq/utils/qqq/TableUtils"; import TableUtils from "qqq/utils/qqq/TableUtils";
@ -89,14 +95,18 @@ const INITIAL_RETRY_MILLIS = 1_500;
const RETRY_MAX_MILLIS = 12_000; const RETRY_MAX_MILLIS = 12_000;
const BACKOFF_AMOUNT = 1.5; const BACKOFF_AMOUNT = 1.5;
//////////////////////////////////////////////////////////////////////////// ///////////////////////////////////////////////////////////////////////////////
// define a function that we can make referenes to, which we'll overwrite // // define some functions that we can make referene to, which we'll overwrite //
// with formik's setFieldValue function, once we're inside formik. // // with functions from formik, once we're inside formik. //
//////////////////////////////////////////////////////////////////////////// ///////////////////////////////////////////////////////////////////////////////
let formikSetFieldValueFunction = (field: string, value: any, shouldValidate?: boolean): void => let formikSetFieldValueFunction = (field: string, value: any, shouldValidate?: boolean): void =>
{ {
}; };
let formikSetTouched = ({}: any, touched: boolean): void =>
{
};
const cachedPossibleValueLabels: { [fieldName: string]: { [id: string | number]: string } } = {}; const cachedPossibleValueLabels: { [fieldName: string]: { [id: string | number]: string } } = {};
function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, isReport, recordIds, closeModalHandler, forceReInit, overrideLabel}: Props): JSX.Element function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, isReport, recordIds, closeModalHandler, forceReInit, overrideLabel}: Props): JSX.Element
@ -120,6 +130,7 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
const [activeStepIndex, setActiveStepIndex] = useState(0); const [activeStepIndex, setActiveStepIndex] = useState(0);
const [activeStep, setActiveStep] = useState(null as QFrontendStepMetaData); const [activeStep, setActiveStep] = useState(null as QFrontendStepMetaData);
const [newStep, setNewStep] = useState(null); const [newStep, setNewStep] = useState(null);
const [stepInstanceCounter, setStepInstanceCounter] = useState(0);
const [steps, setSteps] = useState([] as QFrontendStepMetaData[]); const [steps, setSteps] = useState([] as QFrontendStepMetaData[]);
const [needInitialLoad, setNeedInitialLoad] = useState(true); const [needInitialLoad, setNeedInitialLoad] = useState(true);
const [lastForcedReInit, setLastForcedReInit] = useState(null as number); const [lastForcedReInit, setLastForcedReInit] = useState(null as number);
@ -136,6 +147,7 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
); );
const [showErrorDetail, setShowErrorDetail] = useState(false); const [showErrorDetail, setShowErrorDetail] = useState(false);
const [showFullHelpText, setShowFullHelpText] = useState(false); const [showFullHelpText, setShowFullHelpText] = useState(false);
const [previouslySeenUpdatedFieldMetaDataMap, setPreviouslySeenUpdatedFieldMetaDataMap] = useState(new Map<string, QFieldMetaData>);
const [renderedWidgets, setRenderedWidgets] = useState({} as { [step: string]: { [widgetName: string]: any } }); const [renderedWidgets, setRenderedWidgets] = useState({} as { [step: string]: { [widgetName: string]: any } });
@ -155,8 +167,30 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
const [overrideOnLastStep, setOverrideOnLastStep] = useState(null as boolean); const [overrideOnLastStep, setOverrideOnLastStep] = useState(null as boolean);
const onLastStep = activeStepIndex === steps.length - 2; /////////////////////////////////////////////////////////////////////////////////////
const noMoreSteps = activeStepIndex === steps.length - 1; // determine if we're on the last-step or not (e.g., to decide "Submit" vs "Next") //
/////////////////////////////////////////////////////////////////////////////////////
let onLastStep = false;
if (processMetaData?.stepFlow == "LINEAR" && activeStepIndex === steps.length - 2)
{
onLastStep = true;
}
////////////////////////////////////////////
// determine if any 'next' button appears //
////////////////////////////////////////////
let noMoreSteps = false;
if (processMetaData?.stepFlow == "LINEAR" && activeStepIndex === steps.length - 1)
{
noMoreSteps = true;
}
if(processValues["noMoreSteps"])
{
//////////////////////////////////////////////////////////////////
// this, to allow a non-linear process to request this behavior //
//////////////////////////////////////////////////////////////////
noMoreSteps = true;
}
//////////////// ////////////////
// form state // // form state //
@ -324,6 +358,69 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
return renderedWidget; return renderedWidget;
} }
/***************************************************************************
** callback used by widget blocks, e.g., for input-text-enter-on-submit,
** and action buttons.
***************************************************************************/
function blockWidgetActionCallback(blockData: BlockData, eventValues?: { [name: string]: any }): boolean
{
console.log(`in blockWidgetActionCallback, called by block: ${JSON.stringify(blockData)}`);
///////////////////////////////////////////////////////////////////////////////
// if the eventValues included an actionCode - validate it before proceeding //
///////////////////////////////////////////////////////////////////////////////
if (eventValues && eventValues.actionCode && !ProcessWidgetBlockUtils.isActionCodeValid(eventValues.actionCode, activeStep, processValues))
{
setFormError("Unrecognized action code: " + eventValues.actionCode);
if (eventValues["_fieldToClearIfError"])
{
/////////////////////////////////////////////////////////////////////////////
// if the eventValues included a _fieldToClearIfError, well, then do that. //
/////////////////////////////////////////////////////////////////////////////
formikSetFieldValueFunction(eventValues["_fieldToClearIfError"], "", false);
}
return (false);
}
//////////////////
// ok - submit! //
//////////////////
handleSubmit(eventValues);
return (true);
}
/***************************************************************************
** in a memoized-fashion (YUNO useMemo?), render a component that is an
** adHoc widget (e.g., composite)
***************************************************************************/
function renderAdHocWidget(componentValues: any, componentIndex: number)
{
const key = activeStep.name + "-" + stepInstanceCounter + "-" + componentIndex;
if (renderedWidgets[key])
{
return renderedWidgets[key];
}
const widgetMetaData = new QWidgetMetaData({name: "adHoc"});
const compositeWidgetData = JSON.parse(JSON.stringify(componentValues)) as CompositeData;
compositeWidgetData.styleOverrides = {py: "0.5rem", display: "flex", flexDirection: "column", gap: "0.5rem"};
ProcessWidgetBlockUtils.dynamicEvaluationOfCompositeWidgetData(compositeWidgetData, processValues);
renderedWidgets[key] = <Box key={key} pt={2}>
<CompositeWidget widgetMetaData={widgetMetaData} data={compositeWidgetData} actionCallback={blockWidgetActionCallback} />
</Box>;
setRenderedWidgets(renderedWidgets);
return (renderedWidgets[key]);
}
//////////////////////////////////////////////////// ////////////////////////////////////////////////////
// generate the main form body content for a step // // generate the main form body content for a step //
//////////////////////////////////////////////////// ////////////////////////////////////////////////////
@ -382,7 +479,7 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
if (qJobRunning || step === null) if (qJobRunning || step === null)
{ {
return ( return (
<Grid m={3} mt={9} container> <Grid m={3} mt={9} container maxWidth="calc(100% - 3rem)">
<Grid item xs={0} lg={3} /> <Grid item xs={0} lg={3} />
<Grid item xs={12} lg={6}> <Grid item xs={12} lg={6}>
<Card> <Card>
@ -763,8 +860,29 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
} }
{ {
component.type === QComponentType.WIDGET && ( component.type === QComponentType.WIDGET && (
component.values?.widgetName && <>
renderWidget(component.values?.widgetName) {
///////////////////////////////////////////////////
// if a widget name is given, render that widget //
///////////////////////////////////////////////////
component.values?.widgetName &&
renderWidget(component.values?.widgetName)
}
{
/////////////////////////////////////////////////////////
// if the widget is marked as adHoc, render it as such //
/////////////////////////////////////////////////////////
component.values?.isAdHocWidget &&
renderAdHocWidget(component.values, index)
}
{
////////////////////////////////////////////////
// if neither of those, then programmer error //
////////////////////////////////////////////////
!(component.values?.widgetName || component.values?.isAdHocWidget) &&
<Alert severity="error">Error: Component is marked as WIDGET type, but does not specify a <u>widgetName</u>, nor the <u>isAdHocWidget</u> flag.</Alert>
}
</>
) )
} }
</div> </div>
@ -864,6 +982,11 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
setActiveStepIndex(newIndex); setActiveStepIndex(newIndex);
setOverrideOnLastStep(null); setOverrideOnLastStep(null);
////////////////////////////////////////////////////////////////////////////////////////////////////
// reset formik touched data, so a field that's repeated doesn't immediately show a 'dirty' state //
////////////////////////////////////////////////////////////////////////////////////////////////////
formikSetTouched({}, false);
if (steps) if (steps)
{ {
const activeStep = steps[newIndex]; const activeStep = steps[newIndex];
@ -899,7 +1022,17 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
if (doesStepHaveComponent(activeStep, QComponentType.VALIDATION_REVIEW_SCREEN)) if (doesStepHaveComponent(activeStep, QComponentType.VALIDATION_REVIEW_SCREEN))
{ {
addField("doFullValidation", {type: "radio"}, "true", null); addField("doFullValidation", {type: "radio"}, "true", null);
setOverrideOnLastStep(false);
//////////////////////////////////////////////////////////////////////////////////////////////
// so - if we're on the validation screen, and we don't have a validationSummary right now, //
// and the process supports doing full validation - then the user will choose, via radio, //
// if this is the last step or not - and by default that radio will be true, to make this //
// NOT the last step - so set this value. //
//////////////////////////////////////////////////////////////////////////////////////////////
if(!processValues["validationSummary"] && processValues["supportsFullValidation"])
{
setOverrideOnLastStep(false);
}
} }
if (doesStepHaveComponent(activeStep, QComponentType.GOOGLE_DRIVE_SELECT_FOLDER)) if (doesStepHaveComponent(activeStep, QComponentType.GOOGLE_DRIVE_SELECT_FOLDER))
@ -909,6 +1042,16 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
addField("googleDriveFolderName", {type: "hidden", omitFromQDynamicForm: true}, "", null); addField("googleDriveFolderName", {type: "hidden", omitFromQDynamicForm: true}, "", null);
} }
if (doesStepHaveComponent(activeStep, QComponentType.WIDGET))
{
ProcessWidgetBlockUtils.addFieldsForCompositeWidget(activeStep, (fieldMetaData) =>
{
const dynamicField = DynamicFormUtils.getDynamicField(fieldMetaData);
const validation = DynamicFormUtils.getValidationForField(fieldMetaData);
addField(fieldMetaData.name, dynamicField, processValues[fieldMetaData.name], validation)
});
}
/////////////////////////////////////////////////// ///////////////////////////////////////////////////
// if this step has form fields, set up the form // // if this step has form fields, set up the form //
/////////////////////////////////////////////////// ///////////////////////////////////////////////////
@ -994,7 +1137,7 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
setValidationFunction(() => true); setValidationFunction(() => true);
} }
} }
}, [newStep]); }, [newStep, stepInstanceCounter]); // maybe we could just use stepInstanceCounter...
///////////////////////////////////////////////////////////////////////////////////////////// /////////////////////////////////////////////////////////////////////////////////////////////
// if there are records to load: build a record config, and set the needRecords state flag // // if there are records to load: build a record config, and set the needRecords state flag //
@ -1088,6 +1231,47 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
} }
}, [needRecords]); }, [needRecords]);
/***************************************************************************
**
***************************************************************************/
function updateFieldsInProcess(steps: QFrontendStepMetaData[], updatedFields: Map<string, QFieldMetaData>)
{
if (updatedFields)
{
updatedFields.forEach((field) => previouslySeenUpdatedFieldMetaDataMap.set(field.name, field));
setPreviouslySeenUpdatedFieldMetaDataMap(previouslySeenUpdatedFieldMetaDataMap);
}
for (let step of steps)
{
if (step && step.formFields)
{
for (let i = 0; i < step.formFields.length; i++)
{
let field = step.formFields[i];
if (previouslySeenUpdatedFieldMetaDataMap.has(field.name))
{
step.formFields[i] = previouslySeenUpdatedFieldMetaDataMap.get(field.name);
}
}
}
}
if (processValues.inputFieldList)
{
for (let i = 0; i < processValues.inputFieldList.length; i++)
{
let field = new QFieldMetaData(processValues.inputFieldList[i]);
if (previouslySeenUpdatedFieldMetaDataMap.has(field.name))
{
processValues.inputFieldList[i] = previouslySeenUpdatedFieldMetaDataMap.get(field.name); // todo - uh, not an object?
}
}
}
}
////////////////////////////////////////////////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////////////////////////////////////////////
// handle a response from the server - e.g., after starting a backend job, or getting its status/result // // handle a response from the server - e.g., after starting a backend job, or getting its status/result //
////////////////////////////////////////////////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////////////////////////////////////////////
@ -1112,13 +1296,19 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
// if the process step sent a new frontend-step-list, then refresh what we have in state (constructing new full model objects) // // 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; let frontendSteps = steps;
const updatedFrontendStepList = qJobComplete.updatedFrontendStepList; const updatedFrontendStepList = qJobComplete.processMetaDataAdjustment?.updatedFrontendStepList;
if (updatedFrontendStepList) if (updatedFrontendStepList)
{ {
setSteps(updatedFrontendStepList);
frontendSteps = updatedFrontendStepList; frontendSteps = updatedFrontendStepList;
setSteps(frontendSteps);
} }
////////////////////////////////////////////////////////////////////////////////////////////////////////
// always merge the new updatedFields map (if there is one) with existing updates and existing fields //
////////////////////////////////////////////////////////////////////////////////////////////////////////
updateFieldsInProcess(frontendSteps, qJobComplete.processMetaDataAdjustment?.updatedFields);
setSteps(frontendSteps);
/////////////////////////////////////////////////////////////////////////////////// ///////////////////////////////////////////////////////////////////////////////////
// if the next screen has any PVS fields - look up their labels (display values) // // if the next screen has any PVS fields - look up their labels (display values) //
/////////////////////////////////////////////////////////////////////////////////// ///////////////////////////////////////////////////////////////////////////////////
@ -1159,7 +1349,9 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
setJobUUID(null); setJobUUID(null);
setNewStep(nextStepName); setNewStep(nextStepName);
setStepInstanceCounter(1 + stepInstanceCounter);
setProcessValues(newValues); setProcessValues(newValues);
setRenderedWidgets({});
setQJobRunning(null); setQJobRunning(null);
if (formikSetFieldValueFunction) if (formikSetFieldValueFunction)
@ -1415,15 +1607,19 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
////////////////////////////////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////////////////////////////
// handle user submitting the form - which in qqq means moving forward from any screen. // // handle user submitting the form - which in qqq means moving forward from any screen. //
// caller can pass in a map of values to be added to the form data too //
////////////////////////////////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////////////////////////////
const handleSubmit = async (values: any, actions: any) => const handleSubmit = async (values: any) =>
{ {
setFormError(null); setFormError(null);
const formData = new FormData(); const formData = new FormData();
Object.keys(values).forEach((key) => Object.keys(values).forEach((key) =>
{ {
formData.append(key, values[key]); if (values[key] !== undefined)
{
formData.append(key, values[key]);
}
}); });
if (tableVariantLocalStorageKey && localStorage.getItem(tableVariantLocalStorageKey)) if (tableVariantLocalStorageKey && localStorage.getItem(tableVariantLocalStorageKey))
@ -1503,27 +1699,54 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
}; };
const mainCardStyles: any = {};
const formStyles: any = {}; const formStyles: any = {};
mainCardStyles.minHeight = `calc(100vh - ${isModal ? 150 : 400}px)`; if(isWidget)
if (!processError && (qJobRunning || activeStep === null) && !isModal && !isWidget)
{ {
mainCardStyles.background = "#FFFFFF";
mainCardStyles.boxShadow = "none";
}
if (isWidget)
{
mainCardStyles.background = "none";
mainCardStyles.boxShadow = "none";
mainCardStyles.border = "none";
mainCardStyles.minHeight = "";
mainCardStyles.alignItems = "stretch";
mainCardStyles.flexGrow = 1;
mainCardStyles.display = "flex";
formStyles.display = "flex"; formStyles.display = "flex";
formStyles.flexGrow = 1; formStyles.flexGrow = 1;
} }
/***************************************************************************
**
***************************************************************************/
function makeMainCardStyles(theme: Theme)
{
const mainCardStyles: any = {};
if(!isWidget && !isModal)
{
////////////////////////////////////////////////////////////////
// remove margin around card for non-widget, non-modal, small //
////////////////////////////////////////////////////////////////
mainCardStyles[theme.breakpoints.down("sm")] = {
marginLeft: "-1.5rem",
marginRight: "-1.5rem",
borderRadius: "0"
};
}
mainCardStyles.minHeight = `calc(100vh - ${isModal ? 150 : 400}px)`;
if (!processError && (qJobRunning || activeStep === null) && !isModal && !isWidget)
{
mainCardStyles.background = "#FFFFFF";
mainCardStyles.boxShadow = "none";
}
if (isWidget)
{
mainCardStyles.background = "none";
mainCardStyles.boxShadow = "none";
mainCardStyles.border = "none";
mainCardStyles.minHeight = "";
mainCardStyles.alignItems = "stretch";
mainCardStyles.flexGrow = 1;
mainCardStyles.display = "flex";
}
return mainCardStyles
}
let nextButtonLabel = "Next"; let nextButtonLabel = "Next";
let nextButtonIcon = "arrow_forward"; let nextButtonIcon = "arrow_forward";
if (overrideOnLastStep !== null) if (overrideOnLastStep !== null)
@ -1549,20 +1772,21 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
onSubmit={handleSubmit} onSubmit={handleSubmit}
> >
{({ {({
values, errors, touched, isSubmitting, setFieldValue, values, errors, touched, isSubmitting, setFieldValue, setTouched
}) => }) =>
{ {
/////////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////
// once we're in the formik form, use its setFieldValue function // // once we're in the formik form, capture some of its functions //
// over top of the default one we created globally // // over top of the default ones we created globally //
/////////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////
formikSetFieldValueFunction = setFieldValue; formikSetFieldValueFunction = setFieldValue;
formikSetTouched = setTouched;
return ( return (
<Form style={formStyles} id={formId} autoComplete="off"> <Form style={formStyles} id={formId} autoComplete="off">
<Card sx={mainCardStyles}> <Card sx={makeMainCardStyles}>
{ {
!isWidget && ( !isWidget && processMetaData?.stepFlow == "LINEAR" && (
<Box mx={2} mt={-3} sx={{"& .MuiStepper-horizontal": {minHeight: "5rem"}}}> <Box mx={2} mt={-3} sx={{"& .MuiStepper-horizontal": {minHeight: "5rem"}}}>
<Stepper activeStep={activeStepIndex} alternativeLabel> <Stepper activeStep={activeStepIndex} alternativeLabel>
{steps.map((step) => ( {steps.map((step) => (
@ -1597,7 +1821,7 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
{/******************************** {/********************************
** back &| next/submit buttons ** ** 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"}> <Box mt={3} width="100%" display="flex" justifyContent="space-between" position={isWidget ? "absolute" : "initial"} bottom={isWidget ? "3rem" : "initial"} right={isWidget ? "1.5rem" : "initial"}>
{true || activeStepIndex === 0 ? ( {true || activeStepIndex === 0 ? (
<Box /> <Box />
) : ( ) : (
@ -1607,11 +1831,6 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
<Box /> <Box />
) : ( ) : (
<> <>
{formError && (
<MDTypography component="div" variant="caption" color="error" fontWeight="regular" align="right" fullWidth>
{formError}
</MDTypography>
)}
{ {
noMoreSteps && <QCancelButton noMoreSteps && <QCancelButton
onClickHandler={() => handleCancelClicked(true)} onClickHandler={() => handleCancelClicked(true)}
@ -1647,9 +1866,10 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
const body = ( const body = (
<Box py={3} mb={20} className="processRun"> <Box py={3} mb={20} className="processRun">
<Grid container justifyContent="center" alignItems="center" sx={{height: "100%", mt: 8}}> <Grid container justifyContent="center" alignItems="center" mt={{xs: 0, md: 6}} sx={{height: "100%"}}>
<Grid item xs={12} lg={10} xl={8}> <Grid item xs={12} lg={10} xl={8}>
{form} {form}
{formError && <Alert severity="error" onClose={() => setFormError(null)} sx={{position: "fixed", top: "40px", left: "10vw", width: "calc(80vw)", zIndex: "99999"}}>{formError}</Alert>}
</Grid> </Grid>
</Grid> </Grid>
</Box> </Box>

View File

@ -0,0 +1,225 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {QComponentType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QComponentType";
import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
import {QFrontendStepMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFrontendStepMetaData";
import {CompositeData} from "qqq/components/widgets/CompositeWidget";
/*******************************************************************************
** Utility functions used by ProcessRun for working with ad-hoc, block &
** composite type widgets.
**
*******************************************************************************/
export default class ProcessWidgetBlockUtils
{
/*******************************************************************************
**
*******************************************************************************/
public static isActionCodeValid(actionCode: string, step: QFrontendStepMetaData, processValues: any): boolean
{
///////////////////////////////////////////////////////////
// private recursive function to walk the composite tree //
///////////////////////////////////////////////////////////
function recursiveIsActionCodeValidForCompositeData(compositeWidgetData: CompositeData): boolean
{
for (let i = 0; i < compositeWidgetData.blocks.length; i++)
{
const block = compositeWidgetData.blocks[i];
////////////////////////////////////////////////////////////////
// skip the block if it has a 'conditional', which isn't true //
////////////////////////////////////////////////////////////////
const conditionalFieldName = block.conditional;
if (conditionalFieldName)
{
const value = processValues[conditionalFieldName];
if (!value)
{
continue;
}
}
if (block.blockTypeName == "COMPOSITE")
{
///////////////////////////////////////////////////////////////////////////////////////////////////////////////
// recursive call for composites, but only return if a true is found (in case a subsequent block has a true) //
///////////////////////////////////////////////////////////////////////////////////////////////////////////////
const isValidForThisBlock = recursiveIsActionCodeValidForCompositeData(block as unknown as CompositeData);
if (isValidForThisBlock)
{
return (true);
}
// else, continue...
}
else if (block.blockTypeName == "ACTION_BUTTON")
{
//////////////////////////////////////////////////////////
// actually look at actionCodes on action button blocks //
//////////////////////////////////////////////////////////
if (block.values?.actionCode == actionCode)
{
return (true);
}
}
}
/////////////////////////////////////////
// if code wasn't found, it is invalid //
/////////////////////////////////////////
return false;
}
/////////////////////////////////////////////////////
// iterate over all components in the current step //
/////////////////////////////////////////////////////
for (let i = 0; i < step.components.length; i++)
{
const component = step.components[i];
if (component.type == "WIDGET" && component.values?.isAdHocWidget)
{
///////////////////////////////////////////////////////////////////////////////////////////////
// for ad-hoc widget components, check if this actionCode exists on any action-button blocks //
///////////////////////////////////////////////////////////////////////////////////////////////
const isValidForThisWidget = recursiveIsActionCodeValidForCompositeData(component.values);
if (isValidForThisWidget)
{
return (true);
}
}
}
////////////////////////////////////
// upon fallthrough, it's a false //
////////////////////////////////////
return false;
}
/***************************************************************************
** perform evaluations on a compositeWidget's data, given current process
** values, to do dynamic stuff, like:
** - removing fields with un-true conditions
***************************************************************************/
public static dynamicEvaluationOfCompositeWidgetData(compositeWidgetData: CompositeData, processValues: any)
{
for (let i = 0; i < compositeWidgetData.blocks.length; i++)
{
const block = compositeWidgetData.blocks[i];
////////////////////////////////////////////////////////////////////
// if the block has a conditional, evaluate, and remove if untrue //
////////////////////////////////////////////////////////////////////
const conditionalFieldName = block.conditional;
if (conditionalFieldName)
{
const value = processValues[conditionalFieldName];
if (!value)
{
console.debug(`Splicing away block based on [${conditionalFieldName}]...`);
compositeWidgetData.blocks.splice(i, 1);
i--;
continue;
}
}
if (block.blockTypeName == "COMPOSITE")
{
/////////////////////////////////////////
// make recursive calls for composites //
/////////////////////////////////////////
ProcessWidgetBlockUtils.dynamicEvaluationOfCompositeWidgetData(block as unknown as CompositeData, processValues);
}
else if (block.blockTypeName == "INPUT_FIELD")
{
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
// for input fields, put the process's value for the field-name into the block's values object as '.value' //
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
const fieldName = block.values?.fieldMetaData?.name;
if (processValues.hasOwnProperty(fieldName))
{
block.values.value = processValues[fieldName];
}
}
else if (block.blockTypeName == "TEXT")
{
//////////////////////////////////////////////////////////////////////////////////////
// for text-blocks - interpolate ${fieldName} expressions into their process-values //
//////////////////////////////////////////////////////////////////////////////////////
let text = block.values?.text;
if (text)
{
for (let key of Object.keys(processValues))
{
text = text.replaceAll("${" + key + "}", processValues[key]);
}
block.values.interpolatedText = text;
}
}
}
}
/***************************************************************************
**
***************************************************************************/
public static addFieldsForCompositeWidget(step: QFrontendStepMetaData, addFieldCallback: (fieldMetaData: QFieldMetaData) => void)
{
///////////////////////////////////////////////////////////
// private recursive function to walk the composite tree //
///////////////////////////////////////////////////////////
function recursiveHelper(widgetData: CompositeData)
{
try
{
for (let block of widgetData.blocks)
{
if (block.blockTypeName == "COMPOSITE")
{
recursiveHelper(block as unknown as CompositeData);
}
else if (block.blockTypeName == "INPUT_FIELD")
{
const fieldMetaData = new QFieldMetaData(block.values?.fieldMetaData);
addFieldCallback(fieldMetaData)
}
}
}
catch (e)
{
console.log("Error adding fields for compositeWidget: " + e);
}
}
/////////////////////////////////////////////////////////////////////////////
// foreach component, if it's an adhoc widget, call recursive helper on it //
/////////////////////////////////////////////////////////////////////////////
for (let component of step.components)
{
if (component.type == QComponentType.WIDGET && component.values?.isAdHocWidget)
{
recursiveHelper(component.values as unknown as CompositeData)
}
}
}
}

View File

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

View File

@ -779,13 +779,12 @@ function InputPossibleValueSourceSingle(tableName: string, field: QFieldMetaData
}} }}
> >
<DynamicSelect <DynamicSelect
tableName={tableName} fieldPossibleValueProps={{tableName: tableName, fieldName: field.name, initialDisplayValue: selectedPossibleValue?.label}}
fieldName={field.name}
fieldLabel="Value" fieldLabel="Value"
initialValue={selectedPossibleValue?.id} initialValue={selectedPossibleValue?.id}
initialDisplayValue={selectedPossibleValue?.label}
inForm={false} inForm={false}
onChange={handleChange} onChange={handleChange}
useCase="filter"
// InputProps={applying ? {endAdornment: <Icon>sync</Icon>} : {}} // InputProps={applying ? {endAdornment: <Icon>sync</Icon>} : {}}
/> />
</Box> </Box>
@ -847,13 +846,12 @@ function InputPossibleValueSourceMultiple(tableName: string, field: QFieldMetaDa
}} }}
> >
<DynamicSelect <DynamicSelect
tableName={tableName} fieldPossibleValueProps={{tableName: tableName, fieldName: field.name, initialDisplayValue: null}}
fieldName={field.name}
isMultiple={true} isMultiple={true}
fieldLabel="Value" fieldLabel="Value"
initialValues={selectedPossibleValues}
inForm={false} inForm={false}
onChange={handleChange} onChange={handleChange}
useCase="filter"
/> />
</Box> </Box>
); );

View File

@ -33,8 +33,7 @@ import {QCriteriaOperator} from "@kingsrook/qqq-frontend-core/lib/model/query/QC
import {QFilterCriteria} from "@kingsrook/qqq-frontend-core/lib/model/query/QFilterCriteria"; import {QFilterCriteria} from "@kingsrook/qqq-frontend-core/lib/model/query/QFilterCriteria";
import {QFilterOrderBy} from "@kingsrook/qqq-frontend-core/lib/model/query/QFilterOrderBy"; import {QFilterOrderBy} from "@kingsrook/qqq-frontend-core/lib/model/query/QFilterOrderBy";
import {QQueryFilter} from "@kingsrook/qqq-frontend-core/lib/model/query/QQueryFilter"; import {QQueryFilter} from "@kingsrook/qqq-frontend-core/lib/model/query/QQueryFilter";
import {Alert, Collapse, Menu, Typography} from "@mui/material"; import {Alert, Box, Collapse, Menu, Typography} from "@mui/material";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button"; import Button from "@mui/material/Button";
import Card from "@mui/material/Card"; import Card from "@mui/material/Card";
import Divider from "@mui/material/Divider"; import Divider from "@mui/material/Divider";
@ -92,6 +91,7 @@ interface Props
launchProcess?: QProcessMetaData; launchProcess?: QProcessMetaData;
usage?: QueryScreenUsage; usage?: QueryScreenUsage;
isModal?: boolean; isModal?: boolean;
isPreview?: boolean;
initialQueryFilter?: QQueryFilter; initialQueryFilter?: QQueryFilter;
initialColumns?: QQueryColumns; initialColumns?: QQueryColumns;
allowVariables?: boolean; allowVariables?: boolean;
@ -126,7 +126,7 @@ const getLoadingScreen = (isModal: boolean) =>
** **
** Yuge component. The best. Lots of very smart people are saying so. ** Yuge component. The best. Lots of very smart people are saying so.
*******************************************************************************/ *******************************************************************************/
const RecordQuery = forwardRef(({table, usage, isModal, allowVariables, initialQueryFilter, initialColumns}: Props, ref) => const RecordQuery = forwardRef(({table, usage, isModal, isPreview, allowVariables, initialQueryFilter, initialColumns}: Props, ref) =>
{ {
const tableName = table.name; const tableName = table.name;
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
@ -884,6 +884,18 @@ const RecordQuery = forwardRef(({table, usage, isModal, allowVariables, initialQ
}; };
/*******************************************************************************
** Opens a new query screen in a new window with the current filter
*******************************************************************************/
const openFilterInNewWindow = () =>
{
let filterForBackend = JSON.parse(JSON.stringify(view.queryFilter));
filterForBackend = FilterUtils.prepQueryFilterForBackend(tableMetaData, filterForBackend);
const url = `${metaData?.getTablePathByName(tableName)}?filter=${encodeURIComponent(JSON.stringify(filterForBackend))}`;
window.open(url);
};
/******************************************************************************* /*******************************************************************************
** This is the method that actually executes a query to update the data in the table. ** This is the method that actually executes a query to update the data in the table.
*******************************************************************************/ *******************************************************************************/
@ -2232,12 +2244,25 @@ const RecordQuery = forwardRef(({table, usage, isModal, allowVariables, initialQ
return ( return (
<GridToolbarContainer> <GridToolbarContainer>
<div> <div>
<Button id="refresh-button" onClick={() => updateTable("refresh button")} startIcon={<Icon>refresh</Icon>} sx={{pl: "1rem", pr: "0.5rem", minWidth: "unset"}}></Button> <Tooltip title="Refresh Query">
</div> <Button id="refresh-button" onClick={() => updateTable("refresh button")} startIcon={<Icon>refresh</Icon>} sx={{pl: "1rem", pr: "0.5rem", minWidth: "unset"}}></Button>
<div style={{position: "relative"}}> </Tooltip>
{/* @ts-ignore */}
<GridToolbarDensitySelector nonce={undefined} />
</div> </div>
{
!isPreview && (
<div style={{position: "relative"}}>
{/* @ts-ignore */}
<GridToolbarDensitySelector nonce={undefined} />
</div>
)
}
{
isPreview && (
<Tooltip title="Open In New Window">
<Button id="open-filter-in-new-window-button" onClick={() => openFilterInNewWindow()} startIcon={<Icon>launch</Icon>} sx={{pl: "1rem", pr: "0.5rem", minWidth: "unset"}}></Button>
</Tooltip>
)
}
{ {
usage == "queryScreen" && usage == "queryScreen" &&
@ -2872,7 +2897,7 @@ const RecordQuery = forwardRef(({table, usage, isModal, allowVariables, initialQ
} }
{ {
metaData && tableMetaData && !isPreview && metaData && tableMetaData &&
<BasicAndAdvancedQueryControls <BasicAndAdvancedQueryControls
ref={basicAndAdvancedQueryControlsRef} ref={basicAndAdvancedQueryControlsRef}
metaData={metaData} metaData={metaData}

View File

@ -787,3 +787,20 @@ input[type="search"]::-webkit-search-results-decoration
{ {
margin: 2rem 1rem; margin: 2rem 1rem;
} }
/* default styles for a block widget overlay */
.blockWidgetOverlay
{
font-weight: 400;
position: relative;
top: 15px;
height: 0;
display: flex;
font-size: 14px;
flex-direction: column;
align-items: center;
}
.blockWidgetOverlay a
{
color: #0062FF !important;
}

View File

@ -0,0 +1,50 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import Box from "@mui/material/Box";
import React from "react";
interface DumpJsonBoxProps
{
data: any;
title?: string;
}
/***************************************************************************
** Utillity for debugging an object as JSON
***************************************************************************/
export default function DumpJsonBox({data, title}: DumpJsonBoxProps): JSX.Element
{
return (
<Box border="1px solid gray" my="1rem" borderRadius="0.5rem">
{
title &&
<Box borderBottom="1px solid gray" mb="0.5rem" px="0.25rem" borderRadius="0.5rem 0.5rem 0 0" fontSize="1rem" fontWeight="600kkk" sx={{backgroundColor: "#D0D0D0"}}>
{title}
</Box>
}
<Box maxHeight="200px" p="0.25rem" overflow="auto" sx={{whiteSpace: "pre-wrap", fontFamily: "monospace", fontSize: "0.75rem", lineHeight: "1.2"}}>
{JSON.stringify(data, null, 3)}
</Box>
</Box>
);
}

View File

@ -268,7 +268,15 @@ class ValueUtils
{ {
if (!(date instanceof Date)) if (!(date instanceof Date))
{ {
////////////////////////////////////////////////////////////////////////////////////
// so, a new Date here will interpret the string as being at midnight UTC, but //
// the data object will be in the user/browser timezone. //
// so "2024-08-22", for a user in US/Central, will be "2024-08-21T19:00:00-0500". //
// correct for that by adding the date's timezone offset (converted from minutes //
// to millis) back to it //
////////////////////////////////////////////////////////////////////////////////////
date = new Date(date); date = new Date(date);
date.setTime(date.getTime() + date.getTimezoneOffset() * 60 * 1000)
} }
// @ts-ignore // @ts-ignore
return (`${date.toString("yyyy-MM-dd")}`); return (`${date.toString("yyyy-MM-dd")}`);

View File

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