Compare commits

..

56 Commits

Author SHA1 Message Date
ce9ffaab4d Merge branch 'feature/CE-2261-packing-slip-template-config' into integration/sprint-59 2025-02-19 17:05:31 -06:00
6076c4ddfd CE-2261: updated to respect field column widths on view and edit forms 2025-02-19 17:05:10 -06:00
71dc3f3f65 Merge pull request #79 from Kingsrook/feature/support-CE-2257-ice-logic
Add support for defaultValuesForNewChildRecordsFromParentFields for C…
2025-02-14 20:33:47 -06:00
ce22db2f89 Merge pull request #78 from Kingsrook/feature/CE-2258-manual-add-carton [skip ci]
CE-2258: updated dashboard widgets with a forcereload when child reco…
2025-02-14 20:32:15 -06:00
9816403bec Merged feature/support-CE-2257-ice-logic into integration/sprint-59 2025-02-03 09:27:32 -06:00
aacb239164 Add support for defaultValuesForNewChildRecordsFromParentFields for ChildRecordList; Load display values for possible-value fields when adding them to childRecord list and when opening child-edit form (adding passing of all other-values to the possible-value lookup, for filtered scenarios that need them); 2025-02-03 09:10:39 -06:00
219458ec63 CE-2258: updated dashboard widgets with a forcereload when child record is removed 2025-01-28 15:30:42 -06:00
59fdc72455 Merged feature/filter-json-field-improvements into dev 2025-01-22 20:01:10 -06:00
5c3ddb7dec Take label (e.g., of the field) as parameter 2025-01-21 12:18:21 -06:00
d65c1fb5d8 Padding & margin adjustments for script viewer 2025-01-21 12:12:03 -06:00
19a63d6956 Read filterFieldName and columnsFieldName from widgetData 2025-01-14 10:56:07 -06:00
40f5b55307 CE-1955 add error if no fields mapped 2025-01-07 11:47:52 -06:00
7320b19fbb CE-1955 Add warning about duplicate column headers, and un-selection of dupes if switching from no-header-row mode to header-row mode 2025-01-07 10:12:45 -06:00
3f8a3e7e4d CE-1955 Fix (new) switchLayout method to ... actually save the new layout 2025-01-06 16:52:19 -06:00
3ef2d64327 CE-1955 Bulk load bugs & usability improvements 2024-12-27 14:58:40 -06:00
d793c23861 CE-1955 Add guard around a call to onChangeCallback 2024-12-26 19:14:21 -06:00
d0201d96e1 CE-1955 Fix select box handling of 'x' and typing... 2024-12-26 19:13:48 -06:00
7b66ece466 Try to avoid an error a user is getting where no operatorSeletedValue is being selected when page is loading 2024-12-10 09:14:32 -06:00
02c163899a CE-1955 Handle associated fields; better messaging w/ undefined values 2024-12-04 16:11:49 -06:00
8fafe16a95 CE-1955 handle currentSavedBulkLoadProfile being set, when going back to this screen 2024-12-04 16:11:08 -06:00
722c8d3bcf CE-1955 Update to fetch label for possible-values being used as a default value 2024-12-04 16:10:33 -06:00
85acb612c9 CE-1955 Add add ? to record.associatedRecords?.get to avoid crash if no associations 2024-12-03 20:47:50 -06:00
74c634414a CE-1955 Add helpContent to hasHeaderRow and layout fields 2024-12-03 20:47:27 -06:00
f8368b030c CE-1955 make PreviewRecordUsingTableLayout a private component - try to make it re-render the associated child grids when switching records 2024-12-03 15:55:18 -06:00
dda4ea4f4b CE-1955 Delete an unused effect 2024-12-03 15:53:23 -06:00
0c3a6ac278 Merged dev into feature/bulk-upload-v2 2024-12-03 10:02:16 -06:00
85a8bd2d0a CE-1955 small bulk-load cleanups 2024-12-03 09:16:14 -06:00
4b64c46c57 CE-1955 Add value-mapping details to diff 2024-12-03 09:15:53 -06:00
6db003026b CE-1955 Remove filterOperators from the column objects we produce, since we're not using dataGridPro's filtering any more 2024-12-03 09:15:07 -06:00
65b347b794 CE-1955 Break file-input into its own component, w/ support for FILE_UPLOAD adornment type, to specify drag&drop 2024-12-03 09:11:21 -06:00
1626648dda CE-1955 Update qfc to 1.0.113; add react-dropzone 2024-12-03 09:10:46 -06:00
f503c008ec Make record sidebar stop growing at some point (400px, when screen is 1400) 2024-11-27 15:27:48 -06:00
8a16010977 CE-1955 - Add better support for bulk-load (by doing layout more like view screen), for when formatPreviewRecordUsingTableLayout processValues is present; including associations! 2024-11-27 15:25:50 -06:00
ab530121ca CE-1955 - Avoid a few null pointers if missing compareField 2024-11-27 15:24:40 -06:00
9b5d9f1290 CE-1955 - Add styleOverrides argument to renderSectionOfFields; add css classes recordSidebar and recordWithSidebar 2024-11-27 15:23:47 -06:00
ee9cd5a5f6 CE-1955 - add support for child-record lists on process validation preview, via:
- add properties: gridOnly and gridDensity;
- allow the input query records and tableMetaData to come in as pre-typed TS objects, rather than POJSO's, that need to go through constructors.
2024-11-27 15:22:21 -06:00
45be12c728 CE-1955 - Add support for bulletsOfText on a processSummaryLine 2024-11-27 15:20:46 -06:00
169bd4ee7e CE-1955 - Add support for 'back' in processes. add a 'loadingRecords' state var, to help validation screen not flicker 'none found' 2024-11-27 15:20:31 -06:00
85056b121b CE-1955 - Update qfc to 1.0.112 (add backStep to QJobComplete) 2024-11-27 15:14:46 -06:00
b90b5217ca CE-1955 - Add QAlternateButton 2024-11-27 15:11:49 -06:00
911ba1da21 CE-1955 - Remove unused method 2024-11-27 15:11:27 -06:00
bfa9b1d182 CE-1955 - Trying a sleep (wait) around point of failure... 2024-11-25 11:32:52 -06:00
cce73fcb0b CE-1955 - Update qfc to 1.0.111 2024-11-25 10:48:20 -06:00
f2b41532d4 CE-1955 - Initial checkin of qfmd support for bulk-load 2024-11-25 10:48:00 -06:00
3fc4e37c12 CE-1955 - Break ProcessViewForm out into its own reusable component 2024-11-25 10:35:35 -06:00
451af347f7 CE-1955 - Add support for pre-submit callbacks, defined in components - specifically, ones used by bulk-load. Add awareness of the bulkLoad components, specifically with a block for value-mapping form, to integrate with formik 2024-11-25 10:16:22 -06:00
1630fbacda CE-1955 - Break DynamicFormFieldLabel out into a component that others can use 2024-11-25 10:12:28 -06:00
2220e6f86e CE-1955 - Remove mb from cancel button (incorrectly added in last sprint's work) 2024-11-25 10:12:04 -06:00
a66ffa753d CE-1955 - Add optional name prop 2024-11-25 10:11:41 -06:00
b07d65aaca CE-1955 - Add onChangeCallback to form fields; add ability to get a DynamicSelect out of DynamicFormField; 2024-11-25 10:11:27 -06:00
78fc2c50d0 Merge tag 'version-0.23.0' into dev
Tag release
2024-11-22 16:18:57 -06:00
501b8b34c9 Merge branch 'release/0.23.0' 2024-11-22 16:18:57 -06:00
8a6eef9907 Update for next development version 2024-11-22 15:59:23 -06:00
efd1922ee3 Update versions for release 2024-11-22 15:59:20 -06:00
b4f8fb2e18 CE-1946: minor updates to padding, fixes, etc 2024-11-22 11:41:29 -06:00
204025c2a6 Merged feature/CE-1772-generate-labels-poc into dev 2024-11-21 12:31:44 -06:00
37 changed files with 23528 additions and 3874 deletions

20600
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -6,7 +6,7 @@
"@auth0/auth0-react": "1.10.2", "@auth0/auth0-react": "1.10.2",
"@emotion/react": "11.7.1", "@emotion/react": "11.7.1",
"@emotion/styled": "11.6.0", "@emotion/styled": "11.6.0",
"@kingsrook/qqq-frontend-core": "1.0.110", "@kingsrook/qqq-frontend-core": "1.0.114",
"@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",
@ -44,6 +44,7 @@
"react-dnd": "16.0.1", "react-dnd": "16.0.1",
"react-dnd-html5-backend": "16.0.1", "react-dnd-html5-backend": "16.0.1",
"react-dom": "18.0.0", "react-dom": "18.0.0",
"react-dropzone": "14.3.5",
"react-ga4": "2.1.0", "react-ga4": "2.1.0",
"react-github-btn": "1.2.1", "react-github-btn": "1.2.1",
"react-google-drive-picker": "^1.2.0", "react-google-drive-picker": "^1.2.0",

View File

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

View File

@ -145,7 +145,7 @@ export function QCancelButton({
}: QCancelButtonProps): JSX.Element }: QCancelButtonProps): JSX.Element
{ {
return ( return (
<Box ml={standardML} mb={2} width={standardWidth}> <Box ml={standardML} 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>
@ -180,3 +180,24 @@ QSubmitButton.defaultProps = {
label: "Submit", label: "Submit",
iconName: "check", iconName: "check",
}; };
interface QAlternateButtonProps
{
label: string,
iconName?: string,
disabled: boolean,
onClick?: () => void
}
export function QAlternateButton({label, iconName, disabled, onClick}: QAlternateButtonProps): JSX.Element
{
return (
<Box ml={standardML} width={standardWidth}>
<MDButton type="button" variant="gradient" color="secondary" size="small" fullWidth startIcon={iconName && <Icon>{iconName}</Icon>} onClick={onClick} disabled={disabled}>
{label}
</MDButton>
</Box>
);
}
QAlternateButton.defaultProps = {};

View File

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

View File

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

View File

@ -19,10 +19,12 @@
* 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 {QPossibleValue} from "@kingsrook/qqq-frontend-core/lib/model/QPossibleValue";
import {Box, InputAdornment, InputLabel} from "@mui/material"; import {Box, InputAdornment, InputLabel} from "@mui/material";
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 DynamicFormUtils from "qqq/components/forms/DynamicFormUtils"; import DynamicFormUtils from "qqq/components/forms/DynamicFormUtils";
import DynamicSelect from "qqq/components/forms/DynamicSelect";
import React, {useMemo, useState} from "react"; 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";
@ -43,6 +45,8 @@ interface Props
placeholder?: string; placeholder?: string;
backgroundColor?: string; backgroundColor?: string;
onChangeCallback?: (newValue: any) => void;
[key: string]: any; [key: string]: any;
bulkEditMode?: boolean; bulkEditMode?: boolean;
@ -51,7 +55,7 @@ interface Props
} }
function QDynamicFormField({ function QDynamicFormField({
label, name, displayFormat, value, bulkEditMode, bulkEditSwitchChangeHandler, type, isEditable, placeholder, backgroundColor, formFieldObject, ...rest label, name, displayFormat, value, bulkEditMode, bulkEditSwitchChangeHandler, type, isEditable, placeholder, backgroundColor, formFieldObject, onChangeCallback, ...rest
}: Props): JSX.Element }: Props): JSX.Element
{ {
const [switchChecked, setSwitchChecked] = useState(false); const [switchChecked, setSwitchChecked] = useState(false);
@ -116,9 +120,11 @@ function QDynamicFormField({
// put the onChange in an object and assign it with a spread // // put the onChange in an object and assign it with a spread //
//////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////
let onChange: any = {}; let onChange: any = {};
if (isToUpperCase || isToLowerCase) if (isToUpperCase || isToLowerCase || onChangeCallback)
{ {
onChange.onChange = (e: any) => onChange.onChange = (e: any) =>
{
if(isToUpperCase || isToLowerCase)
{ {
const beforeStart = e.target.selectionStart; const beforeStart = e.target.selectionStart;
const beforeEnd = e.target.selectionEnd; const beforeEnd = e.target.selectionEnd;
@ -135,6 +141,10 @@ function QDynamicFormField({
newValue = newValue.toLowerCase(); newValue = newValue.toLowerCase();
} }
setFieldValue(name, newValue); setFieldValue(name, newValue);
if(onChangeCallback)
{
onChangeCallback(newValue);
}
}); });
const input = document.getElementById(name) as HTMLInputElement; const input = document.getElementById(name) as HTMLInputElement;
@ -142,16 +152,47 @@ function QDynamicFormField({
{ {
input.setSelectionRange(beforeStart, beforeEnd); input.setSelectionRange(beforeStart, beforeEnd);
} }
}
else if(onChangeCallback)
{
onChangeCallback(e.currentTarget.value);
}
}; };
} }
/***************************************************************************
**
***************************************************************************/
function dynamicSelectOnChange(newValue?: QPossibleValue)
{
if(onChangeCallback)
{
onChangeCallback(newValue == null ? null : newValue.id)
}
}
let field; let field;
let getsBulkEditHtmlLabel = true; let getsBulkEditHtmlLabel = true;
if (type === "checkbox") if(formFieldObject.possibleValueProps)
{
field = (<DynamicSelect
name={name}
fieldPossibleValueProps={formFieldObject.possibleValueProps}
isEditable={!isDisabled}
fieldLabel={label}
initialValue={value}
bulkEditMode={bulkEditMode}
bulkEditSwitchChangeHandler={bulkEditSwitchChangeHandler}
onChange={dynamicSelectOnChange}
// otherValues={otherValuesMap}
useCase="form"
/>)
}
else if (type === "checkbox")
{ {
getsBulkEditHtmlLabel = false; getsBulkEditHtmlLabel = false;
field = (<> field = (<>
<BooleanFieldSwitch name={name} label={label} value={value} isDisabled={isDisabled} /> <BooleanFieldSwitch name={name} label={label} value={value} isDisabled={isDisabled} onChangeCallback={onChangeCallback} />
<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={name} render={msg => <span data-field-error="true">{msg}</span>} /></div>} {!isDisabled && <div className="fieldErrorMessage"><ErrorMessage name={name} render={msg => <span data-field-error="true">{msg}</span>} /></div>}
@ -179,6 +220,10 @@ function QDynamicFormField({
onChange={(value: string, event: any) => onChange={(value: string, event: any) =>
{ {
setFieldValue(name, value, false); setFieldValue(name, value, false);
if(onChangeCallback)
{
onChangeCallback(value);
}
}} }}
setOptions={{useWorker: false}} setOptions={{useWorker: false}}
width="100%" width="100%"

View File

@ -38,6 +38,7 @@ interface Props
{ {
fieldPossibleValueProps: FieldPossibleValueProps; fieldPossibleValueProps: FieldPossibleValueProps;
overrideId?: string; overrideId?: string;
name?: string;
fieldLabel: string; fieldLabel: string;
inForm: boolean; inForm: boolean;
initialValue?: any; initialValue?: any;
@ -95,7 +96,7 @@ export const getAutocompleteOutlinedStyle = (isDisabled: boolean) =>
const qController = Client.getInstance(); const qController = Client.getInstance();
function DynamicSelect({fieldPossibleValueProps, overrideId, fieldLabel, inForm, initialValue, initialValues, onChange, isEditable, isMultiple, bulkEditMode, bulkEditSwitchChangeHandler, otherValues, variant, initiallyOpen, useCase}: Props) function DynamicSelect({fieldPossibleValueProps, overrideId, name, fieldLabel, inForm, initialValue, initialValues, onChange, isEditable, isMultiple, bulkEditMode, bulkEditSwitchChangeHandler, otherValues, variant, initiallyOpen, useCase}: Props)
{ {
const {fieldName, initialDisplayValue, possibleValueSourceName, possibleValues, processName, tableName} = fieldPossibleValueProps; const {fieldName, initialDisplayValue, possibleValueSourceName, possibleValues, processName, tableName} = fieldPossibleValueProps;
@ -404,6 +405,7 @@ function DynamicSelect({fieldPossibleValueProps, overrideId, fieldLabel, inForm,
<Box> <Box>
<Autocomplete <Autocomplete
id={overrideId ?? fieldName ?? possibleValueSourceName ?? "anonymous"} id={overrideId ?? fieldName ?? possibleValueSourceName ?? "anonymous"}
name={name}
sx={autocompleteSX} sx={autocompleteSX}
open={open} open={open}
fullWidth fullWidth

View File

@ -66,7 +66,7 @@ interface Props
defaultValues: { [key: string]: string }; defaultValues: { [key: string]: string };
disabledFields: { [key: string]: boolean } | string[]; disabledFields: { [key: string]: boolean } | string[];
isCopy?: boolean; isCopy?: boolean;
onSubmitCallback?: (values: any) => void; onSubmitCallback?: (values: any, tableName: string) => void;
overrideHeading?: string; overrideHeading?: string;
} }
@ -173,7 +173,7 @@ function EntityForm(props: Props): JSX.Element
*******************************************************************************/ *******************************************************************************/
function openAddChildRecord(name: string, widgetData: any) function openAddChildRecord(name: string, widgetData: any)
{ {
let defaultValues = widgetData.defaultValuesForNewChildRecords; let defaultValues = widgetData.defaultValuesForNewChildRecords || {};
let disabledFields = widgetData.disabledFieldsForNewChildRecords; let disabledFields = widgetData.disabledFieldsForNewChildRecords;
if (!disabledFields) if (!disabledFields)
@ -181,6 +181,18 @@ function EntityForm(props: Props): JSX.Element
disabledFields = widgetData.defaultValuesForNewChildRecords; disabledFields = widgetData.defaultValuesForNewChildRecords;
} }
///////////////////////////////////////////////////////////////////////////////////////
// copy values from specified fields in the parent record down into the child record //
///////////////////////////////////////////////////////////////////////////////////////
if(widgetData.defaultValuesForNewChildRecordsFromParentFields)
{
for(let childField in widgetData.defaultValuesForNewChildRecordsFromParentFields)
{
const parentField = widgetData.defaultValuesForNewChildRecordsFromParentFields[childField];
defaultValues[childField] = formValues[parentField];
}
}
doOpenEditChildForm(name, widgetData.childTableMetaData, null, defaultValues, disabledFields); doOpenEditChildForm(name, widgetData.childTableMetaData, null, defaultValues, disabledFields);
} }
@ -208,7 +220,7 @@ function EntityForm(props: Props): JSX.Element
function deleteChildRecord(name: string, widgetData: any, rowIndex: number) function deleteChildRecord(name: string, widgetData: any, rowIndex: number)
{ {
updateChildRecordList(name, "delete", rowIndex); updateChildRecordList(name, "delete", rowIndex);
}; }
/******************************************************************************* /*******************************************************************************
@ -243,16 +255,16 @@ function EntityForm(props: Props): JSX.Element
/******************************************************************************* /*******************************************************************************
** **
*******************************************************************************/ *******************************************************************************/
function submitEditChildForm(values: any) function submitEditChildForm(values: any, tableName: string)
{ {
updateChildRecordList(showEditChildForm.widgetName, showEditChildForm.rowIndex == null ? "insert" : "edit", showEditChildForm.rowIndex, values); updateChildRecordList(showEditChildForm.widgetName, showEditChildForm.rowIndex == null ? "insert" : "edit", showEditChildForm.rowIndex, values, tableName);
} }
/******************************************************************************* /*******************************************************************************
** **
*******************************************************************************/ *******************************************************************************/
async function updateChildRecordList(widgetName: string, action: "insert" | "edit" | "delete", rowIndex?: number, values?: any) async function updateChildRecordList(widgetName: string, action: "insert" | "edit" | "delete", rowIndex?: number, values?: any, childTableName?: string)
{ {
const metaData = await qController.loadMetaData(); const metaData = await qController.loadMetaData();
const widgetMetaData = metaData.widgets.get(widgetName); const widgetMetaData = metaData.widgets.get(widgetName);
@ -263,13 +275,38 @@ function EntityForm(props: Props): JSX.Element
newChildListWidgetData[widgetName].queryOutput.records = []; newChildListWidgetData[widgetName].queryOutput.records = [];
} }
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// build a map of display values for the new record, specifically, for any possible-values that need translated. //
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
const displayValues: {[fieldName: string]: string} = {};
if(childTableName && values)
{
///////////////////////////////////////////////////////////////////////////////////////////////////////
// this function internally memoizes, so, we could potentially avoid an await here, but, seems ok... //
///////////////////////////////////////////////////////////////////////////////////////////////////////
const childTableMetaData = await qController.loadTableMetaData(childTableName)
for (let key in values)
{
const value = values[key];
const field = childTableMetaData.fields.get(key);
if(field.possibleValueSourceName)
{
const possibleValues = await qController.possibleValues(childTableName, null, field.name, null, [value], objectToMap(values), "form")
if(possibleValues && possibleValues.length > 0)
{
displayValues[key] = possibleValues[0].label;
}
}
}
}
switch (action) switch (action)
{ {
case "insert": case "insert":
newChildListWidgetData[widgetName].queryOutput.records.push({values: values}); newChildListWidgetData[widgetName].queryOutput.records.push({values: values, displayValues: displayValues});
break; break;
case "edit": case "edit":
newChildListWidgetData[widgetName].queryOutput.records[rowIndex] = {values: values}; newChildListWidgetData[widgetName].queryOutput.records[rowIndex] = {values: values, displayValues: displayValues};
break; break;
case "delete": case "delete":
newChildListWidgetData[widgetName].queryOutput.records.splice(rowIndex, 1); newChildListWidgetData[widgetName].queryOutput.records.splice(rowIndex, 1);
@ -407,6 +444,7 @@ function EntityForm(props: Props): JSX.Element
widgetMetaData={widgetMetaData} widgetMetaData={widgetMetaData}
widgetData={widgetData} widgetData={widgetData}
recordValues={formValues} recordValues={formValues}
label={tableMetaData?.fields.get(widgetData?.filterFieldName ?? "queryFilterJson")?.label}
onSaveCallback={setFormFieldValuesFromWidget} onSaveCallback={setFormFieldValuesFromWidget}
/>; />;
} }
@ -478,6 +516,26 @@ function EntityForm(props: Props): JSX.Element
} }
/***************************************************************************
**
***************************************************************************/
function objectToMap(object: { [key: string]: any }): Map<string, any>
{
if(object == null)
{
return (null);
}
const rs = new Map<string, any>();
for (let key in object)
{
rs.set(key, object[key]);
}
return rs
}
////////////////// //////////////////
// initial load // // initial load //
////////////////// //////////////////
@ -595,14 +653,21 @@ function EntityForm(props: Props): JSX.Element
if (defaultValue) if (defaultValue)
{ {
initialValues[fieldName] = defaultValue; initialValues[fieldName] = defaultValue;
}
}
/////////////////////////////////////////////////////////////////////////////////////////// /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// we need to set the initialDisplayValue for possible value fields with a default value // // do a second loop, this time looking up display-values for any possible-value fields with a default value //
// so, look them up here now if needed // // do it in a second loop, to pass in all the other values (from initialValues), in case there's a PVS filter that needs them. //
/////////////////////////////////////////////////////////////////////////////////////////// /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
if (fieldMetaData.possibleValueSourceName) for (let i = 0; i < fieldArray.length; i++)
{ {
const results: QPossibleValue[] = await qController.possibleValues(tableName, null, fieldName, null, [initialValues[fieldName]], undefined, "form"); const fieldMetaData = fieldArray[i];
const fieldName = fieldMetaData.name;
const defaultValue = (defaultValues && defaultValues[fieldName]) ? defaultValues[fieldName] : fieldMetaData.defaultValue;
if (defaultValue && fieldMetaData.possibleValueSourceName)
{
const results: QPossibleValue[] = await qController.possibleValues(tableName, null, fieldName, null, [initialValues[fieldName]], objectToMap(initialValues), "form");
if (results && results.length > 0) if (results && results.length > 0)
{ {
defaultDisplayValues.set(fieldName, results[0].label); defaultDisplayValues.set(fieldName, results[0].label);
@ -610,7 +675,6 @@ function EntityForm(props: Props): JSX.Element
} }
} }
} }
}
/////////////////////////////////////////////////// ///////////////////////////////////////////////////
// if an override heading was passed in, use it. // // if an override heading was passed in, use it. //
@ -818,12 +882,12 @@ function EntityForm(props: Props): JSX.Element
{ {
actions.setSubmitting(true); actions.setSubmitting(true);
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// if there's a callback (e.g., for a modal nested on another create/edit screen), then just pass our data back there anre return. // // if there's a callback (e.g., for a modal nested on another create/edit screen), then just pass our data back there and return. //
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
if (props.onSubmitCallback) if (props.onSubmitCallback)
{ {
props.onSubmitCallback(values); props.onSubmitCallback(values, tableName);
return; return;
} }
@ -1290,7 +1354,7 @@ function EntityForm(props: Props): JSX.Element
table={showEditChildForm.table} table={showEditChildForm.table}
defaultValues={showEditChildForm.defaultValues} defaultValues={showEditChildForm.defaultValues}
disabledFields={showEditChildForm.disabledFields} disabledFields={showEditChildForm.disabledFields}
onSubmitCallback={submitEditChildForm} onSubmitCallback={props.onSubmitCallback ? props.onSubmitCallback : submitEditChildForm}
overrideHeading={`${showEditChildForm.rowIndex != null ? "Editing" : "Creating New"} ${showEditChildForm.table.label}`} overrideHeading={`${showEditChildForm.rowIndex != null ? "Editing" : "Creating New"} ${showEditChildForm.table.label}`}
/> />
</div> </div>

View File

@ -0,0 +1,156 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {AdornmentType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/AdornmentType";
import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
import {QFieldType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldType";
import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord";
import {Button, colors, Icon} from "@mui/material";
import Box from "@mui/material/Box";
import Tooltip from "@mui/material/Tooltip";
import {useFormikContext} from "formik";
import MDTypography from "qqq/components/legacy/MDTypography";
import ValueUtils from "qqq/utils/qqq/ValueUtils";
import React, {useCallback, useState} from "react";
import {useDropzone} from "react-dropzone";
interface FileInputFieldProps
{
field: any,
record?: QRecord,
errorMessage?: any
}
export default function FileInputField({field, record, errorMessage}: FileInputFieldProps): JSX.Element
{
const [fileName, setFileName] = useState(null as string);
const formikProps = useFormikContext();
const fileChanged = (event: React.FormEvent<HTMLInputElement>, field: any) =>
{
setFileName(null);
if (event.currentTarget.files && event.currentTarget.files[0])
{
setFileName(event.currentTarget.files[0].name);
}
formikProps.setFieldValue(field.name, event.currentTarget.files[0]);
};
const onDrop = useCallback((acceptedFiles: any) =>
{
setFileName(null);
if (acceptedFiles.length && acceptedFiles[0])
{
setFileName(acceptedFiles[0].name);
}
formikProps.setFieldValue(field.name, acceptedFiles[0]);
}, []);
const {getRootProps, getInputProps, isDragActive} = useDropzone({onDrop});
const removeFile = (fieldName: string) =>
{
setFileName(null);
formikProps.setFieldValue(fieldName, null);
record?.values.delete(fieldName);
record?.displayValues.delete(fieldName);
};
const pseudoField = new QFieldMetaData({name: field.name, type: QFieldType.BLOB});
const fileUploadAdornment = field.fieldMetaData?.getAdornment(AdornmentType.FILE_UPLOAD);
const format = fileUploadAdornment?.values?.get("format") ?? "button";
return (
<Box mb={1.5}>
{
record && record.values.get(field.name) && <Box fontSize="0.875rem" pb={1}>
Current File:
<Box display="inline-flex" pl={1}>
{ValueUtils.getDisplayValue(pseudoField, record, "view")}
<Tooltip placement="bottom" title="Remove current file">
<Icon className="blobIcon" fontSize="small" onClick={(e) => removeFile(field.name)}>delete</Icon>
</Tooltip>
</Box>
</Box>
}
{
format == "button" &&
<Box display="flex" alignItems="center">
<Button variant="outlined" component="label">
<span style={{color: colors.lightBlue[500]}}>Choose file to upload</span>
<input
id={field.name}
name={field.name}
type="file"
hidden
onChange={(event: React.FormEvent<HTMLInputElement>) => fileChanged(event, field)}
/>
</Button>
<Box ml={1} fontSize={"1rem"}>
{fileName}
</Box>
</Box>
}
{
format == "dragAndDrop" &&
<>
<Box {...getRootProps()} sx={
{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: "100%",
height: "300px",
borderRadius: "2rem",
backgroundColor: isDragActive ? colors.lightBlue[50] : "transparent",
border: `2px ${isDragActive ? "solid" : "dashed"} ${colors.lightBlue[500]}`
}}>
<input {...getInputProps()} />
<Box display="flex" alignItems="center" flexDirection="column">
<Icon sx={{fontSize: "4rem !important", color: colors.lightBlue[500]}}>upload_file</Icon>
<Box>Drag and drop a file</Box>
<Box fontSize="1rem" m="0.5rem">or</Box>
<Box border={`2px solid ${colors.lightBlue[500]}`} mt="0.25rem" padding="0.25rem 1rem" borderRadius="0.5rem" sx={{cursor: "pointer"}} fontSize="1rem">
Browse files
</Box>
</Box>
</Box>
<Box fontSize={"1rem"} mt="0.25rem">
{fileName}&nbsp;
</Box>
</>
}
<Box mt={0.75}>
<MDTypography component="div" variant="caption" color="error" fontWeight="regular">
{errorMessage && <span>{errorMessage}</span>}
</MDTypography>
</Box>
</Box>
);
}

View File

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

View File

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

View File

@ -0,0 +1,329 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
import {Checkbox, FormControlLabel, Radio, Tooltip} from "@mui/material";
import Autocomplete from "@mui/material/Autocomplete";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import Icon from "@mui/material/Icon";
import IconButton from "@mui/material/IconButton";
import RadioGroup from "@mui/material/RadioGroup";
import TextField from "@mui/material/TextField";
import {useFormikContext} from "formik";
import colors from "qqq/assets/theme/base/colors";
import QDynamicFormField from "qqq/components/forms/DynamicFormField";
import DynamicFormUtils from "qqq/components/forms/DynamicFormUtils";
import {BulkLoadField, FileDescription} from "qqq/models/processes/BulkLoadModels";
import Client from "qqq/utils/qqq/Client";
import React, {useEffect, useState} from "react";
interface BulkLoadMappingFieldProps
{
bulkLoadField: BulkLoadField,
isRequired: boolean,
removeFieldCallback?: () => void,
fileDescription: FileDescription,
forceParentUpdate?: () => void,
}
const xIconButtonSX =
{
border: `1px solid ${colors.grayLines.main} !important`,
borderRadius: "0.5rem",
textTransform: "none",
fontSize: "1rem",
fontWeight: "400",
width: "30px",
minWidth: "30px",
height: "2rem",
minHeight: "2rem",
paddingLeft: 0,
paddingRight: 0,
marginRight: "0.5rem",
marginTop: "0.5rem",
color: colors.error.main,
"&:hover": {color: colors.error.main},
"&:focus": {color: colors.error.main},
"&:focus:not(:hover)": {color: colors.error.main},
};
const qController = Client.getInstance();
/***************************************************************************
** row for a single field on the bulk load mapping screen.
***************************************************************************/
export default function BulkLoadFileMappingField({bulkLoadField, isRequired, removeFieldCallback, fileDescription, forceParentUpdate}: BulkLoadMappingFieldProps): JSX.Element
{
const columnNames = fileDescription.getColumnNames();
const [valueType, setValueType] = useState(bulkLoadField.valueType);
const [selectedColumn, setSelectedColumn] = useState({label: columnNames[bulkLoadField.columnIndex], value: bulkLoadField.columnIndex});
const [selectedColumnInputValue, setSelectedColumnInputValue] = useState(columnNames[bulkLoadField.columnIndex]);
const [doingInitialLoadOfPossibleValue, setDoingInitialLoadOfPossibleValue] = useState(false);
const [everDidInitialLoadOfPossibleValue, setEverDidInitialLoadOfPossibleValue] = useState(false);
const [possibleValueInitialDisplayValue, setPossibleValueInitialDisplayValue] = useState(null as string);
const fieldMetaData = new QFieldMetaData(bulkLoadField.field);
const dynamicField = DynamicFormUtils.getDynamicField(fieldMetaData);
const dynamicFieldInObject: any = {};
dynamicFieldInObject[fieldMetaData["name"]] = dynamicField;
DynamicFormUtils.addPossibleValueProps(dynamicFieldInObject, [fieldMetaData], bulkLoadField.tableStructure.tableName, null, null);
/////////////////////////////////////////////////////////////////////////////////////
// deal with dynamically loading the initial default value for a possible value... //
/////////////////////////////////////////////////////////////////////////////////////
let actuallyDoingInitialLoadOfPossibleValue = doingInitialLoadOfPossibleValue;
if(dynamicField.possibleValueProps && bulkLoadField.defaultValue && !doingInitialLoadOfPossibleValue && !everDidInitialLoadOfPossibleValue)
{
actuallyDoingInitialLoadOfPossibleValue = true;
setDoingInitialLoadOfPossibleValue(true);
setEverDidInitialLoadOfPossibleValue(true);
(async () =>
{
try
{
const possibleValues = await qController.possibleValues(bulkLoadField.tableStructure.tableName, null, fieldMetaData.name, null, [bulkLoadField.defaultValue], undefined, "filter");
if (possibleValues && possibleValues.length > 0)
{
setPossibleValueInitialDisplayValue(possibleValues[0].label);
}
else
{
setPossibleValueInitialDisplayValue(null);
}
}
catch(e)
{
console.log(`Error loading possible value: ${e}`)
}
actuallyDoingInitialLoadOfPossibleValue = false;
setDoingInitialLoadOfPossibleValue(false);
})();
}
if(dynamicField.possibleValueProps && possibleValueInitialDisplayValue)
{
dynamicField.possibleValueProps.initialDisplayValue = possibleValueInitialDisplayValue;
}
//////////////////////////////////////////////////////
// build array of options for the columns drop down //
// don't allow duplicates //
//////////////////////////////////////////////////////
const columnOptions: { value: number, label: string }[] = [];
const usedLabels: {[label: string]: boolean} = {};
for (let i = 0; i < columnNames.length; i++)
{
const label = columnNames[i];
if(!usedLabels[label])
{
columnOptions.push({label: label, value: i});
usedLabels[label] = true;
}
}
//////////////////////////////////////////////////////////////////////
// try to pick up changes in the hasHeaderRow toggle from way above //
//////////////////////////////////////////////////////////////////////
if(bulkLoadField.columnIndex != null && bulkLoadField.columnIndex != undefined && selectedColumn.label && columnNames[bulkLoadField.columnIndex] != selectedColumn.label)
{
setSelectedColumn({label: columnNames[bulkLoadField.columnIndex], value: bulkLoadField.columnIndex})
setSelectedColumnInputValue(columnNames[bulkLoadField.columnIndex]);
}
const mainFontSize = "0.875rem";
const smallerFontSize = "0.75rem";
/////////////////////////////////////////////////////////////////////////////////////////////
// some field types get their value from formik. //
// so for a pre-populated value, do an on-load useEffect, that'll set the value in formik. //
/////////////////////////////////////////////////////////////////////////////////////////////
const {setFieldValue} = useFormikContext();
useEffect(() =>
{
if (valueType == "defaultValue")
{
setFieldValue(`${bulkLoadField.field.name}.defaultValue`, bulkLoadField.defaultValue);
}
}, []);
/***************************************************************************
**
***************************************************************************/
function columnChanged(event: any, newValue: any, reason: string)
{
setSelectedColumn(newValue);
setSelectedColumnInputValue(newValue == null ? "" : newValue.label);
bulkLoadField.columnIndex = newValue == null ? null : newValue.value;
if (fileDescription.hasHeaderRow)
{
bulkLoadField.headerName = newValue == null ? null : newValue.label;
}
bulkLoadField.error = null;
bulkLoadField.warning = null;
forceParentUpdate && forceParentUpdate();
}
/***************************************************************************
**
***************************************************************************/
function defaultValueChanged(newValue: any)
{
setFieldValue(`${bulkLoadField.field.name}.defaultValue`, newValue);
bulkLoadField.defaultValue = newValue;
bulkLoadField.error = null;
bulkLoadField.warning = null;
forceParentUpdate && forceParentUpdate();
}
/***************************************************************************
**
***************************************************************************/
function valueTypeChanged(isColumn: boolean)
{
const newValueType = isColumn ? "column" : "defaultValue";
bulkLoadField.valueType = newValueType;
setValueType(newValueType);
bulkLoadField.error = null;
bulkLoadField.warning = null;
forceParentUpdate && forceParentUpdate();
}
/***************************************************************************
**
***************************************************************************/
function mapValuesChanged(value: boolean)
{
bulkLoadField.doValueMapping = value;
forceParentUpdate && forceParentUpdate();
}
/***************************************************************************
**
***************************************************************************/
function changeSelectedColumnInputValue(e: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>)
{
setSelectedColumnInputValue(e.target.value);
}
return (<Box py="0.5rem" sx={{borderBottom: "1px solid lightgray", width: "100%", overflow: "auto"}} id={`blfmf-${bulkLoadField.field.name}`}>
<Box display="grid" gridTemplateColumns="200px 400px auto" fontSize="1rem" gap="0.5rem" sx={
{
"& .MuiFormControlLabel-label": {ml: "0 !important", fontWeight: "normal !important", fontSize: mainFontSize}
}}>
<Box display="flex" alignItems="flex-start">
{
(!isRequired) && <Tooltip placement="bottom" title="Remove this field from your mapping.">
<Button sx={xIconButtonSX} onClick={() => removeFieldCallback()}><Icon>clear</Icon></Button>
</Tooltip>
}
<Box pt="0.625rem">
{bulkLoadField.getQualifiedLabel()}
</Box>
</Box>
<RadioGroup name="valueType" value={valueType}>
<Box>
<Box display="flex" alignItems="center" sx={{height: "45px"}}>
<FormControlLabel value="column" control={<Radio size="small" onChange={(event, checked) => valueTypeChanged(checked)} />} label={"File column"} sx={{minWidth: "140px", whiteSpace: "nowrap"}} />
{
valueType == "column" && <Box width="100%">
<Autocomplete
id={bulkLoadField.field.name}
renderInput={(params) => (<TextField {...params} label={""} value={selectedColumnInputValue} onChange={e => changeSelectedColumnInputValue(e)} fullWidth variant="outlined" autoComplete="off" type="search" InputProps={{...params.InputProps}} sx={{"& .MuiOutlinedInput-root": {borderRadius: "0.75rem"}}} />)}
fullWidth
options={columnOptions}
multiple={false}
defaultValue={selectedColumn}
value={selectedColumn}
inputValue={selectedColumnInputValue}
onChange={columnChanged}
getOptionLabel={(option) => typeof (option) == "string" ? option : (option?.label ?? "")}
isOptionEqualToValue={(option, value) => option == null && value == null || option.value == value.value}
renderOption={(props, option, state) => (<li {...props}>{option?.label ?? ""}</li>)}
sx={{"& .MuiOutlinedInput-root": {padding: "0"}}}
/>
</Box>
}
</Box>
<Box display="flex" alignItems="center" sx={{height: "45px"}}>
<FormControlLabel value="defaultValue" control={<Radio size="small" onChange={(event, checked) => valueTypeChanged(!checked)} />} label={"Default value"} sx={{minWidth: "140px", whiteSpace: "nowrap"}} />
{
valueType == "defaultValue" && actuallyDoingInitialLoadOfPossibleValue && <Box width="100%">Loading...</Box>
}
{
valueType == "defaultValue" && !actuallyDoingInitialLoadOfPossibleValue && <Box width="100%">
<QDynamicFormField
name={`${bulkLoadField.field.name}.defaultValue`}
displayFormat={""}
label={""}
formFieldObject={dynamicField}
type={dynamicField.type}
value={bulkLoadField.defaultValue}
onChangeCallback={defaultValueChanged}
/>
</Box>
}
</Box>
</Box>
{
bulkLoadField.warning &&
<Box fontSize={smallerFontSize} color={colors.warning.main} ml="145px" className="bulkLoadFieldError">
{bulkLoadField.warning}
</Box>
}
{
bulkLoadField.error &&
<Box fontSize={smallerFontSize} color={colors.error.main} ml="145px" className="bulkLoadFieldError">
{bulkLoadField.error}
</Box>
}
</RadioGroup>
<Box ml="1rem">
{
valueType == "column" && <>
<Box>
<FormControlLabel value="mapValues" control={<Checkbox size="small" defaultChecked={bulkLoadField.doValueMapping} onChange={(event, checked) => mapValuesChanged(checked)} />} label={"Map values"} sx={{minWidth: "140px", whiteSpace: "nowrap"}} />
</Box>
<Box fontSize={mainFontSize} mt="0.5rem">
Preview Values: <span style={{color: "gray"}}>{(fileDescription.getPreviewValues(selectedColumn?.value) ?? [""]).join(", ")}</span>
</Box>
</>
}
</Box>
</Box>
</Box>);
}

View File

@ -0,0 +1,308 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import Box from "@mui/material/Box";
import Icon from "@mui/material/Icon";
import colors from "qqq/assets/theme/base/colors";
import QHierarchyAutoComplete, {Group, Option} from "qqq/components/misc/QHierarchyAutoComplete";
import BulkLoadFileMappingField from "qqq/components/processes/BulkLoadFileMappingField";
import {BulkLoadField, BulkLoadMapping, FileDescription} from "qqq/models/processes/BulkLoadModels";
import React, {useEffect, useReducer, useState} from "react";
interface BulkLoadMappingFieldsProps
{
bulkLoadMapping: BulkLoadMapping,
fileDescription: FileDescription,
forceParentUpdate?: () => void,
}
const ADD_SINGLE_FIELD_TOOLTIP = "Click to add this field to your mapping.";
const ADD_MANY_FIELD_TOOLTIP = "Click to add this field to your mapping as many times as you need.";
const ALREADY_ADDED_FIELD_TOOLTIP = "This field has already been added to your mapping.";
/***************************************************************************
** The section of the bulk load mapping screen with all the fields.
***************************************************************************/
export default function BulkLoadFileMappingFields({bulkLoadMapping, fileDescription, forceParentUpdate}: BulkLoadMappingFieldsProps): JSX.Element
{
const [, forceUpdate] = useReducer((x) => x + 1, 0);
const [forceHierarchyAutoCompleteRerender, setForceHierarchyAutoCompleteRerender] = useState(0);
////////////////////////////////////////////
// build list of fields that can be added //
////////////////////////////////////////////
const [addFieldsGroup, setAddFieldsGroup] = useState({
label: bulkLoadMapping.tablesByPath[""]?.label,
value: "mainTable",
options: [],
subGroups: []
} as Group);
// const [addFieldsToggleStates, setAddFieldsToggleStates] = useState({} as { [name: string]: boolean });
const [addFieldsDisableStates, setAddFieldsDisableStates] = useState({} as { [name: string]: boolean });
const [tooltips, setTooltips] = useState({} as { [name: string]: string });
useEffect(() =>
{
const newDisableStates: { [name: string]: boolean } = {};
const newTooltips: { [name: string]: string } = {};
/////////////////////////////////////////////////////////////////////////////////////////////
// do the unused fields array first, as we've got some use-case where i think a field from //
// suggested mappings (or profiles?) are in this list, even though they shouldn't be? //
/////////////////////////////////////////////////////////////////////////////////////////////
for (let field of bulkLoadMapping.unusedFields)
{
const qualifiedName = field.getQualifiedName();
newTooltips[qualifiedName] = field.isMany() ? ADD_MANY_FIELD_TOOLTIP : ADD_SINGLE_FIELD_TOOLTIP;
}
//////////////////////////////////////////////////
// then do all the required & additional fields //
//////////////////////////////////////////////////
for (let field of [...(bulkLoadMapping.requiredFields ?? []), ...(bulkLoadMapping.additionalFields ?? [])])
{
const qualifiedName = field.getQualifiedName();
if (bulkLoadMapping.layout == "WIDE" && field.isMany())
{
newDisableStates[qualifiedName] = false;
newTooltips[qualifiedName] = ADD_MANY_FIELD_TOOLTIP;
}
else
{
newDisableStates[qualifiedName] = true;
newTooltips[qualifiedName] = ALREADY_ADDED_FIELD_TOOLTIP;
}
}
setAddFieldsDisableStates(newDisableStates);
setTooltips(newTooltips);
setForceHierarchyAutoCompleteRerender(forceHierarchyAutoCompleteRerender + 1);
}, [bulkLoadMapping, bulkLoadMapping.layout]);
///////////////////////////////////////////////
// initialize this structure on first render //
///////////////////////////////////////////////
if (addFieldsGroup.options.length == 0)
{
for (let qualifiedFieldName in bulkLoadMapping.fieldsByTablePrefix[""])
{
const bulkLoadField = bulkLoadMapping.fieldsByTablePrefix[""][qualifiedFieldName];
const field = bulkLoadField.field;
addFieldsGroup.options.push({label: field.label, value: field.name, bulkLoadField: bulkLoadField});
}
for (let prefix in bulkLoadMapping.fieldsByTablePrefix)
{
if (prefix == "")
{
continue;
}
const associationOptions: Option[] = [];
const tableStructure = bulkLoadMapping.tablesByPath[prefix];
addFieldsGroup.subGroups.push({label: tableStructure.label, value: tableStructure.associationPath, options: associationOptions});
for (let qualifiedFieldName in bulkLoadMapping.fieldsByTablePrefix[prefix])
{
const bulkLoadField = bulkLoadMapping.fieldsByTablePrefix[prefix][qualifiedFieldName];
const field = bulkLoadField.field;
associationOptions.push({label: field.label, value: field.name, bulkLoadField: bulkLoadField});
}
}
}
/***************************************************************************
**
***************************************************************************/
function removeField(bulkLoadField: BulkLoadField)
{
addFieldsDisableStates[bulkLoadField.getQualifiedName()] = false;
setAddFieldsDisableStates(Object.assign({}, addFieldsDisableStates));
if (bulkLoadMapping.layout == "WIDE" && bulkLoadField.isMany())
{
//////////////////////////////////////////////////////////////////////////
// ok, you can add more - so don't disable and don't change the tooltip //
//////////////////////////////////////////////////////////////////////////
}
else
{
tooltips[bulkLoadField.getQualifiedName()] = ADD_SINGLE_FIELD_TOOLTIP;
}
bulkLoadMapping.removeField(bulkLoadField);
forceUpdate();
forceParentUpdate();
setForceHierarchyAutoCompleteRerender(forceHierarchyAutoCompleteRerender + 1);
}
/***************************************************************************
**
***************************************************************************/
function handleToggleField(option: Option, group: Group, newValue: boolean)
{
const fieldKey = group.value == "mainTable" ? option.value : group.value + "." + option.value;
// addFieldsToggleStates[fieldKey] = newValue;
// setAddFieldsToggleStates(Object.assign({}, addFieldsToggleStates));
addFieldsDisableStates[fieldKey] = newValue;
setAddFieldsDisableStates(Object.assign({}, addFieldsDisableStates));
const bulkLoadField = bulkLoadMapping.fields[fieldKey];
if (bulkLoadField)
{
if (newValue)
{
bulkLoadMapping.addField(bulkLoadField);
}
else
{
bulkLoadMapping.removeField(bulkLoadField);
}
forceUpdate();
forceParentUpdate();
}
}
/***************************************************************************
**
***************************************************************************/
function handleAddField(option: Option, group: Group)
{
const fieldKey = group.value == "mainTable" ? option.value : group.value + "." + option.value;
const bulkLoadField = bulkLoadMapping.fields[fieldKey];
if (bulkLoadField)
{
bulkLoadMapping.addField(bulkLoadField);
// addFieldsDisableStates[fieldKey] = true;
// setAddFieldsDisableStates(Object.assign({}, addFieldsDisableStates));
if (bulkLoadMapping.layout == "WIDE" && bulkLoadField.isMany())
{
//////////////////////////////////////////////////////////////////////////
// ok, you can add more - so don't disable and don't change the tooltip //
//////////////////////////////////////////////////////////////////////////
}
else
{
addFieldsDisableStates[fieldKey] = true;
setAddFieldsDisableStates(Object.assign({}, addFieldsDisableStates));
tooltips[fieldKey] = ALREADY_ADDED_FIELD_TOOLTIP;
}
forceUpdate();
forceParentUpdate();
document.getElementById("addFieldsButton")?.scrollIntoView();
}
}
let buttonBackground = "none";
let buttonBorder = colors.grayLines.main;
let buttonColor = colors.gray.main;
const addFieldMenuButtonStyles = {
borderRadius: "0.75rem",
border: `1px solid ${buttonBorder}`,
color: buttonColor,
textTransform: "none",
fontWeight: 500,
fontSize: "0.875rem",
p: "0.5rem",
backgroundColor: buttonBackground,
"&:focus:not(:hover)": {
color: buttonColor,
backgroundColor: buttonBackground,
},
"&:hover": {
color: buttonColor,
backgroundColor: buttonBackground,
}
};
return (
<>
<h5>Required Fields</h5>
<Box pl={"1rem"}>
{
bulkLoadMapping.requiredFields.length == 0 &&
<i style={{fontSize: "0.875rem"}}>There are no required fields in this table.</i>
}
{bulkLoadMapping.requiredFields.map((bulkLoadField) => (
<BulkLoadFileMappingField
fileDescription={fileDescription}
key={bulkLoadField.getKey()}
bulkLoadField={bulkLoadField}
isRequired={true}
forceParentUpdate={forceParentUpdate}
/>
))}
</Box>
<Box mt="1rem">
<h5>Additional Fields</h5>
<Box pl={"1rem"}>
{bulkLoadMapping.additionalFields.map((bulkLoadField) => (
<BulkLoadFileMappingField
fileDescription={fileDescription}
key={bulkLoadField.getKey()}
bulkLoadField={bulkLoadField}
isRequired={false}
removeFieldCallback={() => removeField(bulkLoadField)}
forceParentUpdate={forceParentUpdate}
/>
))}
<Box display="flex" pt="1rem" pl="12.5rem">
<QHierarchyAutoComplete
idPrefix="addFieldAutocomplete"
defaultGroup={addFieldsGroup}
menuDirection="up"
buttonProps={{id: "addFieldsButton", sx: addFieldMenuButtonStyles}}
buttonChildren={<><Icon sx={{mr: "0.5rem"}}>add</Icon> Add Fields <Icon sx={{ml: "0.5rem"}}>keyboard_arrow_down</Icon></>}
isModeSelectOne
keepOpenAfterSelectOne
handleSelectedOption={handleAddField}
forceRerender={forceHierarchyAutoCompleteRerender}
disabledStates={addFieldsDisableStates}
tooltips={tooltips}
/>
</Box>
</Box>
</Box>
</>
);
}

View File

@ -0,0 +1,566 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
import {QFieldType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldType";
import {QFrontendStepMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFrontendStepMetaData";
import {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstance";
import {QProcessMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QProcessMetaData";
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord";
import {Badge, Icon} from "@mui/material";
import Autocomplete from "@mui/material/Autocomplete";
import Box from "@mui/material/Box";
import Grid from "@mui/material/Grid";
import TextField from "@mui/material/TextField";
import Tooltip from "@mui/material/Tooltip/Tooltip";
import {useFormikContext} from "formik";
import colors from "qqq/assets/theme/base/colors";
import {DynamicFormFieldLabel} from "qqq/components/forms/DynamicForm";
import QDynamicFormField from "qqq/components/forms/DynamicFormField";
import MDTypography from "qqq/components/legacy/MDTypography";
import HelpContent from "qqq/components/misc/HelpContent";
import SavedBulkLoadProfiles from "qqq/components/misc/SavedBulkLoadProfiles";
import BulkLoadFileMappingFields from "qqq/components/processes/BulkLoadFileMappingFields";
import {BulkLoadField, BulkLoadMapping, BulkLoadProfile, BulkLoadTableStructure, FileDescription, Wrapper} from "qqq/models/processes/BulkLoadModels";
import {SubFormPreSubmitCallbackResultType} from "qqq/pages/processes/ProcessRun";
import React, {forwardRef, useImperativeHandle, useReducer, useState} from "react";
import ProcessViewForm from "./ProcessViewForm";
interface BulkLoadMappingFormProps
{
processValues: any,
tableMetaData: QTableMetaData,
metaData: QInstance,
setActiveStepLabel: (label: string) => void,
frontendStep: QFrontendStepMetaData,
processMetaData: QProcessMetaData,
}
/***************************************************************************
** process component - screen where user does a bulk-load file mapping.
***************************************************************************/
const BulkLoadFileMappingForm = forwardRef(({processValues, tableMetaData, metaData, setActiveStepLabel, frontendStep, processMetaData}: BulkLoadMappingFormProps, ref) =>
{
const {setFieldValue} = useFormikContext();
const savedBulkLoadProfileRecordProcessValue = processValues.savedBulkLoadProfileRecord;
const [currentSavedBulkLoadProfile, setCurrentSavedBulkLoadProfile] = useState(savedBulkLoadProfileRecordProcessValue == null ? null : new QRecord(savedBulkLoadProfileRecordProcessValue));
const [wrappedCurrentSavedBulkLoadProfile] = useState(new Wrapper<QRecord>(currentSavedBulkLoadProfile));
const [fieldErrors, setFieldErrors] = useState({} as { [fieldName: string]: string });
const [noMappedFieldsError, setNoMappedFieldsError] = useState(null as string);
const [suggestedBulkLoadProfile] = useState(processValues.suggestedBulkLoadProfile as BulkLoadProfile);
const [tableStructure] = useState(processValues.tableStructure as BulkLoadTableStructure);
const [bulkLoadMapping, setBulkLoadMapping] = useState(BulkLoadMapping.fromBulkLoadProfile(tableStructure, processValues.bulkLoadProfile));
const [wrappedBulkLoadMapping] = useState(new Wrapper<BulkLoadMapping>(bulkLoadMapping));
const [fileDescription] = useState(new FileDescription(processValues.headerValues, processValues.headerLetters, processValues.bodyValuesPreview));
fileDescription.setHasHeaderRow(bulkLoadMapping.hasHeaderRow);
const [, forceUpdate] = useReducer((x) => x + 1, 0);
/////////////////////////////////////////////////////////////////////////////////////////////////
// ok - so - ... Autocomplete, at least as we're using it for the layout field - doesn't like //
// to change its initial value. So, we want to work hard to force the Header sub-component to //
// re-render upon external changes to the layout (e.g., new profile being selected). //
// use this state-counter to make that happen (and let's please never speak of it again). //
/////////////////////////////////////////////////////////////////////////////////////////////////
const [rerenderHeader, setRerenderHeader] = useState(1);
////////////////////////////////////////////////////////
// ref-based callback for integration with ProcessRun //
////////////////////////////////////////////////////////
useImperativeHandle(ref, () =>
{
return {
preSubmit(): SubFormPreSubmitCallbackResultType
{
///////////////////////////////////////////////////////////////////////////////////////////////
// convert the BulkLoadMapping to a BulkLoadProfile - the thing that the backend understands //
///////////////////////////////////////////////////////////////////////////////////////////////
const {haveErrors: haveProfileErrors, profile} = wrappedBulkLoadMapping.get().toProfile();
const values: { [name: string]: any } = {};
////////////////////////////////////////////////////
// always re-submit the full profile //
// note mostly a copy in BulkLoadValueMappingForm //
////////////////////////////////////////////////////
values["version"] = profile.version;
values["fieldListJSON"] = JSON.stringify(profile.fieldList);
values["savedBulkLoadProfileId"] = wrappedCurrentSavedBulkLoadProfile.get()?.values?.get("id");
values["layout"] = wrappedBulkLoadMapping.get().layout;
values["hasHeaderRow"] = wrappedBulkLoadMapping.get().hasHeaderRow;
let haveLocalErrors = false;
const fieldErrors: { [fieldName: string]: string } = {};
if (!values["layout"])
{
haveLocalErrors = true;
fieldErrors["layout"] = "This field is required.";
}
if (values["hasHeaderRow"] == null || values["hasHeaderRow"] == undefined)
{
haveLocalErrors = true;
fieldErrors["hasHeaderRow"] = "This field is required.";
}
setFieldErrors(fieldErrors);
if(wrappedBulkLoadMapping.get().requiredFields.length == 0 && wrappedBulkLoadMapping.get().additionalFields.length == 0)
{
setNoMappedFieldsError("You must have at least 1 field.");
haveLocalErrors = true;
setTimeout(() => setNoMappedFieldsError(null), 2500);
}
else
{
setNoMappedFieldsError(null);
}
if(haveProfileErrors)
{
setTimeout(() =>
{
document.querySelector(".bulkLoadFieldError")?.scrollIntoView({behavior: "smooth", block: "center", inline: "center"});
}, 250);
}
return {maySubmit: !haveProfileErrors && !haveLocalErrors, values};
}
};
});
/***************************************************************************
**
***************************************************************************/
function bulkLoadProfileOnChangeCallback(profileRecord: QRecord | null)
{
setCurrentSavedBulkLoadProfile(profileRecord);
wrappedCurrentSavedBulkLoadProfile.set(profileRecord);
let newBulkLoadMapping: BulkLoadMapping;
if (profileRecord)
{
newBulkLoadMapping = BulkLoadMapping.fromSavedProfileRecord(processValues.tableStructure, profileRecord);
}
else
{
newBulkLoadMapping = new BulkLoadMapping(processValues.tableStructure);
}
handleNewBulkLoadMapping(newBulkLoadMapping);
}
/***************************************************************************
**
***************************************************************************/
function bulkLoadProfileResetToSuggestedMappingCallback()
{
handleNewBulkLoadMapping(BulkLoadMapping.fromBulkLoadProfile(processValues.tableStructure, suggestedBulkLoadProfile));
}
/***************************************************************************
**
***************************************************************************/
function handleNewBulkLoadMapping(newBulkLoadMapping: BulkLoadMapping)
{
const newRequiredFields: BulkLoadField[] = [];
for (let field of newBulkLoadMapping.requiredFields)
{
newRequiredFields.push(BulkLoadField.clone(field));
}
newBulkLoadMapping.requiredFields = newRequiredFields;
setBulkLoadMapping(newBulkLoadMapping);
wrappedBulkLoadMapping.set(newBulkLoadMapping);
setFieldValue("hasHeaderRow", newBulkLoadMapping.hasHeaderRow);
setFieldValue("layout", newBulkLoadMapping.layout);
setRerenderHeader(rerenderHeader + 1);
}
if (currentSavedBulkLoadProfile)
{
setActiveStepLabel(`File Mapping / ${currentSavedBulkLoadProfile.values.get("label")}`);
}
else
{
setActiveStepLabel("File Mapping");
}
return (<Box>
<Box py="1rem" display="flex">
<SavedBulkLoadProfiles
metaData={metaData}
tableMetaData={tableMetaData}
tableStructure={tableStructure}
currentSavedBulkLoadProfileRecord={currentSavedBulkLoadProfile}
currentMapping={bulkLoadMapping}
bulkLoadProfileOnChangeCallback={bulkLoadProfileOnChangeCallback}
bulkLoadProfileResetToSuggestedMappingCallback={bulkLoadProfileResetToSuggestedMappingCallback}
fileDescription={fileDescription}
/>
</Box>
<BulkLoadMappingHeader
key={rerenderHeader}
bulkLoadMapping={bulkLoadMapping}
fileDescription={fileDescription}
tableStructure={tableStructure}
fileName={processValues.fileBaseName}
fieldErrors={fieldErrors}
frontendStep={frontendStep}
processMetaData={processMetaData}
forceParentUpdate={() => forceUpdate()}
/>
<Box mt="2rem">
<BulkLoadFileMappingFields
bulkLoadMapping={bulkLoadMapping}
fileDescription={fileDescription}
forceParentUpdate={() =>
{
setRerenderHeader(rerenderHeader + 1);
forceUpdate();
}}
/>
{
noMappedFieldsError && <Box color={colors.error.main} textAlign="right">{noMappedFieldsError}</Box>
}
</Box>
</Box>);
});
export default BulkLoadFileMappingForm;
interface BulkLoadMappingHeaderProps
{
fileDescription: FileDescription,
fileName: string,
bulkLoadMapping?: BulkLoadMapping,
fieldErrors: { [fieldName: string]: string },
tableStructure: BulkLoadTableStructure,
forceParentUpdate?: () => void,
frontendStep: QFrontendStepMetaData,
processMetaData: QProcessMetaData,
}
/***************************************************************************
** private subcomponent - the header section of the bulk load file mapping screen.
***************************************************************************/
function BulkLoadMappingHeader({fileDescription, fileName, bulkLoadMapping, fieldErrors, tableStructure, forceParentUpdate, frontendStep, processMetaData}: BulkLoadMappingHeaderProps): JSX.Element
{
const viewFields = [
new QFieldMetaData({name: "fileName", label: "File Name", type: "STRING"}),
new QFieldMetaData({name: "fileDetails", label: "File Details", type: "STRING"}),
];
const viewValues = {
"fileName": fileName,
"fileDetails": `${fileDescription.getColumnNames().length} column${fileDescription.getColumnNames().length == 1 ? "" : "s"}`
};
const hasHeaderRowFormField = {name: "hasHeaderRow", label: "Does the file have a header row?", type: "checkbox", isRequired: true, isEditable: true};
const layoutOptions = [
{label: "Flat", id: "FLAT"},
{label: "Tall", id: "TALL"},
{label: "Wide", id: "WIDE"},
];
if (!tableStructure.associations)
{
layoutOptions.splice(1);
}
const selectedLayout = layoutOptions.filter(o => o.id == bulkLoadMapping.layout)[0] ?? null;
/***************************************************************************
**
***************************************************************************/
function hasHeaderRowChanged(newValue: any)
{
bulkLoadMapping.hasHeaderRow = newValue;
fileDescription.hasHeaderRow = newValue;
bulkLoadMapping.handleChangeToHasHeaderRow(newValue, fileDescription);
fieldErrors.hasHeaderRow = null;
forceParentUpdate();
}
/***************************************************************************
**
***************************************************************************/
function layoutChanged(event: any, newValue: any)
{
bulkLoadMapping.switchLayout(newValue ? newValue.id : null);
fieldErrors.layout = null;
forceParentUpdate();
}
/***************************************************************************
**
***************************************************************************/
function getFormattedHelpContent(fieldName: string): JSX.Element
{
const field = frontendStep?.formFields?.find(f => f.name == fieldName);
let helpRoles = ["PROCESS_SCREEN", "ALL_SCREENS"];
let formattedHelpContent = <HelpContent helpContents={field?.helpContents} roles={helpRoles} helpContentKey={`process:${processMetaData?.name};field:${fieldName}`} />;
if (formattedHelpContent)
{
const mt = field && field.type == QFieldType.BOOLEAN ? "-0.5rem" : "0.5rem";
return <Box color="#757575" fontSize="0.875rem" mt={mt}>{formattedHelpContent}</Box>;
}
return null;
}
return (
<Box>
<h5>File Details</h5>
<Box ml="1rem">
<ProcessViewForm fields={viewFields} values={viewValues} columns={2} />
<BulkLoadMappingFilePreview fileDescription={fileDescription} bulkLoadMapping={bulkLoadMapping} />
<Grid container pt="1rem">
<Grid item xs={12} md={6}>
<DynamicFormFieldLabel name={hasHeaderRowFormField.name} label={`${hasHeaderRowFormField.label} *`} />
<QDynamicFormField name={hasHeaderRowFormField.name} displayFormat={""} label={""} formFieldObject={hasHeaderRowFormField} type={"checkbox"} value={bulkLoadMapping.hasHeaderRow} onChangeCallback={hasHeaderRowChanged} />
{
fieldErrors.hasHeaderRow &&
<MDTypography component="div" variant="caption" color="error" fontWeight="regular" mt="0.25rem">
{<div className="fieldErrorMessage">{fieldErrors.hasHeaderRow}</div>}
</MDTypography>
}
{getFormattedHelpContent("hasHeaderRow")}
</Grid>
<Grid item xs={12} md={6}>
<DynamicFormFieldLabel name={"layout"} label={"File Layout *"} />
<Autocomplete
id={"layout"}
renderInput={(params) => (<TextField {...params} label={""} fullWidth variant="outlined" autoComplete="off" type="search" InputProps={{...params.InputProps}} sx={{"& .MuiOutlinedInput-root": {borderRadius: "0.75rem"}}} />)}
options={layoutOptions}
multiple={false}
defaultValue={selectedLayout}
onChange={layoutChanged}
getOptionLabel={(option) => typeof (option) == "string" ? option : (option?.label ?? "")}
isOptionEqualToValue={(option, value) => option == null && value == null || option.id == value.id}
renderOption={(props, option, state) => (<li {...props}>{option?.label ?? ""}</li>)}
disableClearable
sx={{"& .MuiOutlinedInput-root": {padding: "0"}}}
/>
{
fieldErrors.layout &&
<MDTypography component="div" variant="caption" color="error" fontWeight="regular" mt="0.25rem">
{<div className="fieldErrorMessage">{fieldErrors.layout}</div>}
</MDTypography>
}
{getFormattedHelpContent("layout")}
</Grid>
</Grid>
</Box>
</Box>
);
}
interface BulkLoadMappingFilePreviewProps
{
fileDescription: FileDescription,
bulkLoadMapping?: BulkLoadMapping
}
/***************************************************************************
** private subcomponent - the file-preview section of the bulk load file mapping screen.
***************************************************************************/
function BulkLoadMappingFilePreview({fileDescription, bulkLoadMapping}: BulkLoadMappingFilePreviewProps): JSX.Element
{
const rows: number[] = [];
for (let i = 0; i < fileDescription.bodyValuesPreview[0].length; i++)
{
rows.push(i);
}
/***************************************************************************
**
***************************************************************************/
function getValue(i: number, j: number)
{
const value = fileDescription.bodyValuesPreview[j][i];
if (value == null)
{
return "";
}
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// this was useful at one point in time when we had an object coming back for xlsx files with many different data types //
// we'd see a .string attribute, which would have the value we'd want to show. not using it now, but keep in case //
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// @ts-ignore
if (value && value.string)
{
// @ts-ignore
return (value.string);
}
return `${value}`;
}
/***************************************************************************
**
***************************************************************************/
function getHeaderColor(count: number): string
{
if (count > 0)
{
return "blue";
}
return "black";
}
/***************************************************************************
**
***************************************************************************/
function getCursor(count: number): string
{
if (count > 0)
{
return "pointer";
}
return "default";
}
/***************************************************************************
**
***************************************************************************/
function getColumnTooltip(fields: BulkLoadField[])
{
return (<Box>
This column is mapped to the field{fields.length == 1 ? "" : "s"}:
<ul style={{marginLeft: "1rem"}}>
{fields.map((field, i) => <li key={i}>{field.getQualifiedLabel()}</li>)}
</ul>
</Box>);
}
return (
<Box sx={{"& table, & td": {border: "1px solid black", borderCollapse: "collapse", padding: "0 0.25rem", fontSize: "0.875rem", whiteSpace: "nowrap"}}}>
<Box sx={{width: "100%", overflow: "auto"}}>
<table cellSpacing="0" width="100%">
<thead>
<tr style={{backgroundColor: "#d3d3d3", height: "1.75rem"}}>
<td></td>
{fileDescription.headerLetters.map((letter, index) =>
{
const fields = bulkLoadMapping.getFieldsForColumnIndex(index);
const count = fields.length;
let dupeWarning = <></>
if(fileDescription.hasHeaderRow && fileDescription.duplicateHeaderIndexes[index])
{
dupeWarning = <Tooltip title="This column header is a duplicate. Only the first occurrance of it will be used." placement="top" enterDelay={500}>
<Icon color="warning" sx={{p: "0.125rem", mr: "0.25rem"}}>warning</Icon>
</Tooltip>
}
return (<td key={letter} style={{textAlign: "center", color: getHeaderColor(count), cursor: getCursor(count)}}>
<>
{
count > 0 &&
<Tooltip title={getColumnTooltip(fields)} placement="top" enterDelay={500}>
<Box>
{dupeWarning}
{letter}
<Badge badgeContent={count} variant={"standard"} color="secondary" sx={{marginTop: ".75rem"}}><Icon></Icon></Badge>
</Box>
</Tooltip>
}
{
count == 0 && <Box>{dupeWarning}{letter}</Box>
}
</>
</td>);
})}
</tr>
</thead>
<tbody>
<tr>
<td style={{backgroundColor: "#d3d3d3", textAlign: "center"}}>1</td>
{fileDescription.headerValues.map((value, index) =>
{
const fields = bulkLoadMapping.getFieldsForColumnIndex(index);
const count = fields.length;
const tdStyle = {color: getHeaderColor(count), cursor: getCursor(count), backgroundColor: ""};
if(fileDescription.hasHeaderRow)
{
tdStyle.backgroundColor = "#ebebeb";
if(count > 0)
{
return <td key={value} style={tdStyle}>
<Tooltip title={getColumnTooltip(fields)} placement="top" enterDelay={500}><Box>{value}</Box></Tooltip>
</td>
}
else
{
return <td key={value} style={tdStyle}>{value}</td>
}
}
else
{
return <td key={value} style={tdStyle}>{value}</td>
}
}
)}
</tr>
{rows.map((i) => (
<tr key={i}>
<td style={{backgroundColor: "#d3d3d3", textAlign: "center"}}>{i + 2}</td>
{fileDescription.headerLetters.map((letter, j) => <td key={j}>{getValue(i, j)}</td>)}
</tr>
))}
</tbody>
</table>
</Box>
</Box>
);
}

View File

@ -0,0 +1,102 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstance";
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord";
import Box from "@mui/material/Box";
import SavedBulkLoadProfiles from "qqq/components/misc/SavedBulkLoadProfiles";
import {BulkLoadMapping, BulkLoadProfile, BulkLoadTableStructure, FileDescription, Wrapper} from "qqq/models/processes/BulkLoadModels";
import {SubFormPreSubmitCallbackResultType} from "qqq/pages/processes/ProcessRun";
import React, {forwardRef, useImperativeHandle, useState} from "react";
interface BulkLoadValueMappingFormProps
{
processValues: any,
tableMetaData: QTableMetaData,
metaData: QInstance
}
/***************************************************************************
** For review & result screens of bulk load - this process component shows
** the SavedBulkLoadProfiles button.
***************************************************************************/
const BulkLoadProfileForm = forwardRef(({processValues, tableMetaData, metaData}: BulkLoadValueMappingFormProps, ref) =>
{
const savedBulkLoadProfileRecordProcessValue = processValues.savedBulkLoadProfileRecord;
const [savedBulkLoadProfileRecord, setSavedBulkLoadProfileRecord] = useState(savedBulkLoadProfileRecordProcessValue == null ? null : new QRecord(savedBulkLoadProfileRecordProcessValue))
const [tableStructure] = useState(processValues.tableStructure as BulkLoadTableStructure);
const [bulkLoadProfile, setBulkLoadProfile] = useState(processValues.bulkLoadProfile as BulkLoadProfile);
const [currentMapping, setCurrentMapping] = useState(BulkLoadMapping.fromBulkLoadProfile(tableStructure, bulkLoadProfile))
const [wrappedCurrentSavedBulkLoadProfile] = useState(new Wrapper<QRecord>(savedBulkLoadProfileRecord));
const [fileDescription] = useState(new FileDescription(processValues.headerValues, processValues.headerLetters, processValues.bodyValuesPreview));
fileDescription.setHasHeaderRow(currentMapping.hasHeaderRow);
useImperativeHandle(ref, () =>
{
return {
preSubmit(): SubFormPreSubmitCallbackResultType
{
const values: { [name: string]: any } = {};
values["savedBulkLoadProfileId"] = wrappedCurrentSavedBulkLoadProfile.get()?.values?.get("id");
return ({maySubmit: true, values});
}
};
});
/***************************************************************************
**
***************************************************************************/
function bulkLoadProfileOnChangeCallback(profileRecord: QRecord | null)
{
setSavedBulkLoadProfileRecord(profileRecord);
wrappedCurrentSavedBulkLoadProfile.set(profileRecord);
const newBulkLoadMapping = BulkLoadMapping.fromSavedProfileRecord(tableStructure, profileRecord);
setCurrentMapping(newBulkLoadMapping);
}
return (<Box>
<Box py="1rem" display="flex">
<SavedBulkLoadProfiles
metaData={metaData}
tableMetaData={tableMetaData}
tableStructure={tableStructure}
currentSavedBulkLoadProfileRecord={savedBulkLoadProfileRecord}
currentMapping={currentMapping}
allowSelectingProfile={false}
fileDescription={fileDescription}
bulkLoadProfileOnChangeCallback={bulkLoadProfileOnChangeCallback}
/>
</Box>
</Box>);
});
export default BulkLoadProfileForm;

View File

@ -0,0 +1,233 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
import {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstance";
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord";
import Box from "@mui/material/Box";
import Icon from "@mui/material/Icon";
import colors from "qqq/assets/theme/base/colors";
import QDynamicFormField from "qqq/components/forms/DynamicFormField";
import SavedBulkLoadProfiles from "qqq/components/misc/SavedBulkLoadProfiles";
import {BulkLoadMapping, BulkLoadProfile, BulkLoadTableStructure, FileDescription, Wrapper} from "qqq/models/processes/BulkLoadModels";
import {SubFormPreSubmitCallbackResultType} from "qqq/pages/processes/ProcessRun";
import React, {forwardRef, useEffect, useImperativeHandle, useReducer, useState} from "react";
interface BulkLoadValueMappingFormProps
{
processValues: any,
setActiveStepLabel: (label: string) => void,
tableMetaData: QTableMetaData,
metaData: QInstance,
formFields: any[]
}
/***************************************************************************
** process component used in bulk-load - on a screen that gets looped for
** each field whose values are being mapped.
***************************************************************************/
const BulkLoadValueMappingForm = forwardRef(({processValues, setActiveStepLabel, tableMetaData, metaData, formFields}: BulkLoadValueMappingFormProps, ref) =>
{
const [field, setField] = useState(processValues.valueMappingField ? new QFieldMetaData(processValues.valueMappingField) : null);
const [fieldFullName, setFieldFullName] = useState(processValues.valueMappingFullFieldName);
const [fileValues, setFileValues] = useState((processValues.fileValues ?? []) as string[]);
const [valueErrors, setValueErrors] = useState({} as { [fileValue: string]: any });
const [bulkLoadProfile, setBulkLoadProfile] = useState(processValues.bulkLoadProfile as BulkLoadProfile);
const savedBulkLoadProfileRecordProcessValue = processValues.savedBulkLoadProfileRecord;
const [savedBulkLoadProfileRecord, setSavedBulkLoadProfileRecord] = useState(savedBulkLoadProfileRecordProcessValue == null ? null : new QRecord(savedBulkLoadProfileRecordProcessValue));
const [wrappedCurrentSavedBulkLoadProfile] = useState(new Wrapper<QRecord>(savedBulkLoadProfileRecord));
const [tableStructure] = useState(processValues.tableStructure as BulkLoadTableStructure);
const [currentMapping, setCurrentMapping] = useState(initializeCurrentBulkLoadMapping());
const [wrappedBulkLoadMapping] = useState(new Wrapper<BulkLoadMapping>(currentMapping));
const [fileDescription] = useState(new FileDescription(processValues.headerValues, processValues.headerLetters, processValues.bodyValuesPreview));
fileDescription.setHasHeaderRow(currentMapping.hasHeaderRow);
const [, forceUpdate] = useReducer((x) => x + 1, 0);
/*******************************************************************************
**
*******************************************************************************/
function initializeCurrentBulkLoadMapping(): BulkLoadMapping
{
const bulkLoadMapping = BulkLoadMapping.fromBulkLoadProfile(tableStructure, bulkLoadProfile);
if (!bulkLoadMapping.valueMappings[fieldFullName])
{
bulkLoadMapping.valueMappings[fieldFullName] = {};
}
return (bulkLoadMapping);
}
useEffect(() =>
{
if (processValues.valueMappingField)
{
setField(new QFieldMetaData(processValues.valueMappingField));
}
else
{
setField(null);
}
}, [processValues.valueMappingField]);
////////////////////////////////////////////////////////
// ref-based callback for integration with ProcessRun //
////////////////////////////////////////////////////////
useImperativeHandle(ref, () =>
{
return {
preSubmit(): SubFormPreSubmitCallbackResultType
{
const values: { [name: string]: any } = {};
let anyErrors = false;
const mappedValues = currentMapping.valueMappings[fieldFullName];
if (field.isRequired)
{
for (let fileValue of fileValues)
{
valueErrors[fileValue] = null;
if (mappedValues[fileValue] == null || mappedValues[fileValue] == undefined || mappedValues[fileValue] == "")
{
valueErrors[fileValue] = "A value is required for this mapping";
anyErrors = true;
}
}
}
///////////////////////////////////////////////////
// always re-submit the full profile //
// note mostly a copy in BulkLoadFileMappingForm //
///////////////////////////////////////////////////
const {haveErrors, profile} = wrappedBulkLoadMapping.get().toProfile();
values["version"] = profile.version;
values["fieldListJSON"] = JSON.stringify(profile.fieldList);
values["savedBulkLoadProfileId"] = wrappedCurrentSavedBulkLoadProfile.get()?.values?.get("id");
values["layout"] = wrappedBulkLoadMapping.get().layout;
values["hasHeaderRow"] = wrappedBulkLoadMapping.get().hasHeaderRow;
values["mappedValuesJSON"] = JSON.stringify(mappedValues);
return ({maySubmit: !anyErrors, values});
}
};
});
if (!field)
{
//////////////////////////////////////////////////////////////////////////////////////
// this happens like between steps - render empty rather than a flash of half-stuff //
//////////////////////////////////////////////////////////////////////////////////////
return (<Box></Box>);
}
/***************************************************************************
**
***************************************************************************/
function mappedValueChanged(fileValue: string, newValue: any)
{
valueErrors[fileValue] = null;
if(newValue == null)
{
delete currentMapping.valueMappings[fieldFullName][fileValue];
}
else
{
currentMapping.valueMappings[fieldFullName][fileValue] = newValue;
}
forceUpdate();
}
/***************************************************************************
**
***************************************************************************/
function bulkLoadProfileOnChangeCallback(profileRecord: QRecord | null)
{
setSavedBulkLoadProfileRecord(profileRecord);
wrappedCurrentSavedBulkLoadProfile.set(profileRecord);
const newBulkLoadMapping = BulkLoadMapping.fromSavedProfileRecord(tableStructure, profileRecord);
setCurrentMapping(newBulkLoadMapping);
wrappedBulkLoadMapping.set(newBulkLoadMapping);
}
setActiveStepLabel(`Value Mapping: ${field.label} (${processValues.valueMappingFieldIndex + 1} of ${processValues.fieldNamesToDoValueMapping?.length})`);
return (<Box>
<Box py="1rem" display="flex">
<SavedBulkLoadProfiles
metaData={metaData}
tableMetaData={tableMetaData}
tableStructure={tableStructure}
currentSavedBulkLoadProfileRecord={savedBulkLoadProfileRecord}
currentMapping={currentMapping}
allowSelectingProfile={false}
bulkLoadProfileOnChangeCallback={bulkLoadProfileOnChangeCallback}
fileDescription={fileDescription}
/>
</Box>
{
fileValues.map((fileValue, i) => (
<Box key={i} py="0.5rem" sx={{borderBottom: "0px solid lightgray", width: "100%", overflow: "auto"}}>
<Box display="grid" gridTemplateColumns="40% auto 60%" fontSize="1rem" gap="0.5rem">
<Box mt="0.5rem" textAlign="right">{fileValue}</Box>
<Box mt="0.625rem"><Icon>arrow_forward</Icon></Box>
<Box maxWidth="300px">
<QDynamicFormField
name={`${fieldFullName}.value.${i}`}
displayFormat={""}
label={""}
formFieldObject={formFields[i]}
type={formFields[i].type}
value={currentMapping.valueMappings[fieldFullName][fileValue]}
onChangeCallback={(newValue) => mappedValueChanged(fileValue, newValue)}
/>
{
valueErrors[fileValue] &&
<Box fontSize={"0.875rem"} mt={"-0.75rem"} color={colors.error.main}>
{valueErrors[fileValue]}
</Box>
}
</Box>
</Box>
</Box>
))
}
</Box>);
});
export default BulkLoadValueMappingForm;

View File

@ -0,0 +1,71 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {AdornmentType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/AdornmentType";
import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
import Grid from "@mui/material/Grid";
import MDTypography from "qqq/components/legacy/MDTypography";
import ValueUtils from "qqq/utils/qqq/ValueUtils";
interface ProcessViewFormProps
{
fields: QFieldMetaData[];
values: { [fieldName: string]: any };
columns?: number;
}
ProcessViewForm.defaultProps = {
columns: 2
};
/***************************************************************************
** a "view form" within a process step
**
***************************************************************************/
export default function ProcessViewForm({fields, values, columns}: ProcessViewFormProps): JSX.Element
{
const sm = Math.floor(12 / columns);
return <Grid container>
{fields.map((field: QFieldMetaData) => (
field.hasAdornment(AdornmentType.ERROR) ? (
values[field.name] && (
<Grid item xs={12} sm={sm} key={field.name} display="flex" py={1} pr={2}>
<MDTypography variant="button" fontWeight="regular">
{ValueUtils.getValueForDisplay(field, values[field.name], undefined, "view")}
</MDTypography>
</Grid>
)
) : (
<Grid item xs={12} sm={sm} key={field.name} display="flex" py={1} pr={2}>
<MDTypography variant="button" fontWeight="bold">
{field.label}
: &nbsp;
</MDTypography>
<MDTypography variant="button" fontWeight="regular" color="text">
{ValueUtils.getValueForDisplay(field, values[field.name], undefined, "view")}
</MDTypography>
</Grid>
)))
}
</Grid>;
}

View File

@ -24,29 +24,45 @@ 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 {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord"; import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord";
import {Box, Button, FormControlLabel, ListItem, Radio, RadioGroup, Typography} from "@mui/material"; import {Button, FormControlLabel, ListItem, Radio, RadioGroup, Typography} from "@mui/material";
import Box from "@mui/material/Box";
import Grid from "@mui/material/Grid"; import Grid from "@mui/material/Grid";
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 List from "@mui/material/List"; import List from "@mui/material/List";
import ListItemText from "@mui/material/ListItemText"; import ListItemText from "@mui/material/ListItemText";
import React, {useState} from "react";
import MDTypography from "qqq/components/legacy/MDTypography"; import MDTypography from "qqq/components/legacy/MDTypography";
import CustomWidthTooltip from "qqq/components/tooltips/CustomWidthTooltip"; import CustomWidthTooltip from "qqq/components/tooltips/CustomWidthTooltip";
import RecordGridWidget, {ChildRecordListData} from "qqq/components/widgets/misc/RecordGridWidget";
import {ProcessSummaryLine} from "qqq/models/processes/ProcessSummaryLine"; import {ProcessSummaryLine} from "qqq/models/processes/ProcessSummaryLine";
import {renderSectionOfFields} from "qqq/pages/records/view/RecordView";
import Client from "qqq/utils/qqq/Client"; import Client from "qqq/utils/qqq/Client";
import TableUtils from "qqq/utils/qqq/TableUtils";
import ValueUtils from "qqq/utils/qqq/ValueUtils"; import ValueUtils from "qqq/utils/qqq/ValueUtils";
import React, {useEffect, useState} from "react";
interface Props interface Props
{ {
qInstance: QInstance; qInstance: QInstance,
process: QProcessMetaData; process: QProcessMetaData,
table: QTableMetaData; table: QTableMetaData,
processValues: any; processValues: any,
step: QFrontendStepMetaData; step: QFrontendStepMetaData,
previewRecords: QRecord[]; previewRecords: QRecord[],
formValues: any; formValues: any,
doFullValidationRadioChangedHandler: any doFullValidationRadioChangedHandler: any,
loadingRecords?: boolean
}
////////////////////////////////////////////////////////////////////////////
// e.g., for bulk-load, where we want to show associations under a record //
// the processValue will have these data, to drive this screen. //
////////////////////////////////////////////////////////////////////////////
interface AssociationPreview
{
tableName: string;
widgetName: string;
associationName: string;
} }
/******************************************************************************* /*******************************************************************************
@ -55,21 +71,76 @@ interface Props
** results when they are available. ** results when they are available.
*******************************************************************************/ *******************************************************************************/
function ValidationReview({ function ValidationReview({
qInstance, process, table = null, processValues, step, previewRecords = [], formValues, doFullValidationRadioChangedHandler, qInstance, process, table = null, processValues, step, previewRecords = [], formValues, doFullValidationRadioChangedHandler, loadingRecords
}: Props): JSX.Element }: Props): JSX.Element
{ {
const [previewRecordIndex, setPreviewRecordIndex] = useState(0); const [previewRecordIndex, setPreviewRecordIndex] = useState(0);
const [sourceTableMetaData, setSourceTableMetaData] = useState(null as QTableMetaData); const [sourceTableMetaData, setSourceTableMetaData] = useState(null as QTableMetaData);
const [previewTableMetaData, setPreviewTableMetaData] = useState(null as QTableMetaData);
const [childTableMetaData, setChildTableMetaData] = useState({} as { [name: string]: QTableMetaData });
const [associationPreviewsByWidgetName, setAssociationPreviewsByWidgetName] = useState({} as { [widgetName: string]: AssociationPreview });
if (processValues.sourceTable && !sourceTableMetaData) if (processValues.sourceTable && !sourceTableMetaData)
{ {
(async () => (async () =>
{ {
const sourceTableMetaData = await Client.getInstance().loadTableMetaData(processValues.sourceTable) const sourceTableMetaData = await Client.getInstance().loadTableMetaData(processValues.sourceTable);
setSourceTableMetaData(sourceTableMetaData); setSourceTableMetaData(sourceTableMetaData);
})(); })();
} }
////////////////////////////////////////////////////////////////////////////////////////
// load meta-data and set up associations-data structure, if so directed from backend //
////////////////////////////////////////////////////////////////////////////////////////
useEffect(() =>
{
if (processValues.formatPreviewRecordUsingTableLayout && !previewTableMetaData)
{
(async () =>
{
const previewTableMetaData = await Client.getInstance().loadTableMetaData(processValues.formatPreviewRecordUsingTableLayout);
setPreviewTableMetaData(previewTableMetaData);
})();
}
try
{
const previewRecordAssociatedTableNames: string[] = processValues.previewRecordAssociatedTableNames ?? [];
const previewRecordAssociatedWidgetNames: string[] = processValues.previewRecordAssociatedWidgetNames ?? [];
const previewRecordAssociationNames: string[] = processValues.previewRecordAssociationNames ?? [];
const associationPreviewsByWidgetName: { [widgetName: string]: AssociationPreview } = {};
for (let i = 0; i < Math.min(previewRecordAssociatedTableNames.length, previewRecordAssociatedWidgetNames.length, previewRecordAssociationNames.length); i++)
{
const associationPreview = {tableName: previewRecordAssociatedTableNames[i], widgetName: previewRecordAssociatedWidgetNames[i], associationName: previewRecordAssociationNames[i]};
associationPreviewsByWidgetName[associationPreview.widgetName] = associationPreview;
}
setAssociationPreviewsByWidgetName(associationPreviewsByWidgetName);
if (Object.keys(associationPreviewsByWidgetName))
{
(async () =>
{
for (let key in associationPreviewsByWidgetName)
{
const associationPreview = associationPreviewsByWidgetName[key];
childTableMetaData[associationPreview.tableName] = await Client.getInstance().loadTableMetaData(associationPreview.tableName);
setChildTableMetaData(Object.assign({}, childTableMetaData));
}
})();
}
}
catch (e)
{
console.log(`Error setting up association previews: ${e}`);
}
}, []);
/***************************************************************************
**
***************************************************************************/
const updatePreviewRecordIndex = (offset: number) => const updatePreviewRecordIndex = (offset: number) =>
{ {
let newIndex = previewRecordIndex + offset; let newIndex = previewRecordIndex + offset;
@ -85,6 +156,10 @@ function ValidationReview({
setPreviewRecordIndex(newIndex); setPreviewRecordIndex(newIndex);
}; };
/***************************************************************************
**
***************************************************************************/
const buildDoFullValidationRadioListItem = (value: "true" | "false", labelText: string, tooltipHTML: JSX.Element): JSX.Element => const buildDoFullValidationRadioListItem = (value: "true" | "false", labelText: string, tooltipHTML: JSX.Element): JSX.Element =>
{ {
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
@ -191,6 +266,7 @@ function ValidationReview({
</List> </List>
); );
const recordPreviewWidget = step.recordListFields && ( const recordPreviewWidget = step.recordListFields && (
<Box border="1px solid rgb(70%, 70%, 70%)" borderRadius="10px" p={2} mt={2}> <Box border="1px solid rgb(70%, 70%, 70%)" borderRadius="10px" p={2} mt={2}>
<Box mx={2} mt={-5} p={1} sx={{width: "fit-content", borderColor: "rgb(70%, 70%, 70%)", borderWidth: "2px", borderStyle: "solid", borderRadius: ".25em", backgroundColor: "#FFFFFF"}} width="initial" color="white"> <Box mx={2} mt={-5} p={1} sx={{width: "fit-content", borderColor: "rgb(70%, 70%, 70%)", borderWidth: "2px", borderStyle: "solid", borderRadius: ".25em", backgroundColor: "#FFFFFF"}} width="initial" color="white">
@ -199,6 +275,8 @@ function ValidationReview({
<Box p={3} pb={0}> <Box p={3} pb={0}>
<MDTypography color="body" variant="body2" component="div" mb={2}> <MDTypography color="body" variant="body2" component="div" mb={2}>
<Box display="flex"> <Box display="flex">
{
loadingRecords ? <i>Loading...</i> : <>
{ {
processValues?.previewMessage && previewRecords && previewRecords.length > 0 ? ( processValues?.previewMessage && previewRecords && previewRecords.length > 0 ? (
<> <>
@ -238,13 +316,15 @@ function ValidationReview({
</> </>
) )
} }
</>
}
</Box> </Box>
</MDTypography> </MDTypography>
<MDTypography color="body" variant="body2" component="div"> <MDTypography color="body" variant="body2" component="div">
<Box sx={{maxHeight: "calc(100vh - 640px)", overflow: "auto", minHeight: "300px", marginRight: "-40px"}}> <Box sx={{maxHeight: "calc(100vh - 640px)", overflow: "auto", minHeight: "300px", marginRight: "-40px"}}>
<Box sx={{paddingRight: "40px"}}> <Box sx={{paddingRight: "40px"}}>
{ {
previewRecords && previewRecords[previewRecordIndex] && step.recordListFields.map((field) => ( previewRecords && !processValues.formatPreviewRecordUsingTableLayout && previewRecords[previewRecordIndex] && step.recordListFields.map((field) => (
<Box key={field.name} style={{marginBottom: "12px"}}> <Box key={field.name} style={{marginBottom: "12px"}}>
<b>{`${field.label}:`}</b> <b>{`${field.label}:`}</b>
{" "} {" "}
@ -254,6 +334,17 @@ function ValidationReview({
</Box> </Box>
)) ))
} }
{
previewRecords && processValues.formatPreviewRecordUsingTableLayout && previewRecords[previewRecordIndex] &&
<PreviewRecordUsingTableLayout
index={previewRecordIndex}
record={previewRecords[previewRecordIndex]}
tableMetaData={previewTableMetaData}
qInstance={qInstance}
associationPreviewsByWidgetName={associationPreviewsByWidgetName}
childTableMetaData={childTableMetaData}
/>
}
</Box> </Box>
</Box> </Box>
{ {
@ -288,4 +379,84 @@ function ValidationReview({
); );
} }
interface PreviewRecordUsingTableLayoutProps
{
index: number
record: QRecord,
tableMetaData: QTableMetaData,
qInstance: QInstance,
associationPreviewsByWidgetName: { [widgetName: string]: AssociationPreview },
childTableMetaData: { [name: string]: QTableMetaData },
}
function PreviewRecordUsingTableLayout({record, tableMetaData, qInstance, associationPreviewsByWidgetName, childTableMetaData, index}: PreviewRecordUsingTableLayoutProps): JSX.Element
{
if (!tableMetaData)
{
return (<i>Loading...</i>);
}
const renderedSections: JSX.Element[] = [];
const tableSections = TableUtils.getSectionsForRecordSidebar(tableMetaData);
for (let i = 0; i < tableSections.length; i++)
{
const section = tableSections[i];
if (section.isHidden)
{
continue;
}
if (section.fieldNames)
{
renderedSections.push(<Box mb="1rem">
<Box><h4>{section.label}</h4></Box>
<Box ml="1rem">
{renderSectionOfFields(section.name, section.fieldNames, tableMetaData, false, record, undefined, {label: {fontWeight: "500"}})}
</Box>
</Box>);
}
else if (section.widgetName)
{
const widget = qInstance.widgets.get(section.widgetName);
if (widget)
{
let data: ChildRecordListData = null;
if (associationPreviewsByWidgetName[section.widgetName])
{
const associationPreview = associationPreviewsByWidgetName[section.widgetName];
const associationRecords = record.associatedRecords?.get(associationPreview.associationName) ?? [];
data = {
canAddChildRecord: false,
childTableMetaData: childTableMetaData[associationPreview.tableName],
defaultValuesForNewChildRecords: {},
disabledFieldsForNewChildRecords: {},
queryOutput: {records: associationRecords},
totalRows: associationRecords.length,
tablePath: "",
title: "",
viewAllLink: "",
};
renderedSections.push(<Box mb="1rem">
{
data && <Box>
<Box mb="0.5rem"><h4>{section.label}</h4></Box>
<Box pl="1rem">
<RecordGridWidget key={index} data={data} widgetMetaData={widget} disableRowClick gridOnly={true} gridDensity={"compact"} />
</Box>
</Box>
}
</Box>);
}
}
}
}
return <>{renderedSections}</>;
}
export default ValidationReview; export default ValidationReview;

View File

@ -118,7 +118,7 @@ const doesOperatorOptionEqualCriteria = (operatorOption: OperatorOption, criteri
** autocomplete), given an array of options, the query's active criteria in this ** autocomplete), given an array of options, the query's active criteria in this
** field, and the default operator to use for this field ** field, and the default operator to use for this field
*******************************************************************************/ *******************************************************************************/
const getOperatorSelectedValue = (operatorOptions: OperatorOption[], criteria: QFilterCriteriaWithId, defaultOperator: QCriteriaOperator): OperatorOption => const getOperatorSelectedValue = (operatorOptions: OperatorOption[], criteria: QFilterCriteriaWithId, defaultOperator: QCriteriaOperator, return0thOptionInsteadOfNull: boolean = false): OperatorOption =>
{ {
if (criteria) if (criteria)
{ {
@ -135,6 +135,23 @@ const getOperatorSelectedValue = (operatorOptions: OperatorOption[], criteria: Q
return (filteredOptions[0]); return (filteredOptions[0]);
} }
if(return0thOptionInsteadOfNull)
{
console.log("Returning 0th operator instead of null - this isn't expected, but has been seen to happen - so here's some additional debugging:");
try
{
console.log("Operator options: " + JSON.stringify(operatorOptions));
console.log("Criteria: " + JSON.stringify(criteria));
console.log("Default Operator: " + JSON.stringify(defaultOperator));
}
catch(e)
{
console.log(`Error in debug output: ${e}`);
}
return operatorOptions[0];
}
return (null); return (null);
}; };
@ -157,7 +174,7 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData
const [criteria, setCriteria] = useState(criteriaParamIsCriteria(criteriaParam) ? Object.assign({}, criteriaParam) as QFilterCriteriaWithId : null); const [criteria, setCriteria] = useState(criteriaParamIsCriteria(criteriaParam) ? Object.assign({}, criteriaParam) as QFilterCriteriaWithId : null);
const [id, setId] = useState(criteriaParamIsCriteria(criteriaParam) ? (criteriaParam as QFilterCriteriaWithId).id : ++seedId); const [id, setId] = useState(criteriaParamIsCriteria(criteriaParam) ? (criteriaParam as QFilterCriteriaWithId).id : ++seedId);
const [operatorSelectedValue, setOperatorSelectedValue] = useState(getOperatorSelectedValue(operatorOptions, criteria, defaultOperator)); const [operatorSelectedValue, setOperatorSelectedValue] = useState(getOperatorSelectedValue(operatorOptions, criteria, defaultOperator, true));
const [operatorInputValue, setOperatorInputValue] = useState(operatorSelectedValue?.label); const [operatorInputValue, setOperatorInputValue] = useState(operatorSelectedValue?.label);
const {criteriaIsValid, criteriaStatusTooltip} = validateCriteria(criteria, operatorSelectedValue); const {criteriaIsValid, criteriaStatusTooltip} = validateCriteria(criteria, operatorSelectedValue);

View File

@ -49,7 +49,7 @@ function ScriptDocsForm({helpText, exampleCode, aceEditorHeight}: Props): JSX.El
<Card sx={{width: "100%", height: "100%"}}> <Card sx={{width: "100%", height: "100%"}}>
<Typography variant="h6" p={2} pb={1}>{heading}</Typography> <Typography variant="h6" p={2} pb={1}>{heading}</Typography>
<Box className="devDocumentation" height="100%"> <Box className="devDocumentation" height="100%">
<Typography variant="body2" sx={{maxWidth: "1200px", margin: "auto", height: "100%"}}> <Typography variant="body2" sx={{maxWidth: "1200px", margin: "auto", height: "calc(100% - 0.5rem)"}}>
<AceEditor <AceEditor
mode={mode} mode={mode}
theme="github" theme="github"
@ -62,7 +62,7 @@ function ScriptDocsForm({helpText, exampleCode, aceEditorHeight}: Props): JSX.El
width="100%" width="100%"
showPrintMargin={false} showPrintMargin={false}
height="100%" height="100%"
style={{borderBottomRightRadius: "1rem", borderBottomLeftRadius: "1rem"}} style={{borderBottomRightRadius: "0.75rem", borderBottomLeftRadius: "0.75rem"}}
/> />
</Typography> </Typography>
</Box> </Box>

View File

@ -107,7 +107,6 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, reco
// modal form controls // // modal form controls //
///////////////////////// /////////////////////////
const [showEditChildForm, setShowEditChildForm] = useState(null as any); const [showEditChildForm, setShowEditChildForm] = useState(null as any);
const [modalTable, setModalTable] = useState(null as QTableMetaData);
let initialSelectedTab = 0; let initialSelectedTab = 0;
let selectedTabKey: string = null; let selectedTabKey: string = null;
@ -314,6 +313,7 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, reco
function deleteChildRecord(name: string, widgetIndex: number, rowIndex: number) function deleteChildRecord(name: string, widgetIndex: number, rowIndex: number)
{ {
updateChildRecordList(name, "delete", rowIndex); updateChildRecordList(name, "delete", rowIndex);
forceUpdate();
actionCallback(widgetData[widgetIndex]); actionCallback(widgetData[widgetIndex]);
}; };
@ -340,13 +340,15 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, reco
*******************************************************************************/ *******************************************************************************/
function openAddChildRecord(name: string, widgetData: any) function openAddChildRecord(name: string, widgetData: any)
{ {
let defaultValues = widgetData.defaultValuesForNewChildRecords;
let disabledFields = widgetData.disabledFieldsForNewChildRecords; let disabledFields = widgetData.disabledFieldsForNewChildRecords;
if (!disabledFields) if (!disabledFields)
{ {
disabledFields = widgetData.defaultValuesForNewChildRecords; disabledFields = widgetData.defaultValuesForNewChildRecords;
} }
doOpenEditChildForm(name, widgetData.childTableMetaData, null, null, disabledFields); doOpenEditChildForm(name, widgetData.childTableMetaData, null, defaultValues, disabledFields);
} }
@ -367,7 +369,7 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, reco
/******************************************************************************* /*******************************************************************************
** **
*******************************************************************************/ *******************************************************************************/
function submitEditChildForm(values: any) function submitEditChildForm(values: any, tableName: string)
{ {
updateChildRecordList(showEditChildForm.widgetName, showEditChildForm.rowIndex == null ? "insert" : "edit", showEditChildForm.rowIndex, values); updateChildRecordList(showEditChildForm.widgetName, showEditChildForm.rowIndex == null ? "insert" : "edit", showEditChildForm.rowIndex, values);
let widgetIndex = determineChildRecordListIndex(showEditChildForm.widgetName); let widgetIndex = determineChildRecordListIndex(showEditChildForm.widgetName);
@ -714,9 +716,10 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, reco
allowRecordDelete={widgetData[i]?.allowRecordDelete} allowRecordDelete={widgetData[i]?.allowRecordDelete}
deleteRecordCallback={(rowIndex) => deleteChildRecord(widgetMetaData.name, i, rowIndex)} deleteRecordCallback={(rowIndex) => deleteChildRecord(widgetMetaData.name, i, rowIndex)}
editRecordCallback={(rowIndex) => openEditChildRecord(widgetMetaData.name, widgetData[i], rowIndex)} editRecordCallback={(rowIndex) => openEditChildRecord(widgetMetaData.name, widgetData[i], rowIndex)}
addNewRecordCallback={() => openAddChildRecord(widgetMetaData.name, widgetData[i])} addNewRecordCallback={widgetData[i]?.isInProcess ? () => openAddChildRecord(widgetMetaData.name, widgetData[i]) : null}
widgetMetaData={widgetMetaData} widgetMetaData={widgetMetaData}
data={widgetData[i]} data={widgetData[i]}
parentRecord={record}
/> />
) )

View File

@ -46,11 +46,12 @@ import React, {useContext, useEffect, useRef, useState} from "react";
interface FilterAndColumnsSetupWidgetProps interface FilterAndColumnsSetupWidgetProps
{ {
isEditable: boolean; isEditable: boolean,
widgetMetaData: QWidgetMetaData; widgetMetaData: QWidgetMetaData,
widgetData: any; widgetData: any,
recordValues: { [name: string]: any }; recordValues: { [name: string]: any },
onSaveCallback?: (values: { [name: string]: any }) => void; onSaveCallback?: (values: { [name: string]: any }) => void,
label?: string
} }
FilterAndColumnsSetupWidget.defaultProps = { FilterAndColumnsSetupWidget.defaultProps = {
@ -83,13 +84,16 @@ const qController = Client.getInstance();
/******************************************************************************* /*******************************************************************************
** Component for editing the main setup of a report - that is: filter & columns ** Component for editing the main setup of a report - that is: filter & columns
*******************************************************************************/ *******************************************************************************/
export default function FilterAndColumnsSetupWidget({isEditable, widgetMetaData, widgetData, recordValues, onSaveCallback}: FilterAndColumnsSetupWidgetProps): JSX.Element export default function FilterAndColumnsSetupWidget({isEditable, widgetMetaData, widgetData, recordValues, onSaveCallback, label}: FilterAndColumnsSetupWidgetProps): JSX.Element
{ {
const [modalOpen, setModalOpen] = useState(false); const [modalOpen, setModalOpen] = useState(false);
const [hideColumns, setHideColumns] = useState(widgetData?.hideColumns); const [hideColumns] = useState(widgetData?.hideColumns);
const [hidePreview, setHidePreview] = useState(widgetData?.hidePreview); const [hidePreview] = useState(widgetData?.hidePreview);
const [tableMetaData, setTableMetaData] = useState(null as QTableMetaData); const [tableMetaData, setTableMetaData] = useState(null as QTableMetaData);
const [filterFieldName] = useState(widgetData?.filterFieldName ?? "queryFilterJson");
const [columnsFieldName] = useState(widgetData?.columnsFieldName ?? "columnsJson");
const [alertContent, setAlertContent] = useState(null as string); const [alertContent, setAlertContent] = useState(null as string);
////////////////////////////////////////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////////////////////////////////////
@ -108,7 +112,7 @@ export default function FilterAndColumnsSetupWidget({isEditable, widgetMetaData,
///////////////////////////// /////////////////////////////
let columns: QQueryColumns = null; let columns: QQueryColumns = null;
let usingDefaultEmptyFilter = false; let usingDefaultEmptyFilter = false;
let queryFilter = recordValues["queryFilterJson"] && JSON.parse(recordValues["queryFilterJson"]) as QQueryFilter; let queryFilter = recordValues[filterFieldName] && JSON.parse(recordValues[filterFieldName]) as QQueryFilter;
const defaultFilterFields = widgetData?.filterDefaultFieldNames; const defaultFilterFields = widgetData?.filterDefaultFieldNames;
if (!queryFilter) if (!queryFilter)
{ {
@ -142,9 +146,9 @@ export default function FilterAndColumnsSetupWidget({isEditable, widgetMetaData,
}); });
} }
if (recordValues["columnsJson"]) if (recordValues[columnsFieldName])
{ {
columns = QQueryColumns.buildFromJSON(recordValues["columnsJson"]); columns = QQueryColumns.buildFromJSON(recordValues[columnsFieldName]);
} }
////////////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////////
@ -230,7 +234,10 @@ export default function FilterAndColumnsSetupWidget({isEditable, widgetMetaData,
setFrontendQueryFilter(view.queryFilter); setFrontendQueryFilter(view.queryFilter);
const filter = FilterUtils.prepQueryFilterForBackend(tableMetaData, view.queryFilter); const filter = FilterUtils.prepQueryFilterForBackend(tableMetaData, view.queryFilter);
onSaveCallback({queryFilterJson: JSON.stringify(filter), columnsJson: JSON.stringify(view.queryColumns)}); const rs: { [key: string]: any } = {};
rs[filterFieldName] = JSON.stringify(filter);
rs[columnsFieldName] = JSON.stringify(view.queryColumns);
onSaveCallback(rs);
closeEditor(); closeEditor();
} }
@ -356,7 +363,7 @@ export default function FilterAndColumnsSetupWidget({isEditable, widgetMetaData,
</Collapse> </Collapse>
<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>{label ?? "Query Filter"}</h5>
<Box fontSize="0.75rem" fontWeight="700">{mayShowQuery() && getCurrentSortIndicator(frontendQueryFilter, tableMetaData, null)}</Box> <Box fontSize="0.75rem" fontWeight="700">{mayShowQuery() && getCurrentSortIndicator(frontendQueryFilter, tableMetaData, null)}</Box>
</Box> </Box>
{ {

View File

@ -28,7 +28,7 @@ import Icon from "@mui/material/Icon";
import IconButton from "@mui/material/IconButton"; import IconButton from "@mui/material/IconButton";
import Tooltip from "@mui/material/Tooltip/Tooltip"; import Tooltip from "@mui/material/Tooltip/Tooltip";
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
import {DataGridPro, GridCallbackDetails, GridEventListener, GridRenderCellParams, GridRowParams, GridToolbarContainer, MuiEvent, useGridApiContext, useGridApiEventHandler} from "@mui/x-data-grid-pro"; import {DataGridPro, GridCallbackDetails, GridDensity, GridEventListener, GridRenderCellParams, GridRowParams, GridToolbarContainer, MuiEvent, useGridApiContext, useGridApiEventHandler} from "@mui/x-data-grid-pro";
import Widget, {AddNewRecordButton, LabelComponent, WidgetData} from "qqq/components/widgets/Widget"; import Widget, {AddNewRecordButton, LabelComponent, WidgetData} from "qqq/components/widgets/Widget";
import DataGridUtils from "qqq/utils/DataGridUtils"; import DataGridUtils from "qqq/utils/DataGridUtils";
import HtmlUtils from "qqq/utils/HtmlUtils"; import HtmlUtils from "qqq/utils/HtmlUtils";
@ -40,7 +40,7 @@ import {Link, useNavigate} from "react-router-dom";
export interface ChildRecordListData extends WidgetData export interface ChildRecordListData extends WidgetData
{ {
title?: string; title?: string;
queryOutput?: { records: { values: any }[] }; queryOutput?: { records: { values: any, displayValues?: any } [] };
childTableMetaData?: QTableMetaData; childTableMetaData?: QTableMetaData;
tablePath?: string; tablePath?: string;
viewAllLink?: string; viewAllLink?: string;
@ -48,30 +48,35 @@ export interface ChildRecordListData extends WidgetData
canAddChildRecord?: boolean; canAddChildRecord?: boolean;
defaultValuesForNewChildRecords?: { [fieldName: string]: any }; defaultValuesForNewChildRecords?: { [fieldName: string]: any };
disabledFieldsForNewChildRecords?: { [fieldName: string]: any }; disabledFieldsForNewChildRecords?: { [fieldName: string]: any };
defaultValuesForNewChildRecordsFromParentFields?: { [fieldName: string]: string };
} }
interface Props interface Props
{ {
widgetMetaData: QWidgetMetaData; widgetMetaData: QWidgetMetaData,
data: ChildRecordListData; data: ChildRecordListData,
addNewRecordCallback?: () => void; addNewRecordCallback?: () => void,
disableRowClick: boolean; disableRowClick: boolean,
allowRecordEdit: boolean; allowRecordEdit: boolean,
editRecordCallback?: (rowIndex: number) => void; editRecordCallback?: (rowIndex: number) => void,
allowRecordDelete: boolean; allowRecordDelete: boolean,
deleteRecordCallback?: (rowIndex: number) => void; deleteRecordCallback?: (rowIndex: number) => void,
gridOnly?: boolean,
gridDensity?: GridDensity,
parentRecord?: QRecord
} }
RecordGridWidget.defaultProps = RecordGridWidget.defaultProps =
{ {
disableRowClick: false, disableRowClick: false,
allowRecordEdit: false, allowRecordEdit: false,
allowRecordDelete: false allowRecordDelete: false,
gridOnly: false,
}; };
const qController = Client.getInstance(); const qController = Client.getInstance();
function RecordGridWidget({widgetMetaData, data, addNewRecordCallback, disableRowClick, allowRecordEdit, editRecordCallback, allowRecordDelete, deleteRecordCallback}: Props): JSX.Element function RecordGridWidget({widgetMetaData, data, addNewRecordCallback, disableRowClick, allowRecordEdit, editRecordCallback, allowRecordDelete, deleteRecordCallback, gridOnly, gridDensity, parentRecord}: Props): JSX.Element
{ {
const instance = useRef({timer: null}); const instance = useRef({timer: null});
const [rows, setRows] = useState([]); const [rows, setRows] = useState([]);
@ -93,12 +98,19 @@ function RecordGridWidget({widgetMetaData, data, addNewRecordCallback, disableRo
if (queryOutputRecords) if (queryOutputRecords)
{ {
for (let i = 0; i < queryOutputRecords.length; i++) for (let i = 0; i < queryOutputRecords.length; i++)
{
if (queryOutputRecords[i] instanceof QRecord)
{
records.push(queryOutputRecords[i] as QRecord);
}
else
{ {
records.push(new QRecord(queryOutputRecords[i])); records.push(new QRecord(queryOutputRecords[i]));
} }
} }
}
const tableMetaData = new QTableMetaData(data.childTableMetaData); const tableMetaData = data.childTableMetaData instanceof QTableMetaData ? data.childTableMetaData as QTableMetaData : new QTableMetaData(data.childTableMetaData);
const rows = DataGridUtils.makeRows(records, tableMetaData, true); const rows = DataGridUtils.makeRows(records, tableMetaData, true);
///////////////////////////////////////////////////////////////////////////////// /////////////////////////////////////////////////////////////////////////////////
@ -242,7 +254,22 @@ function RecordGridWidget({widgetMetaData, data, addNewRecordCallback, disableRo
{ {
disabledFields = data.defaultValuesForNewChildRecords; disabledFields = data.defaultValuesForNewChildRecords;
} }
labelAdditionalComponentsRight.push(new AddNewRecordButton(data.childTableMetaData, data.defaultValuesForNewChildRecords, "Add new", disabledFields, addNewRecordCallback));
const defaultValuesForNewChildRecords = data.defaultValuesForNewChildRecords || {}
///////////////////////////////////////////////////////////////////////////////////////
// copy values from specified fields in the parent record down into the child record //
///////////////////////////////////////////////////////////////////////////////////////
if(data.defaultValuesForNewChildRecordsFromParentFields)
{
for(let childField in data.defaultValuesForNewChildRecordsFromParentFields)
{
const parentField = data.defaultValuesForNewChildRecordsFromParentFields[childField];
defaultValuesForNewChildRecords[childField] = parentRecord?.values?.get(parentField);
}
}
labelAdditionalComponentsRight.push(new AddNewRecordButton(data.childTableMetaData, defaultValuesForNewChildRecords, "Add new", disabledFields, addNewRecordCallback));
} }
@ -295,17 +322,14 @@ function RecordGridWidget({widgetMetaData, data, addNewRecordCallback, disableRo
return (<GridToolbarContainer />); return (<GridToolbarContainer />);
} }
let containerPadding = -3;
if (data?.isInProcess)
{
containerPadding = 0;
}
return (
<Widget const grid = (
widgetMetaData={widgetMetaData}
widgetData={data}
labelAdditionalElementsLeft={labelAdditionalElementsLeft}
labelAdditionalComponentsRight={labelAdditionalComponentsRight}
labelBoxAdditionalSx={{position: "relative", top: "-0.375rem"}}
>
<Box>
<Box>
<DataGridPro <DataGridPro
autoHeight autoHeight
sx={{ sx={{
@ -336,7 +360,7 @@ function RecordGridWidget({widgetMetaData, data, addNewRecordCallback, disableRo
rowCount={data && data.totalRows} rowCount={data && data.totalRows}
// onPageSizeChange={handleRowsPerPageChange} // onPageSizeChange={handleRowsPerPageChange}
// onStateChange={handleStateChange} // onStateChange={handleStateChange}
// density={density} density={gridDensity ?? "standard"}
// loading={loading} // loading={loading}
// filterModel={filterModel} // filterModel={filterModel}
// onFilterModelChange={handleFilterChange} // onFilterModelChange={handleFilterChange}
@ -348,6 +372,24 @@ function RecordGridWidget({widgetMetaData, data, addNewRecordCallback, disableRo
// sortingOrder={[ "asc", "desc" ]} // sortingOrder={[ "asc", "desc" ]}
// sortModel={columnSortModel} // sortModel={columnSortModel}
/> />
);
if (gridOnly)
{
return (grid);
}
return (
<Widget
widgetMetaData={widgetMetaData}
widgetData={data}
labelAdditionalElementsLeft={labelAdditionalElementsLeft}
labelAdditionalComponentsRight={labelAdditionalComponentsRight}
labelBoxAdditionalSx={{position: "relative", top: "-0.375rem"}}
>
<Box mx={containerPadding} mb={containerPadding}>
<Box>
{grid}
</Box> </Box>
</Box> </Box>
</Widget> </Widget>

View File

@ -393,7 +393,7 @@ export default function ScriptViewer({scriptId, associatedScriptTableName, assoc
} }
return ( return (
<Grid container className="scriptViewer" m={-2} pt={4} width={"calc(100% + 2rem)"}> <Grid container className="scriptViewer" my={-3} mx={-3} pt={4} width={"calc(100% + 3rem)"}>
<Grid item xs={12}> <Grid item xs={12}>
<Box> <Box>
{ {
@ -530,7 +530,7 @@ export default function ScriptViewer({scriptId, associatedScriptTableName, assoc
</TabPanel> </TabPanel>
<TabPanel index={2} value={selectedTab}> <TabPanel index={2} value={selectedTab}>
<Box sx={{height: "455px"}} px={2} pb={1}> <Box sx={{height: "455px"}} px={2} pt={1}>
<ScriptTestForm scriptId={scriptId} <ScriptTestForm scriptId={scriptId}
scriptType={scriptTypeRecord} scriptType={scriptTypeRecord}
tableName={associatedScriptTableName} tableName={associatedScriptTableName}
@ -543,7 +543,7 @@ export default function ScriptViewer({scriptId, associatedScriptTableName, assoc
</TabPanel> </TabPanel>
<TabPanel index={3} value={selectedTab}> <TabPanel index={3} value={selectedTab}>
<Box sx={{height: "455px"}} px={2} pb={1}> <Box sx={{height: "455px"}} px={2} pt={1}>
<ScriptDocsForm helpText={scriptTypeRecord?.values.get("helpText")} exampleCode={scriptTypeRecord?.values.get("sampleCode")} /> <ScriptDocsForm helpText={scriptTypeRecord?.values.get("helpText")} exampleCode={scriptTypeRecord?.values.get("sampleCode")} />
</Box> </Box>
</TabPanel> </TabPanel>

View File

@ -35,7 +35,7 @@ export interface ModalEditFormData
defaultValues?: { [key: string]: string }; defaultValues?: { [key: string]: string };
disabledFields?: { [key: string]: boolean } | string[]; disabledFields?: { [key: string]: boolean } | string[];
overrideHeading?: string; overrideHeading?: string;
onSubmitCallback?: (values: any) => void; onSubmitCallback?: (values: any, tableName: String) => void;
initialShowModalValue?: boolean; initialShowModalValue?: boolean;
} }

View File

@ -0,0 +1,852 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
import {QFieldType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldType";
import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord";
export type ValueType = "defaultValue" | "column";
/***************************************************************************
** model of a single field that's part of a bulk-load profile/mapping
***************************************************************************/
export class BulkLoadField
{
field: QFieldMetaData;
tableStructure: BulkLoadTableStructure;
valueType: ValueType;
columnIndex?: number;
headerName?: string = null;
defaultValue?: any = null;
doValueMapping: boolean = false;
wideLayoutIndexPath: number[] = [];
error: string = null;
warning: string = null;
key: string;
/***************************************************************************
**
***************************************************************************/
constructor(field: QFieldMetaData, tableStructure: BulkLoadTableStructure, valueType: ValueType = "column", columnIndex?: number, headerName?: string, defaultValue?: any, doValueMapping?: boolean, wideLayoutIndexPath: number[] = [], error: string = null, warning: string = null)
{
this.field = field;
this.tableStructure = tableStructure;
this.valueType = valueType;
this.columnIndex = columnIndex;
this.headerName = headerName;
this.defaultValue = defaultValue;
this.doValueMapping = doValueMapping;
this.wideLayoutIndexPath = wideLayoutIndexPath;
this.error = error;
this.warning = warning;
this.key = new Date().getTime().toString();
}
/***************************************************************************
**
***************************************************************************/
public static clone(source: BulkLoadField): BulkLoadField
{
return (new BulkLoadField(source.field, source.tableStructure, source.valueType, source.columnIndex, source.headerName, source.defaultValue, source.doValueMapping, source.wideLayoutIndexPath, source.error, source.warning));
}
/***************************************************************************
**
***************************************************************************/
public getQualifiedName(): string
{
if (this.tableStructure.isMain)
{
return this.field.name;
}
return this.tableStructure.associationPath + "." + this.field.name;
}
/***************************************************************************
**
***************************************************************************/
public getQualifiedNameWithWideSuffix(): string
{
let wideLayoutSuffix = "";
if (this.wideLayoutIndexPath.length > 0)
{
wideLayoutSuffix = "." + this.wideLayoutIndexPath.map(i => i + 1).join(".");
}
if (this.tableStructure.isMain)
{
return this.field.name + wideLayoutSuffix;
}
return this.tableStructure.associationPath + "." + this.field.name + wideLayoutSuffix;
}
/***************************************************************************
**
***************************************************************************/
public getKey(): string
{
let wideLayoutSuffix = "";
if (this.wideLayoutIndexPath.length > 0)
{
wideLayoutSuffix = "." + this.wideLayoutIndexPath.map(i => i + 1).join(".");
}
if (this.tableStructure.isMain)
{
return this.field.name + wideLayoutSuffix + this.key;
}
return this.tableStructure.associationPath + "." + this.field.name + wideLayoutSuffix + this.key;
}
/***************************************************************************
**
***************************************************************************/
public getQualifiedLabel(): string
{
let wideLayoutSuffix = "";
if (this.wideLayoutIndexPath.length > 0)
{
wideLayoutSuffix = " (" + this.wideLayoutIndexPath.map(i => i + 1).join(", ") + ")";
}
if (this.tableStructure.isMain)
{
return this.field.label + wideLayoutSuffix;
}
return this.tableStructure.label + ": " + this.field.label + wideLayoutSuffix;
}
/***************************************************************************
**
***************************************************************************/
public isMany(): boolean
{
return this.tableStructure && this.tableStructure.isMany;
}
}
/***************************************************************************
** this is a type defined in qqq backend - a representation of a bulk-load
** table - e.g., how it fits into qqq - and of note - how child / association
** tables are nested too.
***************************************************************************/
export interface BulkLoadTableStructure
{
isMain: boolean;
isMany: boolean;
tableName: string;
label: string;
associationPath: string;
fields: QFieldMetaData[];
associations: BulkLoadTableStructure[];
}
/*******************************************************************************
** this is the internal data structure that the UI works with - but notably,
** is not how we send it to the backend or how backend saves profiles -- see
** BulkLoadProfile for that.
*******************************************************************************/
export class BulkLoadMapping
{
fields: { [qualifiedName: string]: BulkLoadField } = {};
fieldsByTablePrefix: { [prefix: string]: { [qualifiedFieldName: string]: BulkLoadField } } = {};
tablesByPath: { [path: string]: BulkLoadTableStructure } = {};
requiredFields: BulkLoadField[] = [];
additionalFields: BulkLoadField[] = [];
unusedFields: BulkLoadField[] = [];
valueMappings: { [fieldName: string]: { [fileValue: string]: any } } = {};
hasHeaderRow: boolean;
layout: string;
/***************************************************************************
**
***************************************************************************/
constructor(tableStructure: BulkLoadTableStructure)
{
if (tableStructure)
{
this.processTableStructure(tableStructure);
if (!tableStructure.associations)
{
this.layout = "FLAT";
}
}
this.hasHeaderRow = true;
}
/***************************************************************************
**
***************************************************************************/
private processTableStructure(tableStructure: BulkLoadTableStructure)
{
const prefix = tableStructure.isMain ? "" : tableStructure.associationPath;
this.fieldsByTablePrefix[prefix] = {};
this.tablesByPath[prefix] = tableStructure;
for (let field of tableStructure.fields)
{
// todo delete this - backend should only give it to us if editable: if (field.isEditable)
{
const bulkLoadField = new BulkLoadField(field, tableStructure);
const qualifiedName = bulkLoadField.getQualifiedName();
this.fields[qualifiedName] = bulkLoadField;
this.fieldsByTablePrefix[prefix][qualifiedName] = bulkLoadField;
if (tableStructure.isMain && field.isRequired)
{
this.requiredFields.push(bulkLoadField);
}
else
{
this.unusedFields.push(bulkLoadField);
}
}
}
for (let associatedTableStructure of tableStructure.associations ?? [])
{
this.processTableStructure(associatedTableStructure);
}
}
/***************************************************************************
** take a saved bulk load profile - and convert it into a working bulkLoadMapping
** for the frontend to use!
***************************************************************************/
public static fromSavedProfileRecord(tableStructure: BulkLoadTableStructure, profileRecord: QRecord): BulkLoadMapping
{
const bulkLoadProfile = JSON.parse(profileRecord.values.get("mappingJson")) as BulkLoadProfile;
return BulkLoadMapping.fromBulkLoadProfile(tableStructure, bulkLoadProfile);
}
/***************************************************************************
** take a saved bulk load profile - and convert it into a working bulkLoadMapping
** for the frontend to use!
***************************************************************************/
public static fromBulkLoadProfile(tableStructure: BulkLoadTableStructure, bulkLoadProfile: BulkLoadProfile): BulkLoadMapping
{
const bulkLoadMapping = new BulkLoadMapping(tableStructure);
if (bulkLoadProfile.version == "v1")
{
bulkLoadMapping.hasHeaderRow = bulkLoadProfile.hasHeaderRow;
bulkLoadMapping.layout = bulkLoadProfile.layout;
////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// function to get a bulkLoadMapping field by its (full) name - whether that's in the required fields list, //
// or it's an additional field, in which case, we'll go through the addField method to move what list it's in //
////////////////////////////////////////////////////////////////////////////////////////////////////////////////
function getBulkLoadFieldMovingFromUnusedToActiveIfNeeded(bulkLoadMapping: BulkLoadMapping, name: string): BulkLoadField
{
let wideIndex: number = null;
if (name.match(/,\d+$/))
{
wideIndex = Number(name.match(/\d+$/));
name = name.replace(/,\d+$/, "");
}
for (let field of bulkLoadMapping.requiredFields)
{
if (field.getQualifiedName() == name)
{
return (field);
}
}
for (let field of bulkLoadMapping.unusedFields)
{
if (field.getQualifiedName() == name)
{
const addedField = bulkLoadMapping.addField(field, wideIndex);
return (addedField);
}
}
}
//////////////////////////////////////////////////////////////////
// loop over fields in the profile - adding them to the mapping //
//////////////////////////////////////////////////////////////////
for (let bulkLoadProfileField of ((bulkLoadProfile.fieldList ?? []) as BulkLoadProfileField[]))
{
const bulkLoadField = getBulkLoadFieldMovingFromUnusedToActiveIfNeeded(bulkLoadMapping, bulkLoadProfileField.fieldName);
if (!bulkLoadField)
{
console.log(`Couldn't find bulk-load-field by name from profile record [${bulkLoadProfileField.fieldName}]`);
continue;
}
if ((bulkLoadProfileField.columnIndex != null && bulkLoadProfileField.columnIndex != undefined) || (bulkLoadProfileField.headerName != null && bulkLoadProfileField.headerName != undefined))
{
bulkLoadField.valueType = "column";
bulkLoadField.doValueMapping = bulkLoadProfileField.doValueMapping;
bulkLoadField.headerName = bulkLoadProfileField.headerName;
bulkLoadField.columnIndex = bulkLoadProfileField.columnIndex;
if (bulkLoadProfileField.valueMappings)
{
bulkLoadMapping.valueMappings[bulkLoadProfileField.fieldName] = {};
for (let fileValue in bulkLoadProfileField.valueMappings)
{
////////////////////////////////////////////////////
// frontend wants string values here, so, string. //
////////////////////////////////////////////////////
bulkLoadMapping.valueMappings[bulkLoadProfileField.fieldName][String(fileValue)] = bulkLoadProfileField.valueMappings[fileValue];
}
}
}
else
{
bulkLoadField.valueType = "defaultValue";
bulkLoadField.defaultValue = bulkLoadProfileField.defaultValue;
}
}
return (bulkLoadMapping);
}
else
{
throw ("Unexpected version for bulk load profile: " + bulkLoadProfile.version);
}
}
/***************************************************************************
** take a working bulkLoadMapping from the frontend, and convert it to a
** BulkLoadProfile for the backend / for us to save.
***************************************************************************/
public toProfile(): { haveErrors: boolean, profile: BulkLoadProfile }
{
let haveErrors = false;
const profile = new BulkLoadProfile();
profile.version = "v1";
profile.hasHeaderRow = this.hasHeaderRow;
profile.layout = this.layout;
for (let bulkLoadField of [...this.requiredFields, ...this.additionalFields])
{
let fullFieldName = (bulkLoadField.tableStructure.isMain ? "" : bulkLoadField.tableStructure.associationPath + ".") + bulkLoadField.field.name;
if (bulkLoadField.wideLayoutIndexPath != null && bulkLoadField.wideLayoutIndexPath != undefined && bulkLoadField.wideLayoutIndexPath.length)
{
fullFieldName += "," + bulkLoadField.wideLayoutIndexPath.join(".");
}
bulkLoadField.error = null;
if (bulkLoadField.valueType == "column")
{
if (bulkLoadField.columnIndex == undefined || bulkLoadField.columnIndex == null)
{
haveErrors = true;
bulkLoadField.error = "You must select a column.";
}
else
{
const field: BulkLoadProfileField = {fieldName: fullFieldName, columnIndex: bulkLoadField.columnIndex, headerName: bulkLoadField.headerName, doValueMapping: bulkLoadField.doValueMapping};
if (this.valueMappings[fullFieldName])
{
field.valueMappings = this.valueMappings[fullFieldName];
}
profile.fieldList.push(field);
}
}
else if (bulkLoadField.valueType == "defaultValue")
{
if (bulkLoadField.defaultValue == undefined || bulkLoadField.defaultValue == null || bulkLoadField.defaultValue == "")
{
haveErrors = true;
bulkLoadField.error = "A value is required.";
}
else
{
profile.fieldList.push({fieldName: fullFieldName, defaultValue: bulkLoadField.defaultValue});
}
}
}
return {haveErrors, profile};
}
/***************************************************************************
**
***************************************************************************/
public addField(bulkLoadField: BulkLoadField, specifiedWideIndex?: number): BulkLoadField
{
if (bulkLoadField.isMany() && this.layout == "WIDE")
{
let index: number;
if (specifiedWideIndex != null && specifiedWideIndex != undefined)
{
index = specifiedWideIndex;
}
else
{
///////////////////////////////////////////////
// find the max index for this field already //
///////////////////////////////////////////////
let maxIndex = -1;
for (let existingField of [...this.requiredFields, ...this.additionalFields])
{
if (existingField.getQualifiedName() == bulkLoadField.getQualifiedName())
{
const thisIndex = existingField.wideLayoutIndexPath[0];
if (thisIndex != null && thisIndex != undefined && thisIndex > maxIndex)
{
maxIndex = thisIndex;
}
}
}
index = maxIndex + 1;
}
const cloneField = BulkLoadField.clone(bulkLoadField);
cloneField.wideLayoutIndexPath = [index];
this.additionalFields.push(cloneField);
return (cloneField);
}
else
{
this.additionalFields.push(bulkLoadField);
return (bulkLoadField);
}
}
/***************************************************************************
**
***************************************************************************/
public removeField(toRemove: BulkLoadField): void
{
const newAdditionalFields: BulkLoadField[] = [];
for (let bulkLoadField of this.additionalFields)
{
if (bulkLoadField.getQualifiedNameWithWideSuffix() != toRemove.getQualifiedNameWithWideSuffix())
{
newAdditionalFields.push(bulkLoadField);
}
}
this.additionalFields = newAdditionalFields;
}
/***************************************************************************
**
***************************************************************************/
public switchLayout(newLayout: string): void
{
const newAdditionalFields: BulkLoadField[] = [];
let anyChanges = false;
if ("WIDE" != newLayout)
{
////////////////////////////////////////////////////////////////////////////////////////////////////////
// if going to a layout other than WIDE, make sure there aren't any fields with a wideLayoutIndexPath //
////////////////////////////////////////////////////////////////////////////////////////////////////////
const namesWhereOneWideLayoutIndexHasBeenFound: { [name: string]: boolean } = {};
for (let existingField of this.additionalFields)
{
if (existingField.wideLayoutIndexPath.length > 0)
{
const name = existingField.getQualifiedName();
if (namesWhereOneWideLayoutIndexHasBeenFound[name])
{
/////////////////////////////////////////////////////////////////////////////////////////////////////////
// in this case, we're on like the 2nd or 3rd instance of, say, Line Item: SKU - so - just discard it. //
/////////////////////////////////////////////////////////////////////////////////////////////////////////
anyChanges = true;
}
else
{
////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// else, this is the 1st instance of, say, Line Item: SKU - so mark that we've found it - and keep this field //
// (that is, put it in the new array), but with no index path //
////////////////////////////////////////////////////////////////////////////////////////////////////////////////
namesWhereOneWideLayoutIndexHasBeenFound[name] = true;
const newField = BulkLoadField.clone(existingField);
newField.wideLayoutIndexPath = [];
newAdditionalFields.push(newField);
anyChanges = true;
}
}
else
{
//////////////////////////////////////////////////////
// else, non-wide-path fields, just get added as-is //
//////////////////////////////////////////////////////
newAdditionalFields.push(existingField);
}
}
}
else
{
///////////////////////////////////////////////////////////////////////////////////////////////
// if going to WIDE layout, then any field from a child table needs a wide-layout-index-path //
///////////////////////////////////////////////////////////////////////////////////////////////
for (let existingField of this.additionalFields)
{
if (existingField.tableStructure.isMain)
{
////////////////////////////////////////////
// fields from main table come over as-is //
////////////////////////////////////////////
newAdditionalFields.push(existingField);
}
else
{
/////////////////////////////////////////////////////////////////////////////////////////////
// fields from child tables get a wideLayoutIndexPath (and we're assuming just 1 for each) //
/////////////////////////////////////////////////////////////////////////////////////////////
const newField = BulkLoadField.clone(existingField);
newField.wideLayoutIndexPath = [0];
newAdditionalFields.push(newField);
anyChanges = true;
}
}
}
if (anyChanges)
{
this.additionalFields = newAdditionalFields;
}
this.layout = newLayout;
}
/***************************************************************************
**
***************************************************************************/
public getFieldsForColumnIndex(i: number): BulkLoadField[]
{
const rs: BulkLoadField[] = [];
for (let field of [...this.requiredFields, ...this.additionalFields])
{
if (field.valueType == "column" && field.columnIndex == i)
{
rs.push(field);
}
}
return (rs);
}
/***************************************************************************
**
***************************************************************************/
public handleChangeToHasHeaderRow(newValue: any, fileDescription: FileDescription)
{
const newRequiredFields: BulkLoadField[] = [];
let anyChangesToRequiredFields = false;
const newAdditionalFields: BulkLoadField[] = [];
let anyChangesToAdditionalFields = false;
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// if we're switching to have header-rows enabled, then make sure that no columns w/ duplicated headers are selected //
// strategy to do this: build new lists of both required & additional fields - and track if we had to change any //
// column indexes (set to null) - add a warning to them, and only replace the arrays if there were changes. //
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
if (newValue)
{
for (let field of this.requiredFields)
{
if (field.valueType == "column" && fileDescription.duplicateHeaderIndexes[field.columnIndex])
{
const newField = BulkLoadField.clone(field);
newField.columnIndex = null;
newField.warning = "This field was assigned to a column with a duplicated header"
newRequiredFields.push(newField);
anyChangesToRequiredFields = true;
}
else
{
newRequiredFields.push(field);
}
}
for (let field of this.additionalFields)
{
if (field.valueType == "column" && fileDescription.duplicateHeaderIndexes[field.columnIndex])
{
const newField = BulkLoadField.clone(field);
newField.columnIndex = null;
newField.warning = "This field was assigned to a column with a duplicated header"
newAdditionalFields.push(newField);
anyChangesToAdditionalFields = true;
}
else
{
newAdditionalFields.push(field);
}
}
}
if (anyChangesToRequiredFields)
{
this.requiredFields = newRequiredFields;
}
if (anyChangesToAdditionalFields)
{
this.additionalFields = newAdditionalFields;
}
}
}
/***************************************************************************
** meta-data about the file that the user uploaded
***************************************************************************/
export class FileDescription
{
headerValues: string[];
headerLetters: string[];
bodyValuesPreview: string[][];
duplicateHeaderIndexes: boolean[];
// todo - just get this from the profile always - it's not part of the file per-se
hasHeaderRow: boolean = true;
/***************************************************************************
**
***************************************************************************/
constructor(headerValues: string[], headerLetters: string[], bodyValuesPreview: string[][])
{
this.headerValues = headerValues;
this.headerLetters = headerLetters;
this.bodyValuesPreview = bodyValuesPreview;
this.duplicateHeaderIndexes = [];
const usedLabels: { [label: string]: boolean } = {};
for (let i = 0; i < headerValues.length; i++)
{
const label = headerValues[i];
if (usedLabels[label])
{
this.duplicateHeaderIndexes[i] = true;
}
usedLabels[label] = true;
}
}
/***************************************************************************
**
***************************************************************************/
public setHasHeaderRow(hasHeaderRow: boolean)
{
this.hasHeaderRow = hasHeaderRow;
}
/***************************************************************************
**
***************************************************************************/
public getColumnNames(): string[]
{
if (this.hasHeaderRow)
{
return this.headerValues;
}
else
{
return this.headerLetters.map(l => `Column ${l}`);
}
}
/***************************************************************************
**
***************************************************************************/
public getPreviewValues(columnIndex: number, fieldType?: QFieldType): string[]
{
if (columnIndex == undefined)
{
return [];
}
function getTypedValue(value: any): string
{
if (value == null)
{
return "";
}
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// this was useful at one point in time when we had an object coming back for xlsx files with many different data types //
// we'd see a .string attribute, which would have the value we'd want to show. not using it now, but keep in case //
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
if (value && value.string)
{
switch (fieldType)
{
case QFieldType.BOOLEAN:
{
return value.bool;
}
case QFieldType.STRING:
case QFieldType.TEXT:
case QFieldType.HTML:
case QFieldType.PASSWORD:
{
return value.string;
}
case QFieldType.INTEGER:
case QFieldType.LONG:
{
return value.integer;
}
case QFieldType.DECIMAL:
{
return value.decimal;
}
case QFieldType.DATE:
{
return value.date;
}
case QFieldType.TIME:
{
return value.time;
}
case QFieldType.DATE_TIME:
{
return value.dateTime;
}
case QFieldType.BLOB:
return ""; // !!
}
}
return (`${value}`);
}
const valueArray: string[] = [];
if (!this.hasHeaderRow)
{
const typedValue = getTypedValue(this.headerValues[columnIndex]);
valueArray.push(typedValue == null ? "" : `${typedValue}`);
}
for (let value of this.bodyValuesPreview[columnIndex])
{
const typedValue = getTypedValue(value);
valueArray.push(typedValue == null ? "" : `${typedValue}`);
}
return (valueArray);
}
}
/***************************************************************************
** this (BulkLoadProfile & ...Field) is the model of what we save, and is
** also what we submit to the backend during the process.
***************************************************************************/
export class BulkLoadProfile
{
version: string;
fieldList: BulkLoadProfileField[] = [];
hasHeaderRow: boolean;
layout: string;
}
type BulkLoadProfileField =
{
fieldName: string,
columnIndex?: number,
headerName?: string,
defaultValue?: any,
doValueMapping?: boolean,
valueMappings?: { [fileValue: string]: any }
};
/***************************************************************************
** In the bulk load forms, we have some forward-ref callback functions, and
** they like to capture/retain a reference when those functions get defined,
** so we had some trouble updating objects in those functions.
**
** We "solved" this by creating instances of this class, which get captured,
** so then we can replace the wrapped object, and have a better time...
***************************************************************************/
export class Wrapper<T>
{
t: T;
/***************************************************************************
**
***************************************************************************/
constructor(t: T)
{
this.t = t;
}
/***************************************************************************
**
***************************************************************************/
public get(): T
{
return this.t;
}
/***************************************************************************
**
***************************************************************************/
public set(t: T)
{
this.t = t;
}
}

View File

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

View File

@ -20,7 +20,6 @@
*/ */
import {QException} from "@kingsrook/qqq-frontend-core/lib/exceptions/QException"; import {QException} from "@kingsrook/qqq-frontend-core/lib/exceptions/QException";
import {AdornmentType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/AdornmentType";
import {QComponentType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QComponentType"; import {QComponentType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QComponentType";
import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData"; import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
import {QFrontendComponent} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFrontendComponent"; import {QFrontendComponent} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFrontendComponent";
@ -52,16 +51,19 @@ import {Form, Formik} from "formik";
import parse from "html-react-parser"; import parse from "html-react-parser";
import QContext from "QContext"; import QContext from "QContext";
import colors from "qqq/assets/theme/base/colors"; import colors from "qqq/assets/theme/base/colors";
import {QCancelButton, QSubmitButton} from "qqq/components/buttons/DefaultButtons"; import {QAlternateButton, QCancelButton, QSubmitButton} from "qqq/components/buttons/DefaultButtons";
import QDynamicForm from "qqq/components/forms/DynamicForm"; import QDynamicForm from "qqq/components/forms/DynamicForm";
import DynamicFormUtils from "qqq/components/forms/DynamicFormUtils"; import DynamicFormUtils from "qqq/components/forms/DynamicFormUtils";
import MDButton from "qqq/components/legacy/MDButton";
import MDProgress from "qqq/components/legacy/MDProgress"; import MDProgress from "qqq/components/legacy/MDProgress";
import MDTypography from "qqq/components/legacy/MDTypography"; import MDTypography from "qqq/components/legacy/MDTypography";
import HelpContent, {hasHelpContent} from "qqq/components/misc/HelpContent"; import HelpContent, {hasHelpContent} from "qqq/components/misc/HelpContent";
import QRecordSidebar from "qqq/components/misc/RecordSidebar"; import QRecordSidebar from "qqq/components/misc/RecordSidebar";
import BulkLoadFileMappingForm from "qqq/components/processes/BulkLoadFileMappingForm";
import BulkLoadProfileForm from "qqq/components/processes/BulkLoadProfileForm";
import BulkLoadValueMappingForm from "qqq/components/processes/BulkLoadValueMappingForm";
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 ProcessViewForm from "qqq/components/processes/ProcessViewForm";
import ValidationReview from "qqq/components/processes/ValidationReview"; import ValidationReview from "qqq/components/processes/ValidationReview";
import {BlockData} from "qqq/components/widgets/blocks/BlockModels"; import {BlockData} from "qqq/components/widgets/blocks/BlockModels";
import CompositeWidget, {CompositeData} from "qqq/components/widgets/CompositeWidget"; import CompositeWidget, {CompositeData} from "qqq/components/widgets/CompositeWidget";
@ -73,7 +75,7 @@ import {TABLE_VARIANT_LOCAL_STORAGE_KEY_ROOT} from "qqq/pages/records/query/Reco
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";
import ValueUtils from "qqq/utils/qqq/ValueUtils"; import ValueUtils from "qqq/utils/qqq/ValueUtils";
import React, {useContext, useEffect, useState} from "react"; import React, {useContext, useEffect, useRef, useState} from "react";
import {useLocation, useNavigate, useParams} from "react-router-dom"; import {useLocation, useNavigate, useParams} from "react-router-dom";
import * as Yup from "yup"; import * as Yup from "yup";
@ -96,6 +98,8 @@ 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;
const qController = Client.getInstance();
//////////////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////////////
// define some functions that we can make reference to, which we'll overwrite // // define some functions that we can make reference to, which we'll overwrite //
// with functions from formik, once we're inside formik. // // with functions from formik, once we're inside formik. //
@ -110,6 +114,10 @@ let formikSetTouched = ({}: any, touched: boolean): void =>
const cachedPossibleValueLabels: { [fieldName: string]: { [id: string | number]: string } } = {}; const cachedPossibleValueLabels: { [fieldName: string]: { [id: string | number]: string } } = {};
export interface SubFormPreSubmitCallbackResultType {maySubmit: boolean; values: {[name: string]: any}}
type SubFormPreSubmitCallback = () => SubFormPreSubmitCallbackResultType;
type SubFormPreSubmitCallbackWithName = {name: string, callback: SubFormPreSubmitCallback}
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
{ {
const processNameParam = useParams().processName; const processNameParam = useParams().processName;
@ -130,9 +138,11 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
const [qJobRunningDate, setQJobRunningDate] = useState(null as Date); const [qJobRunningDate, setQJobRunningDate] = useState(null as Date);
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 [activeStepLabel, setActiveStepLabel] = useState(null as string);
const [newStep, setNewStep] = useState(null); const [newStep, setNewStep] = useState(null);
const [stepInstanceCounter, setStepInstanceCounter] = useState(0); const [stepInstanceCounter, setStepInstanceCounter] = useState(0);
const [steps, setSteps] = useState([] as QFrontendStepMetaData[]); const [steps, setSteps] = useState([] as QFrontendStepMetaData[]);
const [backStepName, setBackStepName] = useState(null as string);
const [needInitialLoad, setNeedInitialLoad] = useState(true); const [needInitialLoad, setNeedInitialLoad] = useState(true);
const [lastForcedReInit, setLastForcedReInit] = useState(null as number); const [lastForcedReInit, setLastForcedReInit] = useState(null as number);
const [processMetaData, setProcessMetaData] = useState(null); const [processMetaData, setProcessMetaData] = useState(null);
@ -152,6 +162,7 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
const [renderedWidgets, setRenderedWidgets] = useState({} as { [step: string]: { [widgetName: string]: any } }); const [renderedWidgets, setRenderedWidgets] = useState({} as { [step: string]: { [widgetName: string]: any } });
const [controlCallbacks, setControlCallbacks] = useState({} as {[name: string]: () => void}); const [controlCallbacks, setControlCallbacks] = useState({} as {[name: string]: () => void});
const [subFormPreSubmitCallbacks, setSubFormPreSubmitCallbacks] = useState([] as SubFormPreSubmitCallbackWithName[]);
const {pageHeader, recordAnalytics, setPageHeader, helpHelpActive} = useContext(QContext); const {pageHeader, recordAnalytics, setPageHeader, helpHelpActive} = useContext(QContext);
@ -208,6 +219,7 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
// record list state // // record list state //
/////////////////////// ///////////////////////
const [needRecords, setNeedRecords] = useState(false); const [needRecords, setNeedRecords] = useState(false);
const [loadingRecords, setLoadingRecords] = useState(false);
const [recordConfig, setRecordConfig] = useState({} as any); const [recordConfig, setRecordConfig] = useState({} as any);
const [pageNumber, setPageNumber] = useState(0); const [pageNumber, setPageNumber] = useState(0);
const [rowsPerPage, setRowsPerPage] = useState(10); const [rowsPerPage, setRowsPerPage] = useState(10);
@ -222,6 +234,11 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const bulkLoadFileMappingFormRef = useRef();
const bulkLoadValueMappingFormRef = useRef();
const bulkLoadProfileFormRef = useRef();
const [bulkLoadValueMappingFormFields, setBulkLoadValueMappingFormFields] = useState([] as any[])
const doesStepHaveComponent = (step: QFrontendStepMetaData, type: QComponentType): boolean => const doesStepHaveComponent = (step: QFrontendStepMetaData, type: QComponentType): boolean =>
{ {
if (step.components) if (step.components)
@ -677,6 +694,42 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
}); });
} }
////////////////////////////////////////////////////////////////////////////////
// if we have a bulk-load file mapping form, register its pre-submit callback //
////////////////////////////////////////////////////////////////////////////////
if (doesStepHaveComponent(activeStep, QComponentType.BULK_LOAD_FILE_MAPPING_FORM))
{
if(bulkLoadFileMappingFormRef?.current)
{
// @ts-ignore ...
addSubFormPreSubmitCallbacks("bulkLoadFileMappingForm", bulkLoadFileMappingFormRef?.current?.preSubmit)
}
}
/////////////////////////////////////////////////////////////////////////////////
// if we have a bulk-load value mapping form, register its pre-submit callback //
/////////////////////////////////////////////////////////////////////////////////
if (doesStepHaveComponent(activeStep, QComponentType.BULK_LOAD_VALUE_MAPPING_FORM))
{
if(bulkLoadValueMappingFormRef?.current)
{
// @ts-ignore ...
addSubFormPreSubmitCallbacks("bulkLoadValueMappingForm", bulkLoadValueMappingFormRef?.current?.preSubmit)
}
}
///////////////////////////////////////////////////////////////////////////
// if we have a bulk-load profile form, register its pre-submit callback //
///////////////////////////////////////////////////////////////////////////
if (doesStepHaveComponent(activeStep, QComponentType.BULK_LOAD_PROFILE_FORM))
{
if(bulkLoadProfileFormRef?.current)
{
// @ts-ignore ...
addSubFormPreSubmitCallbacks("bulkLoadProfileFormRef", bulkLoadProfileFormRef?.current?.preSubmit)
}
}
///////////////////////////////////// /////////////////////////////////////
// screen(step)-level help content // // screen(step)-level help content //
///////////////////////////////////// /////////////////////////////////////
@ -695,7 +748,7 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
!isWidget && !isFormatScanner && !isWidget && !isFormatScanner &&
<MDTypography variant={isWidget ? "h6" : "h5"} component="div" fontWeight="bold"> <MDTypography variant={isWidget ? "h6" : "h5"} component="div" fontWeight="bold">
{(isModal) ? `${overrideLabel ?? process.label}: ` : ""} {(isModal) ? `${overrideLabel ?? process.label}: ` : ""}
{step?.label} {activeStepLabel}
</MDTypography> </MDTypography>
} }
@ -849,29 +902,7 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
} }
{ {
component.type === QComponentType.VIEW_FORM && step.viewFields && ( component.type === QComponentType.VIEW_FORM && step.viewFields && (
<div> <ProcessViewForm fields={step.viewFields} values={processValues} columns={1} />
{step.viewFields.map((field: QFieldMetaData) => (
field.hasAdornment(AdornmentType.ERROR) ? (
processValues[field.name] && (
<Box key={field.name} display="flex" py={1} pr={2}>
<MDTypography variant="button" fontWeight="regular">
{ValueUtils.getValueForDisplay(field, processValues[field.name], undefined, "view")}
</MDTypography>
</Box>
)
) : (
<Box key={field.name} display="flex" py={1} pr={2}>
<MDTypography variant="button" fontWeight="bold">
{field.label}
: &nbsp;
</MDTypography>
<MDTypography variant="button" fontWeight="regular" color="text">
{ValueUtils.getValueForDisplay(field, processValues[field.name], undefined, "view")}
</MDTypography>
</Box>
)))
}
</div>
) )
} }
{ {
@ -902,6 +933,7 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
processValues={processValues} processValues={processValues}
step={step} step={step}
previewRecords={records} previewRecords={records}
loadingRecords={loadingRecords}
formValues={formData.values} formValues={formData.values}
doFullValidationRadioChangedHandler={(event: any) => doFullValidationRadioChangedHandler={(event: any) =>
{ {
@ -995,6 +1027,41 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
</> </>
) )
} }
{
component.type === QComponentType.BULK_LOAD_FILE_MAPPING_FORM && (
<BulkLoadFileMappingForm
processValues={processValues}
tableMetaData={tableMetaData}
processMetaData={processMetaData}
metaData={qInstance}
ref={bulkLoadFileMappingFormRef}
setActiveStepLabel={setActiveStepLabel}
frontendStep={activeStep}
/>
)
}
{
component.type === QComponentType.BULK_LOAD_VALUE_MAPPING_FORM && (
<BulkLoadValueMappingForm
processValues={processValues}
tableMetaData={tableMetaData}
metaData={qInstance}
ref={bulkLoadValueMappingFormRef}
setActiveStepLabel={setActiveStepLabel}
formFields={bulkLoadValueMappingFormFields}
/>
)
}
{
component.type === QComponentType.BULK_LOAD_PROFILE_FORM && (
<BulkLoadProfileForm
processValues={processValues}
tableMetaData={tableMetaData}
metaData={qInstance}
ref={bulkLoadProfileFormRef}
/>
)
}
</div> </div>
); );
})) }))
@ -1101,6 +1168,7 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
{ {
const activeStep = steps[newIndex]; const activeStep = steps[newIndex];
setActiveStep(activeStep); setActiveStep(activeStep);
setActiveStepLabel(activeStep.label);
setFormId(activeStep.name); setFormId(activeStep.name);
let dynamicFormFields: any = {}; let dynamicFormFields: any = {};
@ -1227,6 +1295,43 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
} }
} }
/////////////////////////////////////////////////////////////////
// Help make this component's fields work with our formik form //
/////////////////////////////////////////////////////////////////
if(activeStep && doesStepHaveComponent(activeStep, QComponentType.BULK_LOAD_VALUE_MAPPING_FORM))
{
const fileValues = processValues.fileValues ?? [];
const valueMapping = processValues.valueMapping ?? {};
const mappedValueLabels = processValues.mappedValueLabels ?? {};
const fieldFullName = processValues.valueMappingFullFieldName;
const fieldTableName = processValues.valueMappingFieldTableName;
const field = new QFieldMetaData(processValues.valueMappingField);
const qFieldMetaData = new QFieldMetaData(field);
const fieldsForComponent: any[] = [];
for (let i = 0; i < fileValues.length; i++)
{
const dynamicField = DynamicFormUtils.getDynamicField(qFieldMetaData);
const wrappedField: any = {};
wrappedField[field.name] = dynamicField;
DynamicFormUtils.addPossibleValueProps(wrappedField, [field], fieldTableName, null, null);
const initialValue = valueMapping[fileValues[i]];
if(dynamicField.possibleValueProps)
{
dynamicField.possibleValueProps.initialDisplayValue = mappedValueLabels[initialValue]
}
addField(`${fieldFullName}.value.${i}`, dynamicField, initialValue, null)
fieldsForComponent.push(dynamicField);
}
setBulkLoadValueMappingFormFields(fieldsForComponent)
}
if (Object.keys(dynamicFormFields).length > 0) if (Object.keys(dynamicFormFields).length > 0)
{ {
/////////////////////////////////////////// ///////////////////////////////////////////
@ -1310,7 +1415,7 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
setNeedRecords(false); setNeedRecords(false);
(async () => (async () =>
{ {
const response = await Client.getInstance().processRecords( const response = await qController.processRecords(
processName, processName,
processUUID, processUUID,
recordConfig.rowsPerPage * recordConfig.pageNo, recordConfig.rowsPerPage * recordConfig.pageNo,
@ -1319,6 +1424,7 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
const {records} = response; const {records} = response;
setRecords(records); setRecords(records);
setLoadingRecords(false);
if (!childRecordData || childRecordData.length == 0) if (!childRecordData || childRecordData.length == 0)
{ {
@ -1411,6 +1517,24 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
} }
/***************************************************************************
** manage adding pre-submit callbacks (so they get added just once)
***************************************************************************/
function addSubFormPreSubmitCallbacks(name: string, callback: SubFormPreSubmitCallback)
{
if(subFormPreSubmitCallbacks.findIndex(c => c.name == name) == -1)
{
const newCallbacks: SubFormPreSubmitCallbackWithName[] = []
for(let i = 0; i < subFormPreSubmitCallbacks.length; i++)
{
newCallbacks[i] = subFormPreSubmitCallbacks[i];
}
newCallbacks.push({name, callback})
setSubFormPreSubmitCallbacks(newCallbacks)
}
}
////////////////////////////////////////////////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////////////////////////////////////////////
// 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 //
////////////////////////////////////////////////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////////////////////////////////////////////
@ -1472,7 +1596,7 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
const fieldName = field.name; const fieldName = field.name;
if (field.possibleValueSourceName && newValues && newValues[fieldName]) if (field.possibleValueSourceName && newValues && newValues[fieldName])
{ {
const results: QPossibleValue[] = await Client.getInstance().possibleValues(null, processName, fieldName, null, [newValues[fieldName]]); const results: QPossibleValue[] = await qController.possibleValues(null, processName, fieldName, null, [newValues[fieldName]]);
if (results && results.length > 0) if (results && results.length > 0)
{ {
if (!cachedPossibleValueLabels[fieldName]) if (!cachedPossibleValueLabels[fieldName])
@ -1486,12 +1610,17 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
} }
} }
//////////////////////////////////////
// reset some state between screens //
//////////////////////////////////////
setJobUUID(null); setJobUUID(null);
setNewStep(nextStepName); setNewStep(nextStepName);
setStepInstanceCounter(1 + stepInstanceCounter); setStepInstanceCounter(1 + stepInstanceCounter);
setProcessValues(newValues); setProcessValues(newValues);
setRenderedWidgets({}); setRenderedWidgets({});
setSubFormPreSubmitCallbacks([]);
setQJobRunning(null); setQJobRunning(null);
setBackStepName(qJobComplete.backStep)
if (formikSetFieldValueFunction) if (formikSetFieldValueFunction)
{ {
@ -1569,7 +1698,7 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
{ {
try try
{ {
const processResponse = await Client.getInstance().processJobStatus( const processResponse = await qController.processJobStatus(
processName, processName,
processUUID, processUUID,
jobUUID, jobUUID,
@ -1670,7 +1799,7 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
try try
{ {
const qInstance = await Client.getInstance().loadMetaData(); const qInstance = await qController.loadMetaData();
ValueUtils.qInstance = qInstance; ValueUtils.qInstance = qInstance;
setQInstance(qInstance); setQInstance(qInstance);
} }
@ -1682,7 +1811,7 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
try try
{ {
const processMetaData = await Client.getInstance().loadProcessMetaData(processName); const processMetaData = await qController.loadProcessMetaData(processName);
setProcessMetaData(processMetaData); setProcessMetaData(processMetaData);
setSteps(processMetaData.frontendSteps); setSteps(processMetaData.frontendSteps);
@ -1693,7 +1822,7 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
{ {
try try
{ {
const tableMetaData = await Client.getInstance().loadTableMetaData(processMetaData.tableName); const tableMetaData = await qController.loadTableMetaData(processMetaData.tableName);
setTableMetaData(tableMetaData); setTableMetaData(tableMetaData);
} }
catch (e) catch (e)
@ -1724,7 +1853,7 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
try try
{ {
const processResponse = await Client.getInstance().processInit(processName, queryStringPairsForInit.join("&")); const processResponse = await qController.processInit(processName, queryStringPairsForInit.join("&"));
setProcessUUID(processResponse.processUUID); setProcessUUID(processResponse.processUUID);
setLastProcessResponse(processResponse); setLastProcessResponse(processResponse);
} }
@ -1741,7 +1870,27 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
////////////////////////////////////////////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////////////////////////////////////////
const handleBack = () => const handleBack = () =>
{ {
setNewStep(activeStepIndex - 1); //////////////////////////////////////////////////////////////////////////////////////////////////
// note, this is kept out of clearStatesBeforeHittingBackend, because in handleSubmit, the form //
// might become invalidated, in which case we'd want a form error, i guess. //
//////////////////////////////////////////////////////////////////////////////////////////////////
setFormError(null);
clearStatesBeforeHittingBackend();
setTimeout(async () =>
{
recordAnalytics({category: "processEvents", action: "processStep", label: activeStep.label});
const processResponse = await qController.processStep(
processName,
processUUID,
backStepName,
"isStepBack=true",
qController.defaultMultipartFormDataHeaders(),
);
setLastProcessResponse(processResponse);
});
}; };
//////////////////////////////////////////// ////////////////////////////////////////////
@ -1749,10 +1898,6 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
//////////////////////////////////////////// ////////////////////////////////////////////
const doSubmit = async (formData: FormData) => const doSubmit = async (formData: FormData) =>
{ {
const formDataHeaders = {
"content-type": "multipart/form-data; boundary=--------------------------320289315924586491558366",
};
setTimeout(async () => setTimeout(async () =>
{ {
recordAnalytics({category: "processEvents", action: "processStep", label: activeStep.label}); recordAnalytics({category: "processEvents", action: "processStep", label: activeStep.label});
@ -1762,7 +1907,7 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
processUUID, processUUID,
activeStep.name, activeStep.name,
formData, formData,
formDataHeaders qController.defaultMultipartFormDataHeaders()
); );
setLastProcessResponse(processResponse); setLastProcessResponse(processResponse);
}); });
@ -1776,6 +1921,27 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
{ {
setFormError(null); setFormError(null);
///////////////////////////////////////////////////////////////
// run any sub-form pre-submit callbacks that are registered //
///////////////////////////////////////////////////////////////
for(let i = 0; i < subFormPreSubmitCallbacks.length; i++)
{
const {maySubmit, values: moreValues} = subFormPreSubmitCallbacks[i].callback();
if(!maySubmit)
{
console.log(`May not submit form, per callback: ${subFormPreSubmitCallbacks[i].name}`);
return;
}
if(moreValues)
{
for (let key in moreValues)
{
values[key] = moreValues[key]
}
}
}
const formData = new FormData(); const formData = new FormData();
Object.keys(values).forEach((key) => Object.keys(values).forEach((key) =>
{ {
@ -1811,6 +1977,8 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
formData.append("bulkEditEnabledFields", bulkEditEnabledFields.join(",")); formData.append("bulkEditEnabledFields", bulkEditEnabledFields.join(","));
} }
clearStatesBeforeHittingBackend();
///////////////////////////////////////////////////////////// /////////////////////////////////////////////////////////////
// convert to regular objects so that they can be jsonized // // convert to regular objects so that they can be jsonized //
///////////////////////////////////////////////////////////// /////////////////////////////////////////////////////////////
@ -1819,13 +1987,32 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
formData.append("frontendRecords", JSON.stringify(childRecordData.queryOutput.records)); formData.append("frontendRecords", JSON.stringify(childRecordData.queryOutput.records));
} }
doSubmit(formData);
};
/*******************************************************************************
** common code shared by 'back' and 'submit' (next) - to clear some state values.
*******************************************************************************/
const clearStatesBeforeHittingBackend = () =>
{
setProcessValues({}); setProcessValues({});
setRecords([]); setRecords([]);
setOverrideOnLastStep(null); setOverrideOnLastStep(null);
setLastProcessResponse(new QJobRunning({message: "Working..."})); setLastProcessResponse(new QJobRunning({message: "Working..."}));
doSubmit(formData); ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
}; // clear out the active step now, to avoid a flash of the old one after the job completes, but before the new one is all set //
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
setActiveStep(null);
////////////////////////////////////////////////////////////////////////////////////////////////////////////
// setting this flag here (initially, for use in ValidationReview) will ensure that the initial render of //
// such a component will show as "loading", rather than a flash of "no records" before going into loading //
////////////////////////////////////////////////////////////////////////////////////////////////////////////
setLoadingRecords(true);
}
/******************************************************************************* /*******************************************************************************
@ -1838,7 +2025,7 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
////////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////
if (!isClose) if (!isClose)
{ {
Client.getInstance().processCancel(processName, processUUID); qController.processCancel(processName, processUUID);
} }
if (isModal && closeModalHandler) if (isModal && closeModalHandler)
@ -1976,12 +2163,7 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
{/******************************** {/********************************
** back &| next/submit buttons ** ** back &| next/submit buttons **
********************************/} ********************************/}
<Box mt={3} 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="flex-end" position={isWidget ? "absolute" : "initial"} bottom={isWidget ? "3rem" : "initial"} right={isWidget ? "1.5rem" : "initial"}>
{true || activeStepIndex === 0 ? (
<Box />
) : (
<MDButton variant="gradient" color="light" onClick={handleBack}>back</MDButton>
)}
{processError || qJobRunning || !activeStep || activeStep?.format?.toLowerCase() == "scanner" ? ( {processError || qJobRunning || !activeStep || activeStep?.format?.toLowerCase() == "scanner" ? (
<Box /> <Box />
) : ( ) : (
@ -2002,6 +2184,13 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
<QCancelButton onClickHandler={() => handleCancelClicked(false)} disabled={isSubmitting} /> <QCancelButton onClickHandler={() => handleCancelClicked(false)} disabled={isSubmitting} />
) )
} }
{backStepName ? (
<QAlternateButton label="Back" onClick={handleBack} disabled={isSubmitting} iconName="arrow_back" />
) : (
<Box />
)}
<QSubmitButton label={nextButtonLabel} iconName={nextButtonIcon} disabled={isSubmitting} /> <QSubmitButton label={nextButtonLabel} iconName={nextButtonIcon} disabled={isSubmitting} />
</Grid> </Grid>
</Box> </Box>
@ -2033,7 +2222,7 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
if (isModal) if (isModal)
{ {
return ( return (
<Box sx={{position: "absolute", overflowY: "auto", maxHeight: "100%", width: "100%"}}> <Box sx={{position: "absolute", overflowY: "auto", maxHeight: "100%", width: "100%"}} id="modalProcessScrollContainer">
{body} {body}
</Box> </Box>
); );

View File

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

View File

@ -191,7 +191,7 @@ function RecordDeveloperView({table}: Props): JSX.Element
<Card sx={{mb: 3}}> <Card sx={{mb: 3}}>
<Typography variant="h6" p={2} pl={3} pb={3}>{field?.label}</Typography> <Typography variant="h6" p={2} pl={3} pb={3}>{field?.label}</Typography>
<Box display="flex" alignItems="center" justifyContent="space-between" gap={2}> <Box display="flex" alignItems="center" justifyContent="space-between" gap={2} mx={3} mb={3} mt={0}>
{scriptId ? {scriptId ?
<ScriptViewer <ScriptViewer
scriptId={scriptId} scriptId={scriptId}

View File

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

View File

@ -804,3 +804,17 @@ input[type="search"]::-webkit-search-results-decoration
{ {
color: #0062FF !important; color: #0062FF !important;
} }
@media (min-width: 1400px)
{
.recordSidebar
{
max-width: 400px !important;
}
.recordWithSidebar
{
max-width: 100% !important;
flex-grow: 1 !important;
}
}

View File

@ -26,62 +26,14 @@ import {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstan
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData"; import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord"; import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord";
import Tooltip from "@mui/material/Tooltip/Tooltip"; import Tooltip from "@mui/material/Tooltip/Tooltip";
import {GridColDef, GridFilterItem, GridRowsProp, MuiEvent} from "@mui/x-data-grid-pro"; import {GridColDef, GridRowsProp, MuiEvent} from "@mui/x-data-grid-pro";
import {GridFilterOperator} from "@mui/x-data-grid/models/gridFilterOperator";
import {GridColumnHeaderParams} from "@mui/x-data-grid/models/params/gridColumnHeaderParams"; import {GridColumnHeaderParams} from "@mui/x-data-grid/models/params/gridColumnHeaderParams";
import HelpContent, {hasHelpContent} from "qqq/components/misc/HelpContent"; import HelpContent, {hasHelpContent} from "qqq/components/misc/HelpContent";
import {buildQGridPvsOperators, QGridBlobOperators, QGridBooleanOperators, QGridNumericOperators, QGridStringOperators} from "qqq/pages/records/query/GridFilterOperators";
import ValueUtils from "qqq/utils/qqq/ValueUtils"; import ValueUtils from "qqq/utils/qqq/ValueUtils";
import React from "react"; import React from "react";
import {Link, NavigateFunction} from "react-router-dom"; import {Link, NavigateFunction} from "react-router-dom";
const emptyApplyFilterFn = (filterItem: GridFilterItem, column: GridColDef): null => null;
function NullInputComponent()
{
return (<React.Fragment />);
}
const makeGridFilterOperator = (value: string, label: string, takesValues: boolean = false): GridFilterOperator =>
{
const rs: GridFilterOperator = {value: value, label: label, getApplyFilterFn: emptyApplyFilterFn};
if (takesValues)
{
rs.InputComponent = NullInputComponent;
}
return (rs);
};
////////////////////////////////////////////////////////////////////////////////////////
// at this point, these may only be used to drive the toolitp on the FILTER button... //
////////////////////////////////////////////////////////////////////////////////////////
const QGridDateOperators = [
makeGridFilterOperator("equals", "equals", true),
makeGridFilterOperator("isNot", "does not equal", true),
makeGridFilterOperator("after", "is after", true),
makeGridFilterOperator("onOrAfter", "is on or after", true),
makeGridFilterOperator("before", "is before", true),
makeGridFilterOperator("onOrBefore", "is on or before", true),
makeGridFilterOperator("isEmpty", "is empty"),
makeGridFilterOperator("isNotEmpty", "is not empty"),
makeGridFilterOperator("between", "is between", true),
makeGridFilterOperator("notBetween", "is not between", true),
];
const QGridDateTimeOperators = [
makeGridFilterOperator("equals", "equals", true),
makeGridFilterOperator("isNot", "does not equal", true),
makeGridFilterOperator("after", "is after", true),
makeGridFilterOperator("onOrAfter", "is at or after", true),
makeGridFilterOperator("before", "is before", true),
makeGridFilterOperator("onOrBefore", "is at or before", true),
makeGridFilterOperator("isEmpty", "is empty"),
makeGridFilterOperator("isNotEmpty", "is not empty"),
makeGridFilterOperator("between", "is between", true),
makeGridFilterOperator("notBetween", "is not between", true),
];
export default class DataGridUtils export default class DataGridUtils
{ {
/******************************************************************************* /*******************************************************************************
@ -299,11 +251,10 @@ export default class DataGridUtils
public static makeColumnFromField = (field: QFieldMetaData, tableMetaData: QTableMetaData, namePrefix?: string, labelPrefix?: string): GridColDef => public static makeColumnFromField = (field: QFieldMetaData, tableMetaData: QTableMetaData, namePrefix?: string, labelPrefix?: string): GridColDef =>
{ {
let columnType = "string"; let columnType = "string";
let filterOperators: GridFilterOperator<any>[] = QGridStringOperators;
if (field.possibleValueSourceName) if (field.possibleValueSourceName)
{ {
filterOperators = buildQGridPvsOperators(tableMetaData.name, field); // noop here
} }
else else
{ {
@ -312,22 +263,17 @@ export default class DataGridUtils
case QFieldType.DECIMAL: case QFieldType.DECIMAL:
case QFieldType.INTEGER: case QFieldType.INTEGER:
columnType = "number"; columnType = "number";
filterOperators = QGridNumericOperators;
break; break;
case QFieldType.DATE: case QFieldType.DATE:
columnType = "date"; columnType = "date";
filterOperators = QGridDateOperators;
break; break;
case QFieldType.DATE_TIME: case QFieldType.DATE_TIME:
columnType = "dateTime"; columnType = "dateTime";
filterOperators = QGridDateTimeOperators;
break; break;
case QFieldType.BOOLEAN: case QFieldType.BOOLEAN:
columnType = "string"; // using boolean gives an odd 'no' for nulls. columnType = "string"; // using boolean gives an odd 'no' for nulls.
filterOperators = QGridBooleanOperators;
break; break;
case QFieldType.BLOB: case QFieldType.BLOB:
filterOperators = QGridBlobOperators;
break; break;
default: default:
// noop - leave as string // noop - leave as string
@ -343,7 +289,6 @@ export default class DataGridUtils
headerName: headerName, headerName: headerName,
width: DataGridUtils.getColumnWidthForField(field, tableMetaData), width: DataGridUtils.getColumnWidthForField(field, tableMetaData),
renderCell: null as any, renderCell: null as any,
filterOperators: filterOperators,
}; };
column.renderCell = (cellValues: any) => ( column.renderCell = (cellValues: any) => (

View File

@ -75,7 +75,8 @@ export default class HtmlUtils
{ {
if (url.startsWith("http")) if (url.startsWith("http"))
{ {
url += encodeURIComponent(`?response-content-disposition=attachment; ${filename}`); const separator = url.includes("?") ? "&" : "?";
url += encodeURIComponent(`${separator}response-content-disposition=attachment; ${filename}`);
} }
const link = document.createElement("a"); const link = document.createElement("a");

View File

@ -0,0 +1,318 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {BulkLoadField, BulkLoadMapping, BulkLoadTableStructure, FileDescription} from "qqq/models/processes/BulkLoadModels";
type FieldMapping = { [name: string]: BulkLoadField }
/***************************************************************************
** Utillity methods for working with saved bulk load profiles.
***************************************************************************/
export class SavedBulkLoadProfileUtils
{
/***************************************************************************
**
***************************************************************************/
private static diffFieldContents = (fileDescription: FileDescription, baseFieldsMap: FieldMapping, compareFieldsMap: FieldMapping, orderedFieldArray: BulkLoadField[]): string[] =>
{
const rs: string[] = [];
for (let bulkLoadField of orderedFieldArray)
{
const fieldName = bulkLoadField.getQualifiedName()
const compareField = compareFieldsMap[fieldName];
const baseField = baseFieldsMap[fieldName];
if(!compareField)
{
continue;
}
if (baseField)
{
if (baseField.valueType != compareField.valueType)
{
/////////////////////////////////////////////////////////////////
// if we changed from a default value to a column, report that //
/////////////////////////////////////////////////////////////////
if (compareField.valueType == "column")
{
const column = fileDescription.getColumnNames()[compareField.columnIndex];
rs.push(`Changed ${compareField.getQualifiedLabel()} from using a default value (${baseField.defaultValue}) to using a file column ${column ? `(${column})` : ""}`);
}
else if (compareField.valueType == "defaultValue")
{
const column = fileDescription.getColumnNames()[baseField.columnIndex];
const value = compareField.defaultValue;
rs.push(`Changed ${compareField.getQualifiedLabel()} from using a file column (${column}) to using a default value ${value === undefined ? "" : `(${value})`}`);
}
}
else if (baseField.valueType == compareField.valueType && baseField.valueType == "defaultValue")
{
//////////////////////////////////////////////////
// if we changed the default value, report that //
//////////////////////////////////////////////////
if (baseField.defaultValue != compareField.defaultValue)
{
const value = compareField.defaultValue;
rs.push(`Changed ${compareField.getQualifiedLabel()} default value from (${baseField.defaultValue}) to ${value === undefined ? "" : `(${value})`}`);
}
}
else if (baseField.valueType == compareField.valueType && baseField.valueType == "column")
{
///////////////////////////////////////////
// if we changed the column, report that //
///////////////////////////////////////////
let isDiff = false;
if (fileDescription.hasHeaderRow)
{
if (baseField.headerName != compareField.headerName)
{
isDiff = true;
}
}
else
{
if (baseField.columnIndex != compareField.columnIndex)
{
isDiff = true;
}
}
if(isDiff)
{
const baseColumn = fileDescription.getColumnNames()[baseField.columnIndex];
const compareColumn = fileDescription.getColumnNames()[compareField.columnIndex];
rs.push(`Changed ${compareField.getQualifiedLabel()} file column from ${baseColumn ? `(${baseColumn})` : "--"} to ${compareColumn ? `(${compareColumn})` : "--"}`);
}
/////////////////////////////////////////////////////////////////////////////////////////////////////
// if the do-value-mapping field changed, report that (note, only if was and still is column-type) //
/////////////////////////////////////////////////////////////////////////////////////////////////////
if ((baseField.doValueMapping == true) != (compareField.doValueMapping == true))
{
rs.push(`Changed ${compareField.getQualifiedLabel()} to ${compareField.doValueMapping ? "" : "not"} map values`);
}
}
}
}
return (rs);
};
/***************************************************************************
**
***************************************************************************/
private static diffFieldSets = (baseFieldsMap: FieldMapping, compareFieldsMap: FieldMapping, messagePrefix: string, orderedFieldArray: BulkLoadField[]): string[] =>
{
const fieldLabels: string[] = [];
for (let bulkLoadField of orderedFieldArray)
{
const fieldName = bulkLoadField.getQualifiedName()
const compareField = compareFieldsMap[fieldName];
if(!compareField)
{
continue;
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// else - we're not checking for changes to individual fields - rather - we're just checking if fields were added or removed. //
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
if (!baseFieldsMap[fieldName])
{
fieldLabels.push(compareField.getQualifiedLabel());
}
}
if (fieldLabels.length)
{
const s = fieldLabels.length == 1 ? "" : "s";
return ([`${messagePrefix} mapping${s} for ${fieldLabels.length} field${s}: ${fieldLabels.join(", ")}`]);
}
else
{
return ([]);
}
};
/***************************************************************************
**
***************************************************************************/
private static getOrderedActiveFields(mapping: BulkLoadMapping): BulkLoadField[]
{
return [...(mapping.requiredFields ?? []), ...(mapping.additionalFields ?? [])]
}
/***************************************************************************
**
***************************************************************************/
private static extractUsedFieldMapFromMapping(mapping: BulkLoadMapping): FieldMapping
{
let rs: { [name: string]: BulkLoadField } = {};
for (let bulkLoadField of this.getOrderedActiveFields(mapping))
{
rs[bulkLoadField.getQualifiedNameWithWideSuffix()] = bulkLoadField;
}
return (rs);
}
/***************************************************************************
**
***************************************************************************/
private static joinUpToN(values: string[], n: number)
{
if(values.length <= n)
{
return (values.join(", "));
}
const others = values.length - n;
return (values.slice(0, n-1).join(", ") + ` and ${others} other${others == 1 ? "" : "s"}`);
}
/***************************************************************************
**
***************************************************************************/
private static diffFieldValueMappings(bulkLoadField: BulkLoadField, baseMapping: { [p: string]: any }, activeMapping: { [p: string]: any }): string
{
const addedMappings: string[] = [];
const removedMappings: string[] = [];
const changedMappings: string[] = [];
/////////////////////////////
// look for added mappings //
/////////////////////////////
for (let value of Object.keys(activeMapping))
{
if(!baseMapping[value])
{
addedMappings.push(value);
}
}
///////////////////////////////
// look for removed mappings //
///////////////////////////////
for (let value of Object.keys(baseMapping))
{
if(!activeMapping[value])
{
removedMappings.push(value);
}
}
///////////////////////////////
// look for changed mappings //
///////////////////////////////
for (let value of Object.keys(activeMapping))
{
if(baseMapping[value] && activeMapping[value] != baseMapping[value])
{
changedMappings.push(value);
}
}
if(addedMappings.length || removedMappings.length || changedMappings.length)
{
let rs = `Updated value mapping for ${bulkLoadField.getQualifiedLabel()}: `
const parts: string[] = [];
if(addedMappings.length)
{
parts.push(`Added value${addedMappings.length == 1 ? "" : "s"} for: ${this.joinUpToN(addedMappings, 5)}`);
}
if(removedMappings.length)
{
parts.push(`Removed value${removedMappings.length == 1 ? "" : "s"} for: ${this.joinUpToN(removedMappings, 5)}`);
}
if(changedMappings.length)
{
parts.push(`Changed value${changedMappings.length == 1 ? "" : "s"} for: ${this.joinUpToN(changedMappings, 5)}`);
}
return rs + parts.join("; ");
}
return null;
}
/***************************************************************************
**
***************************************************************************/
public static diffBulkLoadMappings = (tableStructure: BulkLoadTableStructure, fileDescription: FileDescription, baseMapping: BulkLoadMapping, activeMapping: BulkLoadMapping): string[] =>
{
const diffs: string[] = [];
const baseFieldsMap = this.extractUsedFieldMapFromMapping(baseMapping);
const activeFieldsMap = this.extractUsedFieldMapFromMapping(activeMapping);
const orderedBaseFields = this.getOrderedActiveFields(baseMapping);
const orderedActiveFields = this.getOrderedActiveFields(activeMapping);
////////////////////////
// header-level diffs //
////////////////////////
if ((baseMapping.hasHeaderRow == true) != (activeMapping.hasHeaderRow == true))
{
diffs.push(`Changed does the file have a header row? from ${baseMapping.hasHeaderRow ? "Yes" : "No"} to ${activeMapping.hasHeaderRow ? "Yes" : "No"}`);
}
if (baseMapping.layout != activeMapping.layout)
{
const format = (layout: string) => (layout ?? " ").substring(0, 1) + (layout ?? " ").substring(1).toLowerCase();
diffs.push(`Changed layout from ${format(baseMapping.layout)} to ${format(activeMapping.layout)}`);
}
///////////////////////
// field-level diffs //
///////////////////////
// todo - keep sorted like screen is by ... idk, loop over fields in mapping first
diffs.push(...this.diffFieldSets(baseFieldsMap, activeFieldsMap, "Added", orderedActiveFields));
diffs.push(...this.diffFieldSets(activeFieldsMap, baseFieldsMap, "Removed", orderedBaseFields));
diffs.push(...this.diffFieldContents(fileDescription, baseFieldsMap, activeFieldsMap, orderedActiveFields));
for (let bulkLoadField of orderedActiveFields)
{
try
{
const fieldName = bulkLoadField.getQualifiedName() // todo - does this (and the others calls to this) need suffix?
const valueMappingDiff = this.diffFieldValueMappings(bulkLoadField, baseMapping.valueMappings[fieldName] ?? {}, activeMapping.valueMappings[fieldName] ?? {});
if(valueMappingDiff)
{
diffs.push(valueMappingDiff);
}
}
catch(e)
{
console.log(`Error diffing profiles: ${e}`);
}
}
return diffs;
};
}

View File

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