/* * 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 {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"; 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 HelpContent from "qqq/components/misc/HelpContent"; import SavedBulkLoadProfiles from "qqq/components/misc/SavedBulkLoadProfiles"; import BulkLoadFileMappingFields from "qqq/components/processes/BulkLoadFileMappingFields"; import {BulkLoadField, BulkLoadMapping, BulkLoadProfile, BulkLoadTableStructure, FileDescription, Wrapper} from "qqq/models/processes/BulkLoadModels"; import {SubFormPreSubmitCallbackResultType} from "qqq/pages/processes/ProcessRun"; import React, {forwardRef, useImperativeHandle, useReducer, useState} from "react"; import ProcessViewForm from "./ProcessViewForm"; interface BulkLoadMappingFormProps { processValues: any, tableMetaData: QTableMetaData, metaData: QInstance, setActiveStepLabel: (label: string) => void, frontendStep: QFrontendStepMetaData, processMetaData: QProcessMetaData, } /*************************************************************************** ** process component - screen where user does a bulk-load file mapping. ***************************************************************************/ const BulkLoadFileMappingForm = forwardRef(({processValues, tableMetaData, metaData, setActiveStepLabel, frontendStep, processMetaData}: BulkLoadMappingFormProps, ref) => { const {setFieldValue} = useFormikContext(); const [currentSavedBulkLoadProfile, setCurrentSavedBulkLoadProfile] = useState(null as QRecord); const [wrappedCurrentSavedBulkLoadProfile] = useState(new Wrapper(currentSavedBulkLoadProfile)); const [fieldErrors, setFieldErrors] = useState({} as { [fieldName: string]: string }); const [suggestedBulkLoadProfile] = useState(processValues.suggestedBulkLoadProfile as BulkLoadProfile); const [tableStructure] = useState(processValues.tableStructure as BulkLoadTableStructure); const [bulkLoadMapping, setBulkLoadMapping] = useState(BulkLoadMapping.fromBulkLoadProfile(tableStructure, processValues.bulkLoadProfile)); const [wrappedBulkLoadMapping] = useState(new Wrapper(bulkLoadMapping)); 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}; } }; }); /*************************************************************************** ** ***************************************************************************/ 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, frontendStep: QFrontendStepMetaData, processMetaData: QProcessMetaData, } /*************************************************************************** ** private subcomponent - the header section of the bulk load file mapping screen. ***************************************************************************/ function BulkLoadMappingHeader({fileDescription, fileName, bulkLoadMapping, fieldErrors, tableStructure, forceParentUpdate, frontendStep, processMetaData}: BulkLoadMappingHeaderProps): JSX.Element { const viewFields = [ new QFieldMetaData({name: "fileName", label: "File Name", type: "STRING"}), new QFieldMetaData({name: "fileDetails", label: "File Details", type: "STRING"}), ]; const viewValues = { "fileName": fileName, "fileDetails": `${fileDescription.getColumnNames().length} column${fileDescription.getColumnNames().length == 1 ? "" : "s"}` }; const hasHeaderRowFormField = {name: "hasHeaderRow", label: "Does the file have a header row?", type: "checkbox", isRequired: true, isEditable: true}; const layoutOptions = [ {label: "Flat", id: "FLAT"}, {label: "Tall", id: "TALL"}, {label: "Wide", id: "WIDE"}, ]; if (!tableStructure.associations) { layoutOptions.splice(1); } const selectedLayout = layoutOptions.filter(o => o.id == bulkLoadMapping.layout)[0] ?? null; /*************************************************************************** ** ***************************************************************************/ function hasHeaderRowChanged(newValue: any) { bulkLoadMapping.hasHeaderRow = newValue; fileDescription.hasHeaderRow = newValue; fieldErrors.hasHeaderRow = null; forceParentUpdate(); } /*************************************************************************** ** ***************************************************************************/ function layoutChanged(event: any, newValue: any) { bulkLoadMapping.layout = newValue ? newValue.id : null; fieldErrors.layout = null; forceParentUpdate(); } /*************************************************************************** ** ***************************************************************************/ function getFormattedHelpContent(fieldName: string): JSX.Element { const field = frontendStep?.formFields?.find(f => f.name == fieldName); let helpRoles = ["PROCESS_SCREEN", "ALL_SCREENS"]; let formattedHelpContent = ; if (formattedHelpContent) { const mt = field && field.type == QFieldType.BOOLEAN ? "-0.5rem" : "0.5rem"; return {formattedHelpContent}; } return null; } return (
File Details
{ fieldErrors.hasHeaderRow && {
{fieldErrors.hasHeaderRow}
}
} {getFormattedHelpContent("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}
    }
    } {getFormattedHelpContent("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]}
    ); }