From f2b41532d457f58a47c03242d81d5a4d9d158203 Mon Sep 17 00:00:00 2001 From: Darin Kelkhoff Date: Mon, 25 Nov 2024 10:48:00 -0600 Subject: [PATCH] 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