From 8a6eef9907cd6a151196c8e373c938c9e421c325 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Fri, 22 Nov 2024 15:59:23 -0600 Subject: [PATCH 01/52] Update for next development version --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index c046283..869597d 100644 --- a/pom.xml +++ b/pom.xml @@ -29,7 +29,7 @@ jar - 0.23.0 + 0.24.0-SNAPSHOT UTF-8 UTF-8 From b07d65aacacf5fa62f69e23cc4e25a7a14090c9b Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Mon, 25 Nov 2024 10:11:27 -0600 Subject: [PATCH 02/52] CE-1955 - Add onChangeCallback to form fields; add ability to get a DynamicSelect out of DynamicFormField; --- .../components/forms/BooleanFieldSwitch.tsx | 11 ++- src/qqq/components/forms/DynamicFormField.tsx | 86 ++++++++++++++----- 2 files changed, 74 insertions(+), 23 deletions(-) diff --git a/src/qqq/components/forms/BooleanFieldSwitch.tsx b/src/qqq/components/forms/BooleanFieldSwitch.tsx index 530dfeb..ef0cc2e 100644 --- a/src/qqq/components/forms/BooleanFieldSwitch.tsx +++ b/src/qqq/components/forms/BooleanFieldSwitch.tsx @@ -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" : ""; diff --git a/src/qqq/components/forms/DynamicFormField.tsx b/src/qqq/components/forms/DynamicFormField.tsx index d9991d6..f2183bc 100644 --- a/src/qqq/components/forms/DynamicFormField.tsx +++ b/src/qqq/components/forms/DynamicFormField.tsx @@ -19,10 +19,12 @@ * along with this program. If not, see . */ +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"; @@ -43,6 +45,8 @@ interface Props placeholder?: string; backgroundColor?: string; + onChangeCallback?: (newValue: any) => void; + [key: string]: any; bulkEditMode?: boolean; @@ -51,7 +55,7 @@ interface Props } function QDynamicFormField({ - label, name, displayFormat, value, bulkEditMode, bulkEditSwitchChangeHandler, type, isEditable, placeholder, backgroundColor, formFieldObject, ...rest + label, name, displayFormat, value, bulkEditMode, bulkEditSwitchChangeHandler, type, isEditable, placeholder, backgroundColor, formFieldObject, onChangeCallback, ...rest }: Props): JSX.Element { const [switchChecked, setSwitchChecked] = useState(false); @@ -116,42 +120,76 @@ 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); + 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 = () + } + else if (type === "checkbox") { getsBulkEditHtmlLabel = false; field = (<> - + {!isDisabled &&
{msg}} />
} @@ -179,6 +217,10 @@ function QDynamicFormField({ onChange={(value: string, event: any) => { setFieldValue(name, value, false); + if(onChangeCallback) + { + onChangeCallback(value); + } }} setOptions={{useWorker: false}} width="100%" From a66ffa753d73ce1fa3ba20c2a23860597a9b2fc5 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Mon, 25 Nov 2024 10:11:41 -0600 Subject: [PATCH 03/52] CE-1955 - Add optional name prop --- src/qqq/components/forms/DynamicSelect.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/qqq/components/forms/DynamicSelect.tsx b/src/qqq/components/forms/DynamicSelect.tsx index 70658fe..b849779 100644 --- a/src/qqq/components/forms/DynamicSelect.tsx +++ b/src/qqq/components/forms/DynamicSelect.tsx @@ -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, Date: Mon, 25 Nov 2024 10:12:04 -0600 Subject: [PATCH 04/52] CE-1955 - Remove mb from cancel button (incorrectly added in last sprint's work) --- src/qqq/components/buttons/DefaultButtons.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/qqq/components/buttons/DefaultButtons.tsx b/src/qqq/components/buttons/DefaultButtons.tsx index c277617..07b0cdf 100644 --- a/src/qqq/components/buttons/DefaultButtons.tsx +++ b/src/qqq/components/buttons/DefaultButtons.tsx @@ -145,7 +145,7 @@ export function QCancelButton({ }: QCancelButtonProps): JSX.Element { return ( - + {iconName}} onClick={onClickHandler} disabled={disabled}> {label} From 1630fbacdacad8c980c96f3fa0e6dc5ba3989cea Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Mon, 25 Nov 2024 10:12:28 -0600 Subject: [PATCH 05/52] CE-1955 - Break DynamicFormFieldLabel out into a component that others can use --- src/qqq/components/forms/DynamicForm.tsx | 25 ++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/src/qqq/components/forms/DynamicForm.tsx b/src/qqq/components/forms/DynamicForm.tsx index 8c59440..241d414 100644 --- a/src/qqq/components/forms/DynamicForm.tsx +++ b/src/qqq/components/forms/DynamicForm.tsx @@ -22,18 +22,18 @@ 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 {colors, Icon, InputLabel} from "@mui/material"; +import {colors, Icon} 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 MDTypography from "qqq/components/legacy/MDTypography"; import HelpContent from "qqq/components/misc/HelpContent"; import ValueUtils from "qqq/utils/qqq/ValueUtils"; +import React, {useState} from "react"; interface Props { @@ -105,15 +105,13 @@ function QDynamicForm({formData, formLabel, bulkEditMode, bulkEditSwitchChangeHa values[fieldName] = ""; } - let formattedHelpContent = ; + let formattedHelpContent = ; if(formattedHelpContent) { formattedHelpContent = {formattedHelpContent} } - const labelElement = - - + const labelElement = ; if (field.type === "file") { @@ -224,4 +222,19 @@ QDynamicForm.defaultProps = { }, }; + +interface DynamicFormFieldLabelProps +{ + name: string; + label: string; +} + +export function DynamicFormFieldLabel({name, label}: DynamicFormFieldLabelProps): JSX.Element +{ + return ( + + ); +} + + export default QDynamicForm; From 451af347f79dbed209e7112c7b0f92a33c566f64 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Mon, 25 Nov 2024 10:16:22 -0600 Subject: [PATCH 06/52] CE-1955 - Add support for pre-submit callbacks, defined in components - specifically, ones used by bulk-load. Add awareness of the bulkLoad components, specifically with a block for value-mapping form, to integrate with formik --- src/qqq/pages/processes/ProcessRun.tsx | 170 ++++++++++++++++++++++++- 1 file changed, 168 insertions(+), 2 deletions(-) diff --git a/src/qqq/pages/processes/ProcessRun.tsx b/src/qqq/pages/processes/ProcessRun.tsx index ef3b1fa..0cf07f5 100644 --- a/src/qqq/pages/processes/ProcessRun.tsx +++ b/src/qqq/pages/processes/ProcessRun.tsx @@ -60,6 +60,9 @@ 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 ValidationReview from "qqq/components/processes/ValidationReview"; @@ -72,7 +75,7 @@ import {TABLE_VARIANT_LOCAL_STORAGE_KEY_ROOT} from "qqq/pages/records/query/Reco 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"; @@ -109,6 +112,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,6 +136,7 @@ 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[]); @@ -151,6 +159,7 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is 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); @@ -220,6 +229,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) @@ -652,6 +666,42 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is }); } + //////////////////////////////////////////////////////////////////////////////// + // if we have a bulk-load file mapping form, register its pre-submit callback // + //////////////////////////////////////////////////////////////////////////////// + if (doesStepHaveComponent(activeStep, QComponentType.BULK_LOAD_FILE_MAPPING_FORM)) + { + if(bulkLoadFileMappingFormRef?.current) + { + // @ts-ignore ... + addSubFormPreSubmitCallbacks("bulkLoadFileMappingForm", bulkLoadFileMappingFormRef?.current?.preSubmit) + } + } + + ///////////////////////////////////////////////////////////////////////////////// + // if we have a bulk-load value mapping form, register its pre-submit callback // + ///////////////////////////////////////////////////////////////////////////////// + if (doesStepHaveComponent(activeStep, QComponentType.BULK_LOAD_VALUE_MAPPING_FORM)) + { + if(bulkLoadValueMappingFormRef?.current) + { + // @ts-ignore ... + addSubFormPreSubmitCallbacks("bulkLoadValueMappingForm", bulkLoadValueMappingFormRef?.current?.preSubmit) + } + } + + /////////////////////////////////////////////////////////////////////////// + // if we have a bulk-load profile form, register its pre-submit callback // + /////////////////////////////////////////////////////////////////////////// + if (doesStepHaveComponent(activeStep, QComponentType.BULK_LOAD_PROFILE_FORM)) + { + if(bulkLoadProfileFormRef?.current) + { + // @ts-ignore ... + addSubFormPreSubmitCallbacks("bulkLoadProfileFormRef", bulkLoadProfileFormRef?.current?.preSubmit) + } + } + ///////////////////////////////////// // screen(step)-level help content // ///////////////////////////////////// @@ -670,7 +720,7 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is !isWidget && !isFormatScanner && {(isModal) ? `${overrideLabel ?? process.label}: ` : ""} - {step?.label} + {activeStepLabel} } @@ -970,6 +1020,39 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is ) } + { + component.type === QComponentType.BULK_LOAD_FILE_MAPPING_FORM && ( + + ) + } + { + component.type === QComponentType.BULK_LOAD_VALUE_MAPPING_FORM && ( + + ) + } + { + component.type === QComponentType.BULK_LOAD_PROFILE_FORM && ( + + ) + } ); })) @@ -1076,6 +1159,7 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is { const activeStep = steps[newIndex]; setActiveStep(activeStep); + setActiveStepLabel(activeStep.label); setFormId(activeStep.name); let dynamicFormFields: any = {}; @@ -1202,6 +1286,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) { /////////////////////////////////////////// @@ -1357,6 +1478,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 // ////////////////////////////////////////////////////////////////////////////////////////////////////////// @@ -1437,6 +1576,7 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is setStepInstanceCounter(1 + stepInstanceCounter); setProcessValues(newValues); setRenderedWidgets({}); + setSubFormPreSubmitCallbacks([]); setQJobRunning(null); if (formikSetFieldValueFunction) @@ -1698,6 +1838,27 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is { 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) => { @@ -1742,6 +1903,11 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is setOverrideOnLastStep(null); setLastProcessResponse(new QJobRunning({message: "Working..."})); + /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // 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); + setTimeout(async () => { recordAnalytics({category: "processEvents", action: "processStep", label: activeStep.label}); From 3fc4e37c12164232cc8e44507b713b4cdec74114 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Mon, 25 Nov 2024 10:35:35 -0600 Subject: [PATCH 07/52] CE-1955 - Break ProcessViewForm out into its own reusable component --- .../components/processes/ProcessViewForm.tsx | 71 +++++++++++++++++++ src/qqq/pages/processes/ProcessRun.tsx | 28 +------- 2 files changed, 74 insertions(+), 25 deletions(-) create mode 100644 src/qqq/components/processes/ProcessViewForm.tsx diff --git a/src/qqq/components/processes/ProcessViewForm.tsx b/src/qqq/components/processes/ProcessViewForm.tsx new file mode 100644 index 0000000..8cd04e3 --- /dev/null +++ b/src/qqq/components/processes/ProcessViewForm.tsx @@ -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 . + */ + + +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 + {fields.map((field: QFieldMetaData) => ( + field.hasAdornment(AdornmentType.ERROR) ? ( + values[field.name] && ( + + + {ValueUtils.getValueForDisplay(field, values[field.name], undefined, "view")} + + + ) + ) : ( + + + {field.label} + :   + + + {ValueUtils.getValueForDisplay(field, values[field.name], undefined, "view")} + + + ))) + } + ; +} diff --git a/src/qqq/pages/processes/ProcessRun.tsx b/src/qqq/pages/processes/ProcessRun.tsx index 0cf07f5..b0967aa 100644 --- a/src/qqq/pages/processes/ProcessRun.tsx +++ b/src/qqq/pages/processes/ProcessRun.tsx @@ -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"; @@ -65,6 +64,7 @@ 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"; @@ -874,29 +874,7 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is } { component.type === QComponentType.VIEW_FORM && step.viewFields && ( -
- {step.viewFields.map((field: QFieldMetaData) => ( - field.hasAdornment(AdornmentType.ERROR) ? ( - processValues[field.name] && ( - - - {ValueUtils.getValueForDisplay(field, processValues[field.name], undefined, "view")} - - - ) - ) : ( - - - {field.label} - :   - - - {ValueUtils.getValueForDisplay(field, processValues[field.name], undefined, "view")} - - - ))) - } -
+ ) } { @@ -1906,7 +1884,7 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // 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); + // setActiveStep(null); setTimeout(async () => { From f2b41532d457f58a47c03242d81d5a4d9d158203 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Mon, 25 Nov 2024 10:48:00 -0600 Subject: [PATCH 08/52] CE-1955 - Initial checkin of qfmd support for bulk-load --- .../misc/QHierarchyAutoComplete.tsx | 795 ++++++++++++++++++ .../components/misc/SavedBulkLoadProfiles.tsx | 790 +++++++++++++++++ .../processes/BulkLoadFileMappingField.tsx | 226 +++++ .../processes/BulkLoadFileMappingFields.tsx | 322 +++++++ .../processes/BulkLoadFileMappingForm.tsx | 384 +++++++++ .../processes/BulkLoadProfileForm.tsx | 102 +++ .../processes/BulkLoadValueMappingForm.tsx | 222 +++++ src/qqq/models/processes/BulkLoadModels.ts | 600 +++++++++++++ .../utils/qqq/SavedBulkLoadProfileUtils.ts | 227 +++++ 9 files changed, 3668 insertions(+) create mode 100644 src/qqq/components/misc/QHierarchyAutoComplete.tsx create mode 100644 src/qqq/components/misc/SavedBulkLoadProfiles.tsx create mode 100644 src/qqq/components/processes/BulkLoadFileMappingField.tsx create mode 100644 src/qqq/components/processes/BulkLoadFileMappingFields.tsx create mode 100644 src/qqq/components/processes/BulkLoadFileMappingForm.tsx create mode 100644 src/qqq/components/processes/BulkLoadProfileForm.tsx create mode 100644 src/qqq/components/processes/BulkLoadValueMappingForm.tsx create mode 100644 src/qqq/models/processes/BulkLoadModels.ts create mode 100644 src/qqq/utils/qqq/SavedBulkLoadProfileUtils.ts diff --git a/src/qqq/components/misc/QHierarchyAutoComplete.tsx b/src/qqq/components/misc/QHierarchyAutoComplete.tsx new file mode 100644 index 0000000..d5e844d --- /dev/null +++ b/src/qqq/components/misc/QHierarchyAutoComplete.tsx @@ -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 . + */ + + +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) => 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, 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, 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 | React.MouseEvent | React.MouseEvent, 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) + { + setSearchText(event?.target?.value ?? ""); + doSetFocusedIndex(0, true); + } + + + /******************************************************************************* + ** + *******************************************************************************/ + function doHandleAdornmentClick(option: Option, group: Group, event: React.MouseEvent) + { + 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 ( + <> + + + + { + heading && + + {heading} + + } + + + { + searchText != "" && + { + updateSearch(null); + document.getElementById(textFieldId).focus(); + }}>close + } + + + + { + optionsByGroupToShow.map((groupWithOptions) => + { + let headerContents = null; + const headerGroup = groupWithOptions.group || defaultGroup; + if (groupWithOptions.group || showGroupHeaderEvenIfNoSubGroups) + { + headerContents = ({headerGroup.label}); + } + + if (isModeToggle) + { + headerContents = ( handleGroupToggle(event, headerGroup)} + />} + label={{headerGroup.label} Fields ({groupToggleCounts[headerGroup.value]})} />); + } + + if (isModeToggle) + { + headerContents = ( + <> + toggleCollapsedGroup(headerGroup.value)} + sx={{justifyContent: "flex-start", fontSize: "0.875rem", pt: 0.5, pb: 0, mr: "0.25rem"}} + disableRipple={true} + > + {collapsedGroups[headerGroup.value] ? "expand_less" : "expand_more"} + + {headerContents} + + ); + } + + let marginLeft = "unset"; + if (isModeToggle) + { + marginLeft = "-1rem"; + } + + zIndex += 2; + + return ( + + <> + {headerContents && {headerContents}} + { + 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 (); + } + + 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 = + {label} + handleAdornmentClick(option, groupWithOptions.group, event)}> + {optionEndAdornment} + + ; + } + + let contents = <>{label}; + let paddingLeft = "0.5rem"; + + if (isModeToggle) + { + contents = ( handleOptionToggle(event, option, groupWithOptions.group)} + />} + label={label} />); + paddingLeft = "2.5rem"; + } + + const listItem = + { + handleMouseOver(event, option, groupWithOptions.group, isDisabled) + }} + {...onClick} + >{contents}; + + if(tooltips[fullOptionValue]) + { + return {listItem} + } + else + { + + return listItem + } + }) + } + + + ); + }) + } + { + index == -1 && No options found. + } + + + + + + ); +} diff --git a/src/qqq/components/misc/SavedBulkLoadProfiles.tsx b/src/qqq/components/misc/SavedBulkLoadProfiles.tsx new file mode 100644 index 0000000..eaddda8 --- /dev/null +++ b/src/qqq/components/misc/SavedBulkLoadProfiles.tsx @@ -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 . + */ + +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(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) => + { + 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 + { + ///////////////////////// + // 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 && ( + + { + Bulk Load Profile Actions + } + { + !allowSelectingProfile && + + { + currentSavedBulkLoadProfileRecord ? + You are using the bulk load profile:
{currentSavedBulkLoadProfileRecord.values.get("label")}.

You can manage this profile on this screen.
+ : You are not using a saved bulk load profile.

You can save your profile on this screen.
+ } +
+ } + { + !allowSelectingProfile && + } + { + hasStorePermission && + Save your current mapping, for quick re-use at a later time.

You will be prompted to enter a name if you choose this option.}> + + handleDropdownOptionClick(SAVE_OPTION)}> + save + {currentSavedBulkLoadProfileRecord ? "Save..." : "Save As..."} + + +
+ } + { + hasStorePermission && currentSavedBulkLoadProfileRecord != null && + + + handleDropdownOptionClick(RENAME_OPTION)}> + edit + Rename... + + + + } + { + hasStorePermission && currentSavedBulkLoadProfileRecord != null && + + + handleDropdownOptionClick(DUPLICATE_OPTION)}> + content_copy + Save As... + + + + } + { + hasDeletePermission && currentSavedBulkLoadProfileRecord != null && + + + handleDropdownOptionClick(DELETE_OPTION)}> + delete + Delete... + + + + } + { + allowSelectingProfile && + + + handleDropdownOptionClick(CLEAR_OPTION)}> + monitor + New Bulk Load Profile + + + + } + { + allowSelectingProfile && + + { + + } + Your Saved Bulk Load Profiles + { + yourSavedBulkLoadProfiles && yourSavedBulkLoadProfiles.length > 0 ? ( + yourSavedBulkLoadProfiles.map((record: QRecord, index: number) => + handleSavedBulkLoadProfileRecordOnClick(record)}> + {record.values.get("label")} + + ) + ) : ( + + You do not have any saved bulk load profiles for this table. + + ) + } + Bulk Load Profiles Shared with you + { + bulkLoadProfilesSharedWithYou && bulkLoadProfilesSharedWithYou.length > 0 ? ( + bulkLoadProfilesSharedWithYou.map((record: QRecord, index: number) => + handleSavedBulkLoadProfileRecordOnClick(record)}> + {record.values.get("label")} + + ) + ) : ( + + You do not have any bulk load profiles shared with you for this table. + + ) + } + + } +
+ ); + + 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 ? ( + <> + + + {renderSavedBulkLoadProfilesMenu} + + + + { + savedSuccessMessage && {savedSuccessMessage} + } + { + savedFailedMessage && {savedFailedMessage} + } + { + !currentSavedBulkLoadProfileRecord /*&& bulkLoadProfileIsModified*/ && <> + { + <> + + Unsaved Mapping +
    +
  • You are not using a saved bulk load profile.
  • + { + /*bulkLoadProfileDiffs.map((s: string, i: number) =>
  • {s}
  • )*/ + } +
+ }> + +
+ + {/* vertical rule */} + {allowSelectingProfile && } + + } + + {/* 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 && <> + Reset to: + + + + } + + + + } + { + currentSavedBulkLoadProfileRecord && bulkLoadProfileIsModified && <> + + Unsaved Changes +
    + { + bulkLoadProfileDiffs.map((s: string, i: number) =>
  • {s}
  • ) + } +
+ { + notOwnerTooltipText && {notOwnerTooltipText} + } + }> + {bulkLoadProfileDiffs.length} Unsaved Change{bulkLoadProfileDiffs.length == 1 ? "" : "s"} +
+ + {disabledBecauseNotOwner ? <>   : } + + {/* 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 && <> + + + + } + + } + +
+ { + + { + //////////////////////////////////////////////////// + // make user actually hit delete button // + // but for other modes, let Enter submit the form // + //////////////////////////////////////////////////// + if (e.key == "Enter" && !isDeleteAction) + { + handleDialogButtonOnClick(); + } + }} + > + { + currentSavedBulkLoadProfileRecord ? ( + isDeleteAction ? ( + Delete Bulk Load Profile + ) : ( + isSaveAsAction ? ( + Save Bulk Load Profile As + ) : ( + isRenameAction ? ( + Rename Bulk Load Profile + ) : ( + Update Existing Bulk Load Profile + ) + ) + ) + ) : ( + Save New Bulk Load Profile + ) + } + + {popupAlertContent ? ( + + setPopupAlertContent("")}>{popupAlertContent} + + ) : ("")} + { + (!currentSavedBulkLoadProfileRecord || isSaveAsAction || isRenameAction) && !isDeleteAction ? ( + + { + isSaveAsAction ? ( + Enter a name for this new saved bulk load profile. + ) : ( + Enter a new name for this saved bulk load profile. + ) + } + + { + event.target.select(); + }} + /> + + ) : ( + isDeleteAction ? ( + Are you sure you want to delete the bulk load profile {`'${currentSavedBulkLoadProfileRecord?.values.get("label")}'`}? + ) : ( + Are you sure you want to update the bulk load profile {`'${currentSavedBulkLoadProfileRecord?.values.get("label")}'`}? + ) + ) + } + + + + { + isDeleteAction ? + + : + + } + + + } + + ) : null + ); +} + +export default SavedBulkLoadProfiles; diff --git a/src/qqq/components/processes/BulkLoadFileMappingField.tsx b/src/qqq/components/processes/BulkLoadFileMappingField.tsx new file mode 100644 index 0000000..1bf7591 --- /dev/null +++ b/src/qqq/components/processes/BulkLoadFileMappingField.tsx @@ -0,0 +1,226 @@ +/* + * 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 . + */ + + +import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData"; +import {Checkbox, FormControlLabel, Radio} 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 React, {useEffect, useState} from "react"; + +interface BulkLoadMappingFieldProps +{ + bulkLoadField: BulkLoadField, + isRequired: boolean, + removeFieldCallback?: () => void, + fileDescription: FileDescription, + forceParentUpdate?: () => void, +} + +/*************************************************************************** + ** 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 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); + + const columnOptions: { value: number, label: string }[] = []; + for (let i = 0; i < columnNames.length; i++) + { + columnOptions.push({label: columnNames[i], value: i}); + } + + ////////////////////////////////////////////////////////////////////// + // 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}) + } + + 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); + bulkLoadField.columnIndex = newValue == null ? null : newValue.value; + + if (fileDescription.hasHeaderRow) + { + bulkLoadField.headerName = newValue == null ? null : newValue.label; + } + + bulkLoadField.error = null; + forceParentUpdate && forceParentUpdate(); + } + + + /*************************************************************************** + ** + ***************************************************************************/ + function defaultValueChanged(newValue: any) + { + setFieldValue(`${bulkLoadField.field.name}.defaultValue`, newValue); + bulkLoadField.defaultValue = newValue; + bulkLoadField.error = null; + forceParentUpdate && forceParentUpdate(); + } + + + /*************************************************************************** + ** + ***************************************************************************/ + function valueTypeChanged(isColumn: boolean) + { + const newValueType = isColumn ? "column" : "defaultValue"; + bulkLoadField.valueType = newValueType; + setValueType(newValueType); + bulkLoadField.error = null; + forceParentUpdate && forceParentUpdate(); + } + + + /*************************************************************************** + ** + ***************************************************************************/ + function mapValuesChanged(value: boolean) + { + bulkLoadField.doValueMapping = value; + forceParentUpdate && forceParentUpdate(); + } + + return ( + + + + { + (!isRequired) && removeFieldCallback()} sx={{pt: "0.75rem"}}>remove_circle + } + + {bulkLoadField.getQualifiedLabel()} + + + + + + + valueTypeChanged(checked)} />} label={"File column"} sx={{minWidth: "140px", whiteSpace: "nowrap"}} /> + { + valueType == "column" && + ()} + fullWidth + options={columnOptions} + multiple={false} + defaultValue={selectedColumn} + value={selectedColumn} + inputValue={selectedColumn?.label} + 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) => (
  • {option?.label ?? ""}
  • )} + sx={{"& .MuiOutlinedInput-root": {padding: "0"}}} + /> +
    + } +
    + + valueTypeChanged(!checked)} />} label={"Default value"} sx={{minWidth: "140px", whiteSpace: "nowrap"}} /> + { + valueType == "defaultValue" && + + + } + +
    + { + bulkLoadField.error && + + {bulkLoadField.error} + + } +
    + + + { + valueType == "column" && <> + + mapValuesChanged(checked)} />} label={"Map values"} sx={{minWidth: "140px", whiteSpace: "nowrap"}} /> + + + Preview Values: {(fileDescription.getPreviewValues(selectedColumn?.value) ?? [""]).join(", ")} + + + } + + +
    +
    ); +} diff --git a/src/qqq/components/processes/BulkLoadFileMappingFields.tsx b/src/qqq/components/processes/BulkLoadFileMappingFields.tsx new file mode 100644 index 0000000..44aebdc --- /dev/null +++ b/src/qqq/components/processes/BulkLoadFileMappingFields.tsx @@ -0,0 +1,322 @@ +/* + * 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 . + */ + + +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 [forceRerender, setForceRerender] = 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); + + }, [bulkLoadMapping]); + + + /////////////////////////////////////////////// + // 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) + { + // addFieldsToggleStates[bulkLoadField.getQualifiedName()] = false; + // setAddFieldsToggleStates(Object.assign({}, addFieldsToggleStates)); + + 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(); + setForceRerender(forceRerender + 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(); + } + } + + + /*************************************************************************** + ** + ***************************************************************************/ + function copyWideField(bulkLoadField: BulkLoadField) + { + bulkLoadMapping.addField(bulkLoadField); + forceUpdate(); + //? //? forceParentUpdate(); + //? setForceRerender(forceRerender + 1); + } + + + 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 ( + <> +
    Required Fields
    + + { + bulkLoadMapping.requiredFields.length == 0 && + There are no required fields in this table. + } + {bulkLoadMapping.requiredFields.map((bulkLoadField) => ( + + ))} + + + +
    Additional Fields
    + + {bulkLoadMapping.additionalFields.map((bulkLoadField) => ( + removeField(bulkLoadField)} + forceParentUpdate={forceParentUpdate} + /> + ))} + + + add Add Fields keyboard_arrow_down} + isModeSelectOne + keepOpenAfterSelectOne + handleSelectedOption={handleAddField} + forceRerender={forceRerender} + disabledStates={addFieldsDisableStates} + tooltips={tooltips} + /> + + +
    + + ); +} + diff --git a/src/qqq/components/processes/BulkLoadFileMappingForm.tsx b/src/qqq/components/processes/BulkLoadFileMappingForm.tsx new file mode 100644 index 0000000..987a3e1 --- /dev/null +++ b/src/qqq/components/processes/BulkLoadFileMappingForm.tsx @@ -0,0 +1,384 @@ +/* + * 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 . + */ + +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 Autocomplete from "@mui/material/Autocomplete"; +import Box from "@mui/material/Box"; +import Grid from "@mui/material/Grid"; +import TextField from "@mui/material/TextField"; +import {useFormikContext} from "formik"; +import {DynamicFormFieldLabel} from "qqq/components/forms/DynamicForm"; +import QDynamicFormField from "qqq/components/forms/DynamicFormField"; +import MDTypography from "qqq/components/legacy/MDTypography"; +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, useEffect, useImperativeHandle, useReducer, useState} from "react"; +import ProcessViewForm from "./ProcessViewForm"; + + +interface BulkLoadMappingFormProps +{ + processValues: any; + tableMetaData: QTableMetaData; + metaData: QInstance; + setActiveStepLabel: (label: string) => void; +} + + +/*************************************************************************** + ** process component - screen where user does a bulk-load file mapping. + ***************************************************************************/ +const BulkLoadFileMappingForm = forwardRef(({processValues, tableMetaData, metaData, setActiveStepLabel}: BulkLoadMappingFormProps, ref) => +{ + const {setFieldValue} = useFormikContext(); + + const [currentSavedBulkLoadProfile, setCurrentSavedBulkLoadProfile] = useState(null as QRecord); + const [wrappedCurrentSavedBulkLoadProfile] = useState(new Wrapper(currentSavedBulkLoadProfile)); + + const [fieldErrors, setFieldErrors] = useState({} as { [fieldName: string]: string }); + + const [suggestedBulkLoadProfile] = useState(processValues.bulkLoadProfile as BulkLoadProfile); + const [tableStructure] = useState(processValues.tableStructure as BulkLoadTableStructure); + const [bulkLoadMapping, setBulkLoadMapping] = useState(BulkLoadMapping.fromBulkLoadProfile(tableStructure, suggestedBulkLoadProfile)); + const [wrappedBulkLoadMapping] = useState(new Wrapper(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); + + return {maySubmit: !haveProfileErrors && !haveLocalErrors, values}; + } + }; + }); + + + useEffect(() => + { + console.log("@dk has header row changed!"); + }, [bulkLoadMapping.hasHeaderRow]); + + + /*************************************************************************** + ** + ***************************************************************************/ + 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 ( + + + + + + forceUpdate()} + /> + + + forceUpdate()} + /> + + + ); + +}); + +export default BulkLoadFileMappingForm; + + + + +interface BulkLoadMappingHeaderProps +{ + fileDescription: FileDescription, + fileName: string, + bulkLoadMapping?: BulkLoadMapping, + fieldErrors: { [fieldName: string]: string }, + tableStructure: BulkLoadTableStructure, + forceParentUpdate?: () => void +} + +/*************************************************************************** + ** private subcomponent - the header section of the bulk load file mapping screen. + ***************************************************************************/ +function BulkLoadMappingHeader({fileDescription, fileName, bulkLoadMapping, fieldErrors, tableStructure, forceParentUpdate}: 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}; + + let helpRoles = ["PROCESS_SCREEN", "ALL_SCREENS"]; + + 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; + fieldErrors.hasHeaderRow = null; + forceParentUpdate(); + } + + function layoutChanged(event: any, newValue: any) + { + bulkLoadMapping.layout = newValue ? newValue.id : null; + fieldErrors.layout = null; + forceParentUpdate(); + } + + return ( + +
    File Details
    + + + + + + + + { + fieldErrors.hasHeaderRow && + + {
    {fieldErrors.hasHeaderRow}
    } +
    + } +
    + + + ()} + 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) => (
  • {option?.label ?? ""}
  • )} + sx={{"& .MuiOutlinedInput-root": {padding: "0"}}} + /> + { + fieldErrors.layout && + + {
    {fieldErrors.layout}
    } +
    + } +
    +
    +
    +
    + ); +} + + + +interface BulkLoadMappingFilePreviewProps +{ + fileDescription: FileDescription; +} + +/*************************************************************************** + ** private subcomponent - the file-preview section of the bulk load file mapping screen. + ***************************************************************************/ +function BulkLoadMappingFilePreview({fileDescription}: BulkLoadMappingFilePreviewProps): JSX.Element +{ + const rows: number[] = []; + for (let i = 0; i < fileDescription.bodyValuesPreview[0].length; i++) + { + rows.push(i); + } + + return ( + + + + + + + {fileDescription.headerLetters.map((letter) => )} + + + + + + {fileDescription.headerValues.map((value) => )} + + {rows.map((i) => ( + + + {fileDescription.headerLetters.map((letter, j) => )} + + ))} + +
    {letter}
    1{value}
    {i + 2}{fileDescription.bodyValuesPreview[j][i]}
    +
    +
    + ); +} + + diff --git a/src/qqq/components/processes/BulkLoadProfileForm.tsx b/src/qqq/components/processes/BulkLoadProfileForm.tsx new file mode 100644 index 0000000..f1e5403 --- /dev/null +++ b/src/qqq/components/processes/BulkLoadProfileForm.tsx @@ -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 . + */ + +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(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 ( + + + + + + ); +}); + +export default BulkLoadProfileForm; \ No newline at end of file diff --git a/src/qqq/components/processes/BulkLoadValueMappingForm.tsx b/src/qqq/components/processes/BulkLoadValueMappingForm.tsx new file mode 100644 index 0000000..2c45315 --- /dev/null +++ b/src/qqq/components/processes/BulkLoadValueMappingForm.tsx @@ -0,0 +1,222 @@ +/* + * 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 . + */ + +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, 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(savedBulkLoadProfileRecord)); + + const [tableStructure] = useState(processValues.tableStructure as BulkLoadTableStructure); + + const [currentMapping, setCurrentMapping] = useState(initializeCurrentBulkLoadMapping()); + const [wrappedBulkLoadMapping] = useState(new Wrapper(currentMapping)); + + const [fileDescription] = useState(new FileDescription(processValues.headerValues, processValues.headerLetters, processValues.bodyValuesPreview)); + fileDescription.setHasHeaderRow(currentMapping.hasHeaderRow); + + /******************************************************************************* + ** + *******************************************************************************/ + 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 (); + } + + /*************************************************************************** + ** + ***************************************************************************/ + function mappedValueChanged(fileValue: string, newValue: any) + { + valueErrors[fileValue] = null; + currentMapping.valueMappings[fieldFullName][fileValue] = newValue; + } + + + /*************************************************************************** + ** + ***************************************************************************/ + 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 ( + + + + + + { + fileValues.map((fileValue, i) => ( + + + {fileValue} + arrow_forward + + mappedValueChanged(fileValue, newValue)} + /> + { + valueErrors[fileValue] && + + {valueErrors[fileValue]} + + } + + + + )) + } + ); + +}); + + +export default BulkLoadValueMappingForm; diff --git a/src/qqq/models/processes/BulkLoadModels.ts b/src/qqq/models/processes/BulkLoadModels.ts new file mode 100644 index 0000000..e2e43f0 --- /dev/null +++ b/src/qqq/models/processes/BulkLoadModels.ts @@ -0,0 +1,600 @@ +/* + * 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 . + */ + + +import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData"; +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; + + key: string; + + + /*************************************************************************** + ** + ***************************************************************************/ + constructor(field: QFieldMetaData, tableStructure: BulkLoadTableStructure, valueType: ValueType = "column", columnIndex?: number, headerName?: string, defaultValue?: any, doValueMapping?: boolean, wideLayoutIndexPath: number[] = []) + { + 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.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)); + } + + + /*************************************************************************** + ** + ***************************************************************************/ + 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 + { + index = 0; + /////////////////////////////////////////////////////////// + // count how many copies of this field there are already // + /////////////////////////////////////////////////////////// + for (let existingField of [...this.requiredFields, ...this.additionalFields]) + { + if (existingField.getQualifiedName() == bulkLoadField.getQualifiedName()) + { + index++; + } + } + } + + 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.getQualifiedName() != toRemove.getQualifiedName()) + { + newAdditionalFields.push(bulkLoadField); + } + } + + this.additionalFields = newAdditionalFields; + } +} + + +/*************************************************************************** + ** meta-data about the file that the user uploaded + ***************************************************************************/ +export class FileDescription +{ + headerValues: string[]; + headerLetters: string[]; + bodyValuesPreview: string[][]; + + // 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; + } + + + /*************************************************************************** + ** + ***************************************************************************/ + 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): string[] + { + if (columnIndex == undefined) + { + return []; + } + + if (this.hasHeaderRow) + { + return (this.bodyValuesPreview[columnIndex]); + } + else + { + return ([this.headerValues[columnIndex], ...this.bodyValuesPreview[columnIndex]]); + } + } +} + + +/*************************************************************************** + ** 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; + + /*************************************************************************** + ** + ***************************************************************************/ + constructor(t: T) + { + this.t = t; + } + + + /*************************************************************************** + ** + ***************************************************************************/ + public get(): T + { + return this.t; + } + + + /*************************************************************************** + ** + ***************************************************************************/ + public set(t: T) + { + this.t = t; + } +} + diff --git a/src/qqq/utils/qqq/SavedBulkLoadProfileUtils.ts b/src/qqq/utils/qqq/SavedBulkLoadProfileUtils.ts new file mode 100644 index 0000000..827e6a2 --- /dev/null +++ b/src/qqq/utils/qqq/SavedBulkLoadProfileUtils.ts @@ -0,0 +1,227 @@ +/* + * 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 . + */ + +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.field.name; + const compareField = compareFieldsMap[fieldName]; + const baseField = baseFieldsMap[fieldName]; + + 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})`); + } + else if (compareField.valueType == "defaultValue") + { + const column = fileDescription.getColumnNames()[baseField.columnIndex]; + rs.push(`Changed ${compareField.getQualifiedLabel()} from using a file column (${column}) to using a default value (${compareField.defaultValue})`); + } + } + else if (baseField.valueType == compareField.valueType && baseField.valueType == "defaultValue") + { + ////////////////////////////////////////////////// + // if we changed the default value, report that // + ////////////////////////////////////////////////// + if (baseField.defaultValue != compareField.defaultValue) + { + rs.push(`Changed ${compareField.getQualifiedLabel()} default value from (${baseField.defaultValue}) to (${compareField.defaultValue})`); + } + } + else if (baseField.valueType == compareField.valueType && baseField.valueType == "column") + { + /////////////////////////////////////////// + // if we changed the column, report that // + /////////////////////////////////////////// + if (fileDescription.hasHeaderRow) + { + if (baseField.headerName != compareField.headerName) + { + const baseColumn = fileDescription.getColumnNames()[baseField.columnIndex]; + const compareColumn = fileDescription.getColumnNames()[compareField.columnIndex]; + rs.push(`Changed ${compareField.getQualifiedLabel()} file column from (${baseColumn}) to (${compareColumn})`); + } + } + else + { + if (baseField.columnIndex != compareField.columnIndex) + { + const baseColumn = fileDescription.getColumnNames()[baseField.columnIndex]; + const compareColumn = fileDescription.getColumnNames()[compareField.columnIndex]; + rs.push(`Changed ${compareField.getQualifiedLabel()} file column from (${baseColumn}) to (${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.field.name; + const compareField = compareFieldsMap[fieldName]; + + //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // 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); + } + + + /*************************************************************************** + ** + ***************************************************************************/ + 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.field.name; + + if (JSON.stringify(baseMapping.valueMappings[fieldName] ?? []) != JSON.stringify(activeMapping.valueMappings[fieldName] ?? [])) + { + diffs.push(`Changed value mapping for ${bulkLoadField.getQualifiedLabel()}`) + } + + if (baseMapping.valueMappings[fieldName] && activeMapping.valueMappings[fieldName]) + { + // todo - finish this - better version than just the JSON diff! + } + } + catch(e) + { + console.log(`Error diffing profiles: ${e}`); + } + } + + return diffs; + }; + +} \ No newline at end of file From cce73fcb0bf2b191fcb3ac096db1abc3a8c18565 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Mon, 25 Nov 2024 10:48:20 -0600 Subject: [PATCH 09/52] CE-1955 - Update qfc to 1.0.111 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 6b7c554..82bc0c2 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "@auth0/auth0-react": "1.10.2", "@emotion/react": "11.7.1", "@emotion/styled": "11.6.0", - "@kingsrook/qqq-frontend-core": "1.0.110", + "@kingsrook/qqq-frontend-core": "1.0.111", "@mui/icons-material": "5.4.1", "@mui/material": "5.11.1", "@mui/styles": "5.11.1", From bfa9b1d18228cdfbcdb10c579a55528693a2eb1d Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Mon, 25 Nov 2024 11:32:52 -0600 Subject: [PATCH 10/52] CE-1955 - Trying a sleep (wait) around point of failure... --- .../selenium/tests/BulkEditTest.java | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/BulkEditTest.java b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/BulkEditTest.java index e0a49ac..f52deb5 100755 --- a/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/BulkEditTest.java +++ b/src/test/java/com/kingsrook/qqq/frontend/materialdashboard/selenium/tests/BulkEditTest.java @@ -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(); From 911ba1da21f824510cc2e19d889d2c0d22b83b26 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Wed, 27 Nov 2024 15:11:27 -0600 Subject: [PATCH 11/52] CE-1955 - Remove unused method --- .../processes/BulkLoadFileMappingFields.tsx | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/src/qqq/components/processes/BulkLoadFileMappingFields.tsx b/src/qqq/components/processes/BulkLoadFileMappingFields.tsx index 44aebdc..f8dd561 100644 --- a/src/qqq/components/processes/BulkLoadFileMappingFields.tsx +++ b/src/qqq/components/processes/BulkLoadFileMappingFields.tsx @@ -231,18 +231,6 @@ export default function BulkLoadFileMappingFields({bulkLoadMapping, fileDescript } - /*************************************************************************** - ** - ***************************************************************************/ - function copyWideField(bulkLoadField: BulkLoadField) - { - bulkLoadMapping.addField(bulkLoadField); - forceUpdate(); - //? //? forceParentUpdate(); - //? setForceRerender(forceRerender + 1); - } - - let buttonBackground = "none"; let buttonBorder = colors.grayLines.main; let buttonColor = colors.gray.main; From b90b5217ca7c274106f7ecea3f1ffbe877c8cb63 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Wed, 27 Nov 2024 15:11:49 -0600 Subject: [PATCH 12/52] CE-1955 - Add QAlternateButton --- src/qqq/components/buttons/DefaultButtons.tsx | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/qqq/components/buttons/DefaultButtons.tsx b/src/qqq/components/buttons/DefaultButtons.tsx index 07b0cdf..3a36c48 100644 --- a/src/qqq/components/buttons/DefaultButtons.tsx +++ b/src/qqq/components/buttons/DefaultButtons.tsx @@ -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 ( + + {iconName}} onClick={onClick} disabled={disabled}> + {label} + + + ); +} + +QAlternateButton.defaultProps = {}; From 85056b121b2e0d1915fde9db23435a1967ae4e6a Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Wed, 27 Nov 2024 15:14:46 -0600 Subject: [PATCH 13/52] CE-1955 - Update qfc to 1.0.112 (add backStep to QJobComplete) --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 82bc0c2..663a87f 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "@auth0/auth0-react": "1.10.2", "@emotion/react": "11.7.1", "@emotion/styled": "11.6.0", - "@kingsrook/qqq-frontend-core": "1.0.111", + "@kingsrook/qqq-frontend-core": "1.0.112", "@mui/icons-material": "5.4.1", "@mui/material": "5.11.1", "@mui/styles": "5.11.1", From 169bd4ee7e33d319a5a42f3a34a64efc9eb473e9 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Wed, 27 Nov 2024 15:20:31 -0600 Subject: [PATCH 14/52] CE-1955 - Add support for 'back' in processes. add a 'loadingRecords' state var, to help validation screen not flicker 'none found' --- src/qqq/pages/processes/ProcessRun.tsx | 111 +++++++++++++++++-------- 1 file changed, 77 insertions(+), 34 deletions(-) diff --git a/src/qqq/pages/processes/ProcessRun.tsx b/src/qqq/pages/processes/ProcessRun.tsx index b0967aa..6585c0d 100644 --- a/src/qqq/pages/processes/ProcessRun.tsx +++ b/src/qqq/pages/processes/ProcessRun.tsx @@ -51,10 +51,9 @@ 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"; @@ -98,6 +97,8 @@ const INITIAL_RETRY_MILLIS = 1_500; const RETRY_MAX_MILLIS = 12_000; const BACKOFF_AMOUNT = 1.5; +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. // @@ -140,6 +141,7 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is 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); @@ -216,6 +218,7 @@ 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); @@ -905,6 +908,7 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is processValues={processValues} step={step} previewRecords={records} + loadingRecords={loadingRecords} formValues={formData.values} doFullValidationRadioChangedHandler={(event: any) => { @@ -1384,7 +1388,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, @@ -1393,6 +1397,7 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is const {records} = response; setRecords(records); + setLoadingRecords(false); ///////////////////////////////////////////////////////////////////////////////////////// // re-construct the recordConfig object, so the setState call triggers a new rendering // @@ -1535,7 +1540,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]) @@ -1549,6 +1554,9 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is } } + ////////////////////////////////////// + // reset some state between screens // + ////////////////////////////////////// setJobUUID(null); setNewStep(nextStepName); setStepInstanceCounter(1 + stepInstanceCounter); @@ -1556,6 +1564,7 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is setRenderedWidgets({}); setSubFormPreSubmitCallbacks([]); setQJobRunning(null); + setBackStepName(qJobComplete.backStep) if (formikSetFieldValueFunction) { @@ -1633,7 +1642,7 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is { try { - const processResponse = await Client.getInstance().processJobStatus( + const processResponse = await qController.processJobStatus( processName, processUUID, jobUUID, @@ -1734,7 +1743,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); } @@ -1746,7 +1755,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); @@ -1757,7 +1766,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) @@ -1788,7 +1797,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); } @@ -1805,7 +1814,27 @@ 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); + }); }; ////////////////////////////////////////////////////////////////////////////////////////// @@ -1872,10 +1901,29 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is formData.append("bulkEditEnabledFields", bulkEditEnabledFields.join(",")); } - const formDataHeaders = { - "content-type": "multipart/form-data; boundary=--------------------------320289315924586491558366", - }; + clearStatesBeforeHittingBackend(); + setTimeout(async () => + { + recordAnalytics({category: "processEvents", action: "processStep", label: activeStep.label}); + + const processResponse = await qController.processStep( + processName, + processUUID, + activeStep.name, + formData, + qController.defaultMultipartFormDataHeaders(), + ); + setLastProcessResponse(processResponse); + }); + }; + + + /******************************************************************************* + ** common code shared by 'back' and 'submit' (next) - to clear some state values. + *******************************************************************************/ + const clearStatesBeforeHittingBackend = () => + { setProcessValues({}); setRecords([]); setOverrideOnLastStep(null); @@ -1884,22 +1932,15 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // 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); + setActiveStep(null); - setTimeout(async () => - { - recordAnalytics({category: "processEvents", action: "processStep", label: activeStep.label}); + //////////////////////////////////////////////////////////////////////////////////////////////////////////// + // 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); - const processResponse = await Client.getInstance().processStep( - processName, - processUUID, - activeStep.name, - formData, - formDataHeaders, - ); - setLastProcessResponse(processResponse); - }); - }; + } /******************************************************************************* @@ -1912,7 +1953,7 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is ////////////////////////////////////////////////////////////////// if (!isClose) { - Client.getInstance().processCancel(processName, processUUID); + qController.processCancel(processName, processUUID); } if (isModal && closeModalHandler) @@ -2050,12 +2091,7 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is {/******************************** ** back &| next/submit buttons ** ********************************/} - - {true || activeStepIndex === 0 ? ( - - ) : ( - back - )} + {processError || qJobRunning || !activeStep || activeStep?.format?.toLowerCase() == "scanner" ? ( ) : ( @@ -2076,6 +2112,13 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is handleCancelClicked(false)} disabled={isSubmitting} /> ) } + + {backStepName ? ( + + ) : ( + + )} + From 45be12c728e191bf2c5ac014ddc3fef50936f452 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Wed, 27 Nov 2024 15:20:46 -0600 Subject: [PATCH 15/52] CE-1955 - Add support for bulletsOfText on a processSummaryLine --- src/qqq/models/processes/ProcessSummaryLine.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/qqq/models/processes/ProcessSummaryLine.tsx b/src/qqq/models/processes/ProcessSummaryLine.tsx index a7c898b..0a5f851 100644 --- a/src/qqq/models/processes/ProcessSummaryLine.tsx +++ b/src/qqq/models/processes/ProcessSummaryLine.tsx @@ -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 ) : {lastWord} } + { + this.bulletsOfText &&
      + { + this.bulletsOfText.map((bullet, index) =>
    • {bullet}
    • ) + } +
    + }
    From ee9cd5a5f68a766e59cc00d061f308b2abf9b6d4 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Wed, 27 Nov 2024 15:22:21 -0600 Subject: [PATCH 16/52] CE-1955 - add support for child-record lists on process validation preview, via: - add properties: gridOnly and gridDensity; - allow the input query records and tableMetaData to come in as pre-typed TS objects, rather than POJSO's, that need to go through constructors. --- .../widgets/misc/RecordGridWidget.tsx | 113 ++++++++++-------- 1 file changed, 66 insertions(+), 47 deletions(-) diff --git a/src/qqq/components/widgets/misc/RecordGridWidget.tsx b/src/qqq/components/widgets/misc/RecordGridWidget.tsx index f4323fd..8611f68 100644 --- a/src/qqq/components/widgets/misc/RecordGridWidget.tsx +++ b/src/qqq/components/widgets/misc/RecordGridWidget.tsx @@ -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"; @@ -60,18 +60,21 @@ interface Props editRecordCallback?: (rowIndex: number) => void; allowRecordDelete: boolean; deleteRecordCallback?: (rowIndex: number) => void; + gridOnly?: boolean; + gridDensity?: GridDensity; } 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}: Props): JSX.Element { const instance = useRef({timer: null}); const [rows, setRows] = useState([]); @@ -94,11 +97,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); ///////////////////////////////////////////////////////////////////////////////// @@ -296,6 +306,56 @@ function RecordGridWidget({widgetMetaData, data, addNewRecordCallback, disableRo } + const grid = ( + (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 ( - (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} From 9b5d9f12901f0635a3386e13b40c0ef69c73e4bf Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Wed, 27 Nov 2024 15:23:47 -0600 Subject: [PATCH 17/52] CE-1955 - Add styleOverrides argument to renderSectionOfFields; add css classes recordSidebar and recordWithSidebar --- src/qqq/pages/records/view/RecordView.tsx | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/qqq/pages/records/view/RecordView.tsx b/src/qqq/pages/records/view/RecordView.tsx index 75c2d3d..c1fa463 100644 --- a/src/qqq/pages/records/view/RecordView.tsx +++ b/src/qqq/pages/records/view/RecordView.tsx @@ -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,7 +92,7 @@ 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 { @@ -107,7 +108,7 @@ export function renderSectionOfFields(key: string, fieldNames: string[], tableMe const showHelp = helpHelpActive || hasHelpContent(field.helpContents, helpRoles); const formattedHelpContent = ; - const labelElement = {label}:; + const labelElement = {label}:; return ( @@ -116,7 +117,7 @@ export function renderSectionOfFields(key: string, fieldNames: string[], tableMe showHelp && formattedHelpContent ? {labelElement} : labelElement }
     
    - + {ValueUtils.getDisplayValue(field, record, "view", fieldName)} @@ -993,10 +994,10 @@ function RecordView({table, record: overrideRecord, launchProcess}: Props): JSX. } - + - + From ab530121ca2ef1a37cdf2b298dea49d3a06524a8 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Wed, 27 Nov 2024 15:24:40 -0600 Subject: [PATCH 18/52] CE-1955 - Avoid a few null pointers if missing compareField --- src/qqq/utils/qqq/SavedBulkLoadProfileUtils.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/qqq/utils/qqq/SavedBulkLoadProfileUtils.ts b/src/qqq/utils/qqq/SavedBulkLoadProfileUtils.ts index 827e6a2..5dd0e29 100644 --- a/src/qqq/utils/qqq/SavedBulkLoadProfileUtils.ts +++ b/src/qqq/utils/qqq/SavedBulkLoadProfileUtils.ts @@ -41,6 +41,10 @@ export class SavedBulkLoadProfileUtils const fieldName = bulkLoadField.field.name; const compareField = compareFieldsMap[fieldName]; const baseField = baseFieldsMap[fieldName]; + if(!compareField) + { + continue; + } if (baseField) { @@ -119,6 +123,10 @@ export class SavedBulkLoadProfileUtils { const fieldName = bulkLoadField.field.name; 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. // From 8a160109777e8c21467b6112a8f740786d30fbc8 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Wed, 27 Nov 2024 15:25:50 -0600 Subject: [PATCH 19/52] CE-1955 - Add better support for bulk-load (by doing layout more like view screen), for when formatPreviewRecordUsingTableLayout processValues is present; including associations! --- .../components/processes/ValidationReview.tsx | 257 ++++++++++++++---- 1 file changed, 205 insertions(+), 52 deletions(-) diff --git a/src/qqq/components/processes/ValidationReview.tsx b/src/qqq/components/processes/ValidationReview.tsx index 80767e4..015c3d5 100644 --- a/src/qqq/components/processes/ValidationReview.tsx +++ b/src/qqq/components/processes/ValidationReview.tsx @@ -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,77 @@ function ValidationReview({ ); + /*************************************************************************** + ** + ***************************************************************************/ + function previewRecordUsingTableLayout(record: QRecord) + { + if (!previewTableMetaData) + { + return (Loading...); + } + + const renderedSections: JSX.Element[] = []; + const tableSections = TableUtils.getSectionsForRecordSidebar(previewTableMetaData); + const previewRecord = previewRecords[previewRecordIndex]; + + for (let i = 0; i < tableSections.length; i++) + { + const section = tableSections[i]; + if (section.isHidden) + { + continue; + } + + if (section.fieldNames) + { + renderedSections.push( +

    {section.label}

    + + {renderSectionOfFields(section.name, section.fieldNames, previewTableMetaData, false, previewRecord, undefined, {label: {fontWeight: "500"}})} + +
    ); + } + 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 = previewRecord.associatedRecords.get(associationPreview.associationName) ?? []; + data = { + canAddChildRecord: false, + childTableMetaData: childTableMetaData[associationPreview.tableName], + defaultValuesForNewChildRecords: {}, + disabledFieldsForNewChildRecords: {}, + queryOutput: {records: associationRecords}, + totalRows: associationRecords.length, + tablePath: "", + title: "", + viewAllLink: "", + }; + + renderedSections.push( + { + data && +

    {section.label}

    + + + +
    + } +
    ); + } + } + } + } + + return renderedSections; + } + const recordPreviewWidget = step.recordListFields && ( @@ -200,43 +346,47 @@ function ValidationReview({ { - processValues?.previewMessage && previewRecords && previewRecords.length > 0 ? ( - <> - {processValues?.previewMessage} - - Note that the number of preview records available may be fewer than the total number of records being processed. - - )} - > - info_outlined - - - ) : ( - <> - No record previews are available at this time. - - { - 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. - - ) - } - - )} - > - info_outlined - - - ) + loadingRecords ? Loading... : <> + { + processValues?.previewMessage && previewRecords && previewRecords.length > 0 ? ( + <> + {processValues?.previewMessage} + + Note that the number of preview records available may be fewer than the total number of records being processed. + + )} + > + info_outlined + + + ) : ( + <> + No record previews are available at this time. + + { + 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. + + ) + } + + )} + > + info_outlined + + + ) + } + } @@ -244,16 +394,19 @@ function ValidationReview({ { - previewRecords && previewRecords[previewRecordIndex] && step.recordListFields.map((field) => ( + previewRecords && !processValues.formatPreviewRecordUsingTableLayout && previewRecords[previewRecordIndex] && step.recordListFields.map((field) => ( {`${field.label}:`} {" "} -   +   {" "} {ValueUtils.getDisplayValue(field, previewRecords[previewRecordIndex], "view")} )) } + { + previewRecords && processValues.formatPreviewRecordUsingTableLayout && previewRecords[previewRecordIndex] && previewRecordUsingTableLayout(previewRecords[previewRecordIndex]) + } { From f503c008ecf784b43705b28ca5e434bf528f8bce Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Wed, 27 Nov 2024 15:27:48 -0600 Subject: [PATCH 20/52] Make record sidebar stop growing at some point (400px, when screen is 1400) --- src/qqq/styles/qqq-override-styles.css | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/qqq/styles/qqq-override-styles.css b/src/qqq/styles/qqq-override-styles.css index c1de29c..f95797b 100644 --- a/src/qqq/styles/qqq-override-styles.css +++ b/src/qqq/styles/qqq-override-styles.css @@ -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; + } +} \ No newline at end of file From 1626648dda4e6cb060663372faabc88838c145b7 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Tue, 3 Dec 2024 09:10:46 -0600 Subject: [PATCH 21/52] CE-1955 Update qfc to 1.0.113; add react-dropzone --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 663a87f..987fab6 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "@auth0/auth0-react": "1.10.2", "@emotion/react": "11.7.1", "@emotion/styled": "11.6.0", - "@kingsrook/qqq-frontend-core": "1.0.112", + "@kingsrook/qqq-frontend-core": "1.0.113", "@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", From 65b347b79415e92ee26fb243f2b42a112259478c Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Tue, 3 Dec 2024 09:11:21 -0600 Subject: [PATCH 22/52] CE-1955 Break file-input into its own component, w/ support for FILE_UPLOAD adornment type, to specify drag&drop --- src/qqq/components/forms/DynamicForm.tsx | 107 ++++---------- src/qqq/components/forms/FileInputField.tsx | 156 ++++++++++++++++++++ 2 files changed, 185 insertions(+), 78 deletions(-) create mode 100644 src/qqq/components/forms/FileInputField.tsx diff --git a/src/qqq/components/forms/DynamicForm.tsx b/src/qqq/components/forms/DynamicForm.tsx index 241d414..5a66314 100644 --- a/src/qqq/components/forms/DynamicForm.tsx +++ b/src/qqq/components/forms/DynamicForm.tsx @@ -19,21 +19,16 @@ * along with this program. If not, see . */ -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} 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 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, {useState} from "react"; +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, 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,11 +55,6 @@ function QDynamicForm({formData, formLabel, bulkEditMode, bulkEditSwitchChangeHa {formLabel} - {/* TODO - help text - - Mandatory information - - */} @@ -113,52 +81,34 @@ function QDynamicForm({formData, formLabel, bulkEditMode, bulkEditSwitchChangeHa const labelElement = ; + let itemXS = 12; + let itemSM = 6; + + ///////////// + // files!! // + ///////////// if (field.type === "file") { - const pseudoField = new QFieldMetaData({name: fieldName, type: QFieldType.BLOB}); - return ( - - - {labelElement} - { - record && record.values.get(fieldName) && - Current File: - - {ValueUtils.getDisplayValue(pseudoField, record, "view")} - - removeFile(fieldName)}>delete - - - - } - - - - {fileName} - - + const fileUploadAdornment = field.fieldMetaData?.getAdornment(AdornmentType.FILE_UPLOAD); + const width = fileUploadAdornment?.values?.get("width") ?? "half"; - - - {errors[fieldName] && You must select a file to proceed} - - - + if(width == "full") + { + itemSM = 12; + } + + return ( + + {labelElement} + ); } - // possible values!! - if (field.possibleValueProps) + /////////////////////// + // possible values!! // + /////////////////////// + else if (field.possibleValueProps) { const otherValuesMap = field.possibleValueProps.otherValues ?? new Map(); Object.keys(values).forEach((key) => @@ -167,7 +117,7 @@ function QDynamicForm({formData, formLabel, bulkEditMode, bulkEditSwitchChangeHa }) return ( - + {labelElement} + {labelElement} . + */ + + +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, 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 ( + + { + record && record.values.get(field.name) && + Current File: + + {ValueUtils.getDisplayValue(pseudoField, record, "view")} + + removeFile(field.name)}>delete + + + + } + + { + format == "button" && + + + + {fileName} + + + } + + { + format == "dragAndDrop" && + <> + + + + upload_file + Drag and drop a file + or + + Browse files + + + + + {fileName}  + + + } + + + + {errorMessage && {errorMessage}} + + + + ); +} From 6db003026bec37106c388d02c57b9e63854b4fcc Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Tue, 3 Dec 2024 09:11:59 -0600 Subject: [PATCH 23/52] CE-1955 Remove filterOperators from the column objects we produce, since we're not using dataGridPro's filtering any more --- .../records/query/GridFilterOperators.tsx | 945 ------------------ src/qqq/utils/DataGridUtils.tsx | 59 +- 2 files changed, 2 insertions(+), 1002 deletions(-) delete mode 100644 src/qqq/pages/records/query/GridFilterOperators.tsx diff --git a/src/qqq/pages/records/query/GridFilterOperators.tsx b/src/qqq/pages/records/query/GridFilterOperators.tsx deleted file mode 100644 index fbf7a57..0000000 --- a/src/qqq/pages/records/query/GridFilterOperators.tsx +++ /dev/null @@ -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 . - */ - -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 ( - - { - props && - ( - - - - paste_content - - - ) - } - { - pasteModalIsOpen && - ( - - - - - - - - Bulk Add Filter Values - - 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. - - - - - - - - - - - - - - { - }} - chipData={chipData} - chipType={type} - multiline - fullWidth - variant="outlined" - id="tags" - rows={0} - name="tags" - label="FILTER VALUES REVIEW" - /> - - - - - - - - - SEPARATOR - - - - {delimiter === Delimiter.CUSTOM.valueOf() && ( - - - - - )} - {inputText && delimiter === Delimiter.DETECT_AUTOMATICALLY.valueOf() && ( - - - {detectedText} - - )} - - - - { - errorText && chipData.length > 0 && ( - - error - {errorText} - - ) - } - - - { - chipData && chipData.length > 0 && ( - {chipData.length.toLocaleString()} {chipData.length === 1 ? "value" : "values"} - ) - } - - - - - - - - - - - - - - ) - } - - ); -} - -////////////////////// -// 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) => CustomIsAnyInput("text", props) -}; - -const stringIsNoneOfOperator: GridFilterOperator = { - label: "is none of", - value: "isNone", - getValueAsString: getListValueString, - getApplyFilterFn: () => null, - // @ts-ignore - InputComponent: (props: GridFilterInputMultipleValueProps) => 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(); - 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 ( - - - sync} : {}} - /> - - ); -} - - -////////////////////// -// 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) => CustomIsAnyInput("number", props) -}; - -const numericIsNoneOfOperator: GridFilterOperator = { - label: "is none of", - value: "isNone", - getApplyFilterFn: () => null, - getValueAsString: getListValueString, - // @ts-ignore - InputComponent: (props: GridFilterInputMultipleValueProps) => 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(); - const [filterValueState, setFilterValueState] = useState(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 ( - - sync} : {}} - /> - - ); -} - - -//////////////////////////////////////////////// -// 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(); - 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 ( - - - - ); -} - -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) => InputPossibleValueSourceSingle(tableName, field, props) - }, - { - label: "is not", - value: "isNot", - getApplyFilterFn: () => null, - getValueAsString: getPvsValueString, - InputComponent: (props: GridFilterInputValueProps) => InputPossibleValueSourceSingle(tableName, field, props) - }, - { - label: "is any of", - value: "isAnyOf", - getValueAsString: getPvsValueString, - getApplyFilterFn: () => null, - InputComponent: (props: GridFilterInputValueProps) => InputPossibleValueSourceMultiple(tableName, field, props) - }, - { - label: "is none of", - value: "isNone", - getValueAsString: getPvsValueString, - getApplyFilterFn: () => null, - InputComponent: (props: GridFilterInputValueProps) => 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 - } - ]); -}; diff --git a/src/qqq/utils/DataGridUtils.tsx b/src/qqq/utils/DataGridUtils.tsx index 4cc692f..c5be12a 100644 --- a/src/qqq/utils/DataGridUtils.tsx +++ b/src/qqq/utils/DataGridUtils.tsx @@ -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 (); -} - -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 { /******************************************************************************* @@ -295,11 +247,10 @@ export default class DataGridUtils public static makeColumnFromField = (field: QFieldMetaData, tableMetaData: QTableMetaData, namePrefix?: string, labelPrefix?: string): GridColDef => { let columnType = "string"; - let filterOperators: GridFilterOperator[] = QGridStringOperators; if (field.possibleValueSourceName) { - filterOperators = buildQGridPvsOperators(tableMetaData.name, field); + // noop here } else { @@ -308,22 +259,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,7 +285,6 @@ export default class DataGridUtils headerName: headerName, width: DataGridUtils.getColumnWidthForField(field, tableMetaData), renderCell: null as any, - filterOperators: filterOperators, }; column.renderCell = (cellValues: any) => ( From 4b64c46c577e23c1d20b6982b81ab237515e689d Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Tue, 3 Dec 2024 09:15:31 -0600 Subject: [PATCH 24/52] CE-1955 Add value-mapping details to diff --- .../processes/BulkLoadValueMappingForm.tsx | 15 ++- .../utils/qqq/SavedBulkLoadProfileUtils.ts | 93 +++++++++++++++++-- 2 files changed, 98 insertions(+), 10 deletions(-) diff --git a/src/qqq/components/processes/BulkLoadValueMappingForm.tsx b/src/qqq/components/processes/BulkLoadValueMappingForm.tsx index 2c45315..b785c25 100644 --- a/src/qqq/components/processes/BulkLoadValueMappingForm.tsx +++ b/src/qqq/components/processes/BulkLoadValueMappingForm.tsx @@ -30,7 +30,7 @@ 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, useState} from "react"; +import React, {forwardRef, useEffect, useImperativeHandle, useReducer, useState} from "react"; interface BulkLoadValueMappingFormProps { @@ -67,6 +67,9 @@ const BulkLoadValueMappingForm = forwardRef(({processValues, setActiveStepLabel, const [fileDescription] = useState(new FileDescription(processValues.headerValues, processValues.headerLetters, processValues.bodyValuesPreview)); fileDescription.setHasHeaderRow(currentMapping.hasHeaderRow); + const [, forceUpdate] = useReducer((x) => x + 1, 0); + + /******************************************************************************* ** *******************************************************************************/ @@ -152,7 +155,15 @@ const BulkLoadValueMappingForm = forwardRef(({processValues, setActiveStepLabel, function mappedValueChanged(fileValue: string, newValue: any) { valueErrors[fileValue] = null; - currentMapping.valueMappings[fieldFullName][fileValue] = newValue; + if(newValue == null) + { + delete currentMapping.valueMappings[fieldFullName][fileValue]; + } + else + { + currentMapping.valueMappings[fieldFullName][fileValue] = newValue; + } + forceUpdate(); } diff --git a/src/qqq/utils/qqq/SavedBulkLoadProfileUtils.ts b/src/qqq/utils/qqq/SavedBulkLoadProfileUtils.ts index 5dd0e29..5596762 100644 --- a/src/qqq/utils/qqq/SavedBulkLoadProfileUtils.ts +++ b/src/qqq/utils/qqq/SavedBulkLoadProfileUtils.ts @@ -28,7 +28,6 @@ type FieldMapping = { [name: string]: BulkLoadField } ***************************************************************************/ export class SavedBulkLoadProfileUtils { - /*************************************************************************** ** ***************************************************************************/ @@ -172,6 +171,88 @@ export class SavedBulkLoadProfileUtils } + /*************************************************************************** + ** + ***************************************************************************/ + 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; + } + + /*************************************************************************** ** ***************************************************************************/ @@ -213,14 +294,10 @@ export class SavedBulkLoadProfileUtils { const fieldName = bulkLoadField.field.name; - if (JSON.stringify(baseMapping.valueMappings[fieldName] ?? []) != JSON.stringify(activeMapping.valueMappings[fieldName] ?? [])) + const valueMappingDiff = this.diffFieldValueMappings(bulkLoadField, baseMapping.valueMappings[fieldName] ?? {}, activeMapping.valueMappings[fieldName] ?? {}); + if(valueMappingDiff) { - diffs.push(`Changed value mapping for ${bulkLoadField.getQualifiedLabel()}`) - } - - if (baseMapping.valueMappings[fieldName] && activeMapping.valueMappings[fieldName]) - { - // todo - finish this - better version than just the JSON diff! + diffs.push(valueMappingDiff); } } catch(e) From 85a8bd2d0ad474f276acc0fc3fa0cb0223b0bb16 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Tue, 3 Dec 2024 09:16:14 -0600 Subject: [PATCH 25/52] CE-1955 small bulk-load cleanups --- src/qqq/components/misc/SavedBulkLoadProfiles.tsx | 2 +- src/qqq/components/processes/BulkLoadFileMappingForm.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/qqq/components/misc/SavedBulkLoadProfiles.tsx b/src/qqq/components/misc/SavedBulkLoadProfiles.tsx index eaddda8..b6137ec 100644 --- a/src/qqq/components/misc/SavedBulkLoadProfiles.tsx +++ b/src/qqq/components/misc/SavedBulkLoadProfiles.tsx @@ -435,7 +435,7 @@ function SavedBulkLoadProfiles({metaData, tableMetaData, tableStructure, current { currentSavedBulkLoadProfileRecord ? - You are using the bulk load profile:
    {currentSavedBulkLoadProfileRecord.values.get("label")}.

    You can manage this profile on this screen.
    + You are using the bulk load profile:
    {currentSavedBulkLoadProfileRecord.values.get("label")}

    You can manage this profile on this screen.
    : You are not using a saved bulk load profile.

    You can save your profile on this screen.
    }
    diff --git a/src/qqq/components/processes/BulkLoadFileMappingForm.tsx b/src/qqq/components/processes/BulkLoadFileMappingForm.tsx index 987a3e1..df94ddb 100644 --- a/src/qqq/components/processes/BulkLoadFileMappingForm.tsx +++ b/src/qqq/components/processes/BulkLoadFileMappingForm.tsx @@ -60,9 +60,9 @@ const BulkLoadFileMappingForm = forwardRef(({processValues, tableMetaData, metaD const [fieldErrors, setFieldErrors] = useState({} as { [fieldName: string]: string }); - const [suggestedBulkLoadProfile] = useState(processValues.bulkLoadProfile as BulkLoadProfile); + const [suggestedBulkLoadProfile] = useState(processValues.suggestedBulkLoadProfile as BulkLoadProfile); const [tableStructure] = useState(processValues.tableStructure as BulkLoadTableStructure); - const [bulkLoadMapping, setBulkLoadMapping] = useState(BulkLoadMapping.fromBulkLoadProfile(tableStructure, suggestedBulkLoadProfile)); + const [bulkLoadMapping, setBulkLoadMapping] = useState(BulkLoadMapping.fromBulkLoadProfile(tableStructure, processValues.bulkLoadProfile)); const [wrappedBulkLoadMapping] = useState(new Wrapper(bulkLoadMapping)); const [fileDescription] = useState(new FileDescription(processValues.headerValues, processValues.headerLetters, processValues.bodyValuesPreview)); From dda4ea4f4b72c920dd2b46fde7eafab3dedf4ed4 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Tue, 3 Dec 2024 15:53:23 -0600 Subject: [PATCH 26/52] CE-1955 Delete an unused effect --- src/qqq/components/processes/BulkLoadFileMappingForm.tsx | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/qqq/components/processes/BulkLoadFileMappingForm.tsx b/src/qqq/components/processes/BulkLoadFileMappingForm.tsx index df94ddb..bdfbe52 100644 --- a/src/qqq/components/processes/BulkLoadFileMappingForm.tsx +++ b/src/qqq/components/processes/BulkLoadFileMappingForm.tsx @@ -125,12 +125,6 @@ const BulkLoadFileMappingForm = forwardRef(({processValues, tableMetaData, metaD }); - useEffect(() => - { - console.log("@dk has header row changed!"); - }, [bulkLoadMapping.hasHeaderRow]); - - /*************************************************************************** ** ***************************************************************************/ From f8368b030cd9bd9bb70b4b0ed99772cf65164958 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Tue, 3 Dec 2024 15:55:18 -0600 Subject: [PATCH 27/52] CE-1955 make PreviewRecordUsingTableLayout a private component - try to make it re-render the associated child grids when switching records --- .../components/processes/ValidationReview.tsx | 164 ++++++++++-------- 1 file changed, 91 insertions(+), 73 deletions(-) diff --git a/src/qqq/components/processes/ValidationReview.tsx b/src/qqq/components/processes/ValidationReview.tsx index 015c3d5..7aa1902 100644 --- a/src/qqq/components/processes/ValidationReview.tsx +++ b/src/qqq/components/processes/ValidationReview.tsx @@ -266,76 +266,6 @@ function ValidationReview({ ); - /*************************************************************************** - ** - ***************************************************************************/ - function previewRecordUsingTableLayout(record: QRecord) - { - if (!previewTableMetaData) - { - return (Loading...); - } - - const renderedSections: JSX.Element[] = []; - const tableSections = TableUtils.getSectionsForRecordSidebar(previewTableMetaData); - const previewRecord = previewRecords[previewRecordIndex]; - - for (let i = 0; i < tableSections.length; i++) - { - const section = tableSections[i]; - if (section.isHidden) - { - continue; - } - - if (section.fieldNames) - { - renderedSections.push( -

    {section.label}

    - - {renderSectionOfFields(section.name, section.fieldNames, previewTableMetaData, false, previewRecord, undefined, {label: {fontWeight: "500"}})} - -
    ); - } - 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 = previewRecord.associatedRecords.get(associationPreview.associationName) ?? []; - data = { - canAddChildRecord: false, - childTableMetaData: childTableMetaData[associationPreview.tableName], - defaultValuesForNewChildRecords: {}, - disabledFieldsForNewChildRecords: {}, - queryOutput: {records: associationRecords}, - totalRows: associationRecords.length, - tablePath: "", - title: "", - viewAllLink: "", - }; - - renderedSections.push( - { - data && -

    {section.label}

    - - - -
    - } -
    ); - } - } - } - } - - return renderedSections; - } const recordPreviewWidget = step.recordListFields && ( @@ -370,11 +300,11 @@ function ValidationReview({ { processValues.validationSummary ? ( <> - It appears as though this process does not contain any valid records. + 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. + If you choose to Perform Validation, and there are any valid records, then you will see a preview here. ) } @@ -405,7 +335,15 @@ function ValidationReview({ )) } { - previewRecords && processValues.formatPreviewRecordUsingTableLayout && previewRecords[previewRecordIndex] && previewRecordUsingTableLayout(previewRecords[previewRecordIndex]) + previewRecords && processValues.formatPreviewRecordUsingTableLayout && previewRecords[previewRecordIndex] && + }
    @@ -441,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 (Loading...); + } + + 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( +

    {section.label}

    + + {renderSectionOfFields(section.name, section.fieldNames, tableMetaData, false, record, undefined, {label: {fontWeight: "500"}})} + +
    ); + } + 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( + { + data && +

    {section.label}

    + + + +
    + } +
    ); + } + } + } + } + + return <>{renderedSections}; +} + + export default ValidationReview; From 74c634414abe248117613d6bff308f9e7f63a09d Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Tue, 3 Dec 2024 20:47:27 -0600 Subject: [PATCH 28/52] CE-1955 Add helpContent to hasHeaderRow and layout fields --- .../processes/BulkLoadFileMappingForm.tsx | 58 ++++++++++++++----- src/qqq/pages/processes/ProcessRun.tsx | 4 +- 2 files changed, 48 insertions(+), 14 deletions(-) diff --git a/src/qqq/components/processes/BulkLoadFileMappingForm.tsx b/src/qqq/components/processes/BulkLoadFileMappingForm.tsx index bdfbe52..a6a95ae 100644 --- a/src/qqq/components/processes/BulkLoadFileMappingForm.tsx +++ b/src/qqq/components/processes/BulkLoadFileMappingForm.tsx @@ -20,7 +20,10 @@ */ 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 Autocomplete from "@mui/material/Autocomplete"; @@ -31,27 +34,30 @@ import {useFormikContext} from "formik"; 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, useEffect, useImperativeHandle, useReducer, useState} from "react"; +import React, {forwardRef, useImperativeHandle, useReducer, useState} from "react"; import ProcessViewForm from "./ProcessViewForm"; interface BulkLoadMappingFormProps { - processValues: any; - tableMetaData: QTableMetaData; - metaData: QInstance; - setActiveStepLabel: (label: string) => void; + 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}: BulkLoadMappingFormProps, ref) => +const BulkLoadFileMappingForm = forwardRef(({processValues, tableMetaData, metaData, setActiveStepLabel, frontendStep, processMetaData}: BulkLoadMappingFormProps, ref) => { const {setFieldValue} = useFormikContext(); @@ -208,6 +214,8 @@ const BulkLoadFileMappingForm = forwardRef(({processValues, tableMetaData, metaD tableStructure={tableStructure} fileName={processValues.fileBaseName} fieldErrors={fieldErrors} + frontendStep={frontendStep} + processMetaData={processMetaData} forceParentUpdate={() => forceUpdate()} /> @@ -226,8 +234,6 @@ const BulkLoadFileMappingForm = forwardRef(({processValues, tableMetaData, metaD export default BulkLoadFileMappingForm; - - interface BulkLoadMappingHeaderProps { fileDescription: FileDescription, @@ -235,13 +241,15 @@ interface BulkLoadMappingHeaderProps bulkLoadMapping?: BulkLoadMapping, fieldErrors: { [fieldName: string]: string }, tableStructure: BulkLoadTableStructure, - forceParentUpdate?: () => void + 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}: BulkLoadMappingHeaderProps): JSX.Element +function BulkLoadMappingHeader({fileDescription, fileName, bulkLoadMapping, fieldErrors, tableStructure, forceParentUpdate, frontendStep, processMetaData}: BulkLoadMappingHeaderProps): JSX.Element { const viewFields = [ new QFieldMetaData({name: "fileName", label: "File Name", type: "STRING"}), @@ -255,8 +263,6 @@ function BulkLoadMappingHeader({fileDescription, fileName, bulkLoadMapping, fiel const hasHeaderRowFormField = {name: "hasHeaderRow", label: "Does the file have a header row?", type: "checkbox", isRequired: true, isEditable: true}; - let helpRoles = ["PROCESS_SCREEN", "ALL_SCREENS"]; - const layoutOptions = [ {label: "Flat", id: "FLAT"}, {label: "Tall", id: "TALL"}, @@ -270,6 +276,9 @@ function BulkLoadMappingHeader({fileDescription, fileName, bulkLoadMapping, fiel const selectedLayout = layoutOptions.filter(o => o.id == bulkLoadMapping.layout)[0] ?? null; + /*************************************************************************** + ** + ***************************************************************************/ function hasHeaderRowChanged(newValue: any) { bulkLoadMapping.hasHeaderRow = newValue; @@ -278,6 +287,9 @@ function BulkLoadMappingHeader({fileDescription, fileName, bulkLoadMapping, fiel forceParentUpdate(); } + /*************************************************************************** + ** + ***************************************************************************/ function layoutChanged(event: any, newValue: any) { bulkLoadMapping.layout = newValue ? newValue.id : null; @@ -285,6 +297,25 @@ function BulkLoadMappingHeader({fileDescription, fileName, bulkLoadMapping, fiel forceParentUpdate(); } + /*************************************************************************** + ** + ***************************************************************************/ + function getFormattedHelpContent(fieldName: string): JSX.Element + { + const field = frontendStep?.formFields?.find(f => f.name == fieldName); + let helpRoles = ["PROCESS_SCREEN", "ALL_SCREENS"]; + + let formattedHelpContent = ; + if (formattedHelpContent) + { + const mt = field && field.type == QFieldType.BOOLEAN ? "-0.5rem" : "0.5rem"; + + return {formattedHelpContent}; + } + + return null; + } + return (
    File Details
    @@ -301,6 +332,7 @@ function BulkLoadMappingHeader({fileDescription, fileName, bulkLoadMapping, fiel {
    {fieldErrors.hasHeaderRow}
    }
    } + {getFormattedHelpContent("hasHeaderRow")} @@ -322,6 +354,7 @@ function BulkLoadMappingHeader({fileDescription, fileName, bulkLoadMapping, fiel {
    {fieldErrors.layout}
    } } + {getFormattedHelpContent("layout")}
    @@ -330,7 +363,6 @@ function BulkLoadMappingHeader({fileDescription, fileName, bulkLoadMapping, fiel } - interface BulkLoadMappingFilePreviewProps { fileDescription: FileDescription; diff --git a/src/qqq/pages/processes/ProcessRun.tsx b/src/qqq/pages/processes/ProcessRun.tsx index 0a4bc22..9a322db 100644 --- a/src/qqq/pages/processes/ProcessRun.tsx +++ b/src/qqq/pages/processes/ProcessRun.tsx @@ -1032,9 +1032,11 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is ) } @@ -2220,7 +2222,7 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is if (isModal) { return ( - + {body} ); From 85acb612c91b33817b11d7e4fe789659ed3641ea Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Tue, 3 Dec 2024 20:47:50 -0600 Subject: [PATCH 29/52] CE-1955 Add add `?` to record.associatedRecords?.get to avoid crash if no associations --- src/qqq/components/processes/ValidationReview.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/qqq/components/processes/ValidationReview.tsx b/src/qqq/components/processes/ValidationReview.tsx index 7aa1902..fa85ec8 100644 --- a/src/qqq/components/processes/ValidationReview.tsx +++ b/src/qqq/components/processes/ValidationReview.tsx @@ -427,7 +427,7 @@ function PreviewRecordUsingTableLayout({record, tableMetaData, qInstance, associ if (associationPreviewsByWidgetName[section.widgetName]) { const associationPreview = associationPreviewsByWidgetName[section.widgetName]; - const associationRecords = record.associatedRecords.get(associationPreview.associationName) ?? []; + const associationRecords = record.associatedRecords?.get(associationPreview.associationName) ?? []; data = { canAddChildRecord: false, childTableMetaData: childTableMetaData[associationPreview.tableName], From 722c8d3bcf27e74c075ccc499e87f013b932f072 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Wed, 4 Dec 2024 16:10:33 -0600 Subject: [PATCH 30/52] CE-1955 Update to fetch label for possible-values being used as a default value --- .../processes/BulkLoadFileMappingField.tsx | 55 ++++++++++++++++++- 1 file changed, 53 insertions(+), 2 deletions(-) diff --git a/src/qqq/components/processes/BulkLoadFileMappingField.tsx b/src/qqq/components/processes/BulkLoadFileMappingField.tsx index 1bf7591..a51da05 100644 --- a/src/qqq/components/processes/BulkLoadFileMappingField.tsx +++ b/src/qqq/components/processes/BulkLoadFileMappingField.tsx @@ -24,7 +24,6 @@ import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QF import {Checkbox, FormControlLabel, Radio} 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"; @@ -34,6 +33,7 @@ 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 @@ -45,6 +45,8 @@ interface BulkLoadMappingFieldProps forceParentUpdate?: () => void, } +const qController = Client.getInstance(); + /*************************************************************************** ** row for a single field on the bulk load mapping screen. ***************************************************************************/ @@ -55,12 +57,58 @@ export default function BulkLoadFileMappingField({bulkLoadField, isRequired, rem const [valueType, setValueType] = useState(bulkLoadField.valueType); const [selectedColumn, setSelectedColumn] = useState({label: columnNames[bulkLoadField.columnIndex], value: 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 // + ////////////////////////////////////////////////////// const columnOptions: { value: number, label: string }[] = []; for (let i = 0; i < columnNames.length; i++) { @@ -186,7 +234,10 @@ export default function BulkLoadFileMappingField({bulkLoadField, isRequired, rem valueTypeChanged(!checked)} />} label={"Default value"} sx={{minWidth: "140px", whiteSpace: "nowrap"}} /> { - valueType == "defaultValue" && + valueType == "defaultValue" && actuallyDoingInitialLoadOfPossibleValue && Loading... + } + { + valueType == "defaultValue" && !actuallyDoingInitialLoadOfPossibleValue && Date: Wed, 4 Dec 2024 16:11:08 -0600 Subject: [PATCH 31/52] CE-1955 handle currentSavedBulkLoadProfile being set, when going back to this screen --- src/qqq/components/processes/BulkLoadFileMappingForm.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/qqq/components/processes/BulkLoadFileMappingForm.tsx b/src/qqq/components/processes/BulkLoadFileMappingForm.tsx index a6a95ae..709f768 100644 --- a/src/qqq/components/processes/BulkLoadFileMappingForm.tsx +++ b/src/qqq/components/processes/BulkLoadFileMappingForm.tsx @@ -61,7 +61,8 @@ const BulkLoadFileMappingForm = forwardRef(({processValues, tableMetaData, metaD { const {setFieldValue} = useFormikContext(); - const [currentSavedBulkLoadProfile, setCurrentSavedBulkLoadProfile] = useState(null as QRecord); + const savedBulkLoadProfileRecordProcessValue = processValues.savedBulkLoadProfileRecord; + const [currentSavedBulkLoadProfile, setCurrentSavedBulkLoadProfile] = useState(savedBulkLoadProfileRecordProcessValue == null ? null : new QRecord(savedBulkLoadProfileRecordProcessValue)); const [wrappedCurrentSavedBulkLoadProfile] = useState(new Wrapper(currentSavedBulkLoadProfile)); const [fieldErrors, setFieldErrors] = useState({} as { [fieldName: string]: string }); From 02c163899ac57933b02d4de6d20f4123c88be60e Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Wed, 4 Dec 2024 16:11:49 -0600 Subject: [PATCH 32/52] CE-1955 Handle associated fields; better messaging w/ undefined values --- .../utils/qqq/SavedBulkLoadProfileUtils.ts | 30 +++++++++++-------- 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/src/qqq/utils/qqq/SavedBulkLoadProfileUtils.ts b/src/qqq/utils/qqq/SavedBulkLoadProfileUtils.ts index 5596762..2bb6bce 100644 --- a/src/qqq/utils/qqq/SavedBulkLoadProfileUtils.ts +++ b/src/qqq/utils/qqq/SavedBulkLoadProfileUtils.ts @@ -37,7 +37,7 @@ export class SavedBulkLoadProfileUtils for (let bulkLoadField of orderedFieldArray) { - const fieldName = bulkLoadField.field.name; + const fieldName = bulkLoadField.getQualifiedName() const compareField = compareFieldsMap[fieldName]; const baseField = baseFieldsMap[fieldName]; if(!compareField) @@ -55,12 +55,13 @@ export class SavedBulkLoadProfileUtils 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})`); + 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]; - rs.push(`Changed ${compareField.getQualifiedLabel()} from using a file column (${column}) to using a default value (${compareField.defaultValue})`); + 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") @@ -70,7 +71,8 @@ export class SavedBulkLoadProfileUtils ////////////////////////////////////////////////// if (baseField.defaultValue != compareField.defaultValue) { - rs.push(`Changed ${compareField.getQualifiedLabel()} default value from (${baseField.defaultValue}) to (${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") @@ -78,25 +80,29 @@ export class SavedBulkLoadProfileUtils /////////////////////////////////////////// // if we changed the column, report that // /////////////////////////////////////////// + let isDiff = false; if (fileDescription.hasHeaderRow) { if (baseField.headerName != compareField.headerName) { - const baseColumn = fileDescription.getColumnNames()[baseField.columnIndex]; - const compareColumn = fileDescription.getColumnNames()[compareField.columnIndex]; - rs.push(`Changed ${compareField.getQualifiedLabel()} file column from (${baseColumn}) to (${compareColumn})`); + isDiff = true; } } else { if (baseField.columnIndex != compareField.columnIndex) { - const baseColumn = fileDescription.getColumnNames()[baseField.columnIndex]; - const compareColumn = fileDescription.getColumnNames()[compareField.columnIndex]; - rs.push(`Changed ${compareField.getQualifiedLabel()} file column from (${baseColumn}) to (${compareColumn})`); + 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) // ///////////////////////////////////////////////////////////////////////////////////////////////////// @@ -120,7 +126,7 @@ export class SavedBulkLoadProfileUtils for (let bulkLoadField of orderedFieldArray) { - const fieldName = bulkLoadField.field.name; + const fieldName = bulkLoadField.getQualifiedName() const compareField = compareFieldsMap[fieldName]; if(!compareField) { @@ -292,7 +298,7 @@ export class SavedBulkLoadProfileUtils { try { - const fieldName = bulkLoadField.field.name; + 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) From 7b66ece466003b4595b8cf047e71201dcfc07d61 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Tue, 10 Dec 2024 09:14:32 -0600 Subject: [PATCH 33/52] Try to avoid an error a user is getting where no operatorSeletedValue is being selected when page is loading --- src/qqq/components/query/QuickFilter.tsx | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/src/qqq/components/query/QuickFilter.tsx b/src/qqq/components/query/QuickFilter.tsx index 5d666c9..029d450 100644 --- a/src/qqq/components/query/QuickFilter.tsx +++ b/src/qqq/components/query/QuickFilter.tsx @@ -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); From d0201d96e1cbb4c10bf8fd299d9f1d14479b1389 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Thu, 26 Dec 2024 19:13:48 -0600 Subject: [PATCH 34/52] CE-1955 Fix select box handling of 'x' and typing... --- .../processes/BulkLoadFileMappingField.tsx | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/src/qqq/components/processes/BulkLoadFileMappingField.tsx b/src/qqq/components/processes/BulkLoadFileMappingField.tsx index a51da05..ac06871 100644 --- a/src/qqq/components/processes/BulkLoadFileMappingField.tsx +++ b/src/qqq/components/processes/BulkLoadFileMappingField.tsx @@ -56,6 +56,7 @@ export default function BulkLoadFileMappingField({bulkLoadField, isRequired, rem 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); @@ -121,6 +122,7 @@ export default function BulkLoadFileMappingField({bulkLoadField, isRequired, rem 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"; @@ -146,6 +148,8 @@ export default function BulkLoadFileMappingField({bulkLoadField, isRequired, rem 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) @@ -192,7 +196,15 @@ export default function BulkLoadFileMappingField({bulkLoadField, isRequired, rem forceParentUpdate && forceParentUpdate(); } - return ( + /*************************************************************************** + ** + ***************************************************************************/ + function changeSelectedColumnInputValue(e: React.ChangeEvent) + { + setSelectedColumnInputValue(e.target.value); + } + + return ( ()} + renderInput={(params) => ( 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={selectedColumn?.label} + inputValue={selectedColumnInputValue} onChange={columnChanged} getOptionLabel={(option) => typeof (option) == "string" ? option : (option?.label ?? "")} isOptionEqualToValue={(option, value) => option == null && value == null || option.value == value.value} From d793c23861351ccbe4db29e54a5aaee5802ad13f Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Thu, 26 Dec 2024 19:14:21 -0600 Subject: [PATCH 35/52] CE-1955 Add guard around a call to onChangeCallback --- src/qqq/components/forms/DynamicFormField.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/qqq/components/forms/DynamicFormField.tsx b/src/qqq/components/forms/DynamicFormField.tsx index f2183bc..8112dc1 100644 --- a/src/qqq/components/forms/DynamicFormField.tsx +++ b/src/qqq/components/forms/DynamicFormField.tsx @@ -141,7 +141,10 @@ function QDynamicFormField({ newValue = newValue.toLowerCase(); } setFieldValue(name, newValue); - onChangeCallback(newValue); + if(onChangeCallback) + { + onChangeCallback(newValue); + } }); const input = document.getElementById(name) as HTMLInputElement; From 3ef2d643279e58304fa2201740183367fcdf7cd5 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Fri, 27 Dec 2024 14:58:40 -0600 Subject: [PATCH 36/52] CE-1955 Bulk load bugs & usability improvements --- .../processes/BulkLoadFileMappingField.tsx | 30 ++- .../processes/BulkLoadFileMappingFields.tsx | 12 +- .../processes/BulkLoadFileMappingForm.tsx | 144 ++++++++++++- src/qqq/models/processes/BulkLoadModels.ts | 193 +++++++++++++++++- 4 files changed, 349 insertions(+), 30 deletions(-) diff --git a/src/qqq/components/processes/BulkLoadFileMappingField.tsx b/src/qqq/components/processes/BulkLoadFileMappingField.tsx index ac06871..1e6d82b 100644 --- a/src/qqq/components/processes/BulkLoadFileMappingField.tsx +++ b/src/qqq/components/processes/BulkLoadFileMappingField.tsx @@ -21,9 +21,10 @@ import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData"; -import {Checkbox, FormControlLabel, Radio} from "@mui/material"; +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"; @@ -45,6 +46,27 @@ interface BulkLoadMappingFieldProps 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(); /*************************************************************************** @@ -212,7 +234,9 @@ export default function BulkLoadFileMappingField({bulkLoadField, isRequired, rem { - (!isRequired) && removeFieldCallback()} sx={{pt: "0.75rem"}}>remove_circle + (!isRequired) && + + } {bulkLoadField.getQualifiedLabel()} @@ -265,7 +289,7 @@ export default function BulkLoadFileMappingField({bulkLoadField, isRequired, rem { bulkLoadField.error && - + {bulkLoadField.error} } diff --git a/src/qqq/components/processes/BulkLoadFileMappingFields.tsx b/src/qqq/components/processes/BulkLoadFileMappingFields.tsx index f8dd561..0aa74cf 100644 --- a/src/qqq/components/processes/BulkLoadFileMappingFields.tsx +++ b/src/qqq/components/processes/BulkLoadFileMappingFields.tsx @@ -47,7 +47,7 @@ export default function BulkLoadFileMappingFields({bulkLoadMapping, fileDescript { const [, forceUpdate] = useReducer((x) => x + 1, 0); - const [forceRerender, setForceRerender] = useState(0); + const [forceHierarchyAutoCompleteRerender, setForceHierarchyAutoCompleteRerender] = useState(0); //////////////////////////////////////////// // build list of fields that can be added // @@ -98,8 +98,9 @@ export default function BulkLoadFileMappingFields({bulkLoadMapping, fileDescript setAddFieldsDisableStates(newDisableStates); setTooltips(newTooltips); + setForceHierarchyAutoCompleteRerender(forceHierarchyAutoCompleteRerender + 1); - }, [bulkLoadMapping]); + }, [bulkLoadMapping, bulkLoadMapping.layout]); /////////////////////////////////////////////// @@ -140,9 +141,6 @@ export default function BulkLoadFileMappingFields({bulkLoadMapping, fileDescript ***************************************************************************/ function removeField(bulkLoadField: BulkLoadField) { - // addFieldsToggleStates[bulkLoadField.getQualifiedName()] = false; - // setAddFieldsToggleStates(Object.assign({}, addFieldsToggleStates)); - addFieldsDisableStates[bulkLoadField.getQualifiedName()] = false; setAddFieldsDisableStates(Object.assign({}, addFieldsDisableStates)); @@ -160,7 +158,7 @@ export default function BulkLoadFileMappingFields({bulkLoadMapping, fileDescript bulkLoadMapping.removeField(bulkLoadField); forceUpdate(); forceParentUpdate(); - setForceRerender(forceRerender + 1); + setForceHierarchyAutoCompleteRerender(forceHierarchyAutoCompleteRerender + 1); } /*************************************************************************** @@ -297,7 +295,7 @@ export default function BulkLoadFileMappingFields({bulkLoadMapping, fileDescript isModeSelectOne keepOpenAfterSelectOne handleSelectedOption={handleAddField} - forceRerender={forceRerender} + forceRerender={forceHierarchyAutoCompleteRerender} disabledStates={addFieldsDisableStates} tooltips={tooltips} /> diff --git a/src/qqq/components/processes/BulkLoadFileMappingForm.tsx b/src/qqq/components/processes/BulkLoadFileMappingForm.tsx index 709f768..bacf001 100644 --- a/src/qqq/components/processes/BulkLoadFileMappingForm.tsx +++ b/src/qqq/components/processes/BulkLoadFileMappingForm.tsx @@ -26,10 +26,12 @@ 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 {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 {DynamicFormFieldLabel} from "qqq/components/forms/DynamicForm"; import QDynamicFormField from "qqq/components/forms/DynamicFormField"; @@ -126,6 +128,14 @@ const BulkLoadFileMappingForm = forwardRef(({processValues, tableMetaData, metaD } setFieldErrors(fieldErrors); + if(haveProfileErrors) + { + setTimeout(() => + { + document.querySelector(".bulkLoadFieldError")?.scrollIntoView({behavior: "smooth", block: "center", inline: "center"}); + }, 250); + } + return {maySubmit: !haveProfileErrors && !haveLocalErrors, values}; } }; @@ -224,7 +234,11 @@ const BulkLoadFileMappingForm = forwardRef(({processValues, tableMetaData, metaD forceUpdate()} + forceParentUpdate={() => + { + setRerenderHeader(rerenderHeader + 1); + forceUpdate(); + }} /> @@ -293,7 +307,7 @@ function BulkLoadMappingHeader({fileDescription, fileName, bulkLoadMapping, fiel ***************************************************************************/ function layoutChanged(event: any, newValue: any) { - bulkLoadMapping.layout = newValue ? newValue.id : null; + bulkLoadMapping.switchLayout(newValue ? newValue.id : null); fieldErrors.layout = null; forceParentUpdate(); } @@ -322,7 +336,7 @@ function BulkLoadMappingHeader({fileDescription, fileName, bulkLoadMapping, fiel
    File Details
    - + @@ -347,6 +361,7 @@ function BulkLoadMappingHeader({fileDescription, fileName, bulkLoadMapping, fiel getOptionLabel={(option) => typeof (option) == "string" ? option : (option?.label ?? "")} isOptionEqualToValue={(option, value) => option == null && value == null || option.id == value.id} renderOption={(props, option, state) => (
  • {option?.label ?? ""}
  • )} + disableClearable sx={{"& .MuiOutlinedInput-root": {padding: "0"}}} /> { @@ -366,13 +381,14 @@ function BulkLoadMappingHeader({fileDescription, fileName, bulkLoadMapping, fiel interface BulkLoadMappingFilePreviewProps { - fileDescription: FileDescription; + fileDescription: FileDescription, + bulkLoadMapping?: BulkLoadMapping } /*************************************************************************** ** private subcomponent - the file-preview section of the bulk load file mapping screen. ***************************************************************************/ -function BulkLoadMappingFilePreview({fileDescription}: BulkLoadMappingFilePreviewProps): JSX.Element +function BulkLoadMappingFilePreview({fileDescription, bulkLoadMapping}: BulkLoadMappingFilePreviewProps): JSX.Element { const rows: number[] = []; for (let i = 0; i < fileDescription.bodyValuesPreview[0].length; i++) @@ -380,25 +396,135 @@ function BulkLoadMappingFilePreview({fileDescription}: BulkLoadMappingFilePrevie 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 ( + This column is mapped to the field{fields.length == 1 ? "" : "s"}: +
      + {fields.map((field, i) =>
    • {field.getQualifiedLabel()}
    • )} +
    +
    ); + } + return ( - + - {fileDescription.headerLetters.map((letter) => )} + {fileDescription.headerLetters.map((letter, index) => + { + const fields = bulkLoadMapping.getFieldsForColumnIndex(index); + const count = fields.length; + return (); + })} - {fileDescription.headerValues.map((value) => )} + + {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 + } + else + { + return + } + } + else + { + return + } + } + )} {rows.map((i) => ( - {fileDescription.headerLetters.map((letter, j) => )} + {fileDescription.headerLetters.map((letter, j) => )} ))} diff --git a/src/qqq/models/processes/BulkLoadModels.ts b/src/qqq/models/processes/BulkLoadModels.ts index e2e43f0..92e2c74 100644 --- a/src/qqq/models/processes/BulkLoadModels.ts +++ b/src/qqq/models/processes/BulkLoadModels.ts @@ -21,6 +21,7 @@ import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData"; +import {QFieldType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldType"; import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord"; export type ValueType = "defaultValue" | "column"; @@ -422,17 +423,22 @@ export class BulkLoadMapping } else { - index = 0; - /////////////////////////////////////////////////////////// - // count how many copies of this field there are already // - /////////////////////////////////////////////////////////// + /////////////////////////////////////////////// + // find the max index for this field already // + /////////////////////////////////////////////// + let maxIndex = -1; for (let existingField of [...this.requiredFields, ...this.additionalFields]) { if (existingField.getQualifiedName() == bulkLoadField.getQualifiedName()) { - index++; + const thisIndex = existingField.wideLayoutIndexPath[0] + if (thisIndex != null && thisIndex != undefined && thisIndex > maxIndex) + { + maxIndex = thisIndex; + } } } + index = maxIndex + 1; } const cloneField = BulkLoadField.clone(bulkLoadField); @@ -455,7 +461,7 @@ export class BulkLoadMapping const newAdditionalFields: BulkLoadField[] = []; for (let bulkLoadField of this.additionalFields) { - if (bulkLoadField.getQualifiedName() != toRemove.getQualifiedName()) + if (bulkLoadField.getQualifiedNameWithWideSuffix() != toRemove.getQualifiedNameWithWideSuffix()) { newAdditionalFields.push(bulkLoadField); } @@ -463,6 +469,107 @@ export class BulkLoadMapping 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; + } + } + + + /*************************************************************************** + ** + ***************************************************************************/ + 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); + } } @@ -517,21 +624,85 @@ export class FileDescription /*************************************************************************** ** ***************************************************************************/ - public getPreviewValues(columnIndex: number): string[] + public getPreviewValues(columnIndex: number, fieldType?: QFieldType): string[] { if (columnIndex == undefined) { return []; } - if (this.hasHeaderRow) + function getTypedValue(value: any): string { - return (this.bodyValuesPreview[columnIndex]); + 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}`); } - else + + const valueArray: string[] = []; + + if (!this.hasHeaderRow) { - return ([this.headerValues[columnIndex], ...this.bodyValuesPreview[columnIndex]]); + 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); } } From 3f8a3e7e4d4a280d2504201ab4a8bf9340d5051d Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Mon, 6 Jan 2025 16:52:19 -0600 Subject: [PATCH 37/52] CE-1955 Fix (new) switchLayout method to ... actually save the new layout --- src/qqq/models/processes/BulkLoadModels.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/qqq/models/processes/BulkLoadModels.ts b/src/qqq/models/processes/BulkLoadModels.ts index 92e2c74..9acbc13 100644 --- a/src/qqq/models/processes/BulkLoadModels.ts +++ b/src/qqq/models/processes/BulkLoadModels.ts @@ -550,6 +550,8 @@ export class BulkLoadMapping { this.additionalFields = newAdditionalFields; } + + this.layout = newLayout; } From 7320b19fbbc998dcc62fc3df1cdd241bbf4a397f Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Tue, 7 Jan 2025 10:12:45 -0600 Subject: [PATCH 38/52] CE-1955 Add warning about duplicate column headers, and un-selection of dupes if switching from no-header-row mode to header-row mode --- .../processes/BulkLoadFileMappingField.tsx | 18 +++- .../processes/BulkLoadFileMappingForm.tsx | 15 ++- src/qqq/models/processes/BulkLoadModels.ts | 101 ++++++++++++++++-- 3 files changed, 121 insertions(+), 13 deletions(-) diff --git a/src/qqq/components/processes/BulkLoadFileMappingField.tsx b/src/qqq/components/processes/BulkLoadFileMappingField.tsx index 1e6d82b..b5457b3 100644 --- a/src/qqq/components/processes/BulkLoadFileMappingField.tsx +++ b/src/qqq/components/processes/BulkLoadFileMappingField.tsx @@ -131,11 +131,18 @@ export default function BulkLoadFileMappingField({bulkLoadField, isRequired, rem ////////////////////////////////////////////////////// // 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++) { - columnOptions.push({label: columnNames[i], value: i}); + const label = columnNames[i]; + if(!usedLabels[label]) + { + columnOptions.push({label: label, value: i}); + usedLabels[label] = true; + } } ////////////////////////////////////////////////////////////////////// @@ -180,6 +187,7 @@ export default function BulkLoadFileMappingField({bulkLoadField, isRequired, rem } bulkLoadField.error = null; + bulkLoadField.warning = null; forceParentUpdate && forceParentUpdate(); } @@ -192,6 +200,7 @@ export default function BulkLoadFileMappingField({bulkLoadField, isRequired, rem setFieldValue(`${bulkLoadField.field.name}.defaultValue`, newValue); bulkLoadField.defaultValue = newValue; bulkLoadField.error = null; + bulkLoadField.warning = null; forceParentUpdate && forceParentUpdate(); } @@ -205,6 +214,7 @@ export default function BulkLoadFileMappingField({bulkLoadField, isRequired, rem bulkLoadField.valueType = newValueType; setValueType(newValueType); bulkLoadField.error = null; + bulkLoadField.warning = null; forceParentUpdate && forceParentUpdate(); } @@ -287,6 +297,12 @@ export default function BulkLoadFileMappingField({bulkLoadField, isRequired, rem } + { + bulkLoadField.warning && + + {bulkLoadField.warning} + + } { bulkLoadField.error && diff --git a/src/qqq/components/processes/BulkLoadFileMappingForm.tsx b/src/qqq/components/processes/BulkLoadFileMappingForm.tsx index bacf001..9ea436e 100644 --- a/src/qqq/components/processes/BulkLoadFileMappingForm.tsx +++ b/src/qqq/components/processes/BulkLoadFileMappingForm.tsx @@ -298,6 +298,9 @@ function BulkLoadMappingHeader({fileDescription, fileName, bulkLoadMapping, fiel { bulkLoadMapping.hasHeaderRow = newValue; fileDescription.hasHeaderRow = newValue; + + bulkLoadMapping.handleChangeToHasHeaderRow(newValue, fileDescription); + fieldErrors.hasHeaderRow = null; forceParentUpdate(); } @@ -470,19 +473,29 @@ function BulkLoadMappingFilePreview({fileDescription, bulkLoadMapping}: BulkLoad { const fields = bulkLoadMapping.getFieldsForColumnIndex(index); const count = fields.length; + + let dupeWarning = <> + if(fileDescription.hasHeaderRow && fileDescription.duplicateHeaderIndexes[index]) + { + dupeWarning = + warning + + } + return (); diff --git a/src/qqq/models/processes/BulkLoadModels.ts b/src/qqq/models/processes/BulkLoadModels.ts index 9acbc13..955e355 100644 --- a/src/qqq/models/processes/BulkLoadModels.ts +++ b/src/qqq/models/processes/BulkLoadModels.ts @@ -43,6 +43,7 @@ export class BulkLoadField wideLayoutIndexPath: number[] = []; error: string = null; + warning: string = null; key: string; @@ -50,7 +51,7 @@ export class BulkLoadField /*************************************************************************** ** ***************************************************************************/ - constructor(field: QFieldMetaData, tableStructure: BulkLoadTableStructure, valueType: ValueType = "column", columnIndex?: number, headerName?: string, defaultValue?: any, doValueMapping?: boolean, wideLayoutIndexPath: number[] = []) + 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; @@ -60,6 +61,8 @@ export class BulkLoadField this.defaultValue = defaultValue; this.doValueMapping = doValueMapping; this.wideLayoutIndexPath = wideLayoutIndexPath; + this.error = error; + this.warning = warning; this.key = new Date().getTime().toString(); } @@ -69,7 +72,7 @@ export class BulkLoadField ***************************************************************************/ 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)); + return (new BulkLoadField(source.field, source.tableStructure, source.valueType, source.columnIndex, source.headerName, source.defaultValue, source.doValueMapping, source.wideLayoutIndexPath, source.error, source.warning)); } @@ -431,7 +434,7 @@ export class BulkLoadMapping { if (existingField.getQualifiedName() == bulkLoadField.getQualifiedName()) { - const thisIndex = existingField.wideLayoutIndexPath[0] + const thisIndex = existingField.wideLayoutIndexPath[0]; if (thisIndex != null && thisIndex != undefined && thisIndex > maxIndex) { maxIndex = thisIndex; @@ -506,7 +509,7 @@ export class BulkLoadMapping namesWhereOneWideLayoutIndexHasBeenFound[name] = true; const newField = BulkLoadField.clone(existingField); newField.wideLayoutIndexPath = []; - newAdditionalFields.push(newField) + newAdditionalFields.push(newField); anyChanges = true; } } @@ -515,7 +518,7 @@ export class BulkLoadMapping ////////////////////////////////////////////////////// // else, non-wide-path fields, just get added as-is // ////////////////////////////////////////////////////// - newAdditionalFields.push(existingField) + newAdditionalFields.push(existingField); } } } @@ -531,7 +534,7 @@ export class BulkLoadMapping //////////////////////////////////////////// // fields from main table come over as-is // //////////////////////////////////////////// - newAdditionalFields.push(existingField) + newAdditionalFields.push(existingField); } else { @@ -540,7 +543,7 @@ export class BulkLoadMapping ///////////////////////////////////////////////////////////////////////////////////////////// const newField = BulkLoadField.clone(existingField); newField.wideLayoutIndexPath = [0]; - newAdditionalFields.push(newField) + newAdditionalFields.push(newField); anyChanges = true; } } @@ -564,7 +567,7 @@ export class BulkLoadMapping for (let field of [...this.requiredFields, ...this.additionalFields]) { - if(field.valueType == "column" && field.columnIndex == i) + if (field.valueType == "column" && field.columnIndex == i) { rs.push(field); } @@ -572,6 +575,68 @@ export class BulkLoadMapping 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; + } + } } @@ -584,6 +649,8 @@ export class FileDescription 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; @@ -595,6 +662,18 @@ export class FileDescription 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; + } } @@ -635,7 +714,7 @@ export class FileDescription function getTypedValue(value: any): string { - if(value == null) + if (value == null) { return ""; } @@ -694,13 +773,13 @@ export class FileDescription if (!this.hasHeaderRow) { - const typedValue = getTypedValue(this.headerValues[columnIndex]) + const typedValue = getTypedValue(this.headerValues[columnIndex]); valueArray.push(typedValue == null ? "" : `${typedValue}`); } for (let value of this.bodyValuesPreview[columnIndex]) { - const typedValue = getTypedValue(value) + const typedValue = getTypedValue(value); valueArray.push(typedValue == null ? "" : `${typedValue}`); } From 40f5b553077591bb267dec6186aec80926cd4d7b Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Tue, 7 Jan 2025 11:47:52 -0600 Subject: [PATCH 39/52] CE-1955 add error if no fields mapped --- .../processes/BulkLoadFileMappingForm.tsx | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/qqq/components/processes/BulkLoadFileMappingForm.tsx b/src/qqq/components/processes/BulkLoadFileMappingForm.tsx index 9ea436e..6a6759a 100644 --- a/src/qqq/components/processes/BulkLoadFileMappingForm.tsx +++ b/src/qqq/components/processes/BulkLoadFileMappingForm.tsx @@ -33,6 +33,7 @@ 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"; @@ -68,6 +69,7 @@ const BulkLoadFileMappingForm = forwardRef(({processValues, tableMetaData, metaD const [wrappedCurrentSavedBulkLoadProfile] = useState(new Wrapper(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); @@ -128,6 +130,17 @@ const BulkLoadFileMappingForm = forwardRef(({processValues, tableMetaData, metaD } 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(() => @@ -240,6 +253,9 @@ const BulkLoadFileMappingForm = forwardRef(({processValues, tableMetaData, metaD forceUpdate(); }} /> + { + noMappedFieldsError && {noMappedFieldsError} + } ); From 19a63d6956a16dd1e74d4299b96bfd0d0f673de5 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Tue, 14 Jan 2025 10:56:07 -0600 Subject: [PATCH 40/52] Read filterFieldName and columnsFieldName from widgetData --- .../misc/FilterAndColumnsSetupWidget.tsx | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/qqq/components/widgets/misc/FilterAndColumnsSetupWidget.tsx b/src/qqq/components/widgets/misc/FilterAndColumnsSetupWidget.tsx index 960c163..201b4bc 100644 --- a/src/qqq/components/widgets/misc/FilterAndColumnsSetupWidget.tsx +++ b/src/qqq/components/widgets/misc/FilterAndColumnsSetupWidget.tsx @@ -86,10 +86,13 @@ const qController = Client.getInstance(); export default function FilterAndColumnsSetupWidget({isEditable, widgetMetaData, widgetData, recordValues, onSaveCallback}: 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 +111,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 +145,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 +233,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(); } From d65c1fb5d8b454d155dadde101db189953d4cb7f Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Tue, 21 Jan 2025 12:12:03 -0600 Subject: [PATCH 41/52] Padding & margin adjustments for script viewer --- src/qqq/components/scripts/ScriptDocsForm.tsx | 4 ++-- src/qqq/components/widgets/misc/ScriptViewer.tsx | 6 +++--- src/qqq/pages/records/view/RecordDeveloperView.tsx | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/qqq/components/scripts/ScriptDocsForm.tsx b/src/qqq/components/scripts/ScriptDocsForm.tsx index 8296997..1d4ba82 100644 --- a/src/qqq/components/scripts/ScriptDocsForm.tsx +++ b/src/qqq/components/scripts/ScriptDocsForm.tsx @@ -49,7 +49,7 @@ function ScriptDocsForm({helpText, exampleCode, aceEditorHeight}: Props): JSX.El {heading} - + diff --git a/src/qqq/components/widgets/misc/ScriptViewer.tsx b/src/qqq/components/widgets/misc/ScriptViewer.tsx index e754d7c..4505466 100644 --- a/src/qqq/components/widgets/misc/ScriptViewer.tsx +++ b/src/qqq/components/widgets/misc/ScriptViewer.tsx @@ -393,7 +393,7 @@ export default function ScriptViewer({scriptId, associatedScriptTableName, assoc } return ( - + { @@ -530,7 +530,7 @@ export default function ScriptViewer({scriptId, associatedScriptTableName, assoc - + - + diff --git a/src/qqq/pages/records/view/RecordDeveloperView.tsx b/src/qqq/pages/records/view/RecordDeveloperView.tsx index 0567542..647ac4e 100644 --- a/src/qqq/pages/records/view/RecordDeveloperView.tsx +++ b/src/qqq/pages/records/view/RecordDeveloperView.tsx @@ -191,7 +191,7 @@ function RecordDeveloperView({table}: Props): JSX.Element {field?.label} - + {scriptId ? Date: Tue, 21 Jan 2025 12:18:21 -0600 Subject: [PATCH 42/52] Take label (e.g., of the field) as parameter --- src/qqq/components/forms/EntityForm.tsx | 1 + .../misc/FilterAndColumnsSetupWidget.tsx | 21 ++++++++++--------- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/src/qqq/components/forms/EntityForm.tsx b/src/qqq/components/forms/EntityForm.tsx index 1e08a00..bf41afd 100644 --- a/src/qqq/components/forms/EntityForm.tsx +++ b/src/qqq/components/forms/EntityForm.tsx @@ -407,6 +407,7 @@ function EntityForm(props: Props): JSX.Element widgetMetaData={widgetMetaData} widgetData={widgetData} recordValues={formValues} + label={tableMetaData?.fields.get(widgetData?.filterFieldName ?? "queryFilterJson")?.label} onSaveCallback={setFormFieldValuesFromWidget} />; } diff --git a/src/qqq/components/widgets/misc/FilterAndColumnsSetupWidget.tsx b/src/qqq/components/widgets/misc/FilterAndColumnsSetupWidget.tsx index 201b4bc..b58da82 100644 --- a/src/qqq/components/widgets/misc/FilterAndColumnsSetupWidget.tsx +++ b/src/qqq/components/widgets/misc/FilterAndColumnsSetupWidget.tsx @@ -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,15 +84,15 @@ 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] = 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 [filterFieldName] = useState(widgetData?.filterFieldName ?? "queryFilterJson"); + const [columnsFieldName] = useState(widgetData?.columnsFieldName ?? "columnsJson"); const [alertContent, setAlertContent] = useState(null as string); @@ -233,7 +234,7 @@ export default function FilterAndColumnsSetupWidget({isEditable, widgetMetaData, setFrontendQueryFilter(view.queryFilter); const filter = FilterUtils.prepQueryFilterForBackend(tableMetaData, view.queryFilter); - const rs: {[key: string]: any} = {}; + const rs: { [key: string]: any } = {}; rs[filterFieldName] = JSON.stringify(filter); rs[columnsFieldName] = JSON.stringify(view.queryColumns); onSaveCallback(rs); @@ -362,7 +363,7 @@ export default function FilterAndColumnsSetupWidget({isEditable, widgetMetaData, -
    Query Filter
    +
    {label ?? "Query Filter"}
    {mayShowQuery() && getCurrentSortIndicator(frontendQueryFilter, tableMetaData, null)}
    { From 219458ec639bb4505504d5efa5adcdd34df013fe Mon Sep 17 00:00:00 2001 From: Tim Chamberlain Date: Tue, 28 Jan 2025 15:30:42 -0600 Subject: [PATCH 43/52] CE-2258: updated dashboard widgets with a forcereload when child record is removed --- src/qqq/components/widgets/DashboardWidgets.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/qqq/components/widgets/DashboardWidgets.tsx b/src/qqq/components/widgets/DashboardWidgets.tsx index b62db37..fbd70f7 100644 --- a/src/qqq/components/widgets/DashboardWidgets.tsx +++ b/src/qqq/components/widgets/DashboardWidgets.tsx @@ -313,6 +313,7 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, reco function deleteChildRecord(name: string, widgetIndex: number, rowIndex: number) { updateChildRecordList(name, "delete", rowIndex); + forceUpdate(); actionCallback(widgetData[widgetIndex]); }; From aacb239164a12c582a4875acf35a4b4adf9105c6 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Mon, 3 Feb 2025 09:10:39 -0600 Subject: [PATCH 44/52] Add support for defaultValuesForNewChildRecordsFromParentFields for ChildRecordList; Load display values for possible-value fields when adding them to childRecord list and when opening child-edit form (adding passing of all other-values to the possible-value lookup, for filtered scenarios that need them); --- src/qqq/components/forms/EntityForm.tsx | 101 ++++++++++++++---- .../components/widgets/DashboardWidgets.tsx | 3 +- .../widgets/misc/RecordGridWidget.tsx | 47 +++++--- .../widgets/tables/ModalEditForm.tsx | 2 +- 4 files changed, 117 insertions(+), 36 deletions(-) diff --git a/src/qqq/components/forms/EntityForm.tsx b/src/qqq/components/forms/EntityForm.tsx index bf41afd..6360250 100644 --- a/src/qqq/components/forms/EntityForm.tsx +++ b/src/qqq/components/forms/EntityForm.tsx @@ -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); @@ -479,6 +516,26 @@ function EntityForm(props: Props): JSX.Element } + + /*************************************************************************** + ** + ***************************************************************************/ + function objectToMap(object: { [key: string]: any }): Map + { + if(object == null) + { + return (null); + } + + const rs = new Map(); + for (let key in object) + { + rs.set(key, object[key]); + } + return rs + } + + ////////////////// // initial load // ////////////////// @@ -596,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]], undefined, "form"); - if (results && results.length > 0) - { - defaultDisplayValues.set(fieldName, results[0].label); - } + defaultDisplayValues.set(fieldName, results[0].label); } } } @@ -824,7 +887,7 @@ function EntityForm(props: Props): JSX.Element //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// if (props.onSubmitCallback) { - props.onSubmitCallback(values); + props.onSubmitCallback(values, tableName); return; } diff --git a/src/qqq/components/widgets/DashboardWidgets.tsx b/src/qqq/components/widgets/DashboardWidgets.tsx index b62db37..fbc182b 100644 --- a/src/qqq/components/widgets/DashboardWidgets.tsx +++ b/src/qqq/components/widgets/DashboardWidgets.tsx @@ -368,7 +368,7 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, reco /******************************************************************************* ** *******************************************************************************/ - function submitEditChildForm(values: any) + function submitEditChildForm(values: any, tableName: string) { updateChildRecordList(showEditChildForm.widgetName, showEditChildForm.rowIndex == null ? "insert" : "edit", showEditChildForm.rowIndex, values); let widgetIndex = determineChildRecordListIndex(showEditChildForm.widgetName); @@ -718,6 +718,7 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, reco addNewRecordCallback={widgetData[i]?.isInProcess ? () => openAddChildRecord(widgetMetaData.name, widgetData[i]) : null} widgetMetaData={widgetMetaData} data={widgetData[i]} + parentRecord={record} /> ) diff --git a/src/qqq/components/widgets/misc/RecordGridWidget.tsx b/src/qqq/components/widgets/misc/RecordGridWidget.tsx index 18918c4..f9cd56e 100644 --- a/src/qqq/components/widgets/misc/RecordGridWidget.tsx +++ b/src/qqq/components/widgets/misc/RecordGridWidget.tsx @@ -40,7 +40,7 @@ import {Link, useNavigate} from "react-router-dom"; export interface ChildRecordListData extends WidgetData { title?: string; - queryOutput?: { records: { values: any }[] }; + queryOutput?: { records: { values: any, displayValues?: any } [] }; childTableMetaData?: QTableMetaData; tablePath?: string; viewAllLink?: string; @@ -48,20 +48,22 @@ export interface ChildRecordListData extends WidgetData 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; - gridOnly?: boolean; - gridDensity?: GridDensity; + 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 = @@ -74,7 +76,7 @@ RecordGridWidget.defaultProps = const qController = Client.getInstance(); -function RecordGridWidget({widgetMetaData, data, addNewRecordCallback, disableRowClick, allowRecordEdit, editRecordCallback, allowRecordDelete, deleteRecordCallback, gridOnly, gridDensity}: 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([]); @@ -97,7 +99,7 @@ function RecordGridWidget({widgetMetaData, data, addNewRecordCallback, disableRo { for (let i = 0; i < queryOutputRecords.length; i++) { - if(queryOutputRecords[i] instanceof QRecord) + if (queryOutputRecords[i] instanceof QRecord) { records.push(queryOutputRecords[i] as QRecord); } @@ -252,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)); } @@ -357,7 +374,7 @@ function RecordGridWidget({widgetMetaData, data, addNewRecordCallback, disableRo /> ); - if(gridOnly) + if (gridOnly) { return (grid); } diff --git a/src/qqq/components/widgets/tables/ModalEditForm.tsx b/src/qqq/components/widgets/tables/ModalEditForm.tsx index 69219f9..a53bab4 100644 --- a/src/qqq/components/widgets/tables/ModalEditForm.tsx +++ b/src/qqq/components/widgets/tables/ModalEditForm.tsx @@ -35,7 +35,7 @@ export interface ModalEditFormData defaultValues?: { [key: string]: string }; disabledFields?: { [key: string]: boolean } | string[]; overrideHeading?: string; - onSubmitCallback?: (values: any) => void; + onSubmitCallback?: (values: any, tableName: String) => void; initialShowModalValue?: boolean; } From 7db4f34ddde3b18cc2b825c817e8cccf8394f7ae Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Fri, 14 Feb 2025 20:34:59 -0600 Subject: [PATCH 45/52] add LONG to types that get numeric operators --- src/qqq/components/query/FilterCriteriaRow.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/qqq/components/query/FilterCriteriaRow.tsx b/src/qqq/components/query/FilterCriteriaRow.tsx index a180a1e..d35d046 100644 --- a/src/qqq/components/query/FilterCriteriaRow.tsx +++ b/src/qqq/components/query/FilterCriteriaRow.tsx @@ -109,6 +109,7 @@ export const getOperatorOptions = (tableMetaData: QTableMetaData, fieldName: str { case QFieldType.DECIMAL: case QFieldType.INTEGER: + case QFieldType.LONG: operatorOptions.push({label: "equals", value: QCriteriaOperator.EQUALS, valueMode: ValueMode.SINGLE}); operatorOptions.push({label: "does not equal", value: QCriteriaOperator.NOT_EQUALS_OR_IS_NULL, valueMode: ValueMode.SINGLE}); operatorOptions.push({label: "greater than", value: QCriteriaOperator.GREATER_THAN, valueMode: ValueMode.SINGLE}); From c69a4b8203787f40042f8c8424148d635f268f47 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Fri, 14 Feb 2025 20:36:49 -0600 Subject: [PATCH 46/52] Make variants work for blob/download fields --- .../widgets/misc/RecordGridWidget.tsx | 2 +- src/qqq/pages/records/query/RecordQuery.tsx | 2 +- src/qqq/pages/records/view/RecordView.tsx | 6 ++--- src/qqq/utils/DataGridUtils.tsx | 23 +++++++++++++++---- src/qqq/utils/qqq/ValueUtils.tsx | 20 ++++++++++++---- 5 files changed, 40 insertions(+), 13 deletions(-) diff --git a/src/qqq/components/widgets/misc/RecordGridWidget.tsx b/src/qqq/components/widgets/misc/RecordGridWidget.tsx index f9cd56e..2c90096 100644 --- a/src/qqq/components/widgets/misc/RecordGridWidget.tsx +++ b/src/qqq/components/widgets/misc/RecordGridWidget.tsx @@ -111,7 +111,7 @@ function RecordGridWidget({widgetMetaData, data, addNewRecordCallback, disableRo } const tableMetaData = data.childTableMetaData instanceof QTableMetaData ? data.childTableMetaData as QTableMetaData : new QTableMetaData(data.childTableMetaData); - const rows = DataGridUtils.makeRows(records, tableMetaData, true); + const rows = DataGridUtils.makeRows(records, tableMetaData); ///////////////////////////////////////////////////////////////////////////////// // note - tablePath may be null, if the user doesn't have access to the table. // diff --git a/src/qqq/pages/records/query/RecordQuery.tsx b/src/qqq/pages/records/query/RecordQuery.tsx index 6efcd7a..c26fe8c 100644 --- a/src/qqq/pages/records/query/RecordQuery.tsx +++ b/src/qqq/pages/records/query/RecordQuery.tsx @@ -1103,7 +1103,7 @@ const RecordQuery = forwardRef(({table, usage, isModal, isPreview, allowVariable //////////////////////////////// // make the rows for the grid // //////////////////////////////// - const rows = DataGridUtils.makeRows(results, tableMetaData); + const rows = DataGridUtils.makeRows(results, tableMetaData, tableVariant); setRows(rows); setLoading(false); diff --git a/src/qqq/pages/records/view/RecordView.tsx b/src/qqq/pages/records/view/RecordView.tsx index ca7789c..41cf916 100644 --- a/src/qqq/pages/records/view/RecordView.tsx +++ b/src/qqq/pages/records/view/RecordView.tsx @@ -92,7 +92,7 @@ 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 }, styleOverrides?: {label?: SxProps, value?: SxProps}) +export function renderSectionOfFields(key: string, fieldNames: string[], tableMetaData: QTableMetaData, helpHelpActive: boolean, record: QRecord, fieldMap?: { [name: string]: QFieldMetaData }, styleOverrides?: {label?: SxProps, value?: SxProps}, tableVariant?: QTableVariant) { return { @@ -118,7 +118,7 @@ export function renderSectionOfFields(key: string, fieldNames: string[], tableMe }
     
    - {ValueUtils.getDisplayValue(field, record, "view", fieldName)} + {ValueUtils.getDisplayValue(field, record, "view", fieldName, tableVariant)}
    @@ -597,7 +597,7 @@ function RecordView({table, record: overrideRecord, launchProcess}: Props): JSX. // for a section with field names, render the field values. // // for the T1 section, the "wrapper" will come out below - but for other sections, produce a wrapper too. // //////////////////////////////////////////////////////////////////////////////////////////////////////////// - const fields = renderSectionOfFields(section.name, section.fieldNames, tableMetaData, helpHelpActive, record); + const fields = renderSectionOfFields(section.name, section.fieldNames, tableMetaData, helpHelpActive, record, undefined, undefined, tableVariant); if (section.tier === "T1") { diff --git a/src/qqq/utils/DataGridUtils.tsx b/src/qqq/utils/DataGridUtils.tsx index 2ee5343..b7effe7 100644 --- a/src/qqq/utils/DataGridUtils.tsx +++ b/src/qqq/utils/DataGridUtils.tsx @@ -24,6 +24,7 @@ import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QF import {QFieldType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldType"; import {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstance"; import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData"; +import {QTableVariant} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableVariant"; import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord"; import Tooltip from "@mui/material/Tooltip/Tooltip"; import {GridColDef, GridRowsProp, MuiEvent} from "@mui/x-data-grid-pro"; @@ -70,7 +71,7 @@ export default class DataGridUtils /******************************************************************************* ** *******************************************************************************/ - public static makeRows = (results: QRecord[], tableMetaData: QTableMetaData, allowEmptyId = false): GridRowsProp[] => + public static makeRows = (results: QRecord[], tableMetaData: QTableMetaData, tableVariant?: QTableVariant): GridRowsProp[] => { const fields = [...tableMetaData.fields.values()]; const rows = [] as any[]; @@ -82,7 +83,7 @@ export default class DataGridUtils fields.forEach((field) => { - row[field.name] = ValueUtils.getDisplayValue(field, record, "query"); + row[field.name] = ValueUtils.getDisplayValue(field, record, "query", undefined, tableVariant); }); if (tableMetaData.exposedJoins) @@ -97,7 +98,7 @@ export default class DataGridUtils fields.forEach((field) => { let fieldName = join.joinTable.name + "." + field.name; - row[fieldName] = ValueUtils.getDisplayValue(field, record, "query", fieldName); + row[fieldName] = ValueUtils.getDisplayValue(field, record, "query", fieldName, tableVariant); }); } } @@ -111,7 +112,7 @@ export default class DataGridUtils ///////////////////////////////////////////////////////////////////////////////////////// // DataGrid gets very upset about a null or undefined here, so, try to make it happier // ///////////////////////////////////////////////////////////////////////////////////////// - if (!allowEmptyId) + if (!tableVariant) { row["id"] = "--"; } @@ -241,6 +242,20 @@ export default class DataGridUtils cellValues.value ? e.stopPropagation()}>{cellValues.value} : "" ); } + /* todo wip ... not sure if/how to get tooltipFieldName set in the row... */ + /* + else if (field.hasAdornment(AdornmentType.TOOLTIP)) + { + const tooltipAdornment = field.getAdornment(AdornmentType.TOOLTIP); + const tooltipFieldName: string = tooltipAdornment.getValue("tooltipFieldName"); + if(tooltipFieldName) + { + column.renderCell = (cellValues: any) => ( + cellValues.value ? {cellValues.value} : "" + ); + } + } + */ }); } diff --git a/src/qqq/utils/qqq/ValueUtils.tsx b/src/qqq/utils/qqq/ValueUtils.tsx index 2339c5b..086ea15 100644 --- a/src/qqq/utils/qqq/ValueUtils.tsx +++ b/src/qqq/utils/qqq/ValueUtils.tsx @@ -23,6 +23,7 @@ import {AdornmentType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/Ado import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData"; import {QFieldType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldType"; import {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstance"; +import {QTableVariant} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableVariant"; import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord"; import "datejs"; // https://github.com/datejs/Datejs import {Chip, ClickAwayListener, Icon} from "@mui/material"; @@ -76,14 +77,14 @@ class ValueUtils ** When you have a field, and a record - call this method to get a string or ** element back to display the field's value. *******************************************************************************/ - public static getDisplayValue(field: QFieldMetaData, record: QRecord, usage: "view" | "query" = "view", overrideFieldName?: string): string | JSX.Element | JSX.Element[] + public static getDisplayValue(field: QFieldMetaData, record: QRecord, usage: "view" | "query" = "view", overrideFieldName?: string, tableVariant?: QTableVariant): string | JSX.Element | JSX.Element[] { const fieldName = overrideFieldName ?? field.name; const displayValue = record.displayValues ? record.displayValues.get(fieldName) : undefined; const rawValue = record.values ? record.values.get(fieldName) : undefined; - return ValueUtils.getValueForDisplay(field, rawValue, displayValue, usage); + return ValueUtils.getValueForDisplay(field, rawValue, displayValue, usage, tableVariant); } @@ -91,7 +92,7 @@ class ValueUtils ** When you have a field and a value (either just a raw value, or a raw and ** display value), call this method to get a string Element to display. *******************************************************************************/ - public static getValueForDisplay(field: QFieldMetaData, rawValue: any, displayValue: any = rawValue, usage: "view" | "query" = "view"): string | JSX.Element | JSX.Element[] + public static getValueForDisplay(field: QFieldMetaData, rawValue: any, displayValue: any = rawValue, usage: "view" | "query" = "view", tableVariant?: QTableVariant): string | JSX.Element | JSX.Element[] { if (field.hasAdornment(AdornmentType.LINK)) { @@ -199,9 +200,20 @@ class ValueUtils if (field.type == QFieldType.BLOB || field.hasAdornment(AdornmentType.FILE_DOWNLOAD)) { - return (); + let url = rawValue; + if(tableVariant) + { + url += "?tableVariant=" + encodeURIComponent(JSON.stringify(tableVariant)); + } + + return (); } + // todo if(field.hasAdornment(AdornmentType.CODE)) + // todo { + // todo return {ValueUtils.getUnadornedValueForDisplay(field, rawValue, displayValue)} + // todo } + return (ValueUtils.getUnadornedValueForDisplay(field, rawValue, displayValue)); } From 44a88102605ece976d8b2707d494127f111a2da4 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Fri, 14 Feb 2025 20:37:12 -0600 Subject: [PATCH 47/52] Remove textTransform="capitalize" from pageHeader h3 --- src/qqq/components/horseshoe/NavBar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/qqq/components/horseshoe/NavBar.tsx b/src/qqq/components/horseshoe/NavBar.tsx index 24e3f94..61bf807 100644 --- a/src/qqq/components/horseshoe/NavBar.tsx +++ b/src/qqq/components/horseshoe/NavBar.tsx @@ -270,7 +270,7 @@ function NavBar({absolute, light, isMini}: Props): JSX.Element { pageHeader && - + {pageHeader} From 6076c4ddfd9900e0d9a1d53b1b96af3f9897dc4a Mon Sep 17 00:00:00 2001 From: Tim Chamberlain Date: Wed, 19 Feb 2025 17:05:10 -0600 Subject: [PATCH 48/52] CE-2261: updated to respect field column widths on view and edit forms --- package.json | 2 +- src/qqq/components/forms/DynamicForm.tsx | 17 +++++++++-------- src/qqq/pages/records/view/RecordView.tsx | 11 ++++++----- 3 files changed, 16 insertions(+), 14 deletions(-) diff --git a/package.json b/package.json index 987fab6..102e896 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "@auth0/auth0-react": "1.10.2", "@emotion/react": "11.7.1", "@emotion/styled": "11.6.0", - "@kingsrook/qqq-frontend-core": "1.0.113", + "@kingsrook/qqq-frontend-core": "1.0.114", "@mui/icons-material": "5.4.1", "@mui/material": "5.11.1", "@mui/styles": "5.11.1", diff --git a/src/qqq/components/forms/DynamicForm.tsx b/src/qqq/components/forms/DynamicForm.tsx index 5a66314..62ae6ba 100644 --- a/src/qqq/components/forms/DynamicForm.tsx +++ b/src/qqq/components/forms/DynamicForm.tsx @@ -57,7 +57,7 @@ function QDynamicForm({formData, formLabel, bulkEditMode, bulkEditSwitchChangeHa {formLabel}
    - + {formFields && Object.keys(formFields).length > 0 && Object.keys(formFields).map((fieldName: any) => @@ -74,13 +74,14 @@ function QDynamicForm({formData, formLabel, bulkEditMode, bulkEditSwitchChangeHa } let formattedHelpContent = ; - if(formattedHelpContent) + if (formattedHelpContent) { - formattedHelpContent = {formattedHelpContent} + formattedHelpContent = {formattedHelpContent}; } const labelElement = ; + let itemLG = (field?.fieldMetaData?.gridColumns && field?.fieldMetaData?.gridColumns > 0) ? field.fieldMetaData.gridColumns : 6; let itemXS = 12; let itemSM = 6; @@ -92,13 +93,13 @@ function QDynamicForm({formData, formLabel, bulkEditMode, bulkEditSwitchChangeHa const fileUploadAdornment = field.fieldMetaData?.getAdornment(AdornmentType.FILE_UPLOAD); const width = fileUploadAdornment?.values?.get("width") ?? "half"; - if(width == "full") + if (width == "full") { itemSM = 12; } return ( - + {labelElement} @@ -114,10 +115,10 @@ function QDynamicForm({formData, formLabel, bulkEditMode, bulkEditSwitchChangeHa Object.keys(values).forEach((key) => { otherValuesMap.set(key, values[key]); - }) + }); return ( - + {labelElement} + {labelElement} + return { fieldNames.map((fieldName: string) => { @@ -103,6 +103,7 @@ 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); @@ -111,7 +112,7 @@ export function renderSectionOfFields(key: string, fieldNames: string[], tableMe const labelElement = {label}:; return ( - + <> { showHelp && formattedHelpContent ? {labelElement} : labelElement @@ -121,12 +122,12 @@ export function renderSectionOfFields(key: string, fieldNames: string[], tableMe {ValueUtils.getDisplayValue(field, record, "view", fieldName)} - + ); } }) } - ; +
    ; } From 3bb8451671c7bdd6bc56d6f7ece9cf8c13447b92 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Mon, 24 Feb 2025 11:10:36 -0600 Subject: [PATCH 49/52] add support for `toRecordFromTableDynamic` in LINK adornment, and `downloadUrlDynamic` in FILE_DOWNLOAD adornment --- src/qqq/utils/qqq/ValueUtils.tsx | 54 +++++++++++++++++++++++++++++--- 1 file changed, 49 insertions(+), 5 deletions(-) diff --git a/src/qqq/utils/qqq/ValueUtils.tsx b/src/qqq/utils/qqq/ValueUtils.tsx index 086ea15..c75d6e3 100644 --- a/src/qqq/utils/qqq/ValueUtils.tsx +++ b/src/qqq/utils/qqq/ValueUtils.tsx @@ -84,7 +84,7 @@ class ValueUtils const displayValue = record.displayValues ? record.displayValues.get(fieldName) : undefined; const rawValue = record.values ? record.values.get(fieldName) : undefined; - return ValueUtils.getValueForDisplay(field, rawValue, displayValue, usage, tableVariant); + return ValueUtils.getValueForDisplay(field, rawValue, displayValue, usage, tableVariant, record, fieldName); } @@ -92,14 +92,35 @@ class ValueUtils ** When you have a field and a value (either just a raw value, or a raw and ** display value), call this method to get a string Element to display. *******************************************************************************/ - public static getValueForDisplay(field: QFieldMetaData, rawValue: any, displayValue: any = rawValue, usage: "view" | "query" = "view", tableVariant?: QTableVariant): string | JSX.Element | JSX.Element[] + public static getValueForDisplay(field: QFieldMetaData, rawValue: any, displayValue: any = rawValue, usage: "view" | "query" = "view", tableVariant?: QTableVariant, record?: QRecord, fieldName?: string): string | JSX.Element | JSX.Element[] { if (field.hasAdornment(AdornmentType.LINK)) { const adornment = field.getAdornment(AdornmentType.LINK); - let href = rawValue; + let href = String(rawValue); + + let toRecordFromTable = adornment.getValue("toRecordFromTable"); + + ///////////////////////////////////////////////////////////////////////////////////// + // if the link adornment has a 'toRecordFromTableDynamic', then look for a display // + // value named `fieldName`:toRecordFromTableDynamic for the table name. // + ///////////////////////////////////////////////////////////////////////////////////// + if(adornment.getValue("toRecordFromTableDynamic")) + { + const toRecordFromTableDynamic = record?.displayValues?.get(fieldName + ":toRecordFromTableDynamic"); + if(toRecordFromTableDynamic) + { + toRecordFromTable = toRecordFromTableDynamic; + } + else + { + /////////////////////////////////////////////////////////////////// + // if the table name isn't known, then return w/o the adornment. // + /////////////////////////////////////////////////////////////////// + return (ValueUtils.getUnadornedValueForDisplay(field, rawValue, displayValue)); + } + } - const toRecordFromTable = adornment.getValue("toRecordFromTable"); if (toRecordFromTable) { if (ValueUtils.getQInstance()) @@ -108,7 +129,7 @@ class ValueUtils if (!tablePath) { console.log("Couldn't find path for table: " + toRecordFromTable); - return (displayValue ?? rawValue); + return (ValueUtils.getUnadornedValueForDisplay(field, rawValue, displayValue)); } if (!tablePath.endsWith("/")) @@ -206,6 +227,28 @@ class ValueUtils url += "?tableVariant=" + encodeURIComponent(JSON.stringify(tableVariant)); } + ////////////////////////////////////////////////////////////////////////////// + // if the field has the download adornment with a downloadUrlDynamic value, // + // then get the url from a displayValue of `fieldName`:downloadUrlDynamic. // + ////////////////////////////////////////////////////////////////////////////// + if(field.hasAdornment(AdornmentType.FILE_DOWNLOAD)) + { + const adornment = field.getAdornment(AdornmentType.FILE_DOWNLOAD); + let downloadUrlDynamicAdornmentValue = adornment.getValue("downloadUrlDynamic"); + const downloadUrlDynamicValue = record?.displayValues?.get(fieldName + ":downloadUrlDynamic"); + if (downloadUrlDynamicAdornmentValue && downloadUrlDynamicValue) + { + url = downloadUrlDynamicValue; + } + else + { + //////////////////////////////////////////////////////////////// + // if the url isn't available, then return w/o the adornment. // + //////////////////////////////////////////////////////////////// + return (ValueUtils.getUnadornedValueForDisplay(field, rawValue, displayValue)); + } + } + return (); } @@ -217,6 +260,7 @@ class ValueUtils return (ValueUtils.getUnadornedValueForDisplay(field, rawValue, displayValue)); } + /******************************************************************************* ** After we know there's no element to be returned (e.g., because no adornment), ** this method does the string formatting. From 07cb6fd323c47fac0a38dad27431a880637d4316 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Tue, 25 Feb 2025 10:55:49 -0600 Subject: [PATCH 50/52] Fix show blob download urls when not using dynamic url --- src/qqq/utils/qqq/ValueUtils.tsx | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/src/qqq/utils/qqq/ValueUtils.tsx b/src/qqq/utils/qqq/ValueUtils.tsx index c75d6e3..73fa993 100644 --- a/src/qqq/utils/qqq/ValueUtils.tsx +++ b/src/qqq/utils/qqq/ValueUtils.tsx @@ -235,17 +235,20 @@ class ValueUtils { const adornment = field.getAdornment(AdornmentType.FILE_DOWNLOAD); let downloadUrlDynamicAdornmentValue = adornment.getValue("downloadUrlDynamic"); - const downloadUrlDynamicValue = record?.displayValues?.get(fieldName + ":downloadUrlDynamic"); - if (downloadUrlDynamicAdornmentValue && downloadUrlDynamicValue) + if(downloadUrlDynamicAdornmentValue) { - url = downloadUrlDynamicValue; - } - else - { - //////////////////////////////////////////////////////////////// - // if the url isn't available, then return w/o the adornment. // - //////////////////////////////////////////////////////////////// - return (ValueUtils.getUnadornedValueForDisplay(field, rawValue, displayValue)); + const downloadUrlDynamicValue = record?.displayValues?.get(fieldName + ":downloadUrlDynamic"); + if (downloadUrlDynamicValue) + { + url = downloadUrlDynamicValue; + } + else + { + //////////////////////////////////////////////////////////////// + // if the url isn't available, then return w/o the adornment. // + //////////////////////////////////////////////////////////////// + return (ValueUtils.getUnadornedValueForDisplay(field, rawValue, displayValue)); + } } } From 8ec0ce545519e657e04537a2dd8554d77379a430 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Wed, 5 Mar 2025 19:30:52 -0600 Subject: [PATCH 51/52] Cleanup from code review --- .../widgets/misc/RecordGridWidget.tsx | 8 ++++---- src/qqq/utils/DataGridUtils.tsx | 18 ++---------------- src/qqq/utils/qqq/ValueUtils.tsx | 5 ----- 3 files changed, 6 insertions(+), 25 deletions(-) diff --git a/src/qqq/components/widgets/misc/RecordGridWidget.tsx b/src/qqq/components/widgets/misc/RecordGridWidget.tsx index 2c90096..5f9e8a6 100644 --- a/src/qqq/components/widgets/misc/RecordGridWidget.tsx +++ b/src/qqq/components/widgets/misc/RecordGridWidget.tsx @@ -111,7 +111,7 @@ function RecordGridWidget({widgetMetaData, data, addNewRecordCallback, disableRo } const tableMetaData = data.childTableMetaData instanceof QTableMetaData ? data.childTableMetaData as QTableMetaData : new QTableMetaData(data.childTableMetaData); - const rows = DataGridUtils.makeRows(records, tableMetaData); + const rows = DataGridUtils.makeRows(records, tableMetaData, undefined, true); ///////////////////////////////////////////////////////////////////////////////// // note - tablePath may be null, if the user doesn't have access to the table. // @@ -255,14 +255,14 @@ function RecordGridWidget({widgetMetaData, data, addNewRecordCallback, disableRo disabledFields = data.defaultValuesForNewChildRecords; } - const defaultValuesForNewChildRecords = data.defaultValuesForNewChildRecords || {} + const defaultValuesForNewChildRecords = data.defaultValuesForNewChildRecords || {}; /////////////////////////////////////////////////////////////////////////////////////// // copy values from specified fields in the parent record down into the child record // /////////////////////////////////////////////////////////////////////////////////////// - if(data.defaultValuesForNewChildRecordsFromParentFields) + if (data.defaultValuesForNewChildRecordsFromParentFields) { - for(let childField in data.defaultValuesForNewChildRecordsFromParentFields) + for (let childField in data.defaultValuesForNewChildRecordsFromParentFields) { const parentField = data.defaultValuesForNewChildRecordsFromParentFields[childField]; defaultValuesForNewChildRecords[childField] = parentRecord?.values?.get(parentField); diff --git a/src/qqq/utils/DataGridUtils.tsx b/src/qqq/utils/DataGridUtils.tsx index b7effe7..9ee5fa4 100644 --- a/src/qqq/utils/DataGridUtils.tsx +++ b/src/qqq/utils/DataGridUtils.tsx @@ -71,7 +71,7 @@ export default class DataGridUtils /******************************************************************************* ** *******************************************************************************/ - public static makeRows = (results: QRecord[], tableMetaData: QTableMetaData, tableVariant?: QTableVariant): GridRowsProp[] => + public static makeRows = (results: QRecord[], tableMetaData: QTableMetaData, tableVariant?: QTableVariant, allowEmptyId = false): GridRowsProp[] => { const fields = [...tableMetaData.fields.values()]; const rows = [] as any[]; @@ -112,7 +112,7 @@ export default class DataGridUtils ///////////////////////////////////////////////////////////////////////////////////////// // DataGrid gets very upset about a null or undefined here, so, try to make it happier // ///////////////////////////////////////////////////////////////////////////////////////// - if (!tableVariant) + if (!allowEmptyId) { row["id"] = "--"; } @@ -242,20 +242,6 @@ export default class DataGridUtils cellValues.value ? e.stopPropagation()}>{cellValues.value} : "" ); } - /* todo wip ... not sure if/how to get tooltipFieldName set in the row... */ - /* - else if (field.hasAdornment(AdornmentType.TOOLTIP)) - { - const tooltipAdornment = field.getAdornment(AdornmentType.TOOLTIP); - const tooltipFieldName: string = tooltipAdornment.getValue("tooltipFieldName"); - if(tooltipFieldName) - { - column.renderCell = (cellValues: any) => ( - cellValues.value ? {cellValues.value} : "" - ); - } - } - */ }); } diff --git a/src/qqq/utils/qqq/ValueUtils.tsx b/src/qqq/utils/qqq/ValueUtils.tsx index 73fa993..3d90a67 100644 --- a/src/qqq/utils/qqq/ValueUtils.tsx +++ b/src/qqq/utils/qqq/ValueUtils.tsx @@ -255,11 +255,6 @@ class ValueUtils return (); } - // todo if(field.hasAdornment(AdornmentType.CODE)) - // todo { - // todo return {ValueUtils.getUnadornedValueForDisplay(field, rawValue, displayValue)} - // todo } - return (ValueUtils.getUnadornedValueForDisplay(field, rawValue, displayValue)); } From 67e1e788176909815e1100d166eb4953edd6aa1f Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Thu, 6 Mar 2025 11:04:17 -0600 Subject: [PATCH 52/52] Update versions for release --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 869597d..3ccc2e3 100644 --- a/pom.xml +++ b/pom.xml @@ -29,7 +29,7 @@ jar - 0.24.0-SNAPSHOT + 0.24.0 UTF-8 UTF-8
    {letter} + <> + { + count > 0 && + + + {letter} + + + + } + { + count == 0 && {letter} + } + +
    1{value} + {value} + {value}{value}
    {i + 2}{fileDescription.bodyValuesPreview[j][i]}{getValue(i, j)}
    <> { count > 0 && + {dupeWarning} {letter} } { - count == 0 && {letter} + count == 0 && {dupeWarning}{letter} }