mirror of
https://github.com/Kingsrook/qqq-frontend-material-dashboard.git
synced 2025-07-21 22:58:43 +00:00
Compare commits
65 Commits
snapshot-f
...
snapshot-i
Author | SHA1 | Date | |
---|---|---|---|
ce9ffaab4d | |||
6076c4ddfd | |||
71dc3f3f65 | |||
ce22db2f89 | |||
9816403bec | |||
aacb239164 | |||
219458ec63 | |||
59fdc72455 | |||
5c3ddb7dec | |||
d65c1fb5d8 | |||
19a63d6956 | |||
40f5b55307 | |||
7320b19fbb | |||
3f8a3e7e4d | |||
3ef2d64327 | |||
d793c23861 | |||
d0201d96e1 | |||
7b66ece466 | |||
02c163899a | |||
8fafe16a95 | |||
722c8d3bcf | |||
85acb612c9 | |||
74c634414a | |||
f8368b030c | |||
dda4ea4f4b | |||
0c3a6ac278 | |||
85a8bd2d0a | |||
4b64c46c57 | |||
6db003026b | |||
65b347b794 | |||
1626648dda | |||
f503c008ec | |||
8a16010977 | |||
ab530121ca | |||
9b5d9f1290 | |||
ee9cd5a5f6 | |||
45be12c728 | |||
169bd4ee7e | |||
85056b121b | |||
b90b5217ca | |||
911ba1da21 | |||
bfa9b1d182 | |||
cce73fcb0b | |||
f2b41532d4 | |||
3fc4e37c12 | |||
451af347f7 | |||
1630fbacda | |||
2220e6f86e | |||
a66ffa753d | |||
b07d65aaca | |||
78fc2c50d0 | |||
501b8b34c9 | |||
8a6eef9907 | |||
efd1922ee3 | |||
b4f8fb2e18 | |||
204025c2a6 | |||
6dfc839c30 | |||
726906061d | |||
2514c463a6 | |||
b41a9a6fe6 | |||
cfca47054e | |||
b48ef70c5e | |||
81efb7e18d | |||
387f09f4ad | |||
4fd50936ea |
24408
package-lock.json
generated
24408
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -6,7 +6,7 @@
|
||||
"@auth0/auth0-react": "1.10.2",
|
||||
"@emotion/react": "11.7.1",
|
||||
"@emotion/styled": "11.6.0",
|
||||
"@kingsrook/qqq-frontend-core": "1.0.108",
|
||||
"@kingsrook/qqq-frontend-core": "1.0.114",
|
||||
"@mui/icons-material": "5.4.1",
|
||||
"@mui/material": "5.11.1",
|
||||
"@mui/styles": "5.11.1",
|
||||
@ -44,6 +44,7 @@
|
||||
"react-dnd": "16.0.1",
|
||||
"react-dnd-html5-backend": "16.0.1",
|
||||
"react-dom": "18.0.0",
|
||||
"react-dropzone": "14.3.5",
|
||||
"react-ga4": "2.1.0",
|
||||
"react-github-btn": "1.2.1",
|
||||
"react-google-drive-picker": "^1.2.0",
|
||||
|
2
pom.xml
2
pom.xml
@ -29,7 +29,7 @@
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<properties>
|
||||
<revision>0.23.0-SNAPSHOT</revision>
|
||||
<revision>0.24.0-SNAPSHOT</revision>
|
||||
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
|
||||
|
@ -145,7 +145,7 @@ export function QCancelButton({
|
||||
}: QCancelButtonProps): JSX.Element
|
||||
{
|
||||
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}>
|
||||
{label}
|
||||
</MDButton>
|
||||
@ -180,3 +180,24 @@ QSubmitButton.defaultProps = {
|
||||
label: "Submit",
|
||||
iconName: "check",
|
||||
};
|
||||
|
||||
interface QAlternateButtonProps
|
||||
{
|
||||
label: string,
|
||||
iconName?: string,
|
||||
disabled: boolean,
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
export function QAlternateButton({label, iconName, disabled, onClick}: QAlternateButtonProps): JSX.Element
|
||||
{
|
||||
return (
|
||||
<Box ml={standardML} width={standardWidth}>
|
||||
<MDButton type="button" variant="gradient" color="secondary" size="small" fullWidth startIcon={iconName && <Icon>{iconName}</Icon>} onClick={onClick} disabled={disabled}>
|
||||
{label}
|
||||
</MDButton>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
QAlternateButton.defaultProps = {};
|
||||
|
@ -80,11 +80,12 @@ interface Props
|
||||
label: string;
|
||||
value: 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();
|
||||
|
||||
@ -93,6 +94,10 @@ function BooleanFieldSwitch({name, label, value, isDisabled}: Props) : JSX.Eleme
|
||||
if(!isDisabled)
|
||||
{
|
||||
setFieldValue(name, newValue);
|
||||
if(onChangeCallback)
|
||||
{
|
||||
onChangeCallback(newValue);
|
||||
}
|
||||
event.stopPropagation();
|
||||
}
|
||||
}
|
||||
@ -100,6 +105,10 @@ function BooleanFieldSwitch({name, label, value, isDisabled}: Props) : JSX.Eleme
|
||||
const toggleSwitch = () =>
|
||||
{
|
||||
setFieldValue(name, !value);
|
||||
if(onChangeCallback)
|
||||
{
|
||||
onChangeCallback(!value);
|
||||
}
|
||||
}
|
||||
|
||||
const classNullSwitch = (value === null || value == undefined || `${value}` == "") ? "nullSwitch" : "";
|
||||
|
@ -19,21 +19,16 @@
|
||||
* 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 {AdornmentType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/AdornmentType";
|
||||
import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord";
|
||||
import {colors, Icon, InputLabel} from "@mui/material";
|
||||
import Box from "@mui/material/Box";
|
||||
import Button from "@mui/material/Button";
|
||||
import Grid from "@mui/material/Grid";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
import {useFormikContext} from "formik";
|
||||
import React, {useState} from "react";
|
||||
import QDynamicFormField from "qqq/components/forms/DynamicFormField";
|
||||
import DynamicSelect from "qqq/components/forms/DynamicSelect";
|
||||
import FileInputField from "qqq/components/forms/FileInputField";
|
||||
import MDTypography from "qqq/components/legacy/MDTypography";
|
||||
import HelpContent from "qqq/components/misc/HelpContent";
|
||||
import ValueUtils from "qqq/utils/qqq/ValueUtils";
|
||||
import React from "react";
|
||||
|
||||
interface Props
|
||||
{
|
||||
@ -50,28 +45,6 @@ function QDynamicForm({formData, formLabel, bulkEditMode, bulkEditSwitchChangeHa
|
||||
{
|
||||
const {formFields, values, errors, touched} = formData;
|
||||
|
||||
const formikProps = useFormikContext();
|
||||
const [fileName, setFileName] = useState(null as string);
|
||||
|
||||
const fileChanged = (event: React.FormEvent<HTMLInputElement>, field: any) =>
|
||||
{
|
||||
setFileName(null);
|
||||
if (event.currentTarget.files && event.currentTarget.files[0])
|
||||
{
|
||||
setFileName(event.currentTarget.files[0].name);
|
||||
}
|
||||
|
||||
formikProps.setFieldValue(field.name, event.currentTarget.files[0]);
|
||||
};
|
||||
|
||||
const removeFile = (fieldName: string) =>
|
||||
{
|
||||
setFileName(null);
|
||||
formikProps.setFieldValue(fieldName, null);
|
||||
record?.values.delete(fieldName)
|
||||
record?.displayValues.delete(fieldName)
|
||||
};
|
||||
|
||||
const bulkEditSwitchChanged = (name: string, value: boolean) =>
|
||||
{
|
||||
bulkEditSwitchChangeHandler(name, value);
|
||||
@ -82,14 +55,9 @@ function QDynamicForm({formData, formLabel, bulkEditMode, bulkEditSwitchChangeHa
|
||||
<Box>
|
||||
<Box lineHeight={0}>
|
||||
<MDTypography variant="h5">{formLabel}</MDTypography>
|
||||
{/* TODO - help text
|
||||
<MDTypography variant="button" color="text">
|
||||
Mandatory information
|
||||
</MDTypography>
|
||||
*/}
|
||||
</Box>
|
||||
<Box mt={1.625}>
|
||||
<Grid container spacing={3}>
|
||||
<Grid container lg={12} display="flex" spacing={3}>
|
||||
{formFields
|
||||
&& Object.keys(formFields).length > 0
|
||||
&& Object.keys(formFields).map((fieldName: any) =>
|
||||
@ -105,71 +73,52 @@ function QDynamicForm({formData, formLabel, bulkEditMode, bulkEditSwitchChangeHa
|
||||
values[fieldName] = "";
|
||||
}
|
||||
|
||||
let formattedHelpContent = <HelpContent helpContents={field.fieldMetaData.helpContents} roles={helpRoles} helpContentKey={`${helpContentKeyPrefix ?? ""}field:${fieldName}`} />;
|
||||
if(formattedHelpContent)
|
||||
let formattedHelpContent = <HelpContent helpContents={field?.fieldMetaData?.helpContents} roles={helpRoles} helpContentKey={`${helpContentKeyPrefix ?? ""}field:${fieldName}`} />;
|
||||
if (formattedHelpContent)
|
||||
{
|
||||
formattedHelpContent = <Box color="#757575" fontSize="0.875rem" mt="-0.25rem">{formattedHelpContent}</Box>
|
||||
formattedHelpContent = <Box color="#757575" fontSize="0.875rem" mt="-0.25rem">{formattedHelpContent}</Box>;
|
||||
}
|
||||
|
||||
const labelElement = <Box fontSize="1rem" fontWeight="500" marginBottom="0.25rem">
|
||||
<label htmlFor={field.name}>{field.label}</label>
|
||||
</Box>
|
||||
const labelElement = <DynamicFormFieldLabel name={field.name} label={field.label} />;
|
||||
|
||||
let itemLG = (field?.fieldMetaData?.gridColumns && field?.fieldMetaData?.gridColumns > 0) ? field.fieldMetaData.gridColumns : 6;
|
||||
let itemXS = 12;
|
||||
let itemSM = 6;
|
||||
|
||||
/////////////
|
||||
// files!! //
|
||||
/////////////
|
||||
if (field.type === "file")
|
||||
{
|
||||
const pseudoField = new QFieldMetaData({name: fieldName, type: QFieldType.BLOB});
|
||||
return (
|
||||
<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>
|
||||
const fileUploadAdornment = field.fieldMetaData?.getAdornment(AdornmentType.FILE_UPLOAD);
|
||||
const width = fileUploadAdornment?.values?.get("width") ?? "half";
|
||||
|
||||
<Box mt={0.75}>
|
||||
<MDTypography component="div" variant="caption" color="error" fontWeight="regular">
|
||||
{errors[fieldName] && <span>You must select a file to proceed</span>}
|
||||
</MDTypography>
|
||||
</Box>
|
||||
</Box>
|
||||
if (width == "full")
|
||||
{
|
||||
itemSM = 12;
|
||||
}
|
||||
|
||||
return (
|
||||
<Grid item lg={itemLG} xs={itemXS} sm={itemSM} flexDirection="column" key={fieldName}>
|
||||
{labelElement}
|
||||
<FileInputField field={field} record={record} errorMessage={errors[fieldName]} />
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
|
||||
// possible values!!
|
||||
if (field.possibleValueProps)
|
||||
///////////////////////
|
||||
// possible values!! //
|
||||
///////////////////////
|
||||
else if (field.possibleValueProps)
|
||||
{
|
||||
const otherValuesMap = field.possibleValueProps.otherValues ?? new Map<string, any>();
|
||||
Object.keys(values).forEach((key) =>
|
||||
{
|
||||
otherValuesMap.set(key, values[key]);
|
||||
})
|
||||
});
|
||||
|
||||
return (
|
||||
<Grid item xs={12} sm={6} key={fieldName}>
|
||||
<Grid item lg={itemLG} xs={itemXS} sm={itemSM} key={fieldName}>
|
||||
{labelElement}
|
||||
<DynamicSelect
|
||||
fieldPossibleValueProps={field.possibleValueProps}
|
||||
@ -186,10 +135,11 @@ function QDynamicForm({formData, formLabel, bulkEditMode, bulkEditSwitchChangeHa
|
||||
);
|
||||
}
|
||||
|
||||
// todo? inputProps={{ autoComplete: "" }}
|
||||
// todo? placeholder={password.placeholder}
|
||||
///////////////////////
|
||||
// everything else!! //
|
||||
///////////////////////
|
||||
return (
|
||||
<Grid item xs={12} sm={6} key={fieldName}>
|
||||
<Grid item lg={itemLG} xs={itemXS} sm={itemSM} key={fieldName}>
|
||||
{labelElement}
|
||||
<QDynamicFormField
|
||||
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;
|
||||
|
@ -19,10 +19,12 @@
|
||||
* 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 Switch from "@mui/material/Switch";
|
||||
import {ErrorMessage, Field, useFormikContext} from "formik";
|
||||
import DynamicFormUtils from "qqq/components/forms/DynamicFormUtils";
|
||||
import DynamicSelect from "qqq/components/forms/DynamicSelect";
|
||||
import React, {useMemo, useState} from "react";
|
||||
import AceEditor from "react-ace";
|
||||
import colors from "qqq/assets/theme/base/colors";
|
||||
@ -40,6 +42,10 @@ interface Props
|
||||
value: any;
|
||||
type: string;
|
||||
isEditable?: boolean;
|
||||
placeholder?: string;
|
||||
backgroundColor?: string;
|
||||
|
||||
onChangeCallback?: (newValue: any) => void;
|
||||
|
||||
[key: string]: any;
|
||||
|
||||
@ -49,7 +55,7 @@ interface Props
|
||||
}
|
||||
|
||||
function QDynamicFormField({
|
||||
label, name, displayFormat, value, bulkEditMode, bulkEditSwitchChangeHandler, type, isEditable, formFieldObject, ...rest
|
||||
label, name, displayFormat, value, bulkEditMode, bulkEditSwitchChangeHandler, type, isEditable, placeholder, backgroundColor, formFieldObject, onChangeCallback, ...rest
|
||||
}: Props): JSX.Element
|
||||
{
|
||||
const [switchChecked, setSwitchChecked] = useState(false);
|
||||
@ -65,18 +71,30 @@ function QDynamicFormField({
|
||||
inputLabelProps.shrink = true;
|
||||
}
|
||||
|
||||
const inputProps = {};
|
||||
const inputProps: any = {};
|
||||
if (displayFormat && displayFormat.startsWith("$"))
|
||||
{
|
||||
// @ts-ignore
|
||||
inputProps.startAdornment = <InputAdornment position="start">$</InputAdornment>;
|
||||
}
|
||||
if (displayFormat && displayFormat.endsWith("%%"))
|
||||
{
|
||||
// @ts-ignore
|
||||
inputProps.endAdornment = <InputAdornment position="end">%</InputAdornment>;
|
||||
}
|
||||
|
||||
if (placeholder)
|
||||
{
|
||||
inputProps.placeholder = placeholder
|
||||
}
|
||||
|
||||
if(backgroundColor)
|
||||
{
|
||||
inputProps.sx = {
|
||||
"&.MuiInputBase-root": {
|
||||
backgroundColor: backgroundColor
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
const handleOnWheel = (e) =>
|
||||
{
|
||||
@ -102,42 +120,79 @@ function QDynamicFormField({
|
||||
// put the onChange in an object and assign it with a spread //
|
||||
////////////////////////////////////////////////////////////////////////
|
||||
let onChange: any = {};
|
||||
if (isToUpperCase || isToLowerCase)
|
||||
if (isToUpperCase || isToLowerCase || onChangeCallback)
|
||||
{
|
||||
onChange.onChange = (e: any) =>
|
||||
{
|
||||
const beforeStart = e.target.selectionStart;
|
||||
const beforeEnd = e.target.selectionEnd;
|
||||
|
||||
flushSync(() =>
|
||||
if(isToUpperCase || isToLowerCase)
|
||||
{
|
||||
let newValue = e.currentTarget.value;
|
||||
if (isToUpperCase)
|
||||
{
|
||||
newValue = newValue.toUpperCase();
|
||||
}
|
||||
if (isToLowerCase)
|
||||
{
|
||||
newValue = newValue.toLowerCase();
|
||||
}
|
||||
setFieldValue(name, newValue);
|
||||
});
|
||||
const beforeStart = e.target.selectionStart;
|
||||
const beforeEnd = e.target.selectionEnd;
|
||||
|
||||
const input = document.getElementById(name) as HTMLInputElement;
|
||||
if (input)
|
||||
flushSync(() =>
|
||||
{
|
||||
let newValue = e.currentTarget.value;
|
||||
if (isToUpperCase)
|
||||
{
|
||||
newValue = newValue.toUpperCase();
|
||||
}
|
||||
if (isToLowerCase)
|
||||
{
|
||||
newValue = newValue.toLowerCase();
|
||||
}
|
||||
setFieldValue(name, newValue);
|
||||
if(onChangeCallback)
|
||||
{
|
||||
onChangeCallback(newValue);
|
||||
}
|
||||
});
|
||||
|
||||
const input = document.getElementById(name) as HTMLInputElement;
|
||||
if (input)
|
||||
{
|
||||
input.setSelectionRange(beforeStart, beforeEnd);
|
||||
}
|
||||
}
|
||||
else if(onChangeCallback)
|
||||
{
|
||||
input.setSelectionRange(beforeStart, beforeEnd);
|
||||
onChangeCallback(e.currentTarget.value);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
function dynamicSelectOnChange(newValue?: QPossibleValue)
|
||||
{
|
||||
if(onChangeCallback)
|
||||
{
|
||||
onChangeCallback(newValue == null ? null : newValue.id)
|
||||
}
|
||||
}
|
||||
|
||||
let field;
|
||||
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;
|
||||
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}>
|
||||
<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>}
|
||||
@ -165,6 +220,10 @@ function QDynamicFormField({
|
||||
onChange={(value: string, event: any) =>
|
||||
{
|
||||
setFieldValue(name, value, false);
|
||||
if(onChangeCallback)
|
||||
{
|
||||
onChangeCallback(value);
|
||||
}
|
||||
}}
|
||||
setOptions={{useWorker: false}}
|
||||
width="100%"
|
||||
|
@ -130,18 +130,11 @@ class DynamicFormUtils
|
||||
|
||||
if (effectivelyIsRequired)
|
||||
{
|
||||
if (field.possibleValueSourceName)
|
||||
{
|
||||
////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// the "nullable(true)" here doesn't mean that you're allowed to set the field to null... //
|
||||
// rather, it's more like "null is how empty will be treated" or some-such... //
|
||||
////////////////////////////////////////////////////////////////////////////////////////////
|
||||
return (Yup.string().required(`${field.label ?? "This field"} is required.`).nullable(true));
|
||||
}
|
||||
else
|
||||
{
|
||||
return (Yup.string().required(`${field.label ?? "This field"} is required.`));
|
||||
}
|
||||
////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// the "nullable(true)" here doesn't mean that you're allowed to set the field to null... //
|
||||
// rather, it's more like "null is how empty will be treated" or some-such... //
|
||||
////////////////////////////////////////////////////////////////////////////////////////////
|
||||
return (Yup.string().required(`${field.label ?? "This field"} is required.`).nullable(true));
|
||||
}
|
||||
return (null);
|
||||
}
|
||||
|
@ -38,6 +38,7 @@ interface Props
|
||||
{
|
||||
fieldPossibleValueProps: FieldPossibleValueProps;
|
||||
overrideId?: string;
|
||||
name?: string;
|
||||
fieldLabel: string;
|
||||
inForm: boolean;
|
||||
initialValue?: any;
|
||||
@ -95,7 +96,7 @@ export const getAutocompleteOutlinedStyle = (isDisabled: boolean) =>
|
||||
|
||||
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;
|
||||
|
||||
@ -404,6 +405,7 @@ function DynamicSelect({fieldPossibleValueProps, overrideId, fieldLabel, inForm,
|
||||
<Box>
|
||||
<Autocomplete
|
||||
id={overrideId ?? fieldName ?? possibleValueSourceName ?? "anonymous"}
|
||||
name={name}
|
||||
sx={autocompleteSX}
|
||||
open={open}
|
||||
fullWidth
|
||||
|
@ -66,7 +66,7 @@ interface Props
|
||||
defaultValues: { [key: string]: string };
|
||||
disabledFields: { [key: string]: boolean } | string[];
|
||||
isCopy?: boolean;
|
||||
onSubmitCallback?: (values: any) => void;
|
||||
onSubmitCallback?: (values: any, tableName: string) => void;
|
||||
overrideHeading?: string;
|
||||
}
|
||||
|
||||
@ -173,7 +173,7 @@ function EntityForm(props: Props): JSX.Element
|
||||
*******************************************************************************/
|
||||
function openAddChildRecord(name: string, widgetData: any)
|
||||
{
|
||||
let defaultValues = widgetData.defaultValuesForNewChildRecords;
|
||||
let defaultValues = widgetData.defaultValuesForNewChildRecords || {};
|
||||
|
||||
let disabledFields = widgetData.disabledFieldsForNewChildRecords;
|
||||
if (!disabledFields)
|
||||
@ -181,6 +181,18 @@ function EntityForm(props: Props): JSX.Element
|
||||
disabledFields = widgetData.defaultValuesForNewChildRecords;
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////
|
||||
// copy values from specified fields in the parent record down into the child record //
|
||||
///////////////////////////////////////////////////////////////////////////////////////
|
||||
if(widgetData.defaultValuesForNewChildRecordsFromParentFields)
|
||||
{
|
||||
for(let childField in widgetData.defaultValuesForNewChildRecordsFromParentFields)
|
||||
{
|
||||
const parentField = widgetData.defaultValuesForNewChildRecordsFromParentFields[childField];
|
||||
defaultValues[childField] = formValues[parentField];
|
||||
}
|
||||
}
|
||||
|
||||
doOpenEditChildForm(name, widgetData.childTableMetaData, null, defaultValues, disabledFields);
|
||||
}
|
||||
|
||||
@ -208,7 +220,7 @@ function EntityForm(props: Props): JSX.Element
|
||||
function deleteChildRecord(name: string, widgetData: any, rowIndex: number)
|
||||
{
|
||||
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 widgetMetaData = metaData.widgets.get(widgetName);
|
||||
@ -263,13 +275,38 @@ function EntityForm(props: Props): JSX.Element
|
||||
newChildListWidgetData[widgetName].queryOutput.records = [];
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// build a map of display values for the new record, specifically, for any possible-values that need translated. //
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
const displayValues: {[fieldName: string]: string} = {};
|
||||
if(childTableName && values)
|
||||
{
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// this function internally memoizes, so, we could potentially avoid an await here, but, seems ok... //
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
const childTableMetaData = await qController.loadTableMetaData(childTableName)
|
||||
for (let key in values)
|
||||
{
|
||||
const value = values[key];
|
||||
const field = childTableMetaData.fields.get(key);
|
||||
if(field.possibleValueSourceName)
|
||||
{
|
||||
const possibleValues = await qController.possibleValues(childTableName, null, field.name, null, [value], objectToMap(values), "form")
|
||||
if(possibleValues && possibleValues.length > 0)
|
||||
{
|
||||
displayValues[key] = possibleValues[0].label;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
switch (action)
|
||||
{
|
||||
case "insert":
|
||||
newChildListWidgetData[widgetName].queryOutput.records.push({values: values});
|
||||
newChildListWidgetData[widgetName].queryOutput.records.push({values: values, displayValues: displayValues});
|
||||
break;
|
||||
case "edit":
|
||||
newChildListWidgetData[widgetName].queryOutput.records[rowIndex] = {values: values};
|
||||
newChildListWidgetData[widgetName].queryOutput.records[rowIndex] = {values: values, displayValues: displayValues};
|
||||
break;
|
||||
case "delete":
|
||||
newChildListWidgetData[widgetName].queryOutput.records.splice(rowIndex, 1);
|
||||
@ -407,6 +444,7 @@ function EntityForm(props: Props): JSX.Element
|
||||
widgetMetaData={widgetMetaData}
|
||||
widgetData={widgetData}
|
||||
recordValues={formValues}
|
||||
label={tableMetaData?.fields.get(widgetData?.filterFieldName ?? "queryFilterJson")?.label}
|
||||
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 //
|
||||
//////////////////
|
||||
@ -595,18 +653,24 @@ function EntityForm(props: Props): JSX.Element
|
||||
if (defaultValue)
|
||||
{
|
||||
initialValues[fieldName] = defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////////
|
||||
// we need to set the initialDisplayValue for possible value fields with a default value //
|
||||
// so, look them up here now if needed //
|
||||
///////////////////////////////////////////////////////////////////////////////////////////
|
||||
if (fieldMetaData.possibleValueSourceName)
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// do a second loop, this time looking up display-values for any possible-value fields with a default value //
|
||||
// do it in a second loop, to pass in all the other values (from initialValues), in case there's a PVS filter that needs them. //
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
for (let i = 0; i < fieldArray.length; i++)
|
||||
{
|
||||
const fieldMetaData = fieldArray[i];
|
||||
const fieldName = fieldMetaData.name;
|
||||
const defaultValue = (defaultValues && defaultValues[fieldName]) ? defaultValues[fieldName] : fieldMetaData.defaultValue;
|
||||
if (defaultValue && fieldMetaData.possibleValueSourceName)
|
||||
{
|
||||
const results: QPossibleValue[] = await qController.possibleValues(tableName, null, fieldName, null, [initialValues[fieldName]], objectToMap(initialValues), "form");
|
||||
if (results && results.length > 0)
|
||||
{
|
||||
const results: QPossibleValue[] = await qController.possibleValues(tableName, null, fieldName, null, [initialValues[fieldName]]);
|
||||
if (results && results.length > 0)
|
||||
{
|
||||
defaultDisplayValues.set(fieldName, results[0].label);
|
||||
}
|
||||
defaultDisplayValues.set(fieldName, results[0].label);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -818,12 +882,12 @@ function EntityForm(props: Props): JSX.Element
|
||||
{
|
||||
actions.setSubmitting(true);
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// if there's a callback (e.g., for a modal nested on another create/edit screen), then just pass our data back there 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)
|
||||
{
|
||||
props.onSubmitCallback(values);
|
||||
props.onSubmitCallback(values, tableName);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -1290,7 +1354,7 @@ function EntityForm(props: Props): JSX.Element
|
||||
table={showEditChildForm.table}
|
||||
defaultValues={showEditChildForm.defaultValues}
|
||||
disabledFields={showEditChildForm.disabledFields}
|
||||
onSubmitCallback={submitEditChildForm}
|
||||
onSubmitCallback={props.onSubmitCallback ? props.onSubmitCallback : submitEditChildForm}
|
||||
overrideHeading={`${showEditChildForm.rowIndex != null ? "Editing" : "Creating New"} ${showEditChildForm.table.label}`}
|
||||
/>
|
||||
</div>
|
||||
|
156
src/qqq/components/forms/FileInputField.tsx
Normal file
156
src/qqq/components/forms/FileInputField.tsx
Normal 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}
|
||||
</Box>
|
||||
</>
|
||||
}
|
||||
|
||||
<Box mt={0.75}>
|
||||
<MDTypography component="div" variant="caption" color="error" fontWeight="regular">
|
||||
{errorMessage && <span>{errorMessage}</span>}
|
||||
</MDTypography>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
795
src/qqq/components/misc/QHierarchyAutoComplete.tsx
Normal file
795
src/qqq/components/misc/QHierarchyAutoComplete.tsx
Normal 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> <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>
|
||||
</>
|
||||
);
|
||||
}
|
790
src/qqq/components/misc/SavedBulkLoadProfiles.tsx
Normal file
790
src/qqq/components/misc/SavedBulkLoadProfiles.tsx
Normal 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…</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 ? <> </> : <Button disableRipple={true} sx={linkButtonStyle} onClick={() => handleDropdownOptionClick(SAVE_OPTION)}>Save…</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;
|
329
src/qqq/components/processes/BulkLoadFileMappingField.tsx
Normal file
329
src/qqq/components/processes/BulkLoadFileMappingField.tsx
Normal 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>);
|
||||
}
|
308
src/qqq/components/processes/BulkLoadFileMappingFields.tsx
Normal file
308
src/qqq/components/processes/BulkLoadFileMappingFields.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
566
src/qqq/components/processes/BulkLoadFileMappingForm.tsx
Normal file
566
src/qqq/components/processes/BulkLoadFileMappingForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
|
102
src/qqq/components/processes/BulkLoadProfileForm.tsx
Normal file
102
src/qqq/components/processes/BulkLoadProfileForm.tsx
Normal 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;
|
233
src/qqq/components/processes/BulkLoadValueMappingForm.tsx
Normal file
233
src/qqq/components/processes/BulkLoadValueMappingForm.tsx
Normal 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;
|
71
src/qqq/components/processes/ProcessViewForm.tsx
Normal file
71
src/qqq/components/processes/ProcessViewForm.tsx
Normal 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}
|
||||
:
|
||||
</MDTypography>
|
||||
<MDTypography variant="button" fontWeight="regular" color="text">
|
||||
{ValueUtils.getValueForDisplay(field, values[field.name], undefined, "view")}
|
||||
</MDTypography>
|
||||
</Grid>
|
||||
)))
|
||||
}
|
||||
</Grid>;
|
||||
}
|
@ -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 {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
|
||||
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 Icon from "@mui/material/Icon";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import List from "@mui/material/List";
|
||||
import ListItemText from "@mui/material/ListItemText";
|
||||
import React, {useState} from "react";
|
||||
import MDTypography from "qqq/components/legacy/MDTypography";
|
||||
import CustomWidthTooltip from "qqq/components/tooltips/CustomWidthTooltip";
|
||||
import RecordGridWidget, {ChildRecordListData} from "qqq/components/widgets/misc/RecordGridWidget";
|
||||
import {ProcessSummaryLine} from "qqq/models/processes/ProcessSummaryLine";
|
||||
import {renderSectionOfFields} from "qqq/pages/records/view/RecordView";
|
||||
import Client from "qqq/utils/qqq/Client";
|
||||
import TableUtils from "qqq/utils/qqq/TableUtils";
|
||||
import ValueUtils from "qqq/utils/qqq/ValueUtils";
|
||||
import React, {useEffect, useState} from "react";
|
||||
|
||||
interface Props
|
||||
{
|
||||
qInstance: QInstance;
|
||||
process: QProcessMetaData;
|
||||
table: QTableMetaData;
|
||||
processValues: any;
|
||||
step: QFrontendStepMetaData;
|
||||
previewRecords: QRecord[];
|
||||
formValues: any;
|
||||
doFullValidationRadioChangedHandler: any
|
||||
qInstance: QInstance,
|
||||
process: QProcessMetaData,
|
||||
table: QTableMetaData,
|
||||
processValues: any,
|
||||
step: QFrontendStepMetaData,
|
||||
previewRecords: QRecord[],
|
||||
formValues: any,
|
||||
doFullValidationRadioChangedHandler: any,
|
||||
loadingRecords?: boolean
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
// e.g., for bulk-load, where we want to show associations under a record //
|
||||
// the processValue will have these data, to drive this screen. //
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
interface AssociationPreview
|
||||
{
|
||||
tableName: string;
|
||||
widgetName: string;
|
||||
associationName: string;
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
@ -55,21 +71,76 @@ interface Props
|
||||
** results when they are available.
|
||||
*******************************************************************************/
|
||||
function ValidationReview({
|
||||
qInstance, process, table = null, processValues, step, previewRecords = [], formValues, doFullValidationRadioChangedHandler,
|
||||
qInstance, process, table = null, processValues, step, previewRecords = [], formValues, doFullValidationRadioChangedHandler, loadingRecords
|
||||
}: Props): JSX.Element
|
||||
{
|
||||
const [previewRecordIndex, setPreviewRecordIndex] = useState(0);
|
||||
const [sourceTableMetaData, setSourceTableMetaData] = useState(null as QTableMetaData);
|
||||
const [previewTableMetaData, setPreviewTableMetaData] = useState(null as QTableMetaData);
|
||||
const [childTableMetaData, setChildTableMetaData] = useState({} as { [name: string]: QTableMetaData });
|
||||
|
||||
if(processValues.sourceTable && !sourceTableMetaData)
|
||||
const [associationPreviewsByWidgetName, setAssociationPreviewsByWidgetName] = useState({} as { [widgetName: string]: AssociationPreview });
|
||||
|
||||
if (processValues.sourceTable && !sourceTableMetaData)
|
||||
{
|
||||
(async () =>
|
||||
{
|
||||
const sourceTableMetaData = await Client.getInstance().loadTableMetaData(processValues.sourceTable)
|
||||
const sourceTableMetaData = await Client.getInstance().loadTableMetaData(processValues.sourceTable);
|
||||
setSourceTableMetaData(sourceTableMetaData);
|
||||
})();
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////
|
||||
// load meta-data and set up associations-data structure, if so directed from backend //
|
||||
////////////////////////////////////////////////////////////////////////////////////////
|
||||
useEffect(() =>
|
||||
{
|
||||
if (processValues.formatPreviewRecordUsingTableLayout && !previewTableMetaData)
|
||||
{
|
||||
(async () =>
|
||||
{
|
||||
const previewTableMetaData = await Client.getInstance().loadTableMetaData(processValues.formatPreviewRecordUsingTableLayout);
|
||||
setPreviewTableMetaData(previewTableMetaData);
|
||||
})();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
const previewRecordAssociatedTableNames: string[] = processValues.previewRecordAssociatedTableNames ?? [];
|
||||
const previewRecordAssociatedWidgetNames: string[] = processValues.previewRecordAssociatedWidgetNames ?? [];
|
||||
const previewRecordAssociationNames: string[] = processValues.previewRecordAssociationNames ?? [];
|
||||
|
||||
const associationPreviewsByWidgetName: { [widgetName: string]: AssociationPreview } = {};
|
||||
for (let i = 0; i < Math.min(previewRecordAssociatedTableNames.length, previewRecordAssociatedWidgetNames.length, previewRecordAssociationNames.length); i++)
|
||||
{
|
||||
const associationPreview = {tableName: previewRecordAssociatedTableNames[i], widgetName: previewRecordAssociatedWidgetNames[i], associationName: previewRecordAssociationNames[i]};
|
||||
associationPreviewsByWidgetName[associationPreview.widgetName] = associationPreview;
|
||||
}
|
||||
setAssociationPreviewsByWidgetName(associationPreviewsByWidgetName);
|
||||
|
||||
if (Object.keys(associationPreviewsByWidgetName))
|
||||
{
|
||||
(async () =>
|
||||
{
|
||||
for (let key in associationPreviewsByWidgetName)
|
||||
{
|
||||
const associationPreview = associationPreviewsByWidgetName[key];
|
||||
childTableMetaData[associationPreview.tableName] = await Client.getInstance().loadTableMetaData(associationPreview.tableName);
|
||||
setChildTableMetaData(Object.assign({}, childTableMetaData));
|
||||
}
|
||||
})();
|
||||
}
|
||||
}
|
||||
catch (e)
|
||||
{
|
||||
console.log(`Error setting up association previews: ${e}`);
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
const updatePreviewRecordIndex = (offset: number) =>
|
||||
{
|
||||
let newIndex = previewRecordIndex + offset;
|
||||
@ -85,6 +156,10 @@ function ValidationReview({
|
||||
setPreviewRecordIndex(newIndex);
|
||||
};
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
const buildDoFullValidationRadioListItem = (value: "true" | "false", labelText: string, tooltipHTML: JSX.Element): JSX.Element =>
|
||||
{
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
@ -191,6 +266,7 @@ function ValidationReview({
|
||||
</List>
|
||||
);
|
||||
|
||||
|
||||
const recordPreviewWidget = step.recordListFields && (
|
||||
<Box border="1px solid rgb(70%, 70%, 70%)" borderRadius="10px" p={2} mt={2}>
|
||||
<Box mx={2} mt={-5} p={1} sx={{width: "fit-content", borderColor: "rgb(70%, 70%, 70%)", borderWidth: "2px", borderStyle: "solid", borderRadius: ".25em", backgroundColor: "#FFFFFF"}} width="initial" color="white">
|
||||
@ -200,43 +276,47 @@ function ValidationReview({
|
||||
<MDTypography color="body" variant="body2" component="div" mb={2}>
|
||||
<Box display="flex">
|
||||
{
|
||||
processValues?.previewMessage && previewRecords && previewRecords.length > 0 ? (
|
||||
<>
|
||||
<i>{processValues?.previewMessage}</i>
|
||||
<CustomWidthTooltip
|
||||
title={(
|
||||
<div>
|
||||
Note that the number of preview records available may be fewer than the total number of records being processed.
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<IconButton sx={{py: 0}}><Icon fontSize="small">info_outlined</Icon></IconButton>
|
||||
</CustomWidthTooltip>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<i>No record previews are available at this time.</i>
|
||||
<CustomWidthTooltip
|
||||
title={(
|
||||
<div>
|
||||
{
|
||||
processValues.validationSummary ? (
|
||||
<>
|
||||
It appears as though this process does not contain any valid records.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
If you choose to Perform Validation, and there are any valid records, then you will see a preview here.
|
||||
</>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<IconButton sx={{py: 0}}><Icon fontSize="small">info_outlined</Icon></IconButton>
|
||||
</CustomWidthTooltip>
|
||||
</>
|
||||
)
|
||||
loadingRecords ? <i>Loading...</i> : <>
|
||||
{
|
||||
processValues?.previewMessage && previewRecords && previewRecords.length > 0 ? (
|
||||
<>
|
||||
<i>{processValues?.previewMessage}</i>
|
||||
<CustomWidthTooltip
|
||||
title={(
|
||||
<div>
|
||||
Note that the number of preview records available may be fewer than the total number of records being processed.
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<IconButton sx={{py: 0}}><Icon fontSize="small">info_outlined</Icon></IconButton>
|
||||
</CustomWidthTooltip>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<i>No record previews are available at this time.</i>
|
||||
<CustomWidthTooltip
|
||||
title={(
|
||||
<div>
|
||||
{
|
||||
processValues.validationSummary ? (
|
||||
<>
|
||||
It appears as though this process does not contain any valid records.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
If you choose to Perform Validation, and there are any valid records, then you will see a preview here.
|
||||
</>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<IconButton sx={{py: 0}}><Icon fontSize="small">info_outlined</Icon></IconButton>
|
||||
</CustomWidthTooltip>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</>
|
||||
}
|
||||
</Box>
|
||||
</MDTypography>
|
||||
@ -244,16 +324,27 @@ function ValidationReview({
|
||||
<Box sx={{maxHeight: "calc(100vh - 640px)", overflow: "auto", minHeight: "300px", marginRight: "-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"}}>
|
||||
<b>{`${field.label}:`}</b>
|
||||
{" "}
|
||||
|
||||
|
||||
{" "}
|
||||
{ValueUtils.getDisplayValue(field, previewRecords[previewRecordIndex], "view")}
|
||||
</Box>
|
||||
))
|
||||
}
|
||||
{
|
||||
previewRecords && processValues.formatPreviewRecordUsingTableLayout && previewRecords[previewRecordIndex] &&
|
||||
<PreviewRecordUsingTableLayout
|
||||
index={previewRecordIndex}
|
||||
record={previewRecords[previewRecordIndex]}
|
||||
tableMetaData={previewTableMetaData}
|
||||
qInstance={qInstance}
|
||||
associationPreviewsByWidgetName={associationPreviewsByWidgetName}
|
||||
childTableMetaData={childTableMetaData}
|
||||
/>
|
||||
}
|
||||
</Box>
|
||||
</Box>
|
||||
{
|
||||
@ -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;
|
||||
|
@ -118,7 +118,7 @@ const doesOperatorOptionEqualCriteria = (operatorOption: OperatorOption, criteri
|
||||
** autocomplete), given an array of options, the query's active criteria in this
|
||||
** field, and the default operator to use for this field
|
||||
*******************************************************************************/
|
||||
const getOperatorSelectedValue = (operatorOptions: OperatorOption[], criteria: QFilterCriteriaWithId, defaultOperator: QCriteriaOperator): OperatorOption =>
|
||||
const getOperatorSelectedValue = (operatorOptions: OperatorOption[], criteria: QFilterCriteriaWithId, defaultOperator: QCriteriaOperator, return0thOptionInsteadOfNull: boolean = false): OperatorOption =>
|
||||
{
|
||||
if (criteria)
|
||||
{
|
||||
@ -135,6 +135,23 @@ const getOperatorSelectedValue = (operatorOptions: OperatorOption[], criteria: Q
|
||||
return (filteredOptions[0]);
|
||||
}
|
||||
|
||||
if(return0thOptionInsteadOfNull)
|
||||
{
|
||||
console.log("Returning 0th operator instead of null - this isn't expected, but has been seen to happen - so here's some additional debugging:");
|
||||
try
|
||||
{
|
||||
console.log("Operator options: " + JSON.stringify(operatorOptions));
|
||||
console.log("Criteria: " + JSON.stringify(criteria));
|
||||
console.log("Default Operator: " + JSON.stringify(defaultOperator));
|
||||
}
|
||||
catch(e)
|
||||
{
|
||||
console.log(`Error in debug output: ${e}`);
|
||||
}
|
||||
|
||||
return operatorOptions[0];
|
||||
}
|
||||
|
||||
return (null);
|
||||
};
|
||||
|
||||
@ -157,7 +174,7 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData
|
||||
const [criteria, setCriteria] = useState(criteriaParamIsCriteria(criteriaParam) ? Object.assign({}, criteriaParam) as QFilterCriteriaWithId : null);
|
||||
const [id, setId] = useState(criteriaParamIsCriteria(criteriaParam) ? (criteriaParam as QFilterCriteriaWithId).id : ++seedId);
|
||||
|
||||
const [operatorSelectedValue, setOperatorSelectedValue] = useState(getOperatorSelectedValue(operatorOptions, criteria, defaultOperator));
|
||||
const [operatorSelectedValue, setOperatorSelectedValue] = useState(getOperatorSelectedValue(operatorOptions, criteria, defaultOperator, true));
|
||||
const [operatorInputValue, setOperatorInputValue] = useState(operatorSelectedValue?.label);
|
||||
|
||||
const {criteriaIsValid, criteriaStatusTooltip} = validateCriteria(criteria, operatorSelectedValue);
|
||||
|
@ -49,7 +49,7 @@ function ScriptDocsForm({helpText, exampleCode, aceEditorHeight}: Props): JSX.El
|
||||
<Card sx={{width: "100%", height: "100%"}}>
|
||||
<Typography variant="h6" p={2} pb={1}>{heading}</Typography>
|
||||
<Box className="devDocumentation" height="100%">
|
||||
<Typography variant="body2" sx={{maxWidth: "1200px", margin: "auto", height: "100%"}}>
|
||||
<Typography variant="body2" sx={{maxWidth: "1200px", margin: "auto", height: "calc(100% - 0.5rem)"}}>
|
||||
<AceEditor
|
||||
mode={mode}
|
||||
theme="github"
|
||||
@ -62,7 +62,7 @@ function ScriptDocsForm({helpText, exampleCode, aceEditorHeight}: Props): JSX.El
|
||||
width="100%"
|
||||
showPrintMargin={false}
|
||||
height="100%"
|
||||
style={{borderBottomRightRadius: "1rem", borderBottomLeftRadius: "1rem"}}
|
||||
style={{borderBottomRightRadius: "0.75rem", borderBottomLeftRadius: "0.75rem"}}
|
||||
/>
|
||||
</Typography>
|
||||
</Box>
|
||||
|
@ -40,16 +40,17 @@ import Snackbar from "@mui/material/Snackbar";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
import FormData from "form-data";
|
||||
import React, {useEffect, useReducer, useRef, useState} from "react";
|
||||
import AceEditor from "react-ace";
|
||||
import {QCancelButton, QSaveButton} from "qqq/components/buttons/DefaultButtons";
|
||||
import DynamicSelect from "qqq/components/forms/DynamicSelect";
|
||||
import ScriptDocsForm from "qqq/components/scripts/ScriptDocsForm";
|
||||
import ScriptTestForm from "qqq/components/scripts/ScriptTestForm";
|
||||
import Client from "qqq/utils/qqq/Client";
|
||||
|
||||
import "ace-builds/src-noconflict/ace";
|
||||
import "ace-builds/src-noconflict/mode-javascript";
|
||||
import "ace-builds/src-noconflict/theme-github";
|
||||
import React, {useEffect, useReducer, useRef, useState} from "react";
|
||||
import AceEditor from "react-ace";
|
||||
import "ace-builds/src-noconflict/ext-language_tools";
|
||||
|
||||
export interface ScriptEditorProps
|
||||
@ -69,15 +70,15 @@ const qController = Client.getInstance();
|
||||
|
||||
function buildInitialFileContentsMap(scriptRevisionRecord: QRecord, scriptTypeFileSchemaList: QRecord[]): { [name: string]: string }
|
||||
{
|
||||
const rs: {[name: string]: string} = {};
|
||||
const rs: { [name: string]: string } = {};
|
||||
|
||||
if(!scriptTypeFileSchemaList)
|
||||
if (!scriptTypeFileSchemaList)
|
||||
{
|
||||
console.log("Missing scriptTypeFileSchemaList");
|
||||
}
|
||||
else
|
||||
{
|
||||
let files = scriptRevisionRecord?.associatedRecords?.get("files")
|
||||
let files = scriptRevisionRecord?.associatedRecords?.get("files");
|
||||
|
||||
for (let i = 0; i < scriptTypeFileSchemaList.length; i++)
|
||||
{
|
||||
@ -88,7 +89,7 @@ function buildInitialFileContentsMap(scriptRevisionRecord: QRecord, scriptTypeFi
|
||||
for (let j = 0; j < files?.length; j++)
|
||||
{
|
||||
let file = files[j];
|
||||
if(file.values.get("fileName") == name)
|
||||
if (file.values.get("fileName") == name)
|
||||
{
|
||||
contents = file.values.get("contents");
|
||||
}
|
||||
@ -103,9 +104,9 @@ function buildInitialFileContentsMap(scriptRevisionRecord: QRecord, scriptTypeFi
|
||||
|
||||
function buildFileTypeMap(scriptTypeFileSchemaList: QRecord[]): { [name: string]: string }
|
||||
{
|
||||
const rs: {[name: string]: string} = {};
|
||||
const rs: { [name: string]: string } = {};
|
||||
|
||||
if(!scriptTypeFileSchemaList)
|
||||
if (!scriptTypeFileSchemaList)
|
||||
{
|
||||
console.log("Missing scriptTypeFileSchemaList");
|
||||
}
|
||||
@ -125,21 +126,21 @@ function ScriptEditor({title, scriptId, scriptRevisionRecord, closeCallback, tab
|
||||
{
|
||||
const [closing, setClosing] = useState(false);
|
||||
|
||||
const [apiName, setApiName] = useState(scriptRevisionRecord ? scriptRevisionRecord.values.get("apiName") : null)
|
||||
const [apiNameLabel, setApiNameLabel] = useState(scriptRevisionRecord ? scriptRevisionRecord.displayValues.get("apiName") : null)
|
||||
const [apiVersion, setApiVersion] = useState(scriptRevisionRecord ? scriptRevisionRecord.values.get("apiVersion") : null)
|
||||
const [apiVersionLabel, setApiVersionLabel] = useState(scriptRevisionRecord ? scriptRevisionRecord.displayValues.get("apiVersion") : null)
|
||||
const [apiName, setApiName] = useState(scriptRevisionRecord ? scriptRevisionRecord.values.get("apiName") : null);
|
||||
const [apiNameLabel, setApiNameLabel] = useState(scriptRevisionRecord ? scriptRevisionRecord.displayValues.get("apiName") : null);
|
||||
const [apiVersion, setApiVersion] = useState(scriptRevisionRecord ? scriptRevisionRecord.values.get("apiVersion") : null);
|
||||
const [apiVersionLabel, setApiVersionLabel] = useState(scriptRevisionRecord ? scriptRevisionRecord.displayValues.get("apiVersion") : null);
|
||||
|
||||
const fileNamesFromSchema = scriptTypeFileSchemaList.map((schemaRecord) => schemaRecord.values.get("name"))
|
||||
const fileNamesFromSchema = scriptTypeFileSchemaList.map((schemaRecord) => schemaRecord.values.get("name"));
|
||||
const [availableFileNames, setAvailableFileNames] = useState(fileNamesFromSchema);
|
||||
const [openEditorFileNames, setOpenEditorFileNames] = useState([fileNamesFromSchema[0]])
|
||||
const [fileContents, setFileContents] = useState(buildInitialFileContentsMap(scriptRevisionRecord, scriptTypeFileSchemaList))
|
||||
const [fileTypes, setFileTypes] = useState(buildFileTypeMap(scriptTypeFileSchemaList))
|
||||
const [openEditorFileNames, setOpenEditorFileNames] = useState([fileNamesFromSchema[0]]);
|
||||
const [fileContents, setFileContents] = useState(buildInitialFileContentsMap(scriptRevisionRecord, scriptTypeFileSchemaList));
|
||||
const [fileTypes, setFileTypes] = useState(buildFileTypeMap(scriptTypeFileSchemaList));
|
||||
console.log(`file types: ${JSON.stringify(fileTypes)}`);
|
||||
|
||||
const [commitMessage, setCommitMessage] = useState("")
|
||||
const [commitMessage, setCommitMessage] = useState("");
|
||||
const [openTool, setOpenTool] = useState(null);
|
||||
const [errorAlert, setErrorAlert] = useState("")
|
||||
const [errorAlert, setErrorAlert] = useState("");
|
||||
const [promptForCommitMessageOpen, setPromptForCommitMessageOpen] = useState(false);
|
||||
const [, forceUpdate] = useReducer((x) => x + 1, 0);
|
||||
const ref = useRef();
|
||||
@ -241,19 +242,19 @@ function ScriptEditor({title, scriptId, scriptRevisionRecord, closeCallback, tab
|
||||
// need this to make Ace recognize new height.
|
||||
setTimeout(() =>
|
||||
{
|
||||
window.dispatchEvent(new Event("resize"))
|
||||
window.dispatchEvent(new Event("resize"));
|
||||
}, 100);
|
||||
};
|
||||
|
||||
const saveClicked = (overrideCommitMessage?: string) =>
|
||||
{
|
||||
if(!apiName || !apiVersion)
|
||||
if (!apiName || !apiVersion)
|
||||
{
|
||||
setErrorAlert("You must select a value for both API Name and API Version.")
|
||||
setErrorAlert("You must select a value for both API Name and API Version.");
|
||||
return;
|
||||
}
|
||||
|
||||
if(!commitMessage && !overrideCommitMessage)
|
||||
if (!commitMessage && !overrideCommitMessage)
|
||||
{
|
||||
setPromptForCommitMessageOpen(true);
|
||||
return;
|
||||
@ -267,18 +268,18 @@ function ScriptEditor({title, scriptId, scriptRevisionRecord, closeCallback, tab
|
||||
formData.append("scriptId", scriptId);
|
||||
formData.append("commitMessage", overrideCommitMessage ?? commitMessage);
|
||||
|
||||
if(apiName)
|
||||
if (apiName)
|
||||
{
|
||||
formData.append("apiName", apiName);
|
||||
}
|
||||
|
||||
if(apiVersion)
|
||||
if (apiVersion)
|
||||
{
|
||||
formData.append("apiVersion", apiVersion);
|
||||
}
|
||||
|
||||
|
||||
const fileNamesFromSchema = scriptTypeFileSchemaList.map((schemaRecord) => schemaRecord.values.get("name"))
|
||||
const fileNamesFromSchema = scriptTypeFileSchemaList.map((schemaRecord) => schemaRecord.values.get("name"));
|
||||
formData.append("fileNames", fileNamesFromSchema.join(","));
|
||||
|
||||
for (let fileName in fileContents)
|
||||
@ -299,58 +300,58 @@ function ScriptEditor({title, scriptId, scriptRevisionRecord, closeCallback, tab
|
||||
|
||||
if (processResult instanceof QJobError)
|
||||
{
|
||||
const jobError = processResult as QJobError
|
||||
setErrorAlert(jobError.userFacingError ?? jobError.error)
|
||||
const jobError = processResult as QJobError;
|
||||
setErrorAlert(jobError.userFacingError ?? jobError.error);
|
||||
setClosing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
closeCallback(null, "saved", "Saved New Script Version");
|
||||
}
|
||||
catch(e)
|
||||
catch (e)
|
||||
{
|
||||
// @ts-ignore
|
||||
setErrorAlert(e.message ?? "Unexpected error saving script")
|
||||
setErrorAlert(e.message ?? "Unexpected error saving script");
|
||||
setClosing(false);
|
||||
}
|
||||
})();
|
||||
}
|
||||
};
|
||||
|
||||
const cancelClicked = () =>
|
||||
{
|
||||
setClosing(true);
|
||||
closeCallback(null, "cancelled");
|
||||
}
|
||||
};
|
||||
|
||||
const updateCode = (value: string, event: any, index: number) =>
|
||||
{
|
||||
fileContents[openEditorFileNames[index]] = value;
|
||||
forceUpdate();
|
||||
}
|
||||
};
|
||||
|
||||
const updateCommitMessage = (event: React.ChangeEvent<HTMLInputElement>) =>
|
||||
{
|
||||
setCommitMessage(event.target.value);
|
||||
}
|
||||
};
|
||||
|
||||
const closePromptForCommitMessage = (wasSaveClicked: boolean, message?: string) =>
|
||||
{
|
||||
setPromptForCommitMessageOpen(false);
|
||||
|
||||
if(wasSaveClicked)
|
||||
if (wasSaveClicked)
|
||||
{
|
||||
setCommitMessage(message)
|
||||
setCommitMessage(message);
|
||||
saveClicked(message);
|
||||
}
|
||||
else
|
||||
{
|
||||
setClosing(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const changeApiName = (apiNamePossibleValue?: QPossibleValue) =>
|
||||
{
|
||||
if(apiNamePossibleValue)
|
||||
if (apiNamePossibleValue)
|
||||
{
|
||||
setApiName(apiNamePossibleValue.id);
|
||||
}
|
||||
@ -358,11 +359,11 @@ function ScriptEditor({title, scriptId, scriptRevisionRecord, closeCallback, tab
|
||||
{
|
||||
setApiName(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const changeApiVersion = (apiVersionPossibleValue?: QPossibleValue) =>
|
||||
{
|
||||
if(apiVersionPossibleValue)
|
||||
if (apiVersionPossibleValue)
|
||||
{
|
||||
setApiVersion(apiVersionPossibleValue.id);
|
||||
}
|
||||
@ -370,33 +371,33 @@ function ScriptEditor({title, scriptId, scriptRevisionRecord, closeCallback, tab
|
||||
{
|
||||
setApiVersion(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectingFile = (event: SelectChangeEvent, index: number) =>
|
||||
{
|
||||
openEditorFileNames[index] = event.target.value
|
||||
openEditorFileNames[index] = event.target.value;
|
||||
setOpenEditorFileNames(openEditorFileNames);
|
||||
forceUpdate();
|
||||
}
|
||||
};
|
||||
|
||||
const splitEditorClicked = () =>
|
||||
{
|
||||
openEditorFileNames.push(availableFileNames[0])
|
||||
openEditorFileNames.push(availableFileNames[0]);
|
||||
setOpenEditorFileNames(openEditorFileNames);
|
||||
forceUpdate();
|
||||
}
|
||||
};
|
||||
|
||||
const closeEditorClicked = (index: number) =>
|
||||
{
|
||||
openEditorFileNames.splice(index, 1)
|
||||
openEditorFileNames.splice(index, 1);
|
||||
setOpenEditorFileNames(openEditorFileNames);
|
||||
forceUpdate();
|
||||
}
|
||||
};
|
||||
|
||||
const computeEditorWidth = (): string =>
|
||||
{
|
||||
return (100 / openEditorFileNames.length) + "%"
|
||||
}
|
||||
return (100 / openEditorFileNames.length) + "%";
|
||||
};
|
||||
|
||||
return (
|
||||
<Box className="scriptEditor" sx={{position: "absolute", overflowY: "auto", height: "100%", width: "100%"}} p={6}>
|
||||
@ -408,7 +409,7 @@ function ScriptEditor({title, scriptId, scriptRevisionRecord, closeCallback, tab
|
||||
{
|
||||
return;
|
||||
}
|
||||
setErrorAlert("")
|
||||
setErrorAlert("");
|
||||
}} anchorOrigin={{vertical: "top", horizontal: "center"}}>
|
||||
<Alert color="error" onClose={() => setErrorAlert("")}>
|
||||
{errorAlert}
|
||||
@ -464,19 +465,19 @@ function ScriptEditor({title, scriptId, scriptRevisionRecord, closeCallback, tab
|
||||
<Box>
|
||||
{
|
||||
openEditorFileNames.length > 1 &&
|
||||
<Tooltip title="Close this editor split" enterDelay={500}>
|
||||
<IconButton size="small" onClick={() => closeEditorClicked(index)}>
|
||||
<Icon>close</Icon>
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Close this editor split" enterDelay={500}>
|
||||
<IconButton size="small" onClick={() => closeEditorClicked(index)}>
|
||||
<Icon>close</Icon>
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
}
|
||||
{
|
||||
index == openEditorFileNames.length - 1 &&
|
||||
<Tooltip title="Open a new editor split" enterDelay={500}>
|
||||
<IconButton size="small" onClick={splitEditorClicked}>
|
||||
<Icon>vertical_split</Icon>
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Open a new editor split" enterDelay={500}>
|
||||
<IconButton size="small" onClick={splitEditorClicked}>
|
||||
<Icon>vertical_split</Icon>
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
}
|
||||
</Box>
|
||||
</Box>
|
||||
@ -526,29 +527,29 @@ function ScriptEditor({title, scriptId, scriptRevisionRecord, closeCallback, tab
|
||||
</Grid>
|
||||
</Box>
|
||||
|
||||
<CommitMessagePrompt isOpen={promptForCommitMessageOpen} closeHandler={closePromptForCommitMessage}/>
|
||||
<CommitMessagePrompt isOpen={promptForCommitMessageOpen} closeHandler={closePromptForCommitMessage} />
|
||||
</Card>
|
||||
</Box>
|
||||
|
||||
);
|
||||
}
|
||||
|
||||
function CommitMessagePrompt(props: {isOpen: boolean, closeHandler: (wasSaveClicked: boolean, message?: string) => void})
|
||||
function CommitMessagePrompt(props: { isOpen: boolean, closeHandler: (wasSaveClicked: boolean, message?: string) => void })
|
||||
{
|
||||
const [commitMessage, setCommitMessage] = useState("No commit message given")
|
||||
const [commitMessage, setCommitMessage] = useState("No commit message given");
|
||||
|
||||
const updateCommitMessage = (event: React.ChangeEvent<HTMLInputElement>) =>
|
||||
{
|
||||
setCommitMessage(event.target.value);
|
||||
}
|
||||
};
|
||||
|
||||
const keyPressHandler = (e: React.KeyboardEvent<HTMLDivElement>) =>
|
||||
{
|
||||
if(e.key === "Enter")
|
||||
if (e.key === "Enter")
|
||||
{
|
||||
props.closeHandler(true, commitMessage);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
@ -579,10 +580,10 @@ function CommitMessagePrompt(props: {isOpen: boolean, closeHandler: (wasSaveClic
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<QCancelButton onClickHandler={() => props.closeHandler(false)} disabled={false} />
|
||||
<QSaveButton label="Save" onClickHandler={() => props.closeHandler(true, commitMessage)} disabled={false}/>
|
||||
<QSaveButton label="Save" onClickHandler={() => props.closeHandler(true, commitMessage)} disabled={false} />
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export default ScriptEditor;
|
||||
|
@ -22,19 +22,25 @@
|
||||
|
||||
import {QWidgetMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QWidgetMetaData";
|
||||
import {Box, Skeleton} from "@mui/material";
|
||||
import Card from "@mui/material/Card";
|
||||
import Modal from "@mui/material/Modal";
|
||||
import parse from "html-react-parser";
|
||||
import {BlockData} from "qqq/components/widgets/blocks/BlockModels";
|
||||
import WidgetBlock from "qqq/components/widgets/WidgetBlock";
|
||||
import React from "react";
|
||||
import ProcessWidgetBlockUtils from "qqq/pages/processes/ProcessWidgetBlockUtils";
|
||||
import React, {useEffect, useState} from "react";
|
||||
|
||||
|
||||
export interface CompositeData
|
||||
{
|
||||
blockId: string;
|
||||
blocks: BlockData[];
|
||||
styleOverrides?: any;
|
||||
layout?: string;
|
||||
overlayHtml?: string;
|
||||
overlayStyleOverrides?: any;
|
||||
modalMode: string;
|
||||
styles?: any;
|
||||
}
|
||||
|
||||
|
||||
@ -42,14 +48,15 @@ interface CompositeWidgetProps
|
||||
{
|
||||
widgetMetaData: QWidgetMetaData;
|
||||
data: CompositeData;
|
||||
actionCallback?: (blockData: BlockData) => boolean;
|
||||
actionCallback?: (blockData: BlockData, eventValues?: { [name: string]: any }) => boolean;
|
||||
values?: { [key: string]: any };
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Widget which is a list of Blocks.
|
||||
*******************************************************************************/
|
||||
export default function CompositeWidget({widgetMetaData, data, actionCallback}: CompositeWidgetProps): JSX.Element
|
||||
export default function CompositeWidget({widgetMetaData, data, actionCallback, values}: CompositeWidgetProps): JSX.Element
|
||||
{
|
||||
if (!data || !data.blocks)
|
||||
{
|
||||
@ -75,6 +82,12 @@ export default function CompositeWidget({widgetMetaData, data, actionCallback}:
|
||||
boxStyle.flexWrap = "wrap";
|
||||
boxStyle.gap = "0.5rem";
|
||||
}
|
||||
else if (layout == "FLEX_ROW")
|
||||
{
|
||||
boxStyle.display = "flex";
|
||||
boxStyle.flexDirection = "row";
|
||||
boxStyle.gap = "0.5rem";
|
||||
}
|
||||
else if (layout == "FLEX_ROW_SPACE_BETWEEN")
|
||||
{
|
||||
boxStyle.display = "flex";
|
||||
@ -114,6 +127,19 @@ export default function CompositeWidget({widgetMetaData, data, actionCallback}:
|
||||
boxStyle = {...boxStyle, ...data.styleOverrides};
|
||||
}
|
||||
|
||||
if (data.styles?.backgroundColor)
|
||||
{
|
||||
boxStyle.backgroundColor = ProcessWidgetBlockUtils.processColorFromStyleMap(data.styles.backgroundColor);
|
||||
}
|
||||
|
||||
if (data.styles?.padding)
|
||||
{
|
||||
boxStyle.paddingTop = data.styles?.padding.top + "px"
|
||||
boxStyle.paddingBottom = data.styles?.padding.bottom + "px"
|
||||
boxStyle.paddingLeft = data.styles?.padding.left + "px"
|
||||
boxStyle.paddingRight = data.styles?.padding.right + "px"
|
||||
}
|
||||
|
||||
let overlayStyle: any = {};
|
||||
|
||||
if (data?.overlayStyleOverrides)
|
||||
@ -121,7 +147,7 @@ export default function CompositeWidget({widgetMetaData, data, actionCallback}:
|
||||
overlayStyle = {...overlayStyle, ...data.overlayStyleOverrides};
|
||||
}
|
||||
|
||||
return (
|
||||
const content = (
|
||||
<>
|
||||
{
|
||||
data?.overlayHtml &&
|
||||
@ -131,7 +157,7 @@ export default function CompositeWidget({widgetMetaData, data, actionCallback}:
|
||||
{
|
||||
data.blocks.map((block: BlockData, index) => (
|
||||
<React.Fragment key={index}>
|
||||
<WidgetBlock widgetMetaData={widgetMetaData} block={block} actionCallback={actionCallback} />
|
||||
<WidgetBlock widgetMetaData={widgetMetaData} block={block} actionCallback={actionCallback} values={values} />
|
||||
</React.Fragment>
|
||||
))
|
||||
}
|
||||
@ -139,4 +165,53 @@ export default function CompositeWidget({widgetMetaData, data, actionCallback}:
|
||||
</>
|
||||
);
|
||||
|
||||
if (data.modalMode)
|
||||
{
|
||||
const [isModalOpen, setIsModalOpen] = useState(values && (values[data.blockId] == true));
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
const controlCallback = (newValue: boolean) =>
|
||||
{
|
||||
setIsModalOpen(newValue);
|
||||
};
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
const modalOnClose = (event: object, reason: string) =>
|
||||
{
|
||||
values[data.blockId] = false;
|
||||
setIsModalOpen(false);
|
||||
actionCallback({blockTypeName: "BUTTON", values: {}}, {controlCode: `hideModal:${data.blockId}`});
|
||||
};
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////
|
||||
// register the control-callback function - so when buttons are clicked, we can be told //
|
||||
//////////////////////////////////////////////////////////////////////////////////////////
|
||||
useEffect(() =>
|
||||
{
|
||||
if (actionCallback)
|
||||
{
|
||||
actionCallback(null, {
|
||||
registerControlCallbackName: data.blockId,
|
||||
registerControlCallbackFunction: controlCallback
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (<Modal open={isModalOpen} onClose={modalOnClose}>
|
||||
<Box sx={{position: "absolute", overflowY: "auto", maxHeight: "100%", width: "100%"}}>
|
||||
<Card sx={{my: 5, mx: "auto", p: "1rem", maxWidth: "1024px"}}>
|
||||
{content}
|
||||
</Card>
|
||||
</Box>
|
||||
</Modal>);
|
||||
}
|
||||
else
|
||||
{
|
||||
return content;
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -18,15 +18,18 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
|
||||
import {QWidgetMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QWidgetMetaData";
|
||||
import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord";
|
||||
import {Alert, Skeleton} from "@mui/material";
|
||||
import Box from "@mui/material/Box";
|
||||
import Grid from "@mui/material/Grid";
|
||||
import Modal from "@mui/material/Modal";
|
||||
import Tab from "@mui/material/Tab";
|
||||
import Tabs from "@mui/material/Tabs";
|
||||
import parse from "html-react-parser";
|
||||
import QContext from "QContext";
|
||||
import EntityForm from "qqq/components/forms/EntityForm";
|
||||
import MDTypography from "qqq/components/legacy/MDTypography";
|
||||
import TabPanel from "qqq/components/misc/TabPanel";
|
||||
import BarChart from "qqq/components/widgets/charts/barchart/BarChart";
|
||||
@ -43,7 +46,7 @@ import FieldValueListWidget from "qqq/components/widgets/misc/FieldValueListWidg
|
||||
import FilterAndColumnsSetupWidget from "qqq/components/widgets/misc/FilterAndColumnsSetupWidget";
|
||||
import PivotTableSetupWidget from "qqq/components/widgets/misc/PivotTableSetupWidget";
|
||||
import QuickSightChart from "qqq/components/widgets/misc/QuickSightChart";
|
||||
import RecordGridWidget from "qqq/components/widgets/misc/RecordGridWidget";
|
||||
import RecordGridWidget, {ChildRecordListData} from "qqq/components/widgets/misc/RecordGridWidget";
|
||||
import ScriptViewer from "qqq/components/widgets/misc/ScriptViewer";
|
||||
import StepperCard from "qqq/components/widgets/misc/StepperCard";
|
||||
import USMapWidget from "qqq/components/widgets/misc/USMapWidget";
|
||||
@ -71,6 +74,9 @@ interface Props
|
||||
childUrlParams?: string;
|
||||
parentWidgetMetaData?: QWidgetMetaData;
|
||||
wrapWidgetsInTabPanels: boolean;
|
||||
actionCallback?: (data: any, eventValues?: { [name: string]: any }) => boolean;
|
||||
initialWidgetDataList: any[];
|
||||
values?: { [key: string]: any };
|
||||
}
|
||||
|
||||
DashboardWidgets.defaultProps = {
|
||||
@ -82,11 +88,14 @@ DashboardWidgets.defaultProps = {
|
||||
childUrlParams: "",
|
||||
parentWidgetMetaData: null,
|
||||
wrapWidgetsInTabPanels: false,
|
||||
actionCallback: null,
|
||||
initialWidgetDataList: null,
|
||||
values: {}
|
||||
};
|
||||
|
||||
function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, record, omitWrappingGridContainer, areChildren, childUrlParams, parentWidgetMetaData, wrapWidgetsInTabPanels}: Props): JSX.Element
|
||||
function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, record, omitWrappingGridContainer, areChildren, childUrlParams, parentWidgetMetaData, wrapWidgetsInTabPanels, actionCallback, initialWidgetDataList, values}: Props): JSX.Element
|
||||
{
|
||||
const [widgetData, setWidgetData] = useState([] as any[]);
|
||||
const [widgetData, setWidgetData] = useState(initialWidgetDataList == null ? [] as any[] : initialWidgetDataList);
|
||||
const [widgetCounter, setWidgetCounter] = useState(0);
|
||||
const [, forceUpdate] = useReducer((x) => x + 1, 0);
|
||||
|
||||
@ -94,6 +103,11 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, reco
|
||||
const [haveLoadedParams, setHaveLoadedParams] = useState(false);
|
||||
const {accentColor} = useContext(QContext);
|
||||
|
||||
/////////////////////////
|
||||
// modal form controls //
|
||||
/////////////////////////
|
||||
const [showEditChildForm, setShowEditChildForm] = useState(null as any);
|
||||
|
||||
let initialSelectedTab = 0;
|
||||
let selectedTabKey: string = null;
|
||||
if (parentWidgetMetaData && wrapWidgetsInTabPanels)
|
||||
@ -114,7 +128,15 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, reco
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
if (initialWidgetDataList && initialWidgetDataList.length > 0)
|
||||
{
|
||||
// todo actually, should this check each element of the array, down in the loop? yeah, when we need to, do it that way.
|
||||
console.log("We already have initial widget data, so not fetching from backend.");
|
||||
return;
|
||||
}
|
||||
|
||||
setWidgetData([]);
|
||||
|
||||
for (let i = 0; i < widgetMetaDataList.length; i++)
|
||||
{
|
||||
const widgetMetaData = widgetMetaDataList[i];
|
||||
@ -151,7 +173,7 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, reco
|
||||
|
||||
const reloadWidget = async (index: number, data: string) =>
|
||||
{
|
||||
(async () =>
|
||||
await (async () =>
|
||||
{
|
||||
const urlParams = getQueryParams(widgetMetaDataList[index], data);
|
||||
setCurrentUrlParams(urlParams);
|
||||
@ -270,6 +292,151 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, reco
|
||||
return (rs);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
const closeEditChildForm = (event: object, reason: string) =>
|
||||
{
|
||||
if (reason === "backdropClick" || reason === "escapeKeyDown")
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
setShowEditChildForm(null);
|
||||
};
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
function deleteChildRecord(name: string, widgetIndex: number, rowIndex: number)
|
||||
{
|
||||
updateChildRecordList(name, "delete", rowIndex);
|
||||
forceUpdate();
|
||||
actionCallback(widgetData[widgetIndex]);
|
||||
};
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
function openEditChildRecord(name: string, widgetData: any, rowIndex: number)
|
||||
{
|
||||
let defaultValues = widgetData.queryOutput.records[rowIndex].values;
|
||||
|
||||
let disabledFields = widgetData.disabledFieldsForNewChildRecords;
|
||||
if (!disabledFields)
|
||||
{
|
||||
disabledFields = widgetData.defaultValuesForNewChildRecords;
|
||||
}
|
||||
|
||||
doOpenEditChildForm(name, widgetData.childTableMetaData, rowIndex, defaultValues, disabledFields);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
function openAddChildRecord(name: string, widgetData: any)
|
||||
{
|
||||
let defaultValues = widgetData.defaultValuesForNewChildRecords;
|
||||
|
||||
let disabledFields = widgetData.disabledFieldsForNewChildRecords;
|
||||
if (!disabledFields)
|
||||
{
|
||||
disabledFields = widgetData.defaultValuesForNewChildRecords;
|
||||
}
|
||||
|
||||
doOpenEditChildForm(name, widgetData.childTableMetaData, null, defaultValues, disabledFields);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
function doOpenEditChildForm(widgetName: string, table: QTableMetaData, rowIndex: number, defaultValues: any, disabledFields: any)
|
||||
{
|
||||
const showEditChildForm: any = {};
|
||||
showEditChildForm.widgetName = widgetName;
|
||||
showEditChildForm.table = table;
|
||||
showEditChildForm.rowIndex = rowIndex;
|
||||
showEditChildForm.defaultValues = defaultValues;
|
||||
showEditChildForm.disabledFields = disabledFields;
|
||||
setShowEditChildForm(showEditChildForm);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
function submitEditChildForm(values: any, tableName: string)
|
||||
{
|
||||
updateChildRecordList(showEditChildForm.widgetName, showEditChildForm.rowIndex == null ? "insert" : "edit", showEditChildForm.rowIndex, values);
|
||||
let widgetIndex = determineChildRecordListIndex(showEditChildForm.widgetName);
|
||||
actionCallback(widgetData[widgetIndex]);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
function determineChildRecordListIndex(widgetName: string): number
|
||||
{
|
||||
let widgetIndex = -1;
|
||||
for (var i = 0; i < widgetMetaDataList.length; i++)
|
||||
{
|
||||
const widgetMetaData = widgetMetaDataList[i];
|
||||
if (widgetMetaData.name == widgetName)
|
||||
{
|
||||
widgetIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return (widgetIndex);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
function updateChildRecordList(widgetName: string, action: "insert" | "edit" | "delete", rowIndex?: number, values?: any)
|
||||
{
|
||||
////////////////////////////////////////////////
|
||||
// find the correct child record widget index //
|
||||
////////////////////////////////////////////////
|
||||
let widgetIndex = determineChildRecordListIndex(widgetName);
|
||||
|
||||
if (!widgetData[widgetIndex].queryOutput.records)
|
||||
{
|
||||
widgetData[widgetIndex].queryOutput.records = [];
|
||||
}
|
||||
|
||||
const newChildListWidgetData: ChildRecordListData = widgetData[widgetIndex];
|
||||
if (!newChildListWidgetData.queryOutput.records)
|
||||
{
|
||||
newChildListWidgetData.queryOutput.records = [];
|
||||
}
|
||||
|
||||
switch (action)
|
||||
{
|
||||
case "insert":
|
||||
newChildListWidgetData.queryOutput.records.push({values: values});
|
||||
break;
|
||||
case "edit":
|
||||
newChildListWidgetData.queryOutput.records[rowIndex] = {values: values};
|
||||
break;
|
||||
case "delete":
|
||||
newChildListWidgetData.queryOutput.records.splice(rowIndex, 1);
|
||||
break;
|
||||
}
|
||||
newChildListWidgetData.totalRows = newChildListWidgetData.queryOutput.records.length;
|
||||
widgetData[widgetIndex] = newChildListWidgetData;
|
||||
setWidgetData(widgetData);
|
||||
|
||||
setShowEditChildForm(null);
|
||||
}
|
||||
|
||||
|
||||
const renderWidget = (widgetMetaData: QWidgetMetaData, i: number): JSX.Element =>
|
||||
{
|
||||
const labelAdditionalComponentsRight: LabelComponent[] = [];
|
||||
@ -309,7 +476,7 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, reco
|
||||
)
|
||||
}
|
||||
{
|
||||
widgetMetaData.type === "alert" && widgetData[i]?.html && (
|
||||
widgetMetaData.type === "alert" && widgetData[i]?.html && !widgetData[i]?.hideWidget && (
|
||||
<Widget
|
||||
omitPadding={true}
|
||||
widgetMetaData={widgetMetaData}
|
||||
@ -319,7 +486,16 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, reco
|
||||
labelAdditionalComponentsRight={labelAdditionalComponentsRight}
|
||||
labelAdditionalComponentsLeft={labelAdditionalComponentsLeft}
|
||||
>
|
||||
<Alert severity={widgetData[i]?.alertType?.toLowerCase()}>{parse(widgetData[i]?.html)}</Alert>
|
||||
<Alert severity={widgetData[i]?.alertType?.toLowerCase()}>
|
||||
{parse(widgetData[i]?.html)}
|
||||
{widgetData[i]?.bulletList && (
|
||||
<div style={{fontSize: "14px"}}>
|
||||
{widgetData[i].bulletList.map((bullet: string, index: number) =>
|
||||
<li key={`widget-${i}-${index}`}>{parse(bullet)}</li>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Alert>
|
||||
</Widget>
|
||||
)
|
||||
}
|
||||
@ -501,9 +677,7 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, reco
|
||||
}
|
||||
{
|
||||
widgetMetaData.type === "divider" && (
|
||||
<Box>
|
||||
<DividerWidget />
|
||||
</Box>
|
||||
<DividerWidget />
|
||||
)
|
||||
}
|
||||
{
|
||||
@ -537,8 +711,15 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, reco
|
||||
widgetMetaData.type === "childRecordList" && (
|
||||
widgetData && widgetData[i] &&
|
||||
<RecordGridWidget
|
||||
disableRowClick={widgetData[i]?.disableRowClick}
|
||||
allowRecordEdit={widgetData[i]?.allowRecordEdit}
|
||||
allowRecordDelete={widgetData[i]?.allowRecordDelete}
|
||||
deleteRecordCallback={(rowIndex) => deleteChildRecord(widgetMetaData.name, i, rowIndex)}
|
||||
editRecordCallback={(rowIndex) => openEditChildRecord(widgetMetaData.name, widgetData[i], rowIndex)}
|
||||
addNewRecordCallback={widgetData[i]?.isInProcess ? () => openAddChildRecord(widgetMetaData.name, widgetData[i]) : null}
|
||||
widgetMetaData={widgetMetaData}
|
||||
data={widgetData[i]}
|
||||
parentRecord={record}
|
||||
/>
|
||||
)
|
||||
|
||||
@ -563,7 +744,7 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, reco
|
||||
labelAdditionalComponentsRight={labelAdditionalComponentsRight}
|
||||
labelAdditionalComponentsLeft={labelAdditionalComponentsLeft}
|
||||
>
|
||||
<CompositeWidget widgetMetaData={widgetMetaData} data={widgetData[i]} />
|
||||
<CompositeWidget widgetMetaData={widgetMetaData} data={widgetData[i]} actionCallback={actionCallback} values={values} />
|
||||
</Widget>
|
||||
)
|
||||
}
|
||||
@ -638,23 +819,23 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, reco
|
||||
|
||||
if (!omitWrappingGridContainer)
|
||||
{
|
||||
const gridProps: {[key: string]: any} = {};
|
||||
const gridProps: { [key: string]: any } = {};
|
||||
|
||||
for(let size of ["xs", "sm", "md", "lg", "xl", "xxl"])
|
||||
for (let size of ["xs", "sm", "md", "lg", "xl", "xxl"])
|
||||
{
|
||||
const key = `gridCols:sizeClass:${size}`
|
||||
if(widgetMetaData?.defaultValues?.has(key))
|
||||
const key = `gridCols:sizeClass:${size}`;
|
||||
if (widgetMetaData?.defaultValues?.has(key))
|
||||
{
|
||||
gridProps[size] = widgetMetaData?.defaultValues.get(key);
|
||||
}
|
||||
}
|
||||
|
||||
if(!gridProps["xxl"])
|
||||
if (!gridProps["xxl"])
|
||||
{
|
||||
gridProps["xxl"] = widgetMetaData.gridColumns ? widgetMetaData.gridColumns : 12;
|
||||
}
|
||||
|
||||
if(!gridProps["xs"])
|
||||
if (!gridProps["xs"])
|
||||
{
|
||||
gridProps["xs"] = 12;
|
||||
}
|
||||
@ -710,6 +891,22 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, reco
|
||||
</Grid>
|
||||
)
|
||||
}
|
||||
{
|
||||
showEditChildForm &&
|
||||
<Modal open={showEditChildForm as boolean} onClose={(event, reason) => closeEditChildForm(event, reason)}>
|
||||
<div className="modalEditForm">
|
||||
<EntityForm
|
||||
isModal={true}
|
||||
closeModalHandler={closeEditChildForm}
|
||||
table={showEditChildForm.table}
|
||||
defaultValues={showEditChildForm.defaultValues}
|
||||
disabledFields={showEditChildForm.disabledFields}
|
||||
onSubmitCallback={submitEditChildForm}
|
||||
overrideHeading={`${showEditChildForm.rowIndex != null ? "Editing" : "Creating New"} ${showEditChildForm.table.label}`}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
}
|
||||
</>
|
||||
) : null
|
||||
);
|
||||
|
@ -22,7 +22,7 @@
|
||||
|
||||
import {QWidgetMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QWidgetMetaData";
|
||||
import {Alert, Skeleton} from "@mui/material";
|
||||
import ActionButtonBlock from "qqq/components/widgets/blocks/ActionButtonBlock";
|
||||
import ButtonBlock from "qqq/components/widgets/blocks/ButtonBlock";
|
||||
import AudioBlock from "qqq/components/widgets/blocks/AudioBlock";
|
||||
import InputFieldBlock from "qqq/components/widgets/blocks/InputFieldBlock";
|
||||
import React from "react";
|
||||
@ -42,14 +42,15 @@ interface WidgetBlockProps
|
||||
{
|
||||
widgetMetaData: QWidgetMetaData;
|
||||
block: BlockData;
|
||||
actionCallback?: (blockData: BlockData) => boolean;
|
||||
actionCallback?: (blockData: BlockData, eventValues?: {[name: string]: any}) => boolean;
|
||||
values?: { [key: string]: any };
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Component to render a single Block in the widget framework!
|
||||
*******************************************************************************/
|
||||
export default function WidgetBlock({widgetMetaData, block, actionCallback}: WidgetBlockProps): JSX.Element
|
||||
export default function WidgetBlock({widgetMetaData, block, actionCallback, values}: WidgetBlockProps): JSX.Element
|
||||
{
|
||||
if(!block)
|
||||
{
|
||||
@ -69,7 +70,7 @@ export default function WidgetBlock({widgetMetaData, block, actionCallback}: Wid
|
||||
if(block.blockTypeName == "COMPOSITE")
|
||||
{
|
||||
// @ts-ignore - special case for composite type block...
|
||||
return (<CompositeWidget widgetMetaData={widgetMetaData} data={block} actionCallback={actionCallback} />);
|
||||
return (<CompositeWidget widgetMetaData={widgetMetaData} data={block} actionCallback={actionCallback} values={values} />);
|
||||
}
|
||||
|
||||
switch(block.blockTypeName)
|
||||
@ -90,8 +91,8 @@ export default function WidgetBlock({widgetMetaData, block, actionCallback}: Wid
|
||||
return (<BigNumberBlock widgetMetaData={widgetMetaData} data={block} />);
|
||||
case "INPUT_FIELD":
|
||||
return (<InputFieldBlock widgetMetaData={widgetMetaData} data={block} actionCallback={actionCallback} />);
|
||||
case "ACTION_BUTTON":
|
||||
return (<ActionButtonBlock widgetMetaData={widgetMetaData} data={block} actionCallback={actionCallback} />);
|
||||
case "BUTTON":
|
||||
return (<ButtonBlock widgetMetaData={widgetMetaData} data={block} actionCallback={actionCallback} />);
|
||||
case "AUDIO":
|
||||
return (<AudioBlock widgetMetaData={widgetMetaData} data={block} />);
|
||||
case "IMAGE":
|
||||
|
@ -29,30 +29,56 @@ import React from "react";
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Block that renders ... an action button...
|
||||
** Block that renders ... a button...
|
||||
**
|
||||
*******************************************************************************/
|
||||
export default function ActionButtonBlock({widgetMetaData, data, actionCallback}: StandardBlockComponentProps): JSX.Element
|
||||
export default function ButtonBlock({widgetMetaData, data, actionCallback}: StandardBlockComponentProps): JSX.Element
|
||||
{
|
||||
const icon = data.values.iconName ? <Icon>{data.values.iconName}</Icon> : null;
|
||||
const startIcon = data.values.startIcon?.name ? <Icon>{data.values.startIcon.name}</Icon> : null;
|
||||
const endIcon = data.values.endIcon?.name ? <Icon>{data.values.endIcon.name}</Icon> : null;
|
||||
|
||||
function onClick()
|
||||
{
|
||||
if(actionCallback)
|
||||
if (actionCallback)
|
||||
{
|
||||
actionCallback(data, {actionCode: data.values?.actionCode})
|
||||
actionCallback(data, data.values);
|
||||
}
|
||||
else
|
||||
{
|
||||
console.log("ActionButtonBlock onClick with no actionCallback present, so, noop");
|
||||
console.log("ButtonBlock onClick with no actionCallback present, so, noop");
|
||||
}
|
||||
}
|
||||
|
||||
let buttonVariant: "gradient" | "outlined" | "text" = "gradient";
|
||||
if (data.styles?.format == "outlined")
|
||||
{
|
||||
buttonVariant = "outlined";
|
||||
}
|
||||
else if (data.styles?.format == "text")
|
||||
{
|
||||
buttonVariant = "text";
|
||||
}
|
||||
else if (data.styles?.format == "filled")
|
||||
{
|
||||
buttonVariant = "gradient";
|
||||
}
|
||||
|
||||
// todo - button colors... but to do RGB's, might need to move away from MDButton?
|
||||
|
||||
return (
|
||||
<BlockElementWrapper metaData={widgetMetaData} data={data} slot="">
|
||||
<Box mx={1} my={1} minWidth={standardWidth}>
|
||||
<MDButton type="button" variant="gradient" color="dark" size="small" fullWidth startIcon={icon} onClick={onClick}>
|
||||
{data.values.label ?? "Action"}
|
||||
<MDButton
|
||||
type="button"
|
||||
variant={buttonVariant}
|
||||
color="dark"
|
||||
size="small"
|
||||
fullWidth
|
||||
startIcon={startIcon}
|
||||
endIcon={endIcon}
|
||||
onClick={onClick}
|
||||
>
|
||||
{data.values.label ?? "Button"}
|
||||
</MDButton>
|
||||
</Box>
|
||||
</BlockElementWrapper>
|
@ -52,10 +52,10 @@ export default function InputFieldBlock({widgetMetaData, data, actionCallback}:
|
||||
// so let us remove the default blur handler, for the first (auto) focus/blur //
|
||||
// cycle, and we seem to have a better time. //
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
let onBlurRest: {onBlur?: any} = {}
|
||||
let dynamicFormFieldRest: {onBlur?: any, sx?: any} = {}
|
||||
if(autoFocus && blurCount == 0)
|
||||
{
|
||||
onBlurRest.onBlur = (event: React.SyntheticEvent) =>
|
||||
dynamicFormFieldRest.onBlur = (event: React.SyntheticEvent) =>
|
||||
{
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
@ -120,7 +120,18 @@ export default function InputFieldBlock({widgetMetaData, data, actionCallback}:
|
||||
<BlockElementWrapper metaData={widgetMetaData} data={data} slot="">
|
||||
<>
|
||||
{labelElement}
|
||||
<QDynamicFormField name={fieldMetaData.name} displayFormat={null} label="" formFieldObject={dynamicField} type={fieldMetaData.type} value={value} autoFocus={autoFocus} onKeyUp={eventHandler} {...onBlurRest} />
|
||||
<QDynamicFormField
|
||||
name={fieldMetaData.name}
|
||||
displayFormat={null}
|
||||
label=""
|
||||
placeholder={data.values?.placeholder}
|
||||
backgroundColor="#FFFFFF"
|
||||
formFieldObject={dynamicField}
|
||||
type={fieldMetaData.type}
|
||||
value={value}
|
||||
autoFocus={autoFocus}
|
||||
onKeyUp={eventHandler}
|
||||
{...dynamicFormFieldRest} />
|
||||
</>
|
||||
</BlockElementWrapper>
|
||||
</Box>
|
||||
|
@ -20,9 +20,11 @@
|
||||
*/
|
||||
|
||||
import Box from "@mui/material/Box";
|
||||
import Icon from "@mui/material/Icon";
|
||||
import BlockElementWrapper from "qqq/components/widgets/blocks/BlockElementWrapper";
|
||||
import {StandardBlockComponentProps} from "qqq/components/widgets/blocks/BlockModels";
|
||||
import DumpJsonBox from "qqq/utils/DumpJsonBox";
|
||||
import ProcessWidgetBlockUtils from "qqq/pages/processes/ProcessWidgetBlockUtils";
|
||||
import React from "react";
|
||||
|
||||
/*******************************************************************************
|
||||
** Block that renders ... just some text.
|
||||
@ -32,30 +34,13 @@ import DumpJsonBox from "qqq/utils/DumpJsonBox";
|
||||
export default function TextBlock({widgetMetaData, data}: StandardBlockComponentProps): JSX.Element
|
||||
{
|
||||
let color = "rgba(0, 0, 0, 0.87)";
|
||||
if (data.styles?.standardColor)
|
||||
if (data.styles?.color)
|
||||
{
|
||||
switch (data.styles?.standardColor)
|
||||
{
|
||||
case "SUCCESS":
|
||||
color = "#2BA83F";
|
||||
break;
|
||||
case "WARNING":
|
||||
color = "#FBA132";
|
||||
break;
|
||||
case "ERROR":
|
||||
color = "#FB4141";
|
||||
break;
|
||||
case "INFO":
|
||||
color = "#458CFF";
|
||||
break;
|
||||
case "MUTED":
|
||||
color = "#7b809a";
|
||||
break;
|
||||
}
|
||||
color = ProcessWidgetBlockUtils.processColorFromStyleMap(data.styles.color);
|
||||
}
|
||||
|
||||
let boxStyle = {};
|
||||
if (data.styles?.isAlert)
|
||||
if (data.styles?.format == "alert")
|
||||
{
|
||||
boxStyle =
|
||||
{
|
||||
@ -65,17 +50,112 @@ export default function TextBlock({widgetMetaData, data}: StandardBlockComponent
|
||||
borderRadius: "0.5rem",
|
||||
};
|
||||
}
|
||||
else if (data.styles?.format == "banner")
|
||||
{
|
||||
boxStyle =
|
||||
{
|
||||
background: `${color}40`,
|
||||
padding: "0.5rem",
|
||||
};
|
||||
}
|
||||
|
||||
let fontSize = "1rem";
|
||||
if (data.styles?.size)
|
||||
{
|
||||
switch (data.styles.size.toLowerCase())
|
||||
{
|
||||
case "largest":
|
||||
fontSize = "3rem";
|
||||
break;
|
||||
case "headline":
|
||||
fontSize = "2rem";
|
||||
break;
|
||||
case "title":
|
||||
fontSize = "1.5rem";
|
||||
break;
|
||||
case "body":
|
||||
fontSize = "1rem";
|
||||
break;
|
||||
case "smallest":
|
||||
fontSize = "0.75rem";
|
||||
break;
|
||||
default:
|
||||
{
|
||||
if (data.styles.size.match(/^\d+$/))
|
||||
{
|
||||
fontSize = `${data.styles.size}px`;
|
||||
}
|
||||
else
|
||||
{
|
||||
fontSize = "1rem";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let fontWeight = "400";
|
||||
if (data.styles?.weight)
|
||||
{
|
||||
switch (data.styles.weight.toLowerCase())
|
||||
{
|
||||
case "thin":
|
||||
case "100":
|
||||
fontWeight = "100";
|
||||
break;
|
||||
case "extralight":
|
||||
case "200":
|
||||
fontWeight = "200";
|
||||
break;
|
||||
case "light":
|
||||
case "300":
|
||||
fontWeight = "300";
|
||||
break;
|
||||
case "normal":
|
||||
case "400":
|
||||
fontWeight = "400";
|
||||
break;
|
||||
case "medium":
|
||||
case "500":
|
||||
fontWeight = "500";
|
||||
break;
|
||||
case "semibold":
|
||||
case "600":
|
||||
fontWeight = "600";
|
||||
break;
|
||||
case "bold":
|
||||
case "700":
|
||||
fontWeight = "700";
|
||||
break;
|
||||
case "extrabold":
|
||||
case "800":
|
||||
fontWeight = "800";
|
||||
break;
|
||||
case "black":
|
||||
case "900":
|
||||
fontWeight = "900";
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const text = data.values.interpolatedText ?? data.values.text;
|
||||
const lines = text.split("\n");
|
||||
|
||||
const startIcon = data.values.startIcon?.name ? <Icon>{data.values.startIcon.name}</Icon> : null;
|
||||
const endIcon = data.values.endIcon?.name ? <Icon>{data.values.endIcon.name}</Icon> : null;
|
||||
|
||||
return (
|
||||
<BlockElementWrapper metaData={widgetMetaData} data={data} slot="">
|
||||
<Box display="inline-block" lineHeight="1.2" sx={boxStyle}>
|
||||
<span style={{fontSize: "1rem", color: color}}>
|
||||
<span style={{fontSize: fontSize, color: color, fontWeight: fontWeight}}>
|
||||
{lines.map((line: string, index: number) =>
|
||||
(
|
||||
<div key={index}>{line}</div>
|
||||
<div key={index}>
|
||||
<>
|
||||
{index == 0 && startIcon ? {startIcon} : null}
|
||||
{line}
|
||||
{index == lines.length - 1 && endIcon ? {endIcon} : null}
|
||||
</>
|
||||
</div>
|
||||
))
|
||||
}</span>
|
||||
</Box>
|
||||
|
@ -50,6 +50,7 @@ import DeveloperModeUtils from "qqq/utils/DeveloperModeUtils";
|
||||
import Client from "qqq/utils/qqq/Client";
|
||||
import ValueUtils from "qqq/utils/qqq/ValueUtils";
|
||||
|
||||
import "ace-builds/src-noconflict/ace";
|
||||
import "ace-builds/src-noconflict/mode-java";
|
||||
import "ace-builds/src-noconflict/mode-javascript";
|
||||
import "ace-builds/src-noconflict/mode-json";
|
||||
|
@ -19,13 +19,16 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import Box from "@mui/material/Box";
|
||||
import Divider from "@mui/material/Divider";
|
||||
|
||||
|
||||
function DividerWidget(): JSX.Element
|
||||
{
|
||||
return (
|
||||
<Divider sx={{padding: "1px", background: "red"}}/>
|
||||
<Box pl={3} pt={3} pb={3} width="100%">
|
||||
<Divider sx={{width: "100%", height: "1px", background: "grey"}} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -46,11 +46,12 @@ import React, {useContext, useEffect, useRef, useState} from "react";
|
||||
|
||||
interface FilterAndColumnsSetupWidgetProps
|
||||
{
|
||||
isEditable: boolean;
|
||||
widgetMetaData: QWidgetMetaData;
|
||||
widgetData: any;
|
||||
recordValues: { [name: string]: any };
|
||||
onSaveCallback?: (values: { [name: string]: any }) => void;
|
||||
isEditable: boolean,
|
||||
widgetMetaData: QWidgetMetaData,
|
||||
widgetData: any,
|
||||
recordValues: { [name: string]: any },
|
||||
onSaveCallback?: (values: { [name: string]: any }) => void,
|
||||
label?: string
|
||||
}
|
||||
|
||||
FilterAndColumnsSetupWidget.defaultProps = {
|
||||
@ -83,13 +84,16 @@ const qController = Client.getInstance();
|
||||
/*******************************************************************************
|
||||
** Component for editing the main setup of a report - that is: filter & columns
|
||||
*******************************************************************************/
|
||||
export default function FilterAndColumnsSetupWidget({isEditable, widgetMetaData, widgetData, recordValues, onSaveCallback}: FilterAndColumnsSetupWidgetProps): JSX.Element
|
||||
export default function FilterAndColumnsSetupWidget({isEditable, widgetMetaData, widgetData, recordValues, onSaveCallback, label}: FilterAndColumnsSetupWidgetProps): JSX.Element
|
||||
{
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [hideColumns, setHideColumns] = useState(widgetData?.hideColumns);
|
||||
const [hidePreview, setHidePreview] = useState(widgetData?.hidePreview);
|
||||
const [hideColumns] = useState(widgetData?.hideColumns);
|
||||
const [hidePreview] = useState(widgetData?.hidePreview);
|
||||
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);
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
@ -108,7 +112,7 @@ export default function FilterAndColumnsSetupWidget({isEditable, widgetMetaData,
|
||||
/////////////////////////////
|
||||
let columns: QQueryColumns = null;
|
||||
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;
|
||||
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);
|
||||
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();
|
||||
}
|
||||
@ -356,7 +363,7 @@ export default function FilterAndColumnsSetupWidget({isEditable, widgetMetaData,
|
||||
</Collapse>
|
||||
<Box pt="0.5rem">
|
||||
<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>
|
||||
{
|
||||
|
@ -28,7 +28,7 @@ import Icon from "@mui/material/Icon";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import Tooltip from "@mui/material/Tooltip/Tooltip";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import {DataGridPro, GridCallbackDetails, 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 DataGridUtils from "qqq/utils/DataGridUtils";
|
||||
import HtmlUtils from "qqq/utils/HtmlUtils";
|
||||
@ -39,39 +39,44 @@ import {Link, useNavigate} from "react-router-dom";
|
||||
|
||||
export interface ChildRecordListData extends WidgetData
|
||||
{
|
||||
title: string;
|
||||
queryOutput: { records: { values: any }[] };
|
||||
childTableMetaData: QTableMetaData;
|
||||
tablePath: string;
|
||||
viewAllLink: string;
|
||||
totalRows: number;
|
||||
canAddChildRecord: boolean;
|
||||
defaultValuesForNewChildRecords: { [fieldName: string]: any };
|
||||
disabledFieldsForNewChildRecords: { [fieldName: string]: any };
|
||||
title?: string;
|
||||
queryOutput?: { records: { values: any, displayValues?: any } [] };
|
||||
childTableMetaData?: QTableMetaData;
|
||||
tablePath?: string;
|
||||
viewAllLink?: string;
|
||||
totalRows?: number;
|
||||
canAddChildRecord?: boolean;
|
||||
defaultValuesForNewChildRecords?: { [fieldName: string]: any };
|
||||
disabledFieldsForNewChildRecords?: { [fieldName: string]: any };
|
||||
defaultValuesForNewChildRecordsFromParentFields?: { [fieldName: string]: string };
|
||||
}
|
||||
|
||||
interface Props
|
||||
{
|
||||
widgetMetaData: QWidgetMetaData;
|
||||
data: ChildRecordListData;
|
||||
addNewRecordCallback?: () => void;
|
||||
disableRowClick: boolean;
|
||||
allowRecordEdit: boolean;
|
||||
editRecordCallback?: (rowIndex: number) => void;
|
||||
allowRecordDelete: boolean;
|
||||
deleteRecordCallback?: (rowIndex: number) => void;
|
||||
widgetMetaData: QWidgetMetaData,
|
||||
data: ChildRecordListData,
|
||||
addNewRecordCallback?: () => void,
|
||||
disableRowClick: boolean,
|
||||
allowRecordEdit: boolean,
|
||||
editRecordCallback?: (rowIndex: number) => void,
|
||||
allowRecordDelete: boolean,
|
||||
deleteRecordCallback?: (rowIndex: number) => void,
|
||||
gridOnly?: boolean,
|
||||
gridDensity?: GridDensity,
|
||||
parentRecord?: QRecord
|
||||
}
|
||||
|
||||
RecordGridWidget.defaultProps =
|
||||
{
|
||||
disableRowClick: false,
|
||||
allowRecordEdit: false,
|
||||
allowRecordDelete: false
|
||||
allowRecordDelete: false,
|
||||
gridOnly: false,
|
||||
};
|
||||
|
||||
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 [rows, setRows] = useState([]);
|
||||
@ -94,11 +99,18 @@ function RecordGridWidget({widgetMetaData, data, addNewRecordCallback, disableRo
|
||||
{
|
||||
for (let i = 0; i < queryOutputRecords.length; i++)
|
||||
{
|
||||
records.push(new QRecord(queryOutputRecords[i]));
|
||||
if (queryOutputRecords[i] instanceof QRecord)
|
||||
{
|
||||
records.push(queryOutputRecords[i] as QRecord);
|
||||
}
|
||||
else
|
||||
{
|
||||
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);
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////
|
||||
@ -176,7 +188,7 @@ function RecordGridWidget({widgetMetaData, data, addNewRecordCallback, disableRo
|
||||
setCsv(csv);
|
||||
setFileName(fileName);
|
||||
}
|
||||
}, [data]);
|
||||
}, [JSON.stringify(data?.queryOutput)]);
|
||||
|
||||
///////////////////
|
||||
// view all link //
|
||||
@ -242,7 +254,22 @@ function RecordGridWidget({widgetMetaData, data, addNewRecordCallback, disableRo
|
||||
{
|
||||
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,6 +322,62 @@ function RecordGridWidget({widgetMetaData, data, addNewRecordCallback, disableRo
|
||||
return (<GridToolbarContainer />);
|
||||
}
|
||||
|
||||
let containerPadding = -3;
|
||||
if (data?.isInProcess)
|
||||
{
|
||||
containerPadding = 0;
|
||||
}
|
||||
|
||||
|
||||
const grid = (
|
||||
<DataGridPro
|
||||
autoHeight
|
||||
sx={{
|
||||
borderBottom: "none",
|
||||
borderLeft: "none",
|
||||
borderRight: "none"
|
||||
}}
|
||||
rows={rows}
|
||||
disableSelectionOnClick
|
||||
columns={columns}
|
||||
rowBuffer={10}
|
||||
getRowClassName={(params) => (params.indexRelativeToCurrentPage % 2 === 0 ? "even" : "odd")}
|
||||
onRowClick={handleRowClick}
|
||||
getRowId={(row) => row.__rowIndex}
|
||||
// getRowHeight={() => "auto"} // maybe nice? wraps values in cells...
|
||||
components={{
|
||||
Toolbar: CustomToolbar
|
||||
}}
|
||||
// pinnedColumns={pinnedColumns}
|
||||
// onPinnedColumnsChange={handlePinnedColumnsChange}
|
||||
// pagination
|
||||
// paginationMode="server"
|
||||
// rowsPerPageOptions={[20]}
|
||||
// sortingMode="server"
|
||||
// filterMode="server"
|
||||
// page={pageNumber}
|
||||
// checkboxSelection
|
||||
rowCount={data && data.totalRows}
|
||||
// onPageSizeChange={handleRowsPerPageChange}
|
||||
// onStateChange={handleStateChange}
|
||||
density={gridDensity ?? "standard"}
|
||||
// loading={loading}
|
||||
// filterModel={filterModel}
|
||||
// onFilterModelChange={handleFilterChange}
|
||||
// columnVisibilityModel={columnVisibilityModel}
|
||||
// onColumnVisibilityModelChange={handleColumnVisibilityChange}
|
||||
// onColumnOrderChange={handleColumnOrderChange}
|
||||
// onSelectionModelChange={selectionChanged}
|
||||
// onSortModelChange={handleSortChange}
|
||||
// sortingOrder={[ "asc", "desc" ]}
|
||||
// sortModel={columnSortModel}
|
||||
/>
|
||||
);
|
||||
|
||||
if (gridOnly)
|
||||
{
|
||||
return (grid);
|
||||
}
|
||||
|
||||
return (
|
||||
<Widget
|
||||
@ -304,50 +387,9 @@ function RecordGridWidget({widgetMetaData, data, addNewRecordCallback, disableRo
|
||||
labelAdditionalComponentsRight={labelAdditionalComponentsRight}
|
||||
labelBoxAdditionalSx={{position: "relative", top: "-0.375rem"}}
|
||||
>
|
||||
<Box mx={-3} mb={-3}>
|
||||
<Box mx={containerPadding} mb={containerPadding}>
|
||||
<Box>
|
||||
<DataGridPro
|
||||
autoHeight
|
||||
sx={{
|
||||
borderBottom: "none",
|
||||
borderLeft: "none",
|
||||
borderRight: "none"
|
||||
}}
|
||||
rows={rows}
|
||||
disableSelectionOnClick
|
||||
columns={columns}
|
||||
rowBuffer={10}
|
||||
getRowClassName={(params) => (params.indexRelativeToCurrentPage % 2 === 0 ? "even" : "odd")}
|
||||
onRowClick={handleRowClick}
|
||||
getRowId={(row) => row.__rowIndex}
|
||||
// getRowHeight={() => "auto"} // maybe nice? wraps values in cells...
|
||||
components={{
|
||||
Toolbar: CustomToolbar
|
||||
}}
|
||||
// pinnedColumns={pinnedColumns}
|
||||
// onPinnedColumnsChange={handlePinnedColumnsChange}
|
||||
// pagination
|
||||
// paginationMode="server"
|
||||
// rowsPerPageOptions={[20]}
|
||||
// sortingMode="server"
|
||||
// filterMode="server"
|
||||
// page={pageNumber}
|
||||
// checkboxSelection
|
||||
rowCount={data && data.totalRows}
|
||||
// onPageSizeChange={handleRowsPerPageChange}
|
||||
// onStateChange={handleStateChange}
|
||||
// density={density}
|
||||
// loading={loading}
|
||||
// filterModel={filterModel}
|
||||
// onFilterModelChange={handleFilterChange}
|
||||
// columnVisibilityModel={columnVisibilityModel}
|
||||
// onColumnVisibilityModelChange={handleColumnVisibilityChange}
|
||||
// onColumnOrderChange={handleColumnOrderChange}
|
||||
// onSelectionModelChange={selectionChanged}
|
||||
// onSortModelChange={handleSortChange}
|
||||
// sortingOrder={[ "asc", "desc" ]}
|
||||
// sortModel={columnSortModel}
|
||||
/>
|
||||
{grid}
|
||||
</Box>
|
||||
</Box>
|
||||
</Widget>
|
||||
|
@ -393,7 +393,7 @@ export default function ScriptViewer({scriptId, associatedScriptTableName, assoc
|
||||
}
|
||||
|
||||
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}>
|
||||
<Box>
|
||||
{
|
||||
@ -530,7 +530,7 @@ export default function ScriptViewer({scriptId, associatedScriptTableName, assoc
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel index={2} value={selectedTab}>
|
||||
<Box sx={{height: "455px"}} px={2} pb={1}>
|
||||
<Box sx={{height: "455px"}} px={2} pt={1}>
|
||||
<ScriptTestForm scriptId={scriptId}
|
||||
scriptType={scriptTypeRecord}
|
||||
tableName={associatedScriptTableName}
|
||||
@ -543,7 +543,7 @@ export default function ScriptViewer({scriptId, associatedScriptTableName, assoc
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel index={3} value={selectedTab}>
|
||||
<Box sx={{height: "455px"}} px={2} pb={1}>
|
||||
<Box sx={{height: "455px"}} px={2} pt={1}>
|
||||
<ScriptDocsForm helpText={scriptTypeRecord?.values.get("helpText")} exampleCode={scriptTypeRecord?.values.get("sampleCode")} />
|
||||
</Box>
|
||||
</TabPanel>
|
||||
|
96
src/qqq/components/widgets/tables/ModalEditForm.tsx
Normal file
96
src/qqq/components/widgets/tables/ModalEditForm.tsx
Normal file
@ -0,0 +1,96 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2022. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
|
||||
import Modal from "@mui/material/Modal";
|
||||
import EntityForm from "qqq/components/forms/EntityForm";
|
||||
import Client from "qqq/utils/qqq/Client";
|
||||
import React, {useEffect, useReducer, useState} from "react";
|
||||
|
||||
|
||||
////////////////////////////////
|
||||
// structure of expected data //
|
||||
////////////////////////////////
|
||||
export interface ModalEditFormData
|
||||
{
|
||||
tableName: string;
|
||||
defaultValues?: { [key: string]: string };
|
||||
disabledFields?: { [key: string]: boolean } | string[];
|
||||
overrideHeading?: string;
|
||||
onSubmitCallback?: (values: any, tableName: String) => void;
|
||||
initialShowModalValue?: boolean;
|
||||
}
|
||||
|
||||
const qController = Client.getInstance();
|
||||
|
||||
function ModalEditForm({tableName, defaultValues, disabledFields, overrideHeading, onSubmitCallback, initialShowModalValue}: ModalEditFormData,): JSX.Element
|
||||
{
|
||||
const [showModal, setShowModal] = useState(initialShowModalValue);
|
||||
const [table, setTable] = useState(null as QTableMetaData);
|
||||
const [, forceUpdate] = useReducer((x) => x + 1, 0);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
if (!tableName)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
(async () =>
|
||||
{
|
||||
const tableMetaData = await qController.loadTableMetaData(tableName);
|
||||
setTable(tableMetaData);
|
||||
forceUpdate();
|
||||
})();
|
||||
}, [tableName]);
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
const closeEditChildForm = (event: object, reason: string) =>
|
||||
{
|
||||
if (reason === "backdropClick" || reason === "escapeKeyDown")
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
setShowModal(null);
|
||||
};
|
||||
|
||||
return (
|
||||
table && showModal &&
|
||||
<Modal open={showModal as boolean} onClose={(event, reason) => closeEditChildForm(event, reason)}>
|
||||
<div className="modalEditForm">
|
||||
<EntityForm
|
||||
isModal={true}
|
||||
closeModalHandler={closeEditChildForm}
|
||||
table={table}
|
||||
defaultValues={defaultValues}
|
||||
disabledFields={disabledFields}
|
||||
onSubmitCallback={onSubmitCallback}
|
||||
overrideHeading={overrideHeading}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default ModalEditForm;
|
852
src/qqq/models/processes/BulkLoadModels.ts
Normal file
852
src/qqq/models/processes/BulkLoadModels.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
@ -53,6 +53,8 @@ export class ProcessSummaryLine
|
||||
linkText: string;
|
||||
linkPostText: string;
|
||||
|
||||
bulletsOfText: any[];
|
||||
|
||||
constructor(processSummaryLine: any)
|
||||
{
|
||||
this.status = processSummaryLine.status;
|
||||
@ -66,6 +68,8 @@ export class ProcessSummaryLine
|
||||
this.linkText = processSummaryLine.linkText;
|
||||
this.linkPostText = processSummaryLine.linkPostText;
|
||||
|
||||
this.bulletsOfText = processSummaryLine.bulletsOfText;
|
||||
|
||||
this.filter = processSummaryLine.filter;
|
||||
}
|
||||
|
||||
@ -142,6 +146,13 @@ export class ProcessSummaryLine
|
||||
</span>
|
||||
) : <span>{lastWord}</span>
|
||||
}
|
||||
{
|
||||
this.bulletsOfText && <ul style={{marginLeft: "2rem"}}>
|
||||
{
|
||||
this.bulletsOfText.map((bullet, index) => <li key={index}>{bullet}</li>)
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</ListItemText>
|
||||
</Box>
|
||||
</ListItem>
|
||||
|
@ -20,7 +20,6 @@
|
||||
*/
|
||||
|
||||
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 {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
|
||||
import {QFrontendComponent} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFrontendComponent";
|
||||
@ -52,27 +51,31 @@ import {Form, Formik} from "formik";
|
||||
import parse from "html-react-parser";
|
||||
import QContext from "QContext";
|
||||
import colors from "qqq/assets/theme/base/colors";
|
||||
import {QCancelButton, QSubmitButton} from "qqq/components/buttons/DefaultButtons";
|
||||
import {QAlternateButton, QCancelButton, QSubmitButton} from "qqq/components/buttons/DefaultButtons";
|
||||
import QDynamicForm from "qqq/components/forms/DynamicForm";
|
||||
import DynamicFormUtils from "qqq/components/forms/DynamicFormUtils";
|
||||
import MDButton from "qqq/components/legacy/MDButton";
|
||||
import MDProgress from "qqq/components/legacy/MDProgress";
|
||||
import MDTypography from "qqq/components/legacy/MDTypography";
|
||||
import HelpContent, {hasHelpContent} from "qqq/components/misc/HelpContent";
|
||||
import QRecordSidebar from "qqq/components/misc/RecordSidebar";
|
||||
import 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 ProcessSummaryResults from "qqq/components/processes/ProcessSummaryResults";
|
||||
import ProcessViewForm from "qqq/components/processes/ProcessViewForm";
|
||||
import ValidationReview from "qqq/components/processes/ValidationReview";
|
||||
import {BlockData} from "qqq/components/widgets/blocks/BlockModels";
|
||||
import CompositeWidget, {CompositeData} from "qqq/components/widgets/CompositeWidget";
|
||||
import DashboardWidgets from "qqq/components/widgets/DashboardWidgets";
|
||||
import {ChildRecordListData} from "qqq/components/widgets/misc/RecordGridWidget";
|
||||
import BaseLayout from "qqq/layouts/BaseLayout";
|
||||
import ProcessWidgetBlockUtils from "qqq/pages/processes/ProcessWidgetBlockUtils";
|
||||
import {TABLE_VARIANT_LOCAL_STORAGE_KEY_ROOT} from "qqq/pages/records/query/RecordQuery";
|
||||
import Client from "qqq/utils/qqq/Client";
|
||||
import TableUtils from "qqq/utils/qqq/TableUtils";
|
||||
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 * as Yup from "yup";
|
||||
|
||||
@ -95,10 +98,12 @@ const INITIAL_RETRY_MILLIS = 1_500;
|
||||
const RETRY_MAX_MILLIS = 12_000;
|
||||
const BACKOFF_AMOUNT = 1.5;
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// define some functions that we can make referene to, which we'll overwrite //
|
||||
// with functions from formik, once we're inside formik. //
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
const qController = Client.getInstance();
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
// define some functions that we can make reference to, which we'll overwrite //
|
||||
// with functions from formik, once we're inside formik. //
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
let formikSetFieldValueFunction = (field: string, value: any, shouldValidate?: boolean): void =>
|
||||
{
|
||||
};
|
||||
@ -109,6 +114,10 @@ let formikSetTouched = ({}: any, touched: boolean): void =>
|
||||
|
||||
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
|
||||
{
|
||||
const processNameParam = useParams().processName;
|
||||
@ -129,9 +138,11 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
|
||||
const [qJobRunningDate, setQJobRunningDate] = useState(null as Date);
|
||||
const [activeStepIndex, setActiveStepIndex] = useState(0);
|
||||
const [activeStep, setActiveStep] = useState(null as QFrontendStepMetaData);
|
||||
const [activeStepLabel, setActiveStepLabel] = useState(null as string);
|
||||
const [newStep, setNewStep] = useState(null);
|
||||
const [stepInstanceCounter, setStepInstanceCounter] = useState(0);
|
||||
const [steps, setSteps] = useState([] as QFrontendStepMetaData[]);
|
||||
const [backStepName, setBackStepName] = useState(null as string);
|
||||
const [needInitialLoad, setNeedInitialLoad] = useState(true);
|
||||
const [lastForcedReInit, setLastForcedReInit] = useState(null as number);
|
||||
const [processMetaData, setProcessMetaData] = useState(null);
|
||||
@ -150,6 +161,8 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
|
||||
const [previouslySeenUpdatedFieldMetaDataMap, setPreviouslySeenUpdatedFieldMetaDataMap] = useState(new Map<string, QFieldMetaData>);
|
||||
|
||||
const [renderedWidgets, setRenderedWidgets] = useState({} as { [step: string]: { [widgetName: string]: any } });
|
||||
const [controlCallbacks, setControlCallbacks] = useState({} as {[name: string]: () => void});
|
||||
const [subFormPreSubmitCallbacks, setSubFormPreSubmitCallbacks] = useState([] as SubFormPreSubmitCallbackWithName[]);
|
||||
|
||||
const {pageHeader, recordAnalytics, setPageHeader, helpHelpActive} = useContext(QContext);
|
||||
|
||||
@ -184,7 +197,7 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
|
||||
{
|
||||
noMoreSteps = true;
|
||||
}
|
||||
if(processValues["noMoreSteps"])
|
||||
if (processValues["noMoreSteps"])
|
||||
{
|
||||
//////////////////////////////////////////////////////////////////
|
||||
// this, to allow a non-linear process to request this behavior //
|
||||
@ -206,10 +219,12 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
|
||||
// record list state //
|
||||
///////////////////////
|
||||
const [needRecords, setNeedRecords] = useState(false);
|
||||
const [loadingRecords, setLoadingRecords] = useState(false);
|
||||
const [recordConfig, setRecordConfig] = useState({} as any);
|
||||
const [pageNumber, setPageNumber] = useState(0);
|
||||
const [rowsPerPage, setRowsPerPage] = useState(10);
|
||||
const [records, setRecords] = useState([] as QRecord[]);
|
||||
const [records, setRecords] = useState([] as any);
|
||||
const [childRecordData, setChildRecordData] = useState(null as ChildRecordListData);
|
||||
|
||||
//////////////////////////////
|
||||
// state for bulk edit form //
|
||||
@ -219,6 +234,11 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
|
||||
const navigate = useNavigate();
|
||||
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 =>
|
||||
{
|
||||
if (step.components)
|
||||
@ -328,37 +348,114 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
|
||||
*******************************************************************************/
|
||||
function renderWidget(widgetName: string)
|
||||
{
|
||||
const widgetMetaData = qInstance.widgets.get(widgetName);
|
||||
if (!widgetMetaData)
|
||||
{
|
||||
return (<Alert color="error">Unrecognized widget name: {widgetName}</Alert>);
|
||||
}
|
||||
|
||||
if (!renderedWidgets[activeStep.name])
|
||||
{
|
||||
renderedWidgets[activeStep.name] = {};
|
||||
setRenderedWidgets(renderedWidgets);
|
||||
}
|
||||
|
||||
if (renderedWidgets[activeStep.name][widgetName])
|
||||
let isChildRecordWidget = widgetMetaData.type == "childRecordList";
|
||||
if (!isChildRecordWidget && renderedWidgets[activeStep.name][widgetName])
|
||||
{
|
||||
return renderedWidgets[activeStep.name][widgetName];
|
||||
}
|
||||
|
||||
const widgetMetaData = qInstance.widgets.get(widgetName);
|
||||
if (!widgetMetaData)
|
||||
{
|
||||
return (<Alert color="error">Unrecognized widget name: {widgetName}</Alert>);
|
||||
}
|
||||
|
||||
const queryStringParts: string[] = [];
|
||||
for (let name in processValues)
|
||||
{
|
||||
queryStringParts.push(`${name}=${encodeURIComponent(processValues[name])}`);
|
||||
}
|
||||
|
||||
let initialWidgetDataList = null;
|
||||
if (processValues[widgetName])
|
||||
{
|
||||
processValues[widgetName].hasPermission = true;
|
||||
initialWidgetDataList = [processValues[widgetName]];
|
||||
}
|
||||
|
||||
let actionCallback = blockWidgetActionCallback;
|
||||
if (isChildRecordWidget)
|
||||
{
|
||||
actionCallback = childRecordListWidgetActionCallBack;
|
||||
|
||||
if (childRecordData)
|
||||
{
|
||||
initialWidgetDataList = [childRecordData];
|
||||
}
|
||||
}
|
||||
|
||||
const renderedWidget = (<Box m={-2}>
|
||||
<DashboardWidgets widgetMetaDataList={[widgetMetaData]} omitWrappingGridContainer={true} childUrlParams={queryStringParts.join("&")} />
|
||||
<DashboardWidgets widgetMetaDataList={[widgetMetaData]} omitWrappingGridContainer={true} childUrlParams={queryStringParts.join("&")} initialWidgetDataList={initialWidgetDataList} values={processValues} actionCallback={actionCallback} />
|
||||
</Box>);
|
||||
renderedWidgets[activeStep.name][widgetName] = renderedWidget;
|
||||
return renderedWidget;
|
||||
}
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
function handleControlCode(controlCode: string)
|
||||
{
|
||||
const split = controlCode.split(":", 2);
|
||||
let controlCallbackName: string;
|
||||
let controlCallbackValue: any;
|
||||
if (split.length == 2)
|
||||
{
|
||||
if (split[0] == "showModal")
|
||||
{
|
||||
processValues[split[1]] = true;
|
||||
controlCallbackName = split[1];
|
||||
controlCallbackValue = true;
|
||||
}
|
||||
else if (split[0] == "hideModal")
|
||||
{
|
||||
processValues[split[1]] = false;
|
||||
controlCallbackName = split[1];
|
||||
controlCallbackValue = false;
|
||||
}
|
||||
else if (split[0] == "toggleModal")
|
||||
{
|
||||
const currentValue = processValues[split[1]];
|
||||
processValues[split[1]] = !!!currentValue;
|
||||
controlCallbackName = split[1];
|
||||
controlCallbackValue = processValues[split[1]];
|
||||
}
|
||||
else
|
||||
{
|
||||
console.log(`Unexpected part[0] (before colon) in controlCode: [${controlCode}]`);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
console.log(`Expected controlCode to have 2 colon-delimited parts, but was: [${controlCode}]`);
|
||||
}
|
||||
|
||||
if (controlCallbackName && controlCallbacks[controlCallbackName])
|
||||
{
|
||||
// @ts-ignore ... args are hard
|
||||
controlCallbacks[controlCallbackName](controlCallbackValue);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** callback used by child list widget
|
||||
***************************************************************************/
|
||||
function childRecordListWidgetActionCallBack(data: any): boolean
|
||||
{
|
||||
console.log(`in childRecordListWidgetActionCallBack: ${JSON.stringify(data)}`);
|
||||
setChildRecordData(data as ChildRecordListData);
|
||||
return (true);
|
||||
}
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** callback used by widget blocks, e.g., for input-text-enter-on-submit,
|
||||
** and action buttons.
|
||||
@ -367,29 +464,58 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
|
||||
{
|
||||
console.log(`in blockWidgetActionCallback, called by block: ${JSON.stringify(blockData)}`);
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// if the eventValues included an actionCode - validate it before proceeding //
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
if (eventValues && eventValues.actionCode && !ProcessWidgetBlockUtils.isActionCodeValid(eventValues.actionCode, activeStep, processValues))
|
||||
if (eventValues?.registerControlCallbackName && eventValues?.registerControlCallbackFunction)
|
||||
{
|
||||
setFormError("Unrecognized action code: " + eventValues.actionCode);
|
||||
controlCallbacks[eventValues.registerControlCallbackName] = eventValues.registerControlCallbackFunction;
|
||||
setControlCallbacks(controlCallbacks);
|
||||
return (true);
|
||||
}
|
||||
|
||||
if (eventValues["_fieldToClearIfError"])
|
||||
{
|
||||
/////////////////////////////////////////////////////////////////////////////
|
||||
// if the eventValues included a _fieldToClearIfError, well, then do that. //
|
||||
/////////////////////////////////////////////////////////////////////////////
|
||||
formikSetFieldValueFunction(eventValues["_fieldToClearIfError"], "", false);
|
||||
}
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// we don't validate these on the android frontend, and it seems fine - just let the app validate it? //
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// ///////////////////////////////////////////////////////////////////////////////
|
||||
// // if the eventValues included an actionCode - validate it before proceeding //
|
||||
// ///////////////////////////////////////////////////////////////////////////////
|
||||
// if (eventValues && eventValues.actionCode && !ProcessWidgetBlockUtils.isActionCodeValid(eventValues.actionCode, activeStep, processValues))
|
||||
// {
|
||||
// setFormError("Unrecognized action code: " + eventValues.actionCode);
|
||||
// if (eventValues["_fieldToClearIfError"])
|
||||
// {
|
||||
// /////////////////////////////////////////////////////////////////////////////
|
||||
// // if the eventValues included a _fieldToClearIfError, well, then do that. //
|
||||
// /////////////////////////////////////////////////////////////////////////////
|
||||
// formikSetFieldValueFunction(eventValues["_fieldToClearIfError"], "", false);
|
||||
// }
|
||||
// return (false);
|
||||
// }
|
||||
|
||||
return (false);
|
||||
let doSubmit = false;
|
||||
if (blockData?.blockTypeName == "BUTTON" && eventValues?.actionCode)
|
||||
{
|
||||
doSubmit = true;
|
||||
}
|
||||
else if (blockData?.blockTypeName == "BUTTON" && eventValues?.controlCode)
|
||||
{
|
||||
handleControlCode(eventValues.controlCode);
|
||||
doSubmit = false;
|
||||
}
|
||||
else if (blockData?.blockTypeName == "INPUT_FIELD")
|
||||
{
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// if action callback was fired from an input field, assume that means we're good to submit. //
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////
|
||||
doSubmit = true;
|
||||
}
|
||||
|
||||
//////////////////
|
||||
// ok - submit! //
|
||||
//////////////////
|
||||
handleSubmit(eventValues);
|
||||
return (true);
|
||||
if (doSubmit)
|
||||
{
|
||||
handleFormSubmit(eventValues);
|
||||
return (true);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -412,7 +538,7 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
|
||||
ProcessWidgetBlockUtils.dynamicEvaluationOfCompositeWidgetData(compositeWidgetData, processValues);
|
||||
|
||||
renderedWidgets[key] = <Box key={key} pt={2}>
|
||||
<CompositeWidget widgetMetaData={widgetMetaData} data={compositeWidgetData} actionCallback={blockWidgetActionCallback} />
|
||||
<CompositeWidget widgetMetaData={widgetMetaData} data={compositeWidgetData} actionCallback={blockWidgetActionCallback} values={processValues} />
|
||||
</Box>;
|
||||
|
||||
setRenderedWidgets(renderedWidgets);
|
||||
@ -568,12 +694,49 @@ 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 //
|
||||
/////////////////////////////////////
|
||||
let helpRoles = ["PROCESS_SCREEN", "ALL_SCREENS"];
|
||||
const showHelp = helpHelpActive || hasHelpContent(step.helpContents, helpRoles);
|
||||
const formattedHelpContent = <HelpContent helpContents={step.helpContents} roles={helpRoles} helpContentKey={`process:${processName};step:${step?.name}`} />;
|
||||
const isFormatScanner = step?.format?.toLowerCase() == "scanner";
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -582,10 +745,10 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
|
||||
// hide label on widgets - the Widget component itself provides the label //
|
||||
// for modals, show the process label, but not for full-screen processes (for them, it is in the breadcrumb) //
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
!isWidget &&
|
||||
!isWidget && !isFormatScanner &&
|
||||
<MDTypography variant={isWidget ? "h6" : "h5"} component="div" fontWeight="bold">
|
||||
{(isModal) ? `${overrideLabel ?? process.label}: ` : ""}
|
||||
{step?.label}
|
||||
{activeStepLabel}
|
||||
</MDTypography>
|
||||
}
|
||||
|
||||
@ -739,29 +902,7 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
|
||||
}
|
||||
{
|
||||
component.type === QComponentType.VIEW_FORM && step.viewFields && (
|
||||
<div>
|
||||
{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}
|
||||
:
|
||||
</MDTypography>
|
||||
<MDTypography variant="button" fontWeight="regular" color="text">
|
||||
{ValueUtils.getValueForDisplay(field, processValues[field.name], undefined, "view")}
|
||||
</MDTypography>
|
||||
</Box>
|
||||
)))
|
||||
}
|
||||
</div>
|
||||
<ProcessViewForm fields={step.viewFields} values={processValues} columns={1} />
|
||||
)
|
||||
}
|
||||
{
|
||||
@ -792,6 +933,7 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
|
||||
processValues={processValues}
|
||||
step={step}
|
||||
previewRecords={records}
|
||||
loadingRecords={loadingRecords}
|
||||
formValues={formData.values}
|
||||
doFullValidationRadioChangedHandler={(event: any) =>
|
||||
{
|
||||
@ -880,11 +1022,46 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
|
||||
// if neither of those, then programmer error //
|
||||
////////////////////////////////////////////////
|
||||
!(component.values?.widgetName || component.values?.isAdHocWidget) &&
|
||||
<Alert severity="error">Error: Component is marked as WIDGET type, but does not specify a <u>widgetName</u>, nor the <u>isAdHocWidget</u> flag.</Alert>
|
||||
<Alert severity="error">Error: Component is marked as WIDGET type, but does not specify a <u>widgetName</u>, nor the <u>isAdHocWidget</u> flag.</Alert>
|
||||
}
|
||||
</>
|
||||
)
|
||||
}
|
||||
{
|
||||
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>
|
||||
);
|
||||
}))
|
||||
@ -991,6 +1168,7 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
|
||||
{
|
||||
const activeStep = steps[newIndex];
|
||||
setActiveStep(activeStep);
|
||||
setActiveStepLabel(activeStep.label);
|
||||
setFormId(activeStep.name);
|
||||
|
||||
let dynamicFormFields: any = {};
|
||||
@ -1029,7 +1207,7 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
|
||||
// if this is the last step or not - and by default that radio will be true, to make this //
|
||||
// NOT the last step - so set this value. //
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////
|
||||
if(!processValues["validationSummary"] && processValues["supportsFullValidation"])
|
||||
if (!processValues["validationSummary"] && processValues["supportsFullValidation"])
|
||||
{
|
||||
setOverrideOnLastStep(false);
|
||||
}
|
||||
@ -1044,11 +1222,11 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
|
||||
|
||||
if (doesStepHaveComponent(activeStep, QComponentType.WIDGET))
|
||||
{
|
||||
ProcessWidgetBlockUtils.addFieldsForCompositeWidget(activeStep, (fieldMetaData) =>
|
||||
ProcessWidgetBlockUtils.addFieldsForCompositeWidget(activeStep, processValues, (fieldMetaData) =>
|
||||
{
|
||||
const dynamicField = DynamicFormUtils.getDynamicField(fieldMetaData);
|
||||
const validation = DynamicFormUtils.getValidationForField(fieldMetaData);
|
||||
addField(fieldMetaData.name, dynamicField, processValues[fieldMetaData.name], validation)
|
||||
addField(fieldMetaData.name, dynamicField, processValues[fieldMetaData.name], validation);
|
||||
});
|
||||
}
|
||||
|
||||
@ -1117,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)
|
||||
{
|
||||
///////////////////////////////////////////
|
||||
@ -1200,7 +1415,7 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
|
||||
setNeedRecords(false);
|
||||
(async () =>
|
||||
{
|
||||
const response = await Client.getInstance().processRecords(
|
||||
const response = await qController.processRecords(
|
||||
processName,
|
||||
processUUID,
|
||||
recordConfig.rowsPerPage * recordConfig.pageNo,
|
||||
@ -1209,6 +1424,12 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
|
||||
|
||||
const {records} = response;
|
||||
setRecords(records);
|
||||
setLoadingRecords(false);
|
||||
|
||||
if (!childRecordData || childRecordData.length == 0)
|
||||
{
|
||||
setChildRecordData(convertRecordsToChildRecordData(records));
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////////////
|
||||
// re-construct the recordConfig object, so the setState call triggers a new rendering //
|
||||
@ -1232,6 +1453,30 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
|
||||
}, [needRecords]);
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
function convertRecordsToChildRecordData(records: QRecord[])
|
||||
{
|
||||
const frontendRecords = [] as any[];
|
||||
records.forEach((record: QRecord) =>
|
||||
{
|
||||
const object = {
|
||||
"tableName": record.tableName,
|
||||
"recordLabel": record.recordLabel,
|
||||
"errors": record.errors,
|
||||
"warnings": record.warnings,
|
||||
"values": Object.fromEntries(record.values),
|
||||
"displayValues": Object.fromEntries(record.displayValues),
|
||||
};
|
||||
frontendRecords.push(object);
|
||||
});
|
||||
const newChildListData = {} as ChildRecordListData;
|
||||
newChildListData.queryOutput = {records: frontendRecords};
|
||||
return (newChildListData);
|
||||
}
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
@ -1272,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 //
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
@ -1333,7 +1596,7 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
|
||||
const fieldName = field.name;
|
||||
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 (!cachedPossibleValueLabels[fieldName])
|
||||
@ -1347,12 +1610,17 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
|
||||
}
|
||||
}
|
||||
|
||||
//////////////////////////////////////
|
||||
// reset some state between screens //
|
||||
//////////////////////////////////////
|
||||
setJobUUID(null);
|
||||
setNewStep(nextStepName);
|
||||
setStepInstanceCounter(1 + stepInstanceCounter);
|
||||
setProcessValues(newValues);
|
||||
setRenderedWidgets({});
|
||||
setSubFormPreSubmitCallbacks([]);
|
||||
setQJobRunning(null);
|
||||
setBackStepName(qJobComplete.backStep)
|
||||
|
||||
if (formikSetFieldValueFunction)
|
||||
{
|
||||
@ -1430,7 +1698,7 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
|
||||
{
|
||||
try
|
||||
{
|
||||
const processResponse = await Client.getInstance().processJobStatus(
|
||||
const processResponse = await qController.processJobStatus(
|
||||
processName,
|
||||
processUUID,
|
||||
jobUUID,
|
||||
@ -1531,7 +1799,7 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
|
||||
|
||||
try
|
||||
{
|
||||
const qInstance = await Client.getInstance().loadMetaData();
|
||||
const qInstance = await qController.loadMetaData();
|
||||
ValueUtils.qInstance = qInstance;
|
||||
setQInstance(qInstance);
|
||||
}
|
||||
@ -1543,7 +1811,7 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
|
||||
|
||||
try
|
||||
{
|
||||
const processMetaData = await Client.getInstance().loadProcessMetaData(processName);
|
||||
const processMetaData = await qController.loadProcessMetaData(processName);
|
||||
setProcessMetaData(processMetaData);
|
||||
setSteps(processMetaData.frontendSteps);
|
||||
|
||||
@ -1554,7 +1822,7 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
|
||||
{
|
||||
try
|
||||
{
|
||||
const tableMetaData = await Client.getInstance().loadTableMetaData(processMetaData.tableName);
|
||||
const tableMetaData = await qController.loadTableMetaData(processMetaData.tableName);
|
||||
setTableMetaData(tableMetaData);
|
||||
}
|
||||
catch (e)
|
||||
@ -1585,7 +1853,7 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
|
||||
|
||||
try
|
||||
{
|
||||
const processResponse = await Client.getInstance().processInit(processName, queryStringPairsForInit.join("&"));
|
||||
const processResponse = await qController.processInit(processName, queryStringPairsForInit.join("&"));
|
||||
setProcessUUID(processResponse.processUUID);
|
||||
setLastProcessResponse(processResponse);
|
||||
}
|
||||
@ -1602,17 +1870,78 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
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);
|
||||
});
|
||||
};
|
||||
|
||||
////////////////////////////////////////////
|
||||
// handle user submitting changed records //
|
||||
////////////////////////////////////////////
|
||||
const doSubmit = async (formData: FormData) =>
|
||||
{
|
||||
setTimeout(async () =>
|
||||
{
|
||||
recordAnalytics({category: "processEvents", action: "processStep", label: activeStep.label});
|
||||
|
||||
const processResponse = await Client.getInstance().processStep(
|
||||
processName,
|
||||
processUUID,
|
||||
activeStep.name,
|
||||
formData,
|
||||
qController.defaultMultipartFormDataHeaders()
|
||||
);
|
||||
setLastProcessResponse(processResponse);
|
||||
});
|
||||
};
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////
|
||||
// handle user submitting the form - which in qqq means moving forward from any screen. //
|
||||
// caller can pass in a map of values to be added to the form data too //
|
||||
//////////////////////////////////////////////////////////////////////////////////////////
|
||||
const handleSubmit = async (values: any) =>
|
||||
const handleFormSubmit = async (values: any) =>
|
||||
{
|
||||
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();
|
||||
Object.keys(values).forEach((key) =>
|
||||
{
|
||||
@ -1648,29 +1977,42 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
|
||||
formData.append("bulkEditEnabledFields", bulkEditEnabledFields.join(","));
|
||||
}
|
||||
|
||||
const formDataHeaders = {
|
||||
"content-type": "multipart/form-data; boundary=--------------------------320289315924586491558366",
|
||||
};
|
||||
clearStatesBeforeHittingBackend();
|
||||
|
||||
/////////////////////////////////////////////////////////////
|
||||
// convert to regular objects so that they can be jsonized //
|
||||
/////////////////////////////////////////////////////////////
|
||||
if (childRecordData)
|
||||
{
|
||||
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({});
|
||||
setRecords([]);
|
||||
setOverrideOnLastStep(null);
|
||||
setLastProcessResponse(new QJobRunning({message: "Working..."}));
|
||||
|
||||
setTimeout(async () =>
|
||||
{
|
||||
recordAnalytics({category: "processEvents", action: "processStep", label: activeStep.label});
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// 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);
|
||||
|
||||
const processResponse = await Client.getInstance().processStep(
|
||||
processName,
|
||||
processUUID,
|
||||
activeStep.name,
|
||||
formData,
|
||||
formDataHeaders,
|
||||
);
|
||||
setLastProcessResponse(processResponse);
|
||||
});
|
||||
};
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// 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);
|
||||
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@ -1683,7 +2025,7 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
|
||||
//////////////////////////////////////////////////////////////////
|
||||
if (!isClose)
|
||||
{
|
||||
Client.getInstance().processCancel(processName, processUUID);
|
||||
qController.processCancel(processName, processUUID);
|
||||
}
|
||||
|
||||
if (isModal && closeModalHandler)
|
||||
@ -1700,7 +2042,7 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
|
||||
|
||||
|
||||
const formStyles: any = {};
|
||||
if(isWidget)
|
||||
if (isWidget)
|
||||
{
|
||||
formStyles.display = "flex";
|
||||
formStyles.flexGrow = 1;
|
||||
@ -1714,7 +2056,7 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
|
||||
{
|
||||
const mainCardStyles: any = {};
|
||||
|
||||
if(!isWidget && !isModal)
|
||||
if (!isWidget && !isModal)
|
||||
{
|
||||
////////////////////////////////////////////////////////////////
|
||||
// remove margin around card for non-widget, non-modal, small //
|
||||
@ -1744,7 +2086,7 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
|
||||
mainCardStyles.display = "flex";
|
||||
}
|
||||
|
||||
return mainCardStyles
|
||||
return mainCardStyles;
|
||||
}
|
||||
|
||||
let nextButtonLabel = "Next";
|
||||
@ -1769,7 +2111,7 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
|
||||
initialValues={initialValues}
|
||||
validationSchema={validationScheme}
|
||||
validation={validationFunction}
|
||||
onSubmit={handleSubmit}
|
||||
onSubmit={handleFormSubmit}
|
||||
>
|
||||
{({
|
||||
values, errors, touched, isSubmitting, setFieldValue, setTouched
|
||||
@ -1821,13 +2163,8 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
|
||||
{/********************************
|
||||
** 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"}>
|
||||
{true || activeStepIndex === 0 ? (
|
||||
<Box />
|
||||
) : (
|
||||
<MDButton variant="gradient" color="light" onClick={handleBack}>back</MDButton>
|
||||
)}
|
||||
{processError || qJobRunning || !activeStep ? (
|
||||
<Box mt={3} width="100%" display="flex" justifyContent="flex-end" position={isWidget ? "absolute" : "initial"} bottom={isWidget ? "3rem" : "initial"} right={isWidget ? "1.5rem" : "initial"}>
|
||||
{processError || qJobRunning || !activeStep || activeStep?.format?.toLowerCase() == "scanner" ? (
|
||||
<Box />
|
||||
) : (
|
||||
<>
|
||||
@ -1847,6 +2184,13 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
|
||||
<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} />
|
||||
</Grid>
|
||||
</Box>
|
||||
@ -1878,7 +2222,7 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
|
||||
if (isModal)
|
||||
{
|
||||
return (
|
||||
<Box sx={{position: "absolute", overflowY: "auto", maxHeight: "100%", width: "100%"}}>
|
||||
<Box sx={{position: "absolute", overflowY: "auto", maxHeight: "100%", width: "100%"}} id="modalProcessScrollContainer">
|
||||
{body}
|
||||
</Box>
|
||||
);
|
||||
|
@ -71,11 +71,11 @@ export default class ProcessWidgetBlockUtils
|
||||
}
|
||||
// else, continue...
|
||||
}
|
||||
else if (block.blockTypeName == "ACTION_BUTTON")
|
||||
else if (block.blockTypeName == "BUTTON")
|
||||
{
|
||||
//////////////////////////////////////////////////////////
|
||||
// actually look at actionCodes on action button blocks //
|
||||
//////////////////////////////////////////////////////////
|
||||
//////////////////////////////////////////
|
||||
// look at actionCodes on button blocks //
|
||||
//////////////////////////////////////////
|
||||
if (block.values?.actionCode == actionCode)
|
||||
{
|
||||
return (true);
|
||||
@ -182,7 +182,7 @@ export default class ProcessWidgetBlockUtils
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
public static addFieldsForCompositeWidget(step: QFrontendStepMetaData, addFieldCallback: (fieldMetaData: QFieldMetaData) => void)
|
||||
public static addFieldsForCompositeWidget(step: QFrontendStepMetaData, processValues: any, addFieldCallback: (fieldMetaData: QFieldMetaData) => void)
|
||||
{
|
||||
///////////////////////////////////////////////////////////
|
||||
// private recursive function to walk the composite tree //
|
||||
@ -200,7 +200,7 @@ export default class ProcessWidgetBlockUtils
|
||||
else if (block.blockTypeName == "INPUT_FIELD")
|
||||
{
|
||||
const fieldMetaData = new QFieldMetaData(block.values?.fieldMetaData);
|
||||
addFieldCallback(fieldMetaData)
|
||||
addFieldCallback(fieldMetaData);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -210,14 +210,57 @@ export default class ProcessWidgetBlockUtils
|
||||
}
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////
|
||||
// foreach component, if it's an adhoc widget, call recursive helper on it //
|
||||
/////////////////////////////////////////////////////////////////////////////
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// foreach component, if it's an adhoc widget or a widget w/ its data in the processValues, then, call recursive helper on it //
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
for (let component of step.components)
|
||||
{
|
||||
if (component.type == QComponentType.WIDGET && component.values?.isAdHocWidget)
|
||||
{
|
||||
recursiveHelper(component.values as unknown as CompositeData)
|
||||
recursiveHelper(component.values as unknown as CompositeData);
|
||||
}
|
||||
else if (component.type == QComponentType.WIDGET && processValues[component.values?.widgetName])
|
||||
{
|
||||
recursiveHelper(processValues[component.values?.widgetName] as unknown as CompositeData);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
public static processColorFromStyleMap(colorFromStyleMap?: string): string
|
||||
{
|
||||
if (colorFromStyleMap)
|
||||
{
|
||||
switch (colorFromStyleMap.toUpperCase())
|
||||
{
|
||||
case "SUCCESS":
|
||||
return("#2BA83F");
|
||||
case "WARNING":
|
||||
return("#FBA132");
|
||||
case "ERROR":
|
||||
return("#FB4141");
|
||||
case "INFO":
|
||||
return("#458CFF");
|
||||
case "MUTED":
|
||||
return("#7b809a");
|
||||
default:
|
||||
{
|
||||
if (colorFromStyleMap.match(/^[0-9A-F]{6}$/))
|
||||
{
|
||||
return(`#${colorFromStyleMap}`);
|
||||
}
|
||||
else if (colorFromStyleMap.match(/^[0-9A-F]{8}$/))
|
||||
{
|
||||
return(`#${colorFromStyleMap}`);
|
||||
}
|
||||
else
|
||||
{
|
||||
return(colorFromStyleMap);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
]);
|
||||
};
|
@ -34,6 +34,7 @@ import BaseLayout from "qqq/layouts/BaseLayout";
|
||||
import Client from "qqq/utils/qqq/Client";
|
||||
import ValueUtils from "qqq/utils/qqq/ValueUtils";
|
||||
|
||||
import "ace-builds/src-noconflict/ace";
|
||||
import "ace-builds/src-noconflict/mode-java";
|
||||
import "ace-builds/src-noconflict/mode-javascript";
|
||||
import "ace-builds/src-noconflict/mode-json";
|
||||
@ -190,7 +191,7 @@ function RecordDeveloperView({table}: Props): JSX.Element
|
||||
<Card sx={{mb: 3}}>
|
||||
<Typography variant="h6" p={2} pl={3} pb={3}>{field?.label}</Typography>
|
||||
|
||||
<Box display="flex" alignItems="center" justifyContent="space-between" gap={2}>
|
||||
<Box display="flex" alignItems="center" justifyContent="space-between" gap={2} mx={3} mb={3} mt={0}>
|
||||
{scriptId ?
|
||||
<ScriptViewer
|
||||
scriptId={scriptId}
|
||||
|
@ -47,6 +47,7 @@ import Menu from "@mui/material/Menu";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import Modal from "@mui/material/Modal";
|
||||
import Tooltip from "@mui/material/Tooltip/Tooltip";
|
||||
import {SxProps} from "@mui/system";
|
||||
import QContext from "QContext";
|
||||
import colors from "qqq/assets/theme/base/colors";
|
||||
import AuditBody from "qqq/components/audits/AuditBody";
|
||||
@ -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) =>
|
||||
{
|
||||
@ -102,36 +103,37 @@ export function renderSectionOfFields(key: string, fieldNames: string[], tableMe
|
||||
if (field != null)
|
||||
{
|
||||
let label = field.label;
|
||||
let gridColumns = (field.gridColumns && field.gridColumns > 0) ? field.gridColumns : 12;
|
||||
|
||||
const helpRoles = ["VIEW_SCREEN", "READ_SCREENS", "ALL_SCREENS"];
|
||||
const showHelp = helpHelpActive || hasHelpContent(field.helpContents, helpRoles);
|
||||
const formattedHelpContent = <HelpContent helpContents={field.helpContents} roles={helpRoles} heading={label} helpContentKey={`table:${tableMetaData?.name};field:${fieldName}`} />;
|
||||
|
||||
const labelElement = <Typography variant="button" textTransform="none" fontWeight="bold" pr={1} color="rgb(52, 71, 103)" sx={{cursor: "default"}}>{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 (
|
||||
<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
|
||||
}
|
||||
<div style={{display: "inline-block", width: 0}}> </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)}
|
||||
</Typography>
|
||||
</>
|
||||
</Box>
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
})
|
||||
}
|
||||
</Box>;
|
||||
</Grid>;
|
||||
}
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
**
|
||||
***************************************************************************/
|
||||
export function getVisibleJoinTables(tableMetaData: QTableMetaData): Set<string>
|
||||
{
|
||||
const visibleJoinTables = new Set<string>();
|
||||
@ -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 CREATE_CHILD_KEY = "createChild";
|
||||
|
||||
if (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 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 (pathParts[pathParts.length - 3] === tableName)
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// if our tableName is in the -3 index, and there is no token for updating child records, try to open process //
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
if (!hasChildRecordKey && pathParts[pathParts.length - 3] === tableName)
|
||||
{
|
||||
const processName = pathParts[pathParts.length - 1];
|
||||
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 //
|
||||
// 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 () =>
|
||||
{
|
||||
@ -368,7 +379,7 @@ function RecordView({table, record: overrideRecord, launchProcess}: Props): JSX.
|
||||
for (let i = 0; i < hashParts.length; i++)
|
||||
{
|
||||
const parts = hashParts[i].split("=");
|
||||
if (parts.length > 1 && parts[0] == "createChild")
|
||||
if (parts.length > 1 && parts[0] == CREATE_CHILD_KEY)
|
||||
{
|
||||
(async () =>
|
||||
{
|
||||
@ -490,7 +501,7 @@ function RecordView({table, record: overrideRecord, launchProcess}: Props): JSX.
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
// if the component took in a record object, then we don't need to GET it //
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
if(overrideRecord)
|
||||
if (overrideRecord)
|
||||
{
|
||||
record = overrideRecord;
|
||||
}
|
||||
@ -826,12 +837,12 @@ function RecordView({table, record: overrideRecord, launchProcess}: Props): JSX.
|
||||
{
|
||||
let shareDisabled = true;
|
||||
let disabledTooltipText = "";
|
||||
if(tableMetaData.shareableTableMetaData.thisTableOwnerIdFieldName && record)
|
||||
if (tableMetaData.shareableTableMetaData.thisTableOwnerIdFieldName && record)
|
||||
{
|
||||
const ownerId = record.values.get(tableMetaData.shareableTableMetaData.thisTableOwnerIdFieldName);
|
||||
if(ownerId != currentUserId)
|
||||
if (ownerId != currentUserId)
|
||||
{
|
||||
disabledTooltipText = `Only the owner of a ${tableMetaData.label} may share it.`
|
||||
disabledTooltipText = `Only the owner of a ${tableMetaData.label} may share it.`;
|
||||
shareDisabled = true;
|
||||
}
|
||||
else
|
||||
@ -993,10 +1004,10 @@ function RecordView({table, record: overrideRecord, launchProcess}: Props): JSX.
|
||||
}
|
||||
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} lg={3}>
|
||||
<Grid item xs={12} lg={3} className="recordSidebar">
|
||||
<QRecordSidebar tableSections={tableSections} />
|
||||
</Grid>
|
||||
<Grid item xs={12} lg={9}>
|
||||
<Grid item xs={12} lg={9} className="recordWithSidebar">
|
||||
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} mb={3}>
|
||||
|
@ -804,3 +804,17 @@ input[type="search"]::-webkit-search-results-decoration
|
||||
{
|
||||
color: #0062FF !important;
|
||||
}
|
||||
|
||||
@media (min-width: 1400px)
|
||||
{
|
||||
.recordSidebar
|
||||
{
|
||||
max-width: 400px !important;
|
||||
}
|
||||
|
||||
.recordWithSidebar
|
||||
{
|
||||
max-width: 100% !important;
|
||||
flex-grow: 1 !important;
|
||||
}
|
||||
}
|
@ -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 {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord";
|
||||
import Tooltip from "@mui/material/Tooltip/Tooltip";
|
||||
import {GridColDef, GridFilterItem, GridRowsProp, MuiEvent} from "@mui/x-data-grid-pro";
|
||||
import {GridFilterOperator} from "@mui/x-data-grid/models/gridFilterOperator";
|
||||
import {GridColDef, GridRowsProp, MuiEvent} from "@mui/x-data-grid-pro";
|
||||
import {GridColumnHeaderParams} from "@mui/x-data-grid/models/params/gridColumnHeaderParams";
|
||||
import HelpContent, {hasHelpContent} from "qqq/components/misc/HelpContent";
|
||||
import {buildQGridPvsOperators, QGridBlobOperators, QGridBooleanOperators, QGridNumericOperators, QGridStringOperators} from "qqq/pages/records/query/GridFilterOperators";
|
||||
import ValueUtils from "qqq/utils/qqq/ValueUtils";
|
||||
import React from "react";
|
||||
import {Link, NavigateFunction} from "react-router-dom";
|
||||
|
||||
|
||||
const emptyApplyFilterFn = (filterItem: GridFilterItem, column: GridColDef): null => null;
|
||||
|
||||
function NullInputComponent()
|
||||
{
|
||||
return (<React.Fragment />);
|
||||
}
|
||||
|
||||
const makeGridFilterOperator = (value: string, label: string, takesValues: boolean = false): GridFilterOperator =>
|
||||
{
|
||||
const rs: GridFilterOperator = {value: value, label: label, getApplyFilterFn: emptyApplyFilterFn};
|
||||
if (takesValues)
|
||||
{
|
||||
rs.InputComponent = NullInputComponent;
|
||||
}
|
||||
return (rs);
|
||||
};
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////
|
||||
// at this point, these may only be used to drive the toolitp on the FILTER button... //
|
||||
////////////////////////////////////////////////////////////////////////////////////////
|
||||
const QGridDateOperators = [
|
||||
makeGridFilterOperator("equals", "equals", true),
|
||||
makeGridFilterOperator("isNot", "does not equal", true),
|
||||
makeGridFilterOperator("after", "is after", true),
|
||||
makeGridFilterOperator("onOrAfter", "is on or after", true),
|
||||
makeGridFilterOperator("before", "is before", true),
|
||||
makeGridFilterOperator("onOrBefore", "is on or before", true),
|
||||
makeGridFilterOperator("isEmpty", "is empty"),
|
||||
makeGridFilterOperator("isNotEmpty", "is not empty"),
|
||||
makeGridFilterOperator("between", "is between", true),
|
||||
makeGridFilterOperator("notBetween", "is not between", true),
|
||||
];
|
||||
|
||||
const QGridDateTimeOperators = [
|
||||
makeGridFilterOperator("equals", "equals", true),
|
||||
makeGridFilterOperator("isNot", "does not equal", true),
|
||||
makeGridFilterOperator("after", "is after", true),
|
||||
makeGridFilterOperator("onOrAfter", "is at or after", true),
|
||||
makeGridFilterOperator("before", "is before", true),
|
||||
makeGridFilterOperator("onOrBefore", "is at or before", true),
|
||||
makeGridFilterOperator("isEmpty", "is empty"),
|
||||
makeGridFilterOperator("isNotEmpty", "is not empty"),
|
||||
makeGridFilterOperator("between", "is between", true),
|
||||
makeGridFilterOperator("notBetween", "is not between", true),
|
||||
];
|
||||
|
||||
export default class DataGridUtils
|
||||
{
|
||||
/*******************************************************************************
|
||||
@ -113,7 +65,7 @@ export default class DataGridUtils
|
||||
{
|
||||
console.log(`row-click mouse-up happened ${diff} x or y pixels away from the mouse-down - so not considering it a click.`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
@ -133,13 +85,13 @@ export default class DataGridUtils
|
||||
row[field.name] = ValueUtils.getDisplayValue(field, record, "query");
|
||||
});
|
||||
|
||||
if(tableMetaData.exposedJoins)
|
||||
if (tableMetaData.exposedJoins)
|
||||
{
|
||||
for (let i = 0; i < tableMetaData.exposedJoins.length; i++)
|
||||
{
|
||||
const join = tableMetaData.exposedJoins[i];
|
||||
|
||||
if(join?.joinTable?.fields?.values())
|
||||
if (join?.joinTable?.fields?.values())
|
||||
{
|
||||
const fields = [...join.joinTable.fields.values()];
|
||||
fields.forEach((field) =>
|
||||
@ -151,15 +103,15 @@ export default class DataGridUtils
|
||||
}
|
||||
}
|
||||
|
||||
if(!row["id"])
|
||||
if (!row["id"])
|
||||
{
|
||||
row["id"] = record.values.get(tableMetaData.primaryKeyField) ?? row[tableMetaData.primaryKeyField];
|
||||
if(row["id"] === null || row["id"] === undefined)
|
||||
if (row["id"] === null || row["id"] === undefined)
|
||||
{
|
||||
/////////////////////////////////////////////////////////////////////////////////////////
|
||||
// DataGrid gets very upset about a null or undefined here, so, try to make it happier //
|
||||
/////////////////////////////////////////////////////////////////////////////////////////
|
||||
if(!allowEmptyId)
|
||||
if (!allowEmptyId)
|
||||
{
|
||||
row["id"] = "--";
|
||||
}
|
||||
@ -170,7 +122,7 @@ export default class DataGridUtils
|
||||
});
|
||||
|
||||
return (rows);
|
||||
}
|
||||
};
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
@ -180,24 +132,24 @@ export default class DataGridUtils
|
||||
const columns = [] as GridColDef[];
|
||||
this.addColumnsForTable(tableMetaData, linkBase, columns, columnSort, null, null);
|
||||
|
||||
if(metaData)
|
||||
if (metaData)
|
||||
{
|
||||
if(tableMetaData.exposedJoins)
|
||||
if (tableMetaData.exposedJoins)
|
||||
{
|
||||
for (let i = 0; i < tableMetaData.exposedJoins.length; i++)
|
||||
{
|
||||
const join = tableMetaData.exposedJoins[i];
|
||||
let joinTableName = join.joinTable.name;
|
||||
if(metaData.tables.has(joinTableName) && metaData.tables.get(joinTableName).readPermission)
|
||||
if (metaData.tables.has(joinTableName) && metaData.tables.get(joinTableName).readPermission)
|
||||
{
|
||||
let joinLinkBase = null;
|
||||
joinLinkBase = metaData.getTablePath(join.joinTable);
|
||||
if(joinLinkBase)
|
||||
if (joinLinkBase)
|
||||
{
|
||||
joinLinkBase += joinLinkBase.endsWith("/") ? "" : "/";
|
||||
}
|
||||
|
||||
if(join?.joinTable?.fields?.values())
|
||||
if (join?.joinTable?.fields?.values())
|
||||
{
|
||||
this.addColumnsForTable(join.joinTable, joinLinkBase, columns, columnSort, joinTableName + ".", join.label + ": ");
|
||||
}
|
||||
@ -220,7 +172,7 @@ export default class DataGridUtils
|
||||
////////////////////////////////////////////////////////////////////////
|
||||
// this sorted by sections - e.g., manual sorting by the meta-data... //
|
||||
////////////////////////////////////////////////////////////////////////
|
||||
if(columnSort === "bySection")
|
||||
if (columnSort === "bySection")
|
||||
{
|
||||
for (let i = 0; i < tableMetaData.sections.length; i++)
|
||||
{
|
||||
@ -241,19 +193,23 @@ export default class DataGridUtils
|
||||
///////////////////////////
|
||||
// sort by labels... mmm //
|
||||
///////////////////////////
|
||||
sortedKeys.push(...tableMetaData.fields.keys())
|
||||
sortedKeys.push(...tableMetaData.fields.keys());
|
||||
sortedKeys.sort((a: string, b: string): number =>
|
||||
{
|
||||
return (tableMetaData.fields.get(a).label.localeCompare(tableMetaData.fields.get(b).label))
|
||||
})
|
||||
return (tableMetaData.fields.get(a).label.localeCompare(tableMetaData.fields.get(b).label));
|
||||
});
|
||||
}
|
||||
|
||||
sortedKeys.forEach((key) =>
|
||||
{
|
||||
const field = tableMetaData.fields.get(key);
|
||||
if(field.isHeavy)
|
||||
if (!field)
|
||||
{
|
||||
if(field.type == QFieldType.BLOB)
|
||||
return;
|
||||
}
|
||||
if (field.isHeavy)
|
||||
{
|
||||
if (field.type == QFieldType.BLOB)
|
||||
{
|
||||
////////////////////////////////////////////////////////
|
||||
// assume we DO want heavy blobs - as download links. //
|
||||
@ -270,7 +226,7 @@ export default class DataGridUtils
|
||||
|
||||
const column = this.makeColumnFromField(field, tableMetaData, namePrefix, labelPrefix);
|
||||
|
||||
if(key === tableMetaData.primaryKeyField && linkBase && namePrefix == null)
|
||||
if (key === tableMetaData.primaryKeyField && linkBase && namePrefix == null)
|
||||
{
|
||||
columns.splice(0, 0, column);
|
||||
}
|
||||
@ -295,11 +251,10 @@ export default class DataGridUtils
|
||||
public static makeColumnFromField = (field: QFieldMetaData, tableMetaData: QTableMetaData, namePrefix?: string, labelPrefix?: string): GridColDef =>
|
||||
{
|
||||
let columnType = "string";
|
||||
let filterOperators: GridFilterOperator<any>[] = QGridStringOperators;
|
||||
|
||||
if (field.possibleValueSourceName)
|
||||
{
|
||||
filterOperators = buildQGridPvsOperators(tableMetaData.name, field);
|
||||
// noop here
|
||||
}
|
||||
else
|
||||
{
|
||||
@ -308,22 +263,17 @@ export default class DataGridUtils
|
||||
case QFieldType.DECIMAL:
|
||||
case QFieldType.INTEGER:
|
||||
columnType = "number";
|
||||
filterOperators = QGridNumericOperators;
|
||||
break;
|
||||
case QFieldType.DATE:
|
||||
columnType = "date";
|
||||
filterOperators = QGridDateOperators;
|
||||
break;
|
||||
case QFieldType.DATE_TIME:
|
||||
columnType = "dateTime";
|
||||
filterOperators = QGridDateTimeOperators;
|
||||
break;
|
||||
case QFieldType.BOOLEAN:
|
||||
columnType = "string"; // using boolean gives an odd 'no' for nulls.
|
||||
filterOperators = QGridBooleanOperators;
|
||||
break;
|
||||
case QFieldType.BLOB:
|
||||
filterOperators = QGridBlobOperators;
|
||||
break;
|
||||
default:
|
||||
// noop - leave as string
|
||||
@ -339,16 +289,15 @@ export default class DataGridUtils
|
||||
headerName: headerName,
|
||||
width: DataGridUtils.getColumnWidthForField(field, tableMetaData),
|
||||
renderCell: null as any,
|
||||
filterOperators: filterOperators,
|
||||
};
|
||||
|
||||
column.renderCell = (cellValues: any) => (
|
||||
(cellValues.value)
|
||||
);
|
||||
|
||||
const helpRoles = ["QUERY_SCREEN", "READ_SCREENS", "ALL_SCREENS"]
|
||||
const helpRoles = ["QUERY_SCREEN", "READ_SCREENS", "ALL_SCREENS"];
|
||||
const showHelp = hasHelpContent(field.helpContents, helpRoles); // todo - maybe - take helpHelpActive from context all the way down to here?
|
||||
if(showHelp)
|
||||
if (showHelp)
|
||||
{
|
||||
const formattedHelpContent = <HelpContent helpContents={field.helpContents} roles={helpRoles} heading={headerName} helpContentKey={`table:${tableMetaData.name};field:${fieldName}`} />;
|
||||
column.renderHeader = (params: GridColumnHeaderParams) => (
|
||||
@ -361,7 +310,7 @@ export default class DataGridUtils
|
||||
}
|
||||
|
||||
return (column);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@ -390,7 +339,7 @@ export default class DataGridUtils
|
||||
}
|
||||
}
|
||||
|
||||
if(field.possibleValueSourceName)
|
||||
if (field.possibleValueSourceName)
|
||||
{
|
||||
return (200);
|
||||
}
|
||||
@ -415,6 +364,6 @@ export default class DataGridUtils
|
||||
}
|
||||
|
||||
return (200);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -19,7 +19,8 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import Client from "qqq/utils/qqq/Client";
|
||||
import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
|
||||
import {QFieldType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldType";
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility functions for basic html/webpage/browser things.
|
||||
@ -68,10 +69,16 @@ export default class HtmlUtils
|
||||
** it was originally built like this when we had to submit full access token to backend...
|
||||
**
|
||||
*******************************************************************************/
|
||||
static downloadUrlViaIFrame = (url: string, filename: string) =>
|
||||
static downloadUrlViaIFrame = (field: QFieldMetaData, url: string, filename: string) =>
|
||||
{
|
||||
if(url.startsWith("data:"))
|
||||
if (url.startsWith("data:") || url.startsWith("http"))
|
||||
{
|
||||
if (url.startsWith("http"))
|
||||
{
|
||||
const separator = url.includes("?") ? "&" : "?";
|
||||
url += encodeURIComponent(`${separator}response-content-disposition=attachment; ${filename}`);
|
||||
}
|
||||
|
||||
const link = document.createElement("a");
|
||||
link.download = filename;
|
||||
link.href = url;
|
||||
@ -93,8 +100,14 @@ export default class HtmlUtils
|
||||
// todo - onload event handler to let us know when done?
|
||||
document.body.appendChild(iframe);
|
||||
|
||||
var method = "get";
|
||||
if (QFieldType.BLOB == field.type)
|
||||
{
|
||||
method = "post";
|
||||
}
|
||||
|
||||
const form = document.createElement("form");
|
||||
form.setAttribute("method", "post");
|
||||
form.setAttribute("method", method);
|
||||
form.setAttribute("action", url);
|
||||
form.setAttribute("target", "downloadIframe");
|
||||
iframe.appendChild(form);
|
||||
@ -117,7 +130,7 @@ export default class HtmlUtils
|
||||
*******************************************************************************/
|
||||
static openInNewWindow = (url: string, filename: string) =>
|
||||
{
|
||||
if(url.startsWith("data:"))
|
||||
if (url.startsWith("data:"))
|
||||
{
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////
|
||||
@ -153,4 +166,4 @@ export default class HtmlUtils
|
||||
};
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
@ -133,7 +133,7 @@ class FilterUtils
|
||||
}
|
||||
else
|
||||
{
|
||||
values = await qController.possibleValues(fieldTable.name, null, field.name, "", values);
|
||||
values = await qController.possibleValues(fieldTable.name, null, field.name, "", values, undefined, "filter");
|
||||
}
|
||||
}
|
||||
|
||||
|
318
src/qqq/utils/qqq/SavedBulkLoadProfileUtils.ts
Normal file
318
src/qqq/utils/qqq/SavedBulkLoadProfileUtils.ts
Normal 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;
|
||||
};
|
||||
|
||||
}
|
@ -28,18 +28,17 @@ import "datejs"; // https://github.com/datejs/Datejs
|
||||
import {Chip, ClickAwayListener, Icon} from "@mui/material";
|
||||
import Box from "@mui/material/Box";
|
||||
import Button from "@mui/material/Button";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
import {makeStyles} from "@mui/styles";
|
||||
import parse from "html-react-parser";
|
||||
import React, {Fragment, useReducer, useState} from "react";
|
||||
import AceEditor from "react-ace";
|
||||
import {Link} from "react-router-dom";
|
||||
import HtmlUtils from "qqq/utils/HtmlUtils";
|
||||
import Client from "qqq/utils/qqq/Client";
|
||||
|
||||
import "ace-builds/src-noconflict/ace";
|
||||
import "ace-builds/src-noconflict/mode-sql";
|
||||
import React, {Fragment, useReducer, useState} from "react";
|
||||
import AceEditor from "react-ace";
|
||||
import "ace-builds/src-noconflict/mode-velocity";
|
||||
import {Link} from "react-router-dom";
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class for working with QQQ Values
|
||||
@ -198,7 +197,7 @@ class ValueUtils
|
||||
);
|
||||
}
|
||||
|
||||
if (field.type == QFieldType.BLOB)
|
||||
if (field.type == QFieldType.BLOB || field.hasAdornment(AdornmentType.FILE_DOWNLOAD))
|
||||
{
|
||||
return (<BlobComponent field={field} url={rawValue} filename={displayValue} usage={usage} />);
|
||||
}
|
||||
@ -219,7 +218,7 @@ class ValueUtils
|
||||
|
||||
if (field.type === QFieldType.DATE_TIME)
|
||||
{
|
||||
if(displayValue && displayValue != rawValue)
|
||||
if (displayValue && displayValue != rawValue)
|
||||
{
|
||||
//////////////////////////////////////////////////////////////////////////////
|
||||
// if the date-time actually has a displayValue set, and it isn't just the //
|
||||
@ -276,7 +275,7 @@ class ValueUtils
|
||||
// to millis) back to it //
|
||||
////////////////////////////////////////////////////////////////////////////////////
|
||||
date = new Date(date);
|
||||
date.setTime(date.getTime() + date.getTimezoneOffset() * 60 * 1000)
|
||||
date.setTime(date.getTime() + date.getTimezoneOffset() * 60 * 1000);
|
||||
}
|
||||
// @ts-ignore
|
||||
return (`${date.toString("yyyy-MM-dd")}`);
|
||||
@ -474,7 +473,7 @@ class ValueUtils
|
||||
*******************************************************************************/
|
||||
public static cleanForCsv(param: any): string
|
||||
{
|
||||
if(param === undefined || param === null)
|
||||
if (param === undefined || param === null)
|
||||
{
|
||||
return ("");
|
||||
}
|
||||
@ -499,7 +498,7 @@ class ValueUtils
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// little private component here, for rendering an AceEditor with some buttons/controls/state //
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
function CodeViewer({name, mode, code}: {name: string; mode: string; code: string;}): JSX.Element
|
||||
function CodeViewer({name, mode, code}: { name: string; mode: string; code: string; }): JSX.Element
|
||||
{
|
||||
const [activeCode, setActiveCode] = useState(code);
|
||||
const [isFormatted, setIsFormatted] = useState(false);
|
||||
@ -596,7 +595,7 @@ function CodeViewer({name, mode, code}: {name: string; mode: string; code: strin
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// little private component here, for rendering "secret-ish" values, that you can click to reveal or copy //
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
function RevealComponent({fieldName, value, usage}: {fieldName: string, value: string, usage: string;}): JSX.Element
|
||||
function RevealComponent({fieldName, value, usage}: { fieldName: string, value: string, usage: string; }): JSX.Element
|
||||
{
|
||||
const [adornmentFieldsMap, setAdornmentFieldsMap] = useState(new Map<string, boolean>);
|
||||
const [, forceUpdate] = useReducer((x) => x + 1, 0);
|
||||
@ -653,7 +652,7 @@ function RevealComponent({fieldName, value, usage}: {fieldName: string, value: s
|
||||
</Tooltip>
|
||||
</ClickAwayListener>
|
||||
</Box>
|
||||
):(
|
||||
) : (
|
||||
<Box display="inline"><Icon onClick={(e) => handleRevealIconClick(e, fieldName)} sx={{cursor: "pointer", fontSize: "15px !important", position: "relative", top: "3px", marginRight: "5px"}}>visibility_off</Icon>{displayValue}</Box>
|
||||
)
|
||||
)
|
||||
@ -680,7 +679,7 @@ function BlobComponent({field, url, filename, usage}: BlobComponentProps): JSX.E
|
||||
const download = (event: React.MouseEvent<HTMLSpanElement>) =>
|
||||
{
|
||||
event.stopPropagation();
|
||||
HtmlUtils.downloadUrlViaIFrame(url, filename);
|
||||
HtmlUtils.downloadUrlViaIFrame(field, url, filename);
|
||||
};
|
||||
|
||||
const open = (event: React.MouseEvent<HTMLSpanElement>) =>
|
||||
@ -689,7 +688,7 @@ function BlobComponent({field, url, filename, usage}: BlobComponentProps): JSX.E
|
||||
HtmlUtils.openInNewWindow(url, filename);
|
||||
};
|
||||
|
||||
if(!filename || !url)
|
||||
if (!filename || !url)
|
||||
{
|
||||
return (<React.Fragment />);
|
||||
}
|
||||
@ -704,10 +703,22 @@ function BlobComponent({field, url, filename, usage}: BlobComponentProps): JSX.E
|
||||
usage == "view" && filename
|
||||
}
|
||||
<Tooltip placement={tooltipPlacement} title="Open file">
|
||||
<Icon className={"blobIcon"} fontSize="small" onClick={(e) => open(e)}>open_in_new</Icon>
|
||||
{
|
||||
field.type == QFieldType.BLOB ? (
|
||||
<Icon className={"blobIcon"} fontSize="small" onClick={(e) => open(e)}>open_in_new</Icon>
|
||||
) : (
|
||||
<a style={{color: "inherit"}} rel="noopener noreferrer" href={url} target="_blank"><Icon className={"blobIcon"} fontSize="small">open_in_new</Icon></a>
|
||||
)
|
||||
}
|
||||
</Tooltip>
|
||||
<Tooltip placement={tooltipPlacement} title="Download file">
|
||||
<Icon className={"blobIcon"} fontSize="small" onClick={(e) => download(e)}>save_alt</Icon>
|
||||
{
|
||||
field.type == QFieldType.BLOB ? (
|
||||
<Icon className={"blobIcon"} fontSize="small" onClick={(e) => download(e)}>save_alt</Icon>
|
||||
) : (
|
||||
<a style={{color: "inherit"}} href={url} download="test.pdf"><Icon className={"blobIcon"} fontSize="small">save_alt</Icon></a>
|
||||
)
|
||||
}
|
||||
</Tooltip>
|
||||
{
|
||||
usage == "query" && filename
|
||||
@ -717,5 +728,4 @@ function BlobComponent({field, url, filename, usage}: BlobComponentProps): JSX.E
|
||||
}
|
||||
|
||||
|
||||
|
||||
export default ValueUtils;
|
||||
|
@ -57,4 +57,5 @@ module.exports = function (app)
|
||||
app.use("/images", getRequestHandler());
|
||||
app.use("/api*", getRequestHandler());
|
||||
app.use("/*api", getRequestHandler());
|
||||
app.use("/qqq/*", getRequestHandler());
|
||||
};
|
||||
|
@ -22,6 +22,7 @@
|
||||
package com.kingsrook.qqq.frontend.materialdashboard.selenium.tests;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.QBaseSeleniumTest;
|
||||
import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.javalin.QSeleniumJavalin;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@ -32,6 +33,9 @@ import org.junit.jupiter.api.Test;
|
||||
*******************************************************************************/
|
||||
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("div", "records on this page are selected");
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////
|
||||
// locally, passing fine, but in CI, failing around here ... trying a sleep... //
|
||||
/////////////////////////////////////////////////////////////////////////////////
|
||||
LOG.debug("Trying a sleep...");
|
||||
qSeleniumLib.waitForMillis(1000);
|
||||
LOG.debug("Proceeding post-sleep");
|
||||
|
||||
qSeleniumLib.waitForSelectorContaining("button", "action").click();
|
||||
qSeleniumLib.waitForSelectorContaining("li", "bulk edit").click();
|
||||
|
||||
|
Reference in New Issue
Block a user