mirror of
https://github.com/Kingsrook/qqq-frontend-material-dashboard.git
synced 2025-07-18 13:20:43 +00:00
CE-1955 - Initial checkin of qfmd support for bulk-load
This commit is contained in:
795
src/qqq/components/misc/QHierarchyAutoComplete.tsx
Normal file
795
src/qqq/components/misc/QHierarchyAutoComplete.tsx
Normal file
@ -0,0 +1,795 @@
|
|||||||
|
/*
|
||||||
|
* QQQ - Low-code Application Framework for Engineers.
|
||||||
|
* Copyright (C) 2021-2024. Kingsrook, LLC
|
||||||
|
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||||
|
* contact@kingsrook.com
|
||||||
|
* https://github.com/Kingsrook/
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as
|
||||||
|
* published by the Free Software Foundation, either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import Button from "@mui/material/Button";
|
||||||
|
import FormControlLabel from "@mui/material/FormControlLabel";
|
||||||
|
import Icon from "@mui/material/Icon";
|
||||||
|
import IconButton from "@mui/material/IconButton";
|
||||||
|
import List from "@mui/material/List/List";
|
||||||
|
import ListItem, {ListItemProps} from "@mui/material/ListItem/ListItem";
|
||||||
|
import Menu from "@mui/material/Menu";
|
||||||
|
import Switch from "@mui/material/Switch";
|
||||||
|
import TextField from "@mui/material/TextField";
|
||||||
|
import Tooltip from "@mui/material/Tooltip/Tooltip";
|
||||||
|
import React, {useState} from "react";
|
||||||
|
|
||||||
|
export type Option = { label: string, value: string | number, [key: string]: any }
|
||||||
|
|
||||||
|
export type Group = { label: string, value: string | number, options: Option[], subGroups?: Group[], [key: string]: any }
|
||||||
|
|
||||||
|
type StringOrNumber = string | number
|
||||||
|
|
||||||
|
interface QHierarchyAutoCompleteProps
|
||||||
|
{
|
||||||
|
idPrefix: string;
|
||||||
|
heading?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
defaultGroup: Group;
|
||||||
|
showGroupHeaderEvenIfNoSubGroups: boolean;
|
||||||
|
optionValuesToHide?: StringOrNumber[];
|
||||||
|
buttonProps: any;
|
||||||
|
buttonChildren: JSX.Element | string;
|
||||||
|
menuDirection: "down" | "up";
|
||||||
|
|
||||||
|
isModeSelectOne?: boolean;
|
||||||
|
keepOpenAfterSelectOne?: boolean;
|
||||||
|
handleSelectedOption?: (option: Option, group: Group) => void;
|
||||||
|
|
||||||
|
isModeToggle?: boolean;
|
||||||
|
toggleStates?: { [optionValue: string]: boolean };
|
||||||
|
disabledStates?: { [optionValue: string]: boolean };
|
||||||
|
tooltips?: { [optionValue: string]: string };
|
||||||
|
handleToggleOption?: (option: Option, group: Group, newValue: boolean) => void;
|
||||||
|
|
||||||
|
optionEndAdornment?: JSX.Element;
|
||||||
|
handleAdornmentClick?: (option: Option, group: Group, event: React.MouseEvent<any>) => void;
|
||||||
|
forceRerender?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
QHierarchyAutoComplete.defaultProps = {
|
||||||
|
menuDirection: "down",
|
||||||
|
showGroupHeaderEvenIfNoSubGroups: false,
|
||||||
|
isModeSelectOne: false,
|
||||||
|
keepOpenAfterSelectOne: false,
|
||||||
|
isModeToggle: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
interface GroupWithOptions
|
||||||
|
{
|
||||||
|
group?: Group;
|
||||||
|
options: Option[];
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/***************************************************************************
|
||||||
|
** a sort of re-implementation of Autocomplete, that can display headers
|
||||||
|
** & children, which may be collapsable (Is that only for toggle mode?)
|
||||||
|
** but which also can have adornments that trigger actions, or be in a
|
||||||
|
** single-click-do-something mode.
|
||||||
|
*
|
||||||
|
** Originally built just for fields exposed on a table query screen, but
|
||||||
|
** then factored out of that for use in bulk-load (where it wasn't based on
|
||||||
|
** exposed joins).
|
||||||
|
***************************************************************************/
|
||||||
|
export default function QHierarchyAutoComplete({idPrefix, heading, placeholder, defaultGroup, showGroupHeaderEvenIfNoSubGroups, optionValuesToHide, buttonProps, buttonChildren, isModeSelectOne, keepOpenAfterSelectOne, isModeToggle, handleSelectedOption, toggleStates, disabledStates, tooltips, handleToggleOption, optionEndAdornment, handleAdornmentClick, menuDirection, forceRerender}: QHierarchyAutoCompleteProps): JSX.Element
|
||||||
|
{
|
||||||
|
const [menuAnchorElement, setMenuAnchorElement] = useState(null);
|
||||||
|
const [searchText, setSearchText] = useState("");
|
||||||
|
const [focusedIndex, setFocusedIndex] = useState(null as number);
|
||||||
|
|
||||||
|
const [optionsByGroup, setOptionsByGroup] = useState([] as GroupWithOptions[]);
|
||||||
|
const [collapsedGroups, setCollapsedGroups] = useState({} as { [groupValue: string | number]: boolean });
|
||||||
|
|
||||||
|
const [lastMouseOverXY, setLastMouseOverXY] = useState({x: 0, y: 0});
|
||||||
|
const [timeOfLastArrow, setTimeOfLastArrow] = useState(0);
|
||||||
|
|
||||||
|
//////////////////
|
||||||
|
// check usages //
|
||||||
|
//////////////////
|
||||||
|
if(isModeSelectOne)
|
||||||
|
{
|
||||||
|
if(!handleSelectedOption)
|
||||||
|
{
|
||||||
|
throw("In QAutoComplete, if isModeSelectOne=true, then a callback for handleSelectedOption must be provided.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if(isModeToggle)
|
||||||
|
{
|
||||||
|
if(!toggleStates)
|
||||||
|
{
|
||||||
|
throw("In QAutoComplete, if isModeToggle=true, then a model for toggleStates must be provided.");
|
||||||
|
}
|
||||||
|
if(!handleToggleOption)
|
||||||
|
{
|
||||||
|
throw("In QAutoComplete, if isModeToggle=true, then a callback for handleToggleOption must be provided.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/////////////////////
|
||||||
|
// init some stuff //
|
||||||
|
/////////////////////
|
||||||
|
if (optionsByGroup.length == 0)
|
||||||
|
{
|
||||||
|
collapsedGroups[defaultGroup.value] = false;
|
||||||
|
|
||||||
|
if (defaultGroup.subGroups?.length > 0)
|
||||||
|
{
|
||||||
|
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
// if we have exposed joins, put the table meta data with its fields, and then all of the join tables & fields too //
|
||||||
|
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
optionsByGroup.push({group: defaultGroup, options: getGroupOptionsAsAlphabeticalArray(defaultGroup)});
|
||||||
|
|
||||||
|
for (let i = 0; i < defaultGroup.subGroups?.length; i++)
|
||||||
|
{
|
||||||
|
const subGroup = defaultGroup.subGroups[i];
|
||||||
|
optionsByGroup.push({group: subGroup, options: getGroupOptionsAsAlphabeticalArray(subGroup)});
|
||||||
|
|
||||||
|
collapsedGroups[subGroup.value] = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
///////////////////////////////////////////////////////////
|
||||||
|
// no exposed joins - just the table (w/o its meta-data) //
|
||||||
|
///////////////////////////////////////////////////////////
|
||||||
|
optionsByGroup.push({options: getGroupOptionsAsAlphabeticalArray(defaultGroup)});
|
||||||
|
}
|
||||||
|
|
||||||
|
setOptionsByGroup(optionsByGroup);
|
||||||
|
setCollapsedGroups(collapsedGroups);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/*******************************************************************************
|
||||||
|
**
|
||||||
|
*******************************************************************************/
|
||||||
|
function getGroupOptionsAsAlphabeticalArray(group: Group): Option[]
|
||||||
|
{
|
||||||
|
const options: Option[] = [];
|
||||||
|
group.options.forEach(option =>
|
||||||
|
{
|
||||||
|
let fullOptionValue = option.value;
|
||||||
|
if(group.value != defaultGroup.value)
|
||||||
|
{
|
||||||
|
fullOptionValue = `${defaultGroup.value}.${option.value}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if(optionValuesToHide && optionValuesToHide.indexOf(fullOptionValue) > -1)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
options.push(option)
|
||||||
|
});
|
||||||
|
options.sort((a, b) => a.label.localeCompare(b.label));
|
||||||
|
return (options);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const optionsByGroupToShow: GroupWithOptions[] = [];
|
||||||
|
let maxOptionIndex = 0;
|
||||||
|
optionsByGroup.forEach((groupWithOptions) =>
|
||||||
|
{
|
||||||
|
let optionsToShowForThisGroup = groupWithOptions.options.filter(doesOptionMatchSearchText);
|
||||||
|
if (optionsToShowForThisGroup.length > 0)
|
||||||
|
{
|
||||||
|
optionsByGroupToShow.push({group: groupWithOptions.group, options: optionsToShowForThisGroup});
|
||||||
|
maxOptionIndex += optionsToShowForThisGroup.length;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
/*******************************************************************************
|
||||||
|
**
|
||||||
|
*******************************************************************************/
|
||||||
|
function doesOptionMatchSearchText(option: Option): boolean
|
||||||
|
{
|
||||||
|
if (searchText == "")
|
||||||
|
{
|
||||||
|
return (true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const columnLabelMinusTable = option.label.replace(/.*: /, "");
|
||||||
|
if (columnLabelMinusTable.toLowerCase().startsWith(searchText.toLowerCase()))
|
||||||
|
{
|
||||||
|
return (true);
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
////////////////////////////////////////////////////////////
|
||||||
|
// try to match word-boundary followed by the filter text //
|
||||||
|
// e.g., "name" would match "First Name" or "Last Name" //
|
||||||
|
////////////////////////////////////////////////////////////
|
||||||
|
const re = new RegExp("\\b" + searchText.toLowerCase());
|
||||||
|
if (columnLabelMinusTable.toLowerCase().match(re))
|
||||||
|
{
|
||||||
|
return (true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (e)
|
||||||
|
{
|
||||||
|
//////////////////////////////////////////////////////////////////////////////////
|
||||||
|
// in case text is an invalid regex... well, at least do a starts-with match... //
|
||||||
|
//////////////////////////////////////////////////////////////////////////////////
|
||||||
|
if (columnLabelMinusTable.toLowerCase().startsWith(searchText.toLowerCase()))
|
||||||
|
{
|
||||||
|
return (true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tableLabel = option.label.replace(/:.*/, "");
|
||||||
|
if (tableLabel)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
////////////////////////////////////////////////////////////
|
||||||
|
// try to match word-boundary followed by the filter text //
|
||||||
|
// e.g., "name" would match "First Name" or "Last Name" //
|
||||||
|
////////////////////////////////////////////////////////////
|
||||||
|
const re = new RegExp("\\b" + searchText.toLowerCase());
|
||||||
|
if (tableLabel.toLowerCase().match(re))
|
||||||
|
{
|
||||||
|
return (true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (e)
|
||||||
|
{
|
||||||
|
//////////////////////////////////////////////////////////////////////////////////
|
||||||
|
// in case text is an invalid regex... well, at least do a starts-with match... //
|
||||||
|
//////////////////////////////////////////////////////////////////////////////////
|
||||||
|
if (tableLabel.toLowerCase().startsWith(searchText.toLowerCase()))
|
||||||
|
{
|
||||||
|
return (true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*******************************************************************************
|
||||||
|
**
|
||||||
|
*******************************************************************************/
|
||||||
|
function openMenu(event: any)
|
||||||
|
{
|
||||||
|
setFocusedIndex(null);
|
||||||
|
setMenuAnchorElement(event.currentTarget);
|
||||||
|
setTimeout(() =>
|
||||||
|
{
|
||||||
|
document.getElementById(`field-list-dropdown-${idPrefix}-textField`).focus();
|
||||||
|
doSetFocusedIndex(0, true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/*******************************************************************************
|
||||||
|
**
|
||||||
|
*******************************************************************************/
|
||||||
|
function closeMenu()
|
||||||
|
{
|
||||||
|
setMenuAnchorElement(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/*******************************************************************************
|
||||||
|
** Event handler for toggling an option in toggle mode
|
||||||
|
*******************************************************************************/
|
||||||
|
function handleOptionToggle(event: React.ChangeEvent<HTMLInputElement>, option: Option, group: Group)
|
||||||
|
{
|
||||||
|
event.stopPropagation();
|
||||||
|
handleToggleOption(option, group, event.target.checked);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/*******************************************************************************
|
||||||
|
** Event handler for toggling a group in toggle mode
|
||||||
|
*******************************************************************************/
|
||||||
|
function handleGroupToggle(event: React.ChangeEvent<HTMLInputElement>, group: Group)
|
||||||
|
{
|
||||||
|
event.stopPropagation();
|
||||||
|
|
||||||
|
const optionsList = [...group.options.values()];
|
||||||
|
for (let i = 0; i < optionsList.length; i++)
|
||||||
|
{
|
||||||
|
const option = optionsList[i];
|
||||||
|
if (doesOptionMatchSearchText(option))
|
||||||
|
{
|
||||||
|
handleToggleOption(option, group, event.target.checked);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/*******************************************************************************
|
||||||
|
**
|
||||||
|
*******************************************************************************/
|
||||||
|
function toggleCollapsedGroup(value: string | number)
|
||||||
|
{
|
||||||
|
collapsedGroups[value] = !collapsedGroups[value];
|
||||||
|
setCollapsedGroups(Object.assign({}, collapsedGroups));
|
||||||
|
}
|
||||||
|
|
||||||
|
/*******************************************************************************
|
||||||
|
**
|
||||||
|
*******************************************************************************/
|
||||||
|
function getShownOptionAndGroupByIndex(targetIndex: number): { option: Option, group: Group }
|
||||||
|
{
|
||||||
|
let index = -1;
|
||||||
|
for (let i = 0; i < optionsByGroupToShow.length; i++)
|
||||||
|
{
|
||||||
|
const groupWithOption = optionsByGroupToShow[i];
|
||||||
|
for (let j = 0; j < groupWithOption.options.length; j++)
|
||||||
|
{
|
||||||
|
index++;
|
||||||
|
|
||||||
|
if (index == targetIndex)
|
||||||
|
{
|
||||||
|
return {option: groupWithOption.options[j], group: groupWithOption.group};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (null);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/*******************************************************************************
|
||||||
|
** event handler for keys presses
|
||||||
|
*******************************************************************************/
|
||||||
|
function keyDown(event: any)
|
||||||
|
{
|
||||||
|
// console.log(`Event key: ${event.key}`);
|
||||||
|
setTimeout(() => document.getElementById(`field-list-dropdown-${idPrefix}-textField`).focus());
|
||||||
|
|
||||||
|
if (isModeSelectOne && event.key == "Enter" && focusedIndex != null)
|
||||||
|
{
|
||||||
|
setTimeout(() =>
|
||||||
|
{
|
||||||
|
event.stopPropagation();
|
||||||
|
|
||||||
|
const {option, group} = getShownOptionAndGroupByIndex(focusedIndex);
|
||||||
|
if (option)
|
||||||
|
{
|
||||||
|
const fullOptionValue = group && group.value != defaultGroup.value ? `${group.value}.${option.value}` : option.value;
|
||||||
|
const isDisabled = disabledStates && disabledStates[fullOptionValue]
|
||||||
|
if(isDisabled)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if(!keepOpenAfterSelectOne)
|
||||||
|
{
|
||||||
|
closeMenu();
|
||||||
|
}
|
||||||
|
|
||||||
|
handleSelectedOption(option, group ?? defaultGroup);
|
||||||
|
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyOffsetMap: { [key: string]: number } = {
|
||||||
|
"End": 10000,
|
||||||
|
"Home": -10000,
|
||||||
|
"ArrowDown": 1,
|
||||||
|
"ArrowUp": -1,
|
||||||
|
"PageDown": 5,
|
||||||
|
"PageUp": -5,
|
||||||
|
};
|
||||||
|
|
||||||
|
const offset = keyOffsetMap[event.key];
|
||||||
|
if (offset)
|
||||||
|
{
|
||||||
|
event.stopPropagation();
|
||||||
|
setTimeOfLastArrow(new Date().getTime());
|
||||||
|
|
||||||
|
if (isModeSelectOne)
|
||||||
|
{
|
||||||
|
let startIndex = focusedIndex;
|
||||||
|
if (offset > 0)
|
||||||
|
{
|
||||||
|
/////////////////
|
||||||
|
// a down move //
|
||||||
|
/////////////////
|
||||||
|
if (startIndex == null)
|
||||||
|
{
|
||||||
|
startIndex = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let goalIndex = startIndex + offset;
|
||||||
|
if (goalIndex > maxOptionIndex - 1)
|
||||||
|
{
|
||||||
|
goalIndex = maxOptionIndex - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
doSetFocusedIndex(goalIndex, true);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
////////////////
|
||||||
|
// an up move //
|
||||||
|
////////////////
|
||||||
|
let goalIndex = startIndex + offset;
|
||||||
|
if (goalIndex < 0)
|
||||||
|
{
|
||||||
|
goalIndex = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
doSetFocusedIndex(goalIndex, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/*******************************************************************************
|
||||||
|
**
|
||||||
|
*******************************************************************************/
|
||||||
|
function doSetFocusedIndex(i: number, tryToScrollIntoView: boolean): void
|
||||||
|
{
|
||||||
|
if (isModeSelectOne)
|
||||||
|
{
|
||||||
|
setFocusedIndex(i);
|
||||||
|
console.log(`Setting index to ${i}`);
|
||||||
|
|
||||||
|
if (tryToScrollIntoView)
|
||||||
|
{
|
||||||
|
const element = document.getElementById(`field-list-dropdown-${idPrefix}-${i}`);
|
||||||
|
element?.scrollIntoView({block: "center"});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/*******************************************************************************
|
||||||
|
**
|
||||||
|
*******************************************************************************/
|
||||||
|
function setFocusedOption(option: Option, group: Group, tryToScrollIntoView: boolean)
|
||||||
|
{
|
||||||
|
let index = -1;
|
||||||
|
for (let i = 0; i < optionsByGroupToShow.length; i++)
|
||||||
|
{
|
||||||
|
const groupWithOption = optionsByGroupToShow[i];
|
||||||
|
for (let j = 0; j < groupWithOption.options.length; j++)
|
||||||
|
{
|
||||||
|
const loopOption = groupWithOption.options[j];
|
||||||
|
index++;
|
||||||
|
|
||||||
|
const groupMatches = (group == null || group.value == groupWithOption.group.value);
|
||||||
|
if (groupMatches && option.value == loopOption.value)
|
||||||
|
{
|
||||||
|
doSetFocusedIndex(index, tryToScrollIntoView);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/*******************************************************************************
|
||||||
|
** event handler for mouse-over the menu
|
||||||
|
*******************************************************************************/
|
||||||
|
function handleMouseOver(event: React.MouseEvent<HTMLAnchorElement> | React.MouseEvent<HTMLDivElement> | React.MouseEvent<HTMLLIElement>, option: Option, group: Group, isDisabled: boolean)
|
||||||
|
{
|
||||||
|
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
// so we're trying to fix the case where, if you put your mouse over an element, but then press up/down arrows, //
|
||||||
|
// where the mouse will become over a different element after the scroll, and the focus will follow the mouse instead of keyboard. //
|
||||||
|
// the last x/y isn't really useful, because the mouse generally isn't left exactly where it was when the mouse-over happened (edge of the element) //
|
||||||
|
// but the keyboard last-arrow time that we capture, that's what's actually being useful in here //
|
||||||
|
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
if (event.clientX == lastMouseOverXY.x && event.clientY == lastMouseOverXY.y)
|
||||||
|
{
|
||||||
|
// console.log("mouse didn't move, so, doesn't count");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().getTime();
|
||||||
|
// console.log(`Compare now [${now}] to last arrow [${timeOfLastArrow}] (diff: [${now - timeOfLastArrow}])`);
|
||||||
|
if (now < timeOfLastArrow + 300)
|
||||||
|
{
|
||||||
|
// console.log("An arrow event happened less than 300 mills ago, so doesn't count.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// console.log("yay, mouse over...");
|
||||||
|
if(isDisabled)
|
||||||
|
{
|
||||||
|
setFocusedIndex(null);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
setFocusedOption(option, group, false);
|
||||||
|
}
|
||||||
|
setLastMouseOverXY({x: event.clientX, y: event.clientY});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/*******************************************************************************
|
||||||
|
** event handler for text input changes
|
||||||
|
*******************************************************************************/
|
||||||
|
function updateSearch(event: React.ChangeEvent<HTMLInputElement>)
|
||||||
|
{
|
||||||
|
setSearchText(event?.target?.value ?? "");
|
||||||
|
doSetFocusedIndex(0, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/*******************************************************************************
|
||||||
|
**
|
||||||
|
*******************************************************************************/
|
||||||
|
function doHandleAdornmentClick(option: Option, group: Group, event: React.MouseEvent<any>)
|
||||||
|
{
|
||||||
|
console.log("In doHandleAdornmentClick");
|
||||||
|
closeMenu();
|
||||||
|
handleAdornmentClick(option, group, event);
|
||||||
|
}
|
||||||
|
|
||||||
|
/////////////////////////////////////////////////////////
|
||||||
|
// compute the group-level toggle state & count values //
|
||||||
|
/////////////////////////////////////////////////////////
|
||||||
|
const groupToggleStates: { [value: string]: boolean } = {};
|
||||||
|
const groupToggleCounts: { [value: string]: number } = {};
|
||||||
|
|
||||||
|
if (isModeToggle)
|
||||||
|
{
|
||||||
|
const {allOn, count} = getGroupToggleState(defaultGroup, true);
|
||||||
|
groupToggleStates[defaultGroup.value] = allOn;
|
||||||
|
groupToggleCounts[defaultGroup.value] = count;
|
||||||
|
|
||||||
|
for (let i = 0; i < defaultGroup.subGroups?.length; i++)
|
||||||
|
{
|
||||||
|
const subGroup = defaultGroup.subGroups[i];
|
||||||
|
const {allOn, count} = getGroupToggleState(subGroup, false);
|
||||||
|
groupToggleStates[subGroup.value] = allOn;
|
||||||
|
groupToggleCounts[subGroup.value] = count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/*******************************************************************************
|
||||||
|
**
|
||||||
|
*******************************************************************************/
|
||||||
|
function getGroupToggleState(group: Group, isMainGroup: boolean): {allOn: boolean, count: number}
|
||||||
|
{
|
||||||
|
const optionsList = [...group.options.values()];
|
||||||
|
let allOn = true;
|
||||||
|
let count = 0;
|
||||||
|
for (let i = 0; i < optionsList.length; i++)
|
||||||
|
{
|
||||||
|
const option = optionsList[i];
|
||||||
|
const name = isMainGroup ? option.value : `${group.value}.${option.value}`;
|
||||||
|
if(!toggleStates[name])
|
||||||
|
{
|
||||||
|
allOn = false;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ({allOn: allOn, count: count});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
let index = -1;
|
||||||
|
const textFieldId = `field-list-dropdown-${idPrefix}-textField`;
|
||||||
|
let listItemPadding = isModeToggle ? "0.125rem" : "0.5rem";
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
// for z-indexes, we set each table header to i+1, then the fields in that table to i (so they go behind it) //
|
||||||
|
// then we increment i by 2 for the next table (so the next header goes above the previous header) //
|
||||||
|
// this fixes a thing where, if one table's name wrapped to 2 lines, then when the next table below it would //
|
||||||
|
// come up, if it was only 1 line, then the second line from the previous one would bleed through. //
|
||||||
|
///////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
let zIndex = 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button onClick={openMenu} {...buttonProps}>
|
||||||
|
{buttonChildren}
|
||||||
|
</Button>
|
||||||
|
<Menu
|
||||||
|
anchorEl={menuAnchorElement}
|
||||||
|
anchorOrigin={{vertical: menuDirection == "down" ? "bottom" : "top", horizontal: "left"}}
|
||||||
|
transformOrigin={{vertical: menuDirection == "down" ? "top" : "bottom", horizontal: "left"}}
|
||||||
|
open={menuAnchorElement != null}
|
||||||
|
onClose={closeMenu}
|
||||||
|
onKeyDown={keyDown} // this is added here so arrow-key-up/down events don't make the whole menu become "focused" (blue outline). it works.
|
||||||
|
keepMounted
|
||||||
|
>
|
||||||
|
<Box width={isModeToggle ? "305px" : "265px"} borderRadius={2} className={`fieldListMenuBody fieldListMenuBody-${idPrefix}`}>
|
||||||
|
{
|
||||||
|
heading &&
|
||||||
|
<Box px={1} py={0.5} fontWeight={"700"}>
|
||||||
|
{heading}
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
<Box p={1} pt={0.5}>
|
||||||
|
<TextField id={textFieldId} variant="outlined" placeholder={placeholder ?? "Search Fields"} fullWidth value={searchText} onChange={updateSearch} onKeyDown={keyDown} inputProps={{sx: {pr: "2rem"}}} />
|
||||||
|
{
|
||||||
|
searchText != "" && <IconButton sx={{position: "absolute", right: "0.5rem", top: "0.5rem"}} onClick={() =>
|
||||||
|
{
|
||||||
|
updateSearch(null);
|
||||||
|
document.getElementById(textFieldId).focus();
|
||||||
|
}}><Icon fontSize="small">close</Icon></IconButton>
|
||||||
|
}
|
||||||
|
</Box>
|
||||||
|
<Box maxHeight={"445px"} minHeight={"445px"} overflow="auto" mr={"-0.5rem"} sx={{scrollbarGutter: "stable"}}>
|
||||||
|
<List sx={{px: "0.5rem", cursor: "default"}}>
|
||||||
|
{
|
||||||
|
optionsByGroupToShow.map((groupWithOptions) =>
|
||||||
|
{
|
||||||
|
let headerContents = null;
|
||||||
|
const headerGroup = groupWithOptions.group || defaultGroup;
|
||||||
|
if (groupWithOptions.group || showGroupHeaderEvenIfNoSubGroups)
|
||||||
|
{
|
||||||
|
headerContents = (<b>{headerGroup.label}</b>);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isModeToggle)
|
||||||
|
{
|
||||||
|
headerContents = (<FormControlLabel
|
||||||
|
sx={{display: "flex", alignItems: "flex-start", "& .MuiFormControlLabel-label": {lineHeight: "1.4", fontWeight: "500 !important"}}}
|
||||||
|
control={<Switch
|
||||||
|
size="small"
|
||||||
|
sx={{top: "1px"}}
|
||||||
|
checked={toggleStates[headerGroup.value]}
|
||||||
|
onChange={(event) => handleGroupToggle(event, headerGroup)}
|
||||||
|
/>}
|
||||||
|
label={<span style={{marginTop: "0.25rem", display: "inline-block"}}><b>{headerGroup.label} Fields</b> <span style={{fontWeight: 400}}>({groupToggleCounts[headerGroup.value]})</span></span>} />);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isModeToggle)
|
||||||
|
{
|
||||||
|
headerContents = (
|
||||||
|
<>
|
||||||
|
<IconButton
|
||||||
|
onClick={() => toggleCollapsedGroup(headerGroup.value)}
|
||||||
|
sx={{justifyContent: "flex-start", fontSize: "0.875rem", pt: 0.5, pb: 0, mr: "0.25rem"}}
|
||||||
|
disableRipple={true}
|
||||||
|
>
|
||||||
|
<Icon sx={{fontSize: "1.5rem !important", position: "relative", top: "2px"}}>{collapsedGroups[headerGroup.value] ? "expand_less" : "expand_more"}</Icon>
|
||||||
|
</IconButton>
|
||||||
|
{headerContents}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let marginLeft = "unset";
|
||||||
|
if (isModeToggle)
|
||||||
|
{
|
||||||
|
marginLeft = "-1rem";
|
||||||
|
}
|
||||||
|
|
||||||
|
zIndex += 2;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment key={groupWithOptions.group?.value ?? "theGroup"}>
|
||||||
|
<>
|
||||||
|
{headerContents && <ListItem sx={{position: "sticky", top: -1, zIndex: zIndex + 1, padding: listItemPadding, ml: marginLeft, display: "flex", alignItems: "flex-start", backgroundImage: "linear-gradient(to bottom, rgba(255,255,255,1), rgba(255,255,255,1) 90%, rgba(255,255,255,0))"}}>{headerContents}</ListItem>}
|
||||||
|
{
|
||||||
|
groupWithOptions.options.map((option) =>
|
||||||
|
{
|
||||||
|
index++;
|
||||||
|
const key = `${groupWithOptions?.group?.value}-${option.value}`;
|
||||||
|
|
||||||
|
let label: JSX.Element | string = option.label;
|
||||||
|
const fullOptionValue = groupWithOptions.group && groupWithOptions.group.value != defaultGroup.value ? `${groupWithOptions.group.value}.${option.value}` : option.value;
|
||||||
|
const isDisabled = disabledStates && disabledStates[fullOptionValue]
|
||||||
|
|
||||||
|
if (collapsedGroups[headerGroup.value])
|
||||||
|
{
|
||||||
|
return (<React.Fragment key={key} />);
|
||||||
|
}
|
||||||
|
|
||||||
|
let style = {};
|
||||||
|
if (index == focusedIndex)
|
||||||
|
{
|
||||||
|
style = {backgroundColor: "#EFEFEF"};
|
||||||
|
}
|
||||||
|
|
||||||
|
const onClick: ListItemProps = {};
|
||||||
|
if (isModeSelectOne)
|
||||||
|
{
|
||||||
|
onClick.onClick = () =>
|
||||||
|
{
|
||||||
|
if(isDisabled)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if(!keepOpenAfterSelectOne)
|
||||||
|
{
|
||||||
|
closeMenu();
|
||||||
|
}
|
||||||
|
handleSelectedOption(option, groupWithOptions.group ?? defaultGroup);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (optionEndAdornment)
|
||||||
|
{
|
||||||
|
label = <Box width="100%" display="inline-flex" justifyContent="space-between">
|
||||||
|
{label}
|
||||||
|
<Box onClick={(event) => handleAdornmentClick(option, groupWithOptions.group, event)}>
|
||||||
|
{optionEndAdornment}
|
||||||
|
</Box>
|
||||||
|
</Box>;
|
||||||
|
}
|
||||||
|
|
||||||
|
let contents = <>{label}</>;
|
||||||
|
let paddingLeft = "0.5rem";
|
||||||
|
|
||||||
|
if (isModeToggle)
|
||||||
|
{
|
||||||
|
contents = (<FormControlLabel
|
||||||
|
sx={{display: "flex", alignItems: "flex-start", "& .MuiFormControlLabel-label": {lineHeight: "1.4", color: "#606060", fontWeight: "500 !important"}}}
|
||||||
|
control={<Switch
|
||||||
|
size="small"
|
||||||
|
sx={{top: "-3px"}}
|
||||||
|
checked={toggleStates[fullOptionValue]}
|
||||||
|
onChange={(event) => handleOptionToggle(event, option, groupWithOptions.group)}
|
||||||
|
/>}
|
||||||
|
label={label} />);
|
||||||
|
paddingLeft = "2.5rem";
|
||||||
|
}
|
||||||
|
|
||||||
|
const listItem = <ListItem
|
||||||
|
key={key}
|
||||||
|
id={`field-list-dropdown-${idPrefix}-${index}`}
|
||||||
|
sx={{color: isDisabled ? "#C0C0C0" : "#757575", p: 1, borderRadius: ".5rem", padding: listItemPadding, pl: paddingLeft, scrollMarginTop: "3rem", zIndex: zIndex, background: "#FFFFFF", ...style}}
|
||||||
|
onMouseOver={(event) =>
|
||||||
|
{
|
||||||
|
handleMouseOver(event, option, groupWithOptions.group, isDisabled)
|
||||||
|
}}
|
||||||
|
{...onClick}
|
||||||
|
>{contents}</ListItem>;
|
||||||
|
|
||||||
|
if(tooltips[fullOptionValue])
|
||||||
|
{
|
||||||
|
return <Tooltip key={key} title={tooltips[fullOptionValue]} placement="right" enterDelay={500}>{listItem}</Tooltip>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
|
||||||
|
return listItem
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</>
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
{
|
||||||
|
index == -1 && <ListItem sx={{p: "0.5rem"}}><i>No options found.</i></ListItem>
|
||||||
|
}
|
||||||
|
</List>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Menu>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
790
src/qqq/components/misc/SavedBulkLoadProfiles.tsx
Normal file
790
src/qqq/components/misc/SavedBulkLoadProfiles.tsx
Normal file
@ -0,0 +1,790 @@
|
|||||||
|
/*
|
||||||
|
* QQQ - Low-code Application Framework for Engineers.
|
||||||
|
* Copyright (C) 2021-2022. Kingsrook, LLC
|
||||||
|
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||||
|
* contact@kingsrook.com
|
||||||
|
* https://github.com/Kingsrook/
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as
|
||||||
|
* published by the Free Software Foundation, either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {QController} from "@kingsrook/qqq-frontend-core/lib/controllers/QController";
|
||||||
|
import {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstance";
|
||||||
|
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
|
||||||
|
import {QJobComplete} from "@kingsrook/qqq-frontend-core/lib/model/processes/QJobComplete";
|
||||||
|
import {QJobError} from "@kingsrook/qqq-frontend-core/lib/model/processes/QJobError";
|
||||||
|
import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord";
|
||||||
|
import {Alert, Button} from "@mui/material";
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import Dialog from "@mui/material/Dialog";
|
||||||
|
import DialogActions from "@mui/material/DialogActions";
|
||||||
|
import DialogContent from "@mui/material/DialogContent";
|
||||||
|
import DialogTitle from "@mui/material/DialogTitle";
|
||||||
|
import Divider from "@mui/material/Divider";
|
||||||
|
import Icon from "@mui/material/Icon";
|
||||||
|
import ListItemIcon from "@mui/material/ListItemIcon";
|
||||||
|
import Menu from "@mui/material/Menu";
|
||||||
|
import MenuItem from "@mui/material/MenuItem";
|
||||||
|
import TextField from "@mui/material/TextField";
|
||||||
|
import Tooltip from "@mui/material/Tooltip";
|
||||||
|
import {TooltipProps} from "@mui/material/Tooltip/Tooltip";
|
||||||
|
import FormData from "form-data";
|
||||||
|
import QContext from "QContext";
|
||||||
|
import colors from "qqq/assets/theme/base/colors";
|
||||||
|
import {QCancelButton, QDeleteButton, QSaveButton} from "qqq/components/buttons/DefaultButtons";
|
||||||
|
import {BulkLoadMapping, BulkLoadTableStructure, FileDescription} from "qqq/models/processes/BulkLoadModels";
|
||||||
|
import Client from "qqq/utils/qqq/Client";
|
||||||
|
import {SavedBulkLoadProfileUtils} from "qqq/utils/qqq/SavedBulkLoadProfileUtils";
|
||||||
|
import React, {useContext, useEffect, useRef, useState} from "react";
|
||||||
|
import {useLocation} from "react-router-dom";
|
||||||
|
|
||||||
|
interface Props
|
||||||
|
{
|
||||||
|
metaData: QInstance,
|
||||||
|
tableMetaData: QTableMetaData,
|
||||||
|
tableStructure: BulkLoadTableStructure,
|
||||||
|
currentSavedBulkLoadProfileRecord: QRecord,
|
||||||
|
currentMapping: BulkLoadMapping,
|
||||||
|
bulkLoadProfileOnChangeCallback?: (record: QRecord | null) => void,
|
||||||
|
allowSelectingProfile?: boolean,
|
||||||
|
fileDescription?: FileDescription,
|
||||||
|
bulkLoadProfileResetToSuggestedMappingCallback?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
SavedBulkLoadProfiles.defaultProps = {
|
||||||
|
allowSelectingProfile: true
|
||||||
|
};
|
||||||
|
|
||||||
|
const qController = Client.getInstance();
|
||||||
|
|
||||||
|
/***************************************************************************
|
||||||
|
** menu-button, text elements, and modal(s) that let you work with saved
|
||||||
|
** bulk-load profiles.
|
||||||
|
***************************************************************************/
|
||||||
|
function SavedBulkLoadProfiles({metaData, tableMetaData, tableStructure, currentSavedBulkLoadProfileRecord, bulkLoadProfileOnChangeCallback, currentMapping, allowSelectingProfile, fileDescription, bulkLoadProfileResetToSuggestedMappingCallback}: Props): JSX.Element
|
||||||
|
{
|
||||||
|
const [yourSavedBulkLoadProfiles, setYourSavedBulkLoadProfiles] = useState([] as QRecord[]);
|
||||||
|
const [bulkLoadProfilesSharedWithYou, setBulkLoadProfilesSharedWithYou] = useState([] as QRecord[]);
|
||||||
|
const [savedBulkLoadProfilesMenu, setSavedBulkLoadProfilesMenu] = useState(null);
|
||||||
|
const [savedBulkLoadProfilesHaveLoaded, setSavedBulkLoadProfilesHaveLoaded] = useState(false);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const [savePopupOpen, setSavePopupOpen] = useState(false);
|
||||||
|
const [isSaveAsAction, setIsSaveAsAction] = useState(false);
|
||||||
|
const [isRenameAction, setIsRenameAction] = useState(false);
|
||||||
|
const [isDeleteAction, setIsDeleteAction] = useState(false);
|
||||||
|
const [savedBulkLoadProfileNameInputValue, setSavedBulkLoadProfileNameInputValue] = useState(null as string);
|
||||||
|
const [popupAlertContent, setPopupAlertContent] = useState("");
|
||||||
|
|
||||||
|
const [savedSuccessMessage, setSavedSuccessMessage] = useState(null as string);
|
||||||
|
const [savedFailedMessage, setSavedFailedMessage] = useState(null as string);
|
||||||
|
|
||||||
|
const anchorRef = useRef<HTMLDivElement>(null);
|
||||||
|
const location = useLocation();
|
||||||
|
const [saveOptionsOpen, setSaveOptionsOpen] = useState(false);
|
||||||
|
|
||||||
|
const SAVE_OPTION = "Save...";
|
||||||
|
const DUPLICATE_OPTION = "Duplicate...";
|
||||||
|
const RENAME_OPTION = "Rename...";
|
||||||
|
const DELETE_OPTION = "Delete...";
|
||||||
|
const CLEAR_OPTION = "New Profile";
|
||||||
|
const RESET_TO_SUGGESTION = "Reset to Suggested Mapping";
|
||||||
|
|
||||||
|
const {accentColor, accentColorLight, userId: currentUserId} = useContext(QContext);
|
||||||
|
|
||||||
|
const openSavedBulkLoadProfilesMenu = (event: any) => setSavedBulkLoadProfilesMenu(event.currentTarget);
|
||||||
|
const closeSavedBulkLoadProfilesMenu = () => setSavedBulkLoadProfilesMenu(null);
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////
|
||||||
|
// load records on first run (if user is allowed to select a profile) //
|
||||||
|
////////////////////////////////////////////////////////////////////////
|
||||||
|
useEffect(() =>
|
||||||
|
{
|
||||||
|
if (allowSelectingProfile)
|
||||||
|
{
|
||||||
|
loadSavedBulkLoadProfiles()
|
||||||
|
.then(() =>
|
||||||
|
{
|
||||||
|
setSavedBulkLoadProfilesHaveLoaded(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
|
||||||
|
const baseBulkLoadMapping: BulkLoadMapping = currentSavedBulkLoadProfileRecord ? BulkLoadMapping.fromSavedProfileRecord(tableStructure, currentSavedBulkLoadProfileRecord) : new BulkLoadMapping(tableStructure);
|
||||||
|
const bulkLoadProfileDiffs: any[] = SavedBulkLoadProfileUtils.diffBulkLoadMappings(tableStructure, fileDescription, baseBulkLoadMapping, currentMapping);
|
||||||
|
let bulkLoadProfileIsModified = false;
|
||||||
|
if (bulkLoadProfileDiffs.length > 0)
|
||||||
|
{
|
||||||
|
bulkLoadProfileIsModified = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*******************************************************************************
|
||||||
|
** make request to load all saved profiles from backend
|
||||||
|
*******************************************************************************/
|
||||||
|
async function loadSavedBulkLoadProfiles()
|
||||||
|
{
|
||||||
|
if (!tableMetaData)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("tableName", tableMetaData.name);
|
||||||
|
|
||||||
|
const savedBulkLoadProfiles = await makeSavedBulkLoadProfileRequest("querySavedBulkLoadProfile", formData);
|
||||||
|
const yourSavedBulkLoadProfiles: QRecord[] = [];
|
||||||
|
const bulkLoadProfilesSharedWithYou: QRecord[] = [];
|
||||||
|
for (let i = 0; i < savedBulkLoadProfiles.length; i++)
|
||||||
|
{
|
||||||
|
const record = savedBulkLoadProfiles[i];
|
||||||
|
if (record.values.get("userId") == currentUserId)
|
||||||
|
{
|
||||||
|
yourSavedBulkLoadProfiles.push(record);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
bulkLoadProfilesSharedWithYou.push(record);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setYourSavedBulkLoadProfiles(yourSavedBulkLoadProfiles);
|
||||||
|
setBulkLoadProfilesSharedWithYou(bulkLoadProfilesSharedWithYou);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/*******************************************************************************
|
||||||
|
** fired when a saved record is clicked from the dropdown
|
||||||
|
*******************************************************************************/
|
||||||
|
const handleSavedBulkLoadProfileRecordOnClick = async (record: QRecord) =>
|
||||||
|
{
|
||||||
|
setSavePopupOpen(false);
|
||||||
|
closeSavedBulkLoadProfilesMenu();
|
||||||
|
|
||||||
|
if (bulkLoadProfileOnChangeCallback)
|
||||||
|
{
|
||||||
|
bulkLoadProfileOnChangeCallback(record);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
/*******************************************************************************
|
||||||
|
** fired when a save option is selected from the save... button/dropdown combo
|
||||||
|
*******************************************************************************/
|
||||||
|
const handleDropdownOptionClick = (optionName: string) =>
|
||||||
|
{
|
||||||
|
setSaveOptionsOpen(false);
|
||||||
|
setPopupAlertContent("");
|
||||||
|
closeSavedBulkLoadProfilesMenu();
|
||||||
|
setSavePopupOpen(true);
|
||||||
|
setIsSaveAsAction(false);
|
||||||
|
setIsRenameAction(false);
|
||||||
|
setIsDeleteAction(false);
|
||||||
|
|
||||||
|
switch (optionName)
|
||||||
|
{
|
||||||
|
case SAVE_OPTION:
|
||||||
|
if (currentSavedBulkLoadProfileRecord == null)
|
||||||
|
{
|
||||||
|
setSavedBulkLoadProfileNameInputValue("");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case DUPLICATE_OPTION:
|
||||||
|
setSavedBulkLoadProfileNameInputValue("");
|
||||||
|
setIsSaveAsAction(true);
|
||||||
|
break;
|
||||||
|
case CLEAR_OPTION:
|
||||||
|
setSavePopupOpen(false);
|
||||||
|
if (bulkLoadProfileOnChangeCallback)
|
||||||
|
{
|
||||||
|
bulkLoadProfileOnChangeCallback(null);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case RESET_TO_SUGGESTION:
|
||||||
|
setSavePopupOpen(false);
|
||||||
|
if(bulkLoadProfileResetToSuggestedMappingCallback)
|
||||||
|
{
|
||||||
|
bulkLoadProfileResetToSuggestedMappingCallback();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case RENAME_OPTION:
|
||||||
|
if (currentSavedBulkLoadProfileRecord != null)
|
||||||
|
{
|
||||||
|
setSavedBulkLoadProfileNameInputValue(currentSavedBulkLoadProfileRecord.values.get("label"));
|
||||||
|
}
|
||||||
|
setIsRenameAction(true);
|
||||||
|
break;
|
||||||
|
case DELETE_OPTION:
|
||||||
|
setIsDeleteAction(true);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
/*******************************************************************************
|
||||||
|
** fired when save or delete button saved on confirmation dialogs
|
||||||
|
*******************************************************************************/
|
||||||
|
async function handleDialogButtonOnClick()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
setPopupAlertContent("");
|
||||||
|
setIsSubmitting(true);
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
if (isDeleteAction)
|
||||||
|
{
|
||||||
|
formData.append("id", currentSavedBulkLoadProfileRecord.values.get("id"));
|
||||||
|
await makeSavedBulkLoadProfileRequest("deleteSavedBulkLoadProfile", formData);
|
||||||
|
|
||||||
|
setSavePopupOpen(false);
|
||||||
|
setSaveOptionsOpen(false);
|
||||||
|
|
||||||
|
await (async () =>
|
||||||
|
{
|
||||||
|
handleDropdownOptionClick(CLEAR_OPTION);
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
formData.append("tableName", tableMetaData.name);
|
||||||
|
|
||||||
|
/////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
// convert the BulkLoadMapping object to a BulkLoadProfile - the thing that gets saved //
|
||||||
|
/////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
const bulkLoadProfile = currentMapping.toProfile();
|
||||||
|
const mappingJson = JSON.stringify(bulkLoadProfile.profile);
|
||||||
|
formData.append("mappingJson", mappingJson);
|
||||||
|
|
||||||
|
if (isSaveAsAction || isRenameAction || currentSavedBulkLoadProfileRecord == null)
|
||||||
|
{
|
||||||
|
formData.append("label", savedBulkLoadProfileNameInputValue);
|
||||||
|
if (currentSavedBulkLoadProfileRecord != null && isRenameAction)
|
||||||
|
{
|
||||||
|
formData.append("id", currentSavedBulkLoadProfileRecord.values.get("id"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
formData.append("id", currentSavedBulkLoadProfileRecord.values.get("id"));
|
||||||
|
formData.append("label", currentSavedBulkLoadProfileRecord?.values.get("label"));
|
||||||
|
}
|
||||||
|
const recordList = await makeSavedBulkLoadProfileRequest("storeSavedBulkLoadProfile", formData);
|
||||||
|
await (async () =>
|
||||||
|
{
|
||||||
|
if (recordList && recordList.length > 0)
|
||||||
|
{
|
||||||
|
setSavedBulkLoadProfilesHaveLoaded(false);
|
||||||
|
setSavedSuccessMessage("Profile Saved.");
|
||||||
|
setTimeout(() => setSavedSuccessMessage(null), 2500);
|
||||||
|
|
||||||
|
if (allowSelectingProfile)
|
||||||
|
{
|
||||||
|
loadSavedBulkLoadProfiles();
|
||||||
|
handleSavedBulkLoadProfileRecordOnClick(recordList[0]);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (bulkLoadProfileOnChangeCallback)
|
||||||
|
{
|
||||||
|
bulkLoadProfileOnChangeCallback(recordList[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
setSavePopupOpen(false);
|
||||||
|
setSaveOptionsOpen(false);
|
||||||
|
}
|
||||||
|
catch (e: any)
|
||||||
|
{
|
||||||
|
let message = JSON.stringify(e);
|
||||||
|
if (typeof e == "string")
|
||||||
|
{
|
||||||
|
message = e;
|
||||||
|
}
|
||||||
|
else if (typeof e == "object" && e.message)
|
||||||
|
{
|
||||||
|
message = e.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPopupAlertContent(message);
|
||||||
|
console.log(`Setting error: ${message}`);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/*******************************************************************************
|
||||||
|
** stores the current dialog input text to state
|
||||||
|
*******************************************************************************/
|
||||||
|
const handleSaveDialogInputChange = (event: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>) =>
|
||||||
|
{
|
||||||
|
setSavedBulkLoadProfileNameInputValue(event.target.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
/*******************************************************************************
|
||||||
|
** closes current dialog
|
||||||
|
*******************************************************************************/
|
||||||
|
const handleSavePopupClose = () =>
|
||||||
|
{
|
||||||
|
setSavePopupOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
/*******************************************************************************
|
||||||
|
** make a request to the backend for various savedBulkLoadProfile processes
|
||||||
|
*******************************************************************************/
|
||||||
|
async function makeSavedBulkLoadProfileRequest(processName: string, formData: FormData): Promise<QRecord[]>
|
||||||
|
{
|
||||||
|
/////////////////////////
|
||||||
|
// fetch saved records //
|
||||||
|
/////////////////////////
|
||||||
|
let savedBulkLoadProfiles = [] as QRecord[];
|
||||||
|
try
|
||||||
|
{
|
||||||
|
//////////////////////////////////////////////////////////////////
|
||||||
|
// we don't want this job to go async, so, pass a large timeout //
|
||||||
|
//////////////////////////////////////////////////////////////////
|
||||||
|
formData.append(QController.STEP_TIMEOUT_MILLIS_PARAM_NAME, 60 * 1000);
|
||||||
|
const processResult = await qController.processInit(processName, formData, qController.defaultMultipartFormDataHeaders());
|
||||||
|
if (processResult instanceof QJobError)
|
||||||
|
{
|
||||||
|
const jobError = processResult as QJobError;
|
||||||
|
throw (jobError.error);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
const result = processResult as QJobComplete;
|
||||||
|
if (result.values.savedBulkLoadProfileList)
|
||||||
|
{
|
||||||
|
for (let i = 0; i < result.values.savedBulkLoadProfileList.length; i++)
|
||||||
|
{
|
||||||
|
const qRecord = new QRecord(result.values.savedBulkLoadProfileList[i]);
|
||||||
|
savedBulkLoadProfiles.push(qRecord);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (e)
|
||||||
|
{
|
||||||
|
throw (e);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (savedBulkLoadProfiles);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasStorePermission = metaData?.processes.has("storeSavedBulkLoadProfile");
|
||||||
|
const hasDeletePermission = metaData?.processes.has("deleteSavedBulkLoadProfile");
|
||||||
|
const hasQueryPermission = metaData?.processes.has("querySavedBulkLoadProfile");
|
||||||
|
|
||||||
|
const tooltipMaxWidth = (maxWidth: string) =>
|
||||||
|
{
|
||||||
|
return ({
|
||||||
|
slotProps: {
|
||||||
|
tooltip: {
|
||||||
|
sx: {
|
||||||
|
maxWidth: maxWidth
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const menuTooltipAttribs = {...tooltipMaxWidth("250px"), placement: "left", enterDelay: 1000} as TooltipProps;
|
||||||
|
|
||||||
|
let disabledBecauseNotOwner = false;
|
||||||
|
let notOwnerTooltipText = null;
|
||||||
|
if (currentSavedBulkLoadProfileRecord && currentSavedBulkLoadProfileRecord.values.get("userId") != currentUserId)
|
||||||
|
{
|
||||||
|
disabledBecauseNotOwner = true;
|
||||||
|
notOwnerTooltipText = "You may not save changes to this bulk load profile, because you are not its owner.";
|
||||||
|
}
|
||||||
|
|
||||||
|
const menuWidth = "300px";
|
||||||
|
const renderSavedBulkLoadProfilesMenu = tableMetaData && (
|
||||||
|
<Menu
|
||||||
|
anchorEl={savedBulkLoadProfilesMenu}
|
||||||
|
anchorOrigin={{vertical: "bottom", horizontal: "left",}}
|
||||||
|
transformOrigin={{vertical: "top", horizontal: "left",}}
|
||||||
|
open={Boolean(savedBulkLoadProfilesMenu)}
|
||||||
|
onClose={closeSavedBulkLoadProfilesMenu}
|
||||||
|
keepMounted
|
||||||
|
PaperProps={{style: {maxHeight: "calc(100vh - 200px)", minWidth: menuWidth}}}
|
||||||
|
>
|
||||||
|
{
|
||||||
|
<MenuItem sx={{width: menuWidth}} disabled style={{opacity: "initial"}}><b>Bulk Load Profile Actions</b></MenuItem>
|
||||||
|
}
|
||||||
|
{
|
||||||
|
!allowSelectingProfile &&
|
||||||
|
<MenuItem sx={{width: menuWidth}} disabled style={{opacity: "initial", whiteSpace: "wrap", display: "block"}}>
|
||||||
|
{
|
||||||
|
currentSavedBulkLoadProfileRecord ?
|
||||||
|
<span>You are using the bulk load profile:<br /><b style={{paddingLeft: "1rem"}}>{currentSavedBulkLoadProfileRecord.values.get("label")}</b>.<br /><br />You can manage this profile on this screen.</span>
|
||||||
|
: <span>You are not using a saved bulk load profile.<br /><br />You can save your profile on this screen.</span>
|
||||||
|
}
|
||||||
|
</MenuItem>
|
||||||
|
}
|
||||||
|
{
|
||||||
|
!allowSelectingProfile && <Divider />
|
||||||
|
}
|
||||||
|
{
|
||||||
|
hasStorePermission &&
|
||||||
|
<Tooltip {...menuTooltipAttribs} title={notOwnerTooltipText ?? <>Save your current mapping, for quick re-use at a later time.<br /><br />You will be prompted to enter a name if you choose this option.</>}>
|
||||||
|
<span>
|
||||||
|
<MenuItem disabled={disabledBecauseNotOwner} onClick={() => handleDropdownOptionClick(SAVE_OPTION)}>
|
||||||
|
<ListItemIcon><Icon>save</Icon></ListItemIcon>
|
||||||
|
{currentSavedBulkLoadProfileRecord ? "Save..." : "Save As..."}
|
||||||
|
</MenuItem>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
{
|
||||||
|
hasStorePermission && currentSavedBulkLoadProfileRecord != null &&
|
||||||
|
<Tooltip {...menuTooltipAttribs} title={notOwnerTooltipText ?? "Change the name for this saved bulk load profile."}>
|
||||||
|
<span>
|
||||||
|
<MenuItem disabled={currentSavedBulkLoadProfileRecord === null || disabledBecauseNotOwner} onClick={() => handleDropdownOptionClick(RENAME_OPTION)}>
|
||||||
|
<ListItemIcon><Icon>edit</Icon></ListItemIcon>
|
||||||
|
Rename...
|
||||||
|
</MenuItem>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
{
|
||||||
|
hasStorePermission && currentSavedBulkLoadProfileRecord != null &&
|
||||||
|
<Tooltip {...menuTooltipAttribs} title="Save a new copy this bulk load profile, with a different name, separate from the original.">
|
||||||
|
<span>
|
||||||
|
<MenuItem disabled={currentSavedBulkLoadProfileRecord === null} onClick={() => handleDropdownOptionClick(DUPLICATE_OPTION)}>
|
||||||
|
<ListItemIcon><Icon>content_copy</Icon></ListItemIcon>
|
||||||
|
Save As...
|
||||||
|
</MenuItem>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
{
|
||||||
|
hasDeletePermission && currentSavedBulkLoadProfileRecord != null &&
|
||||||
|
<Tooltip {...menuTooltipAttribs} title={notOwnerTooltipText ?? "Delete this saved bulk load profile."}>
|
||||||
|
<span>
|
||||||
|
<MenuItem disabled={currentSavedBulkLoadProfileRecord === null || disabledBecauseNotOwner} onClick={() => handleDropdownOptionClick(DELETE_OPTION)}>
|
||||||
|
<ListItemIcon><Icon>delete</Icon></ListItemIcon>
|
||||||
|
Delete...
|
||||||
|
</MenuItem>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
{
|
||||||
|
allowSelectingProfile &&
|
||||||
|
<Tooltip {...menuTooltipAttribs} title="Create a new blank bulk load profile for this table, removing all mappings.">
|
||||||
|
<span>
|
||||||
|
<MenuItem onClick={() => handleDropdownOptionClick(CLEAR_OPTION)}>
|
||||||
|
<ListItemIcon><Icon>monitor</Icon></ListItemIcon>
|
||||||
|
New Bulk Load Profile
|
||||||
|
</MenuItem>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
{
|
||||||
|
allowSelectingProfile &&
|
||||||
|
<Box>
|
||||||
|
{
|
||||||
|
<Divider />
|
||||||
|
}
|
||||||
|
<MenuItem disabled style={{"opacity": "initial"}}><b>Your Saved Bulk Load Profiles</b></MenuItem>
|
||||||
|
{
|
||||||
|
yourSavedBulkLoadProfiles && yourSavedBulkLoadProfiles.length > 0 ? (
|
||||||
|
yourSavedBulkLoadProfiles.map((record: QRecord, index: number) =>
|
||||||
|
<MenuItem sx={{paddingLeft: "50px"}} key={`savedFiler-${index}`} onClick={() => handleSavedBulkLoadProfileRecordOnClick(record)}>
|
||||||
|
{record.values.get("label")}
|
||||||
|
</MenuItem>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<MenuItem disabled sx={{opacity: "1 !important"}}>
|
||||||
|
<i>You do not have any saved bulk load profiles for this table.</i>
|
||||||
|
</MenuItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
<MenuItem disabled style={{"opacity": "initial"}}><b>Bulk Load Profiles Shared with you</b></MenuItem>
|
||||||
|
{
|
||||||
|
bulkLoadProfilesSharedWithYou && bulkLoadProfilesSharedWithYou.length > 0 ? (
|
||||||
|
bulkLoadProfilesSharedWithYou.map((record: QRecord, index: number) =>
|
||||||
|
<MenuItem sx={{paddingLeft: "50px"}} key={`savedFiler-${index}`} onClick={() => handleSavedBulkLoadProfileRecordOnClick(record)}>
|
||||||
|
{record.values.get("label")}
|
||||||
|
</MenuItem>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<MenuItem disabled sx={{opacity: "1 !important"}}>
|
||||||
|
<i>You do not have any bulk load profiles shared with you for this table.</i>
|
||||||
|
</MenuItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
</Menu>
|
||||||
|
);
|
||||||
|
|
||||||
|
let buttonText = "Saved Bulk Load Profiles";
|
||||||
|
let buttonBackground = "none";
|
||||||
|
let buttonBorder = colors.grayLines.main;
|
||||||
|
let buttonColor = colors.gray.main;
|
||||||
|
|
||||||
|
if (currentSavedBulkLoadProfileRecord)
|
||||||
|
{
|
||||||
|
if (bulkLoadProfileIsModified)
|
||||||
|
{
|
||||||
|
buttonBackground = accentColorLight;
|
||||||
|
buttonBorder = buttonBackground;
|
||||||
|
buttonColor = accentColor;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
buttonBackground = accentColor;
|
||||||
|
buttonBorder = buttonBackground;
|
||||||
|
buttonColor = "#FFFFFF";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const buttonStyles = {
|
||||||
|
border: `1px solid ${buttonBorder}`,
|
||||||
|
backgroundColor: buttonBackground,
|
||||||
|
color: buttonColor,
|
||||||
|
"&:focus:not(:hover)": {
|
||||||
|
color: buttonColor,
|
||||||
|
backgroundColor: buttonBackground,
|
||||||
|
},
|
||||||
|
"&:hover": {
|
||||||
|
color: buttonColor,
|
||||||
|
backgroundColor: buttonBackground,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/*******************************************************************************
|
||||||
|
**
|
||||||
|
*******************************************************************************/
|
||||||
|
function isSaveButtonDisabled(): boolean
|
||||||
|
{
|
||||||
|
if (isSubmitting)
|
||||||
|
{
|
||||||
|
return (true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const haveInputText = (savedBulkLoadProfileNameInputValue != null && savedBulkLoadProfileNameInputValue.trim() != "");
|
||||||
|
|
||||||
|
if (isSaveAsAction || isRenameAction || currentSavedBulkLoadProfileRecord == null)
|
||||||
|
{
|
||||||
|
if (!haveInputText)
|
||||||
|
{
|
||||||
|
return (true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (false);
|
||||||
|
}
|
||||||
|
|
||||||
|
const linkButtonStyle = {
|
||||||
|
minWidth: "unset",
|
||||||
|
textTransform: "none",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
fontWeight: "500",
|
||||||
|
padding: "0.5rem"
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
hasQueryPermission && tableMetaData ? (
|
||||||
|
<>
|
||||||
|
<Box order="1" mr={"0.5rem"}>
|
||||||
|
<Button
|
||||||
|
onClick={openSavedBulkLoadProfilesMenu}
|
||||||
|
sx={{
|
||||||
|
borderRadius: "0.75rem",
|
||||||
|
textTransform: "none",
|
||||||
|
fontWeight: 500,
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
p: "0.5rem",
|
||||||
|
...buttonStyles
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon sx={{mr: "0.5rem"}}>save</Icon>
|
||||||
|
{buttonText}
|
||||||
|
<Icon sx={{ml: "0.5rem"}}>keyboard_arrow_down</Icon>
|
||||||
|
</Button>
|
||||||
|
{renderSavedBulkLoadProfilesMenu}
|
||||||
|
</Box>
|
||||||
|
<Box order="3" display="flex" justifyContent="center" flexDirection="column">
|
||||||
|
<Box pl={2} pr={2} fontSize="0.875rem" sx={{display: "flex", alignItems: "center"}}>
|
||||||
|
{
|
||||||
|
savedSuccessMessage && <Box color={colors.success.main}>{savedSuccessMessage}</Box>
|
||||||
|
}
|
||||||
|
{
|
||||||
|
savedFailedMessage && <Box color={colors.error.main}>{savedFailedMessage}</Box>
|
||||||
|
}
|
||||||
|
{
|
||||||
|
!currentSavedBulkLoadProfileRecord /*&& bulkLoadProfileIsModified*/ && <>
|
||||||
|
{
|
||||||
|
<>
|
||||||
|
<Tooltip {...tooltipMaxWidth("24rem")} sx={{cursor: "pointer"}} title={<>
|
||||||
|
<b>Unsaved Mapping</b>
|
||||||
|
<ul style={{padding: "0.5rem 1rem"}}>
|
||||||
|
<li>You are not using a saved bulk load profile.</li>
|
||||||
|
{
|
||||||
|
/*bulkLoadProfileDiffs.map((s: string, i: number) => <li key={i}>{s}</li>)*/
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</>}>
|
||||||
|
<Button disableRipple={true} sx={linkButtonStyle} onClick={() => handleDropdownOptionClick(SAVE_OPTION)}>Save Bulk Load Profile As…</Button>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
{/* vertical rule */}
|
||||||
|
{allowSelectingProfile && <Box display="inline-block" borderLeft={`1px solid ${colors.grayLines.main}`} height="1rem" width="1px" position="relative" />}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
|
||||||
|
{/* for the no-profile use-case, don't give a reset-link on screens other than the first (file mapping) one - which is tied to the allowSelectingProfile attribute */}
|
||||||
|
{allowSelectingProfile && <>
|
||||||
|
<Box pl="0.5rem">Reset to:</Box>
|
||||||
|
<Button disableRipple={true} sx={{color: colors.gray.main, ...linkButtonStyle}} onClick={() => handleDropdownOptionClick(CLEAR_OPTION)}>Empty Mapping</Button>
|
||||||
|
<Box display="inline-block" borderLeft={`1px solid ${colors.grayLines.main}`} height="1rem" width="1px" position="relative" />
|
||||||
|
<Button disableRipple={true} sx={{color: colors.gray.main, ...linkButtonStyle}} onClick={() => handleDropdownOptionClick(RESET_TO_SUGGESTION)}>Suggested Mapping</Button>
|
||||||
|
</>}
|
||||||
|
|
||||||
|
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
{
|
||||||
|
currentSavedBulkLoadProfileRecord && bulkLoadProfileIsModified && <>
|
||||||
|
<Tooltip {...tooltipMaxWidth("24rem")} sx={{cursor: "pointer"}} title={<>
|
||||||
|
<b>Unsaved Changes</b>
|
||||||
|
<ul style={{padding: "0.5rem 1rem"}}>
|
||||||
|
{
|
||||||
|
bulkLoadProfileDiffs.map((s: string, i: number) => <li key={i}>{s}</li>)
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
{
|
||||||
|
notOwnerTooltipText && <i>{notOwnerTooltipText}</i>
|
||||||
|
}
|
||||||
|
</>}>
|
||||||
|
<Box display="inline" sx={{...linkButtonStyle, p: 0, cursor: "default", position: "relative", top: "-1px"}}>{bulkLoadProfileDiffs.length} Unsaved Change{bulkLoadProfileDiffs.length == 1 ? "" : "s"}</Box>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
{disabledBecauseNotOwner ? <> </> : <Button disableRipple={true} sx={linkButtonStyle} onClick={() => handleDropdownOptionClick(SAVE_OPTION)}>Save…</Button>}
|
||||||
|
|
||||||
|
{/* vertical rule */}
|
||||||
|
{/* also, don't give a reset-link on screens other than the first (file mapping) one - which is tied to the allowSelectingProfile attribute */}
|
||||||
|
{/* partly because it isn't correctly resetting the values, but also because, it's a litle unclear that what, it would reset changes from other screens too?? */}
|
||||||
|
{
|
||||||
|
allowSelectingProfile && <>
|
||||||
|
<Box display="inline-block" borderLeft={`1px solid ${colors.grayLines.main}`} height="1rem" width="1px" position="relative" />
|
||||||
|
<Button disableRipple={true} sx={{color: colors.gray.main, ...linkButtonStyle}} onClick={() => handleSavedBulkLoadProfileRecordOnClick(currentSavedBulkLoadProfileRecord)}>Reset All Changes</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
{
|
||||||
|
<Dialog
|
||||||
|
open={savePopupOpen}
|
||||||
|
onClose={handleSavePopupClose}
|
||||||
|
aria-labelledby="alert-dialog-title"
|
||||||
|
aria-describedby="alert-dialog-description"
|
||||||
|
onKeyPress={(e) =>
|
||||||
|
{
|
||||||
|
////////////////////////////////////////////////////
|
||||||
|
// make user actually hit delete button //
|
||||||
|
// but for other modes, let Enter submit the form //
|
||||||
|
////////////////////////////////////////////////////
|
||||||
|
if (e.key == "Enter" && !isDeleteAction)
|
||||||
|
{
|
||||||
|
handleDialogButtonOnClick();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{
|
||||||
|
currentSavedBulkLoadProfileRecord ? (
|
||||||
|
isDeleteAction ? (
|
||||||
|
<DialogTitle id="alert-dialog-title">Delete Bulk Load Profile</DialogTitle>
|
||||||
|
) : (
|
||||||
|
isSaveAsAction ? (
|
||||||
|
<DialogTitle id="alert-dialog-title">Save Bulk Load Profile As</DialogTitle>
|
||||||
|
) : (
|
||||||
|
isRenameAction ? (
|
||||||
|
<DialogTitle id="alert-dialog-title">Rename Bulk Load Profile</DialogTitle>
|
||||||
|
) : (
|
||||||
|
<DialogTitle id="alert-dialog-title">Update Existing Bulk Load Profile</DialogTitle>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<DialogTitle id="alert-dialog-title">Save New Bulk Load Profile</DialogTitle>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
<DialogContent sx={{width: "500px"}}>
|
||||||
|
{popupAlertContent ? (
|
||||||
|
<Box mb={1}>
|
||||||
|
<Alert severity="error" onClose={() => setPopupAlertContent("")}>{popupAlertContent}</Alert>
|
||||||
|
</Box>
|
||||||
|
) : ("")}
|
||||||
|
{
|
||||||
|
(!currentSavedBulkLoadProfileRecord || isSaveAsAction || isRenameAction) && !isDeleteAction ? (
|
||||||
|
<Box>
|
||||||
|
{
|
||||||
|
isSaveAsAction ? (
|
||||||
|
<Box mb={3}>Enter a name for this new saved bulk load profile.</Box>
|
||||||
|
) : (
|
||||||
|
<Box mb={3}>Enter a new name for this saved bulk load profile.</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
<TextField
|
||||||
|
autoFocus
|
||||||
|
name="custom-delimiter-value"
|
||||||
|
placeholder="Bulk Load Profile Name"
|
||||||
|
inputProps={{width: "100%", maxLength: 100}}
|
||||||
|
value={savedBulkLoadProfileNameInputValue}
|
||||||
|
sx={{width: "100%"}}
|
||||||
|
onChange={handleSaveDialogInputChange}
|
||||||
|
onFocus={event =>
|
||||||
|
{
|
||||||
|
event.target.select();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
isDeleteAction ? (
|
||||||
|
<Box>Are you sure you want to delete the bulk load profile {`'${currentSavedBulkLoadProfileRecord?.values.get("label")}'`}?</Box>
|
||||||
|
) : (
|
||||||
|
<Box>Are you sure you want to update the bulk load profile {`'${currentSavedBulkLoadProfileRecord?.values.get("label")}'`}?</Box>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<QCancelButton onClickHandler={handleSavePopupClose} disabled={false} />
|
||||||
|
{
|
||||||
|
isDeleteAction ?
|
||||||
|
<QDeleteButton onClickHandler={handleDialogButtonOnClick} disabled={isSubmitting} />
|
||||||
|
:
|
||||||
|
<QSaveButton label="Save" onClickHandler={handleDialogButtonOnClick} disabled={isSaveButtonDisabled()} />
|
||||||
|
}
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
}
|
||||||
|
</>
|
||||||
|
) : null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SavedBulkLoadProfiles;
|
226
src/qqq/components/processes/BulkLoadFileMappingField.tsx
Normal file
226
src/qqq/components/processes/BulkLoadFileMappingField.tsx
Normal file
@ -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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
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 (<Box py="0.5rem" sx={{borderBottom: "1px solid lightgray", width: "100%", overflow: "auto"}}>
|
||||||
|
<Box display="grid" gridTemplateColumns="200px 400px auto" fontSize="1rem" gap="0.5rem" sx={
|
||||||
|
{
|
||||||
|
"& .MuiFormControlLabel-label": {ml: "0 !important", fontWeight: "normal !important", fontSize: mainFontSize}
|
||||||
|
}}>
|
||||||
|
|
||||||
|
<Box display="flex" alignItems="flex-start">
|
||||||
|
{
|
||||||
|
(!isRequired) && <IconButton onClick={() => removeFieldCallback()} sx={{pt: "0.75rem"}}><Icon fontSize="small">remove_circle</Icon></IconButton>
|
||||||
|
}
|
||||||
|
<Box pt="0.625rem">
|
||||||
|
{bulkLoadField.getQualifiedLabel()}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<RadioGroup name="valueType" value={valueType}>
|
||||||
|
<Box>
|
||||||
|
<Box display="flex" alignItems="center" sx={{height: "45px"}}>
|
||||||
|
<FormControlLabel value="column" control={<Radio size="small" onChange={(event, checked) => valueTypeChanged(checked)} />} label={"File column"} sx={{minWidth: "140px", whiteSpace: "nowrap"}} />
|
||||||
|
{
|
||||||
|
valueType == "column" && <Box width="100%">
|
||||||
|
<Autocomplete
|
||||||
|
id={bulkLoadField.field.name}
|
||||||
|
renderInput={(params) => (<TextField {...params} label={""} value={selectedColumn?.label} fullWidth variant="outlined" autoComplete="off" type="search" InputProps={{...params.InputProps}} sx={{"& .MuiOutlinedInput-root": {borderRadius: "0.75rem"}}} />)}
|
||||||
|
fullWidth
|
||||||
|
options={columnOptions}
|
||||||
|
multiple={false}
|
||||||
|
defaultValue={selectedColumn}
|
||||||
|
value={selectedColumn}
|
||||||
|
inputValue={selectedColumn?.label}
|
||||||
|
onChange={columnChanged}
|
||||||
|
getOptionLabel={(option) => typeof (option) == "string" ? option : (option?.label ?? "")}
|
||||||
|
isOptionEqualToValue={(option, value) => option == null && value == null || option.value == value.value}
|
||||||
|
renderOption={(props, option, state) => (<li {...props}>{option?.label ?? ""}</li>)}
|
||||||
|
sx={{"& .MuiOutlinedInput-root": {padding: "0"}}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
</Box>
|
||||||
|
<Box display="flex" alignItems="center" sx={{height: "45px"}}>
|
||||||
|
<FormControlLabel value="defaultValue" control={<Radio size="small" onChange={(event, checked) => valueTypeChanged(!checked)} />} label={"Default value"} sx={{minWidth: "140px", whiteSpace: "nowrap"}} />
|
||||||
|
{
|
||||||
|
valueType == "defaultValue" && <Box width="100%">
|
||||||
|
<QDynamicFormField
|
||||||
|
name={`${bulkLoadField.field.name}.defaultValue`}
|
||||||
|
displayFormat={""}
|
||||||
|
label={""}
|
||||||
|
formFieldObject={dynamicField}
|
||||||
|
type={dynamicField.type}
|
||||||
|
value={bulkLoadField.defaultValue}
|
||||||
|
onChangeCallback={defaultValueChanged}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
{
|
||||||
|
bulkLoadField.error &&
|
||||||
|
<Box fontSize={smallerFontSize} color={colors.error.main} ml="145px">
|
||||||
|
{bulkLoadField.error}
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
</RadioGroup>
|
||||||
|
|
||||||
|
<Box ml="1rem">
|
||||||
|
{
|
||||||
|
valueType == "column" && <>
|
||||||
|
<Box>
|
||||||
|
<FormControlLabel value="mapValues" control={<Checkbox size="small" defaultChecked={bulkLoadField.doValueMapping} onChange={(event, checked) => mapValuesChanged(checked)} />} label={"Map values"} sx={{minWidth: "140px", whiteSpace: "nowrap"}} />
|
||||||
|
</Box>
|
||||||
|
<Box fontSize={mainFontSize} mt="0.5rem">
|
||||||
|
Preview Values: <span style={{color: "gray"}}>{(fileDescription.getPreviewValues(selectedColumn?.value) ?? [""]).join(", ")}</span>
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
</Box>
|
||||||
|
</Box>);
|
||||||
|
}
|
322
src/qqq/components/processes/BulkLoadFileMappingFields.tsx
Normal file
322
src/qqq/components/processes/BulkLoadFileMappingFields.tsx
Normal file
@ -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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import Icon from "@mui/material/Icon";
|
||||||
|
import colors from "qqq/assets/theme/base/colors";
|
||||||
|
import QHierarchyAutoComplete, {Group, Option} from "qqq/components/misc/QHierarchyAutoComplete";
|
||||||
|
import BulkLoadFileMappingField from "qqq/components/processes/BulkLoadFileMappingField";
|
||||||
|
import {BulkLoadField, BulkLoadMapping, FileDescription} from "qqq/models/processes/BulkLoadModels";
|
||||||
|
import React, {useEffect, useReducer, useState} from "react";
|
||||||
|
|
||||||
|
interface BulkLoadMappingFieldsProps
|
||||||
|
{
|
||||||
|
bulkLoadMapping: BulkLoadMapping,
|
||||||
|
fileDescription: FileDescription,
|
||||||
|
forceParentUpdate?: () => void,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const ADD_SINGLE_FIELD_TOOLTIP = "Click to add this field to your mapping.";
|
||||||
|
const ADD_MANY_FIELD_TOOLTIP = "Click to add this field to your mapping as many times as you need.";
|
||||||
|
const ALREADY_ADDED_FIELD_TOOLTIP = "This field has already been added to your mapping.";
|
||||||
|
|
||||||
|
/***************************************************************************
|
||||||
|
** The section of the bulk load mapping screen with all the fields.
|
||||||
|
***************************************************************************/
|
||||||
|
export default function BulkLoadFileMappingFields({bulkLoadMapping, fileDescription, forceParentUpdate}: BulkLoadMappingFieldsProps): JSX.Element
|
||||||
|
{
|
||||||
|
const [, forceUpdate] = useReducer((x) => x + 1, 0);
|
||||||
|
|
||||||
|
const [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 (
|
||||||
|
<>
|
||||||
|
<h5>Required Fields</h5>
|
||||||
|
<Box pl={"1rem"}>
|
||||||
|
{
|
||||||
|
bulkLoadMapping.requiredFields.length == 0 &&
|
||||||
|
<i style={{fontSize: "0.875rem"}}>There are no required fields in this table.</i>
|
||||||
|
}
|
||||||
|
{bulkLoadMapping.requiredFields.map((bulkLoadField) => (
|
||||||
|
<BulkLoadFileMappingField
|
||||||
|
fileDescription={fileDescription}
|
||||||
|
key={bulkLoadField.getKey()}
|
||||||
|
bulkLoadField={bulkLoadField}
|
||||||
|
isRequired={true}
|
||||||
|
forceParentUpdate={forceParentUpdate}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box mt="1rem">
|
||||||
|
<h5>Additional Fields</h5>
|
||||||
|
<Box pl={"1rem"}>
|
||||||
|
{bulkLoadMapping.additionalFields.map((bulkLoadField) => (
|
||||||
|
<BulkLoadFileMappingField
|
||||||
|
fileDescription={fileDescription}
|
||||||
|
key={bulkLoadField.getKey()}
|
||||||
|
bulkLoadField={bulkLoadField}
|
||||||
|
isRequired={false}
|
||||||
|
removeFieldCallback={() => removeField(bulkLoadField)}
|
||||||
|
forceParentUpdate={forceParentUpdate}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Box display="flex" pt="1rem" pl="12.5rem">
|
||||||
|
<QHierarchyAutoComplete
|
||||||
|
idPrefix="addFieldAutocomplete"
|
||||||
|
defaultGroup={addFieldsGroup}
|
||||||
|
menuDirection="up"
|
||||||
|
buttonProps={{id: "addFieldsButton", sx: addFieldMenuButtonStyles}}
|
||||||
|
buttonChildren={<><Icon sx={{mr: "0.5rem"}}>add</Icon> Add Fields <Icon sx={{ml: "0.5rem"}}>keyboard_arrow_down</Icon></>}
|
||||||
|
isModeSelectOne
|
||||||
|
keepOpenAfterSelectOne
|
||||||
|
handleSelectedOption={handleAddField}
|
||||||
|
forceRerender={forceRerender}
|
||||||
|
disabledStates={addFieldsDisableStates}
|
||||||
|
tooltips={tooltips}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
384
src/qqq/components/processes/BulkLoadFileMappingForm.tsx
Normal file
384
src/qqq/components/processes/BulkLoadFileMappingForm.tsx
Normal file
@ -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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
|
||||||
|
import {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstance";
|
||||||
|
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
|
||||||
|
import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord";
|
||||||
|
import 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<QRecord>(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>(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 (<Box>
|
||||||
|
|
||||||
|
<Box py="1rem" display="flex">
|
||||||
|
<SavedBulkLoadProfiles
|
||||||
|
metaData={metaData}
|
||||||
|
tableMetaData={tableMetaData}
|
||||||
|
tableStructure={tableStructure}
|
||||||
|
currentSavedBulkLoadProfileRecord={currentSavedBulkLoadProfile}
|
||||||
|
currentMapping={bulkLoadMapping}
|
||||||
|
bulkLoadProfileOnChangeCallback={bulkLoadProfileOnChangeCallback}
|
||||||
|
bulkLoadProfileResetToSuggestedMappingCallback={bulkLoadProfileResetToSuggestedMappingCallback}
|
||||||
|
fileDescription={fileDescription}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<BulkLoadMappingHeader
|
||||||
|
key={rerenderHeader}
|
||||||
|
bulkLoadMapping={bulkLoadMapping}
|
||||||
|
fileDescription={fileDescription}
|
||||||
|
tableStructure={tableStructure}
|
||||||
|
fileName={processValues.fileBaseName}
|
||||||
|
fieldErrors={fieldErrors}
|
||||||
|
forceParentUpdate={() => forceUpdate()}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Box mt="2rem">
|
||||||
|
<BulkLoadFileMappingFields
|
||||||
|
bulkLoadMapping={bulkLoadMapping}
|
||||||
|
fileDescription={fileDescription}
|
||||||
|
forceParentUpdate={() => forceUpdate()}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
</Box>);
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Box>
|
||||||
|
<h5>File Details</h5>
|
||||||
|
<Box ml="1rem">
|
||||||
|
<ProcessViewForm fields={viewFields} values={viewValues} columns={2} />
|
||||||
|
<BulkLoadMappingFilePreview fileDescription={fileDescription} />
|
||||||
|
<Grid container pt="1rem">
|
||||||
|
<Grid item xs={12} md={6}>
|
||||||
|
<DynamicFormFieldLabel name={hasHeaderRowFormField.name} label={`${hasHeaderRowFormField.label} *`} />
|
||||||
|
<QDynamicFormField name={hasHeaderRowFormField.name} displayFormat={""} label={""} formFieldObject={hasHeaderRowFormField} type={"checkbox"} value={bulkLoadMapping.hasHeaderRow} onChangeCallback={hasHeaderRowChanged} />
|
||||||
|
{
|
||||||
|
fieldErrors.hasHeaderRow &&
|
||||||
|
<MDTypography component="div" variant="caption" color="error" fontWeight="regular" mt="0.25rem">
|
||||||
|
{<div className="fieldErrorMessage">{fieldErrors.hasHeaderRow}</div>}
|
||||||
|
</MDTypography>
|
||||||
|
}
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} md={6}>
|
||||||
|
<DynamicFormFieldLabel name={"layout"} label={"File Layout *"} />
|
||||||
|
<Autocomplete
|
||||||
|
id={"layout"}
|
||||||
|
renderInput={(params) => (<TextField {...params} label={""} fullWidth variant="outlined" autoComplete="off" type="search" InputProps={{...params.InputProps}} sx={{"& .MuiOutlinedInput-root": {borderRadius: "0.75rem"}}} />)}
|
||||||
|
options={layoutOptions}
|
||||||
|
multiple={false}
|
||||||
|
defaultValue={selectedLayout}
|
||||||
|
onChange={layoutChanged}
|
||||||
|
getOptionLabel={(option) => typeof (option) == "string" ? option : (option?.label ?? "")}
|
||||||
|
isOptionEqualToValue={(option, value) => option == null && value == null || option.id == value.id}
|
||||||
|
renderOption={(props, option, state) => (<li {...props}>{option?.label ?? ""}</li>)}
|
||||||
|
sx={{"& .MuiOutlinedInput-root": {padding: "0"}}}
|
||||||
|
/>
|
||||||
|
{
|
||||||
|
fieldErrors.layout &&
|
||||||
|
<MDTypography component="div" variant="caption" color="error" fontWeight="regular" mt="0.25rem">
|
||||||
|
{<div className="fieldErrorMessage">{fieldErrors.layout}</div>}
|
||||||
|
</MDTypography>
|
||||||
|
}
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Box sx={{"& table, & td": {border: "1px solid black", borderCollapse: "collapse", padding: "0 0.25rem", fontSize: "0.875rem", whiteSpace: "nowrap"}}}>
|
||||||
|
<Box sx={{width: "100%", overflow: "auto"}}>
|
||||||
|
<table cellSpacing="0" width="100%">
|
||||||
|
<thead>
|
||||||
|
<tr style={{backgroundColor: "#d3d3d3"}}>
|
||||||
|
<td></td>
|
||||||
|
{fileDescription.headerLetters.map((letter) => <td key={letter} style={{textAlign: "center"}}>{letter}</td>)}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style={{backgroundColor: "#d3d3d3", textAlign: "center"}}>1</td>
|
||||||
|
{fileDescription.headerValues.map((value) => <td key={value} style={{backgroundColor: fileDescription.hasHeaderRow ? "#ebebeb" : ""}}>{value}</td>)}
|
||||||
|
</tr>
|
||||||
|
{rows.map((i) => (
|
||||||
|
<tr key={i}>
|
||||||
|
<td style={{backgroundColor: "#d3d3d3", textAlign: "center"}}>{i + 2}</td>
|
||||||
|
{fileDescription.headerLetters.map((letter, j) => <td key={j}>{fileDescription.bodyValuesPreview[j][i]}</td>)}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
102
src/qqq/components/processes/BulkLoadProfileForm.tsx
Normal file
102
src/qqq/components/processes/BulkLoadProfileForm.tsx
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
/*
|
||||||
|
* QQQ - Low-code Application Framework for Engineers.
|
||||||
|
* Copyright (C) 2021-2024. Kingsrook, LLC
|
||||||
|
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||||
|
* contact@kingsrook.com
|
||||||
|
* https://github.com/Kingsrook/
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as
|
||||||
|
* published by the Free Software Foundation, either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstance";
|
||||||
|
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
|
||||||
|
import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord";
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import SavedBulkLoadProfiles from "qqq/components/misc/SavedBulkLoadProfiles";
|
||||||
|
import {BulkLoadMapping, BulkLoadProfile, BulkLoadTableStructure, FileDescription, Wrapper} from "qqq/models/processes/BulkLoadModels";
|
||||||
|
import {SubFormPreSubmitCallbackResultType} from "qqq/pages/processes/ProcessRun";
|
||||||
|
import React, {forwardRef, useImperativeHandle, useState} from "react";
|
||||||
|
|
||||||
|
interface BulkLoadValueMappingFormProps
|
||||||
|
{
|
||||||
|
processValues: any,
|
||||||
|
tableMetaData: QTableMetaData,
|
||||||
|
metaData: QInstance
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/***************************************************************************
|
||||||
|
** For review & result screens of bulk load - this process component shows
|
||||||
|
** the SavedBulkLoadProfiles button.
|
||||||
|
***************************************************************************/
|
||||||
|
const BulkLoadProfileForm = forwardRef(({processValues, tableMetaData, metaData}: BulkLoadValueMappingFormProps, ref) =>
|
||||||
|
{
|
||||||
|
const savedBulkLoadProfileRecordProcessValue = processValues.savedBulkLoadProfileRecord;
|
||||||
|
const [savedBulkLoadProfileRecord, setSavedBulkLoadProfileRecord] = useState(savedBulkLoadProfileRecordProcessValue == null ? null : new QRecord(savedBulkLoadProfileRecordProcessValue))
|
||||||
|
|
||||||
|
const [tableStructure] = useState(processValues.tableStructure as BulkLoadTableStructure);
|
||||||
|
|
||||||
|
const [bulkLoadProfile, setBulkLoadProfile] = useState(processValues.bulkLoadProfile as BulkLoadProfile);
|
||||||
|
const [currentMapping, setCurrentMapping] = useState(BulkLoadMapping.fromBulkLoadProfile(tableStructure, bulkLoadProfile))
|
||||||
|
const [wrappedCurrentSavedBulkLoadProfile] = useState(new Wrapper<QRecord>(savedBulkLoadProfileRecord));
|
||||||
|
|
||||||
|
const [fileDescription] = useState(new FileDescription(processValues.headerValues, processValues.headerLetters, processValues.bodyValuesPreview));
|
||||||
|
fileDescription.setHasHeaderRow(currentMapping.hasHeaderRow);
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () =>
|
||||||
|
{
|
||||||
|
return {
|
||||||
|
preSubmit(): SubFormPreSubmitCallbackResultType
|
||||||
|
{
|
||||||
|
const values: { [name: string]: any } = {};
|
||||||
|
values["savedBulkLoadProfileId"] = wrappedCurrentSavedBulkLoadProfile.get()?.values?.get("id");
|
||||||
|
|
||||||
|
return ({maySubmit: true, values});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
/***************************************************************************
|
||||||
|
**
|
||||||
|
***************************************************************************/
|
||||||
|
function bulkLoadProfileOnChangeCallback(profileRecord: QRecord | null)
|
||||||
|
{
|
||||||
|
setSavedBulkLoadProfileRecord(profileRecord);
|
||||||
|
wrappedCurrentSavedBulkLoadProfile.set(profileRecord);
|
||||||
|
|
||||||
|
const newBulkLoadMapping = BulkLoadMapping.fromSavedProfileRecord(tableStructure, profileRecord);
|
||||||
|
setCurrentMapping(newBulkLoadMapping);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
return (<Box>
|
||||||
|
|
||||||
|
<Box py="1rem" display="flex">
|
||||||
|
<SavedBulkLoadProfiles
|
||||||
|
metaData={metaData}
|
||||||
|
tableMetaData={tableMetaData}
|
||||||
|
tableStructure={tableStructure}
|
||||||
|
currentSavedBulkLoadProfileRecord={savedBulkLoadProfileRecord}
|
||||||
|
currentMapping={currentMapping}
|
||||||
|
allowSelectingProfile={false}
|
||||||
|
fileDescription={fileDescription}
|
||||||
|
bulkLoadProfileOnChangeCallback={bulkLoadProfileOnChangeCallback}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
</Box>);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default BulkLoadProfileForm;
|
222
src/qqq/components/processes/BulkLoadValueMappingForm.tsx
Normal file
222
src/qqq/components/processes/BulkLoadValueMappingForm.tsx
Normal file
@ -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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
|
||||||
|
import {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstance";
|
||||||
|
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
|
||||||
|
import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord";
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import Icon from "@mui/material/Icon";
|
||||||
|
import colors from "qqq/assets/theme/base/colors";
|
||||||
|
import QDynamicFormField from "qqq/components/forms/DynamicFormField";
|
||||||
|
import SavedBulkLoadProfiles from "qqq/components/misc/SavedBulkLoadProfiles";
|
||||||
|
import {BulkLoadMapping, BulkLoadProfile, BulkLoadTableStructure, FileDescription, Wrapper} from "qqq/models/processes/BulkLoadModels";
|
||||||
|
import {SubFormPreSubmitCallbackResultType} from "qqq/pages/processes/ProcessRun";
|
||||||
|
import React, {forwardRef, useEffect, useImperativeHandle, useState} from "react";
|
||||||
|
|
||||||
|
interface BulkLoadValueMappingFormProps
|
||||||
|
{
|
||||||
|
processValues: any,
|
||||||
|
setActiveStepLabel: (label: string) => void,
|
||||||
|
tableMetaData: QTableMetaData,
|
||||||
|
metaData: QInstance,
|
||||||
|
formFields: any[]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/***************************************************************************
|
||||||
|
** process component used in bulk-load - on a screen that gets looped for
|
||||||
|
** each field whose values are being mapped.
|
||||||
|
***************************************************************************/
|
||||||
|
const BulkLoadValueMappingForm = forwardRef(({processValues, setActiveStepLabel, tableMetaData, metaData, formFields}: BulkLoadValueMappingFormProps, ref) =>
|
||||||
|
{
|
||||||
|
const [field, setField] = useState(processValues.valueMappingField ? new QFieldMetaData(processValues.valueMappingField) : null);
|
||||||
|
const [fieldFullName, setFieldFullName] = useState(processValues.valueMappingFullFieldName);
|
||||||
|
const [fileValues, setFileValues] = useState((processValues.fileValues ?? []) as string[]);
|
||||||
|
const [valueErrors, setValueErrors] = useState({} as { [fileValue: string]: any });
|
||||||
|
|
||||||
|
const [bulkLoadProfile, setBulkLoadProfile] = useState(processValues.bulkLoadProfile as BulkLoadProfile);
|
||||||
|
|
||||||
|
const savedBulkLoadProfileRecordProcessValue = processValues.savedBulkLoadProfileRecord;
|
||||||
|
const [savedBulkLoadProfileRecord, setSavedBulkLoadProfileRecord] = useState(savedBulkLoadProfileRecordProcessValue == null ? null : new QRecord(savedBulkLoadProfileRecordProcessValue));
|
||||||
|
const [wrappedCurrentSavedBulkLoadProfile] = useState(new Wrapper<QRecord>(savedBulkLoadProfileRecord));
|
||||||
|
|
||||||
|
const [tableStructure] = useState(processValues.tableStructure as BulkLoadTableStructure);
|
||||||
|
|
||||||
|
const [currentMapping, setCurrentMapping] = useState(initializeCurrentBulkLoadMapping());
|
||||||
|
const [wrappedBulkLoadMapping] = useState(new Wrapper<BulkLoadMapping>(currentMapping));
|
||||||
|
|
||||||
|
const [fileDescription] = useState(new FileDescription(processValues.headerValues, processValues.headerLetters, processValues.bodyValuesPreview));
|
||||||
|
fileDescription.setHasHeaderRow(currentMapping.hasHeaderRow);
|
||||||
|
|
||||||
|
/*******************************************************************************
|
||||||
|
**
|
||||||
|
*******************************************************************************/
|
||||||
|
function initializeCurrentBulkLoadMapping(): BulkLoadMapping
|
||||||
|
{
|
||||||
|
const bulkLoadMapping = BulkLoadMapping.fromBulkLoadProfile(tableStructure, bulkLoadProfile);
|
||||||
|
|
||||||
|
if (!bulkLoadMapping.valueMappings[fieldFullName])
|
||||||
|
{
|
||||||
|
bulkLoadMapping.valueMappings[fieldFullName] = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return (bulkLoadMapping);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() =>
|
||||||
|
{
|
||||||
|
if (processValues.valueMappingField)
|
||||||
|
{
|
||||||
|
setField(new QFieldMetaData(processValues.valueMappingField));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
setField(null);
|
||||||
|
}
|
||||||
|
}, [processValues.valueMappingField]);
|
||||||
|
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////
|
||||||
|
// ref-based callback for integration with ProcessRun //
|
||||||
|
////////////////////////////////////////////////////////
|
||||||
|
useImperativeHandle(ref, () =>
|
||||||
|
{
|
||||||
|
return {
|
||||||
|
preSubmit(): SubFormPreSubmitCallbackResultType
|
||||||
|
{
|
||||||
|
const values: { [name: string]: any } = {};
|
||||||
|
|
||||||
|
let anyErrors = false;
|
||||||
|
const mappedValues = currentMapping.valueMappings[fieldFullName];
|
||||||
|
if (field.isRequired)
|
||||||
|
{
|
||||||
|
for (let fileValue of fileValues)
|
||||||
|
{
|
||||||
|
valueErrors[fileValue] = null;
|
||||||
|
if (mappedValues[fileValue] == null || mappedValues[fileValue] == undefined || mappedValues[fileValue] == "")
|
||||||
|
{
|
||||||
|
valueErrors[fileValue] = "A value is required for this mapping";
|
||||||
|
anyErrors = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////
|
||||||
|
// always re-submit the full profile //
|
||||||
|
// note mostly a copy in BulkLoadFileMappingForm //
|
||||||
|
///////////////////////////////////////////////////
|
||||||
|
const {haveErrors, profile} = wrappedBulkLoadMapping.get().toProfile();
|
||||||
|
values["version"] = profile.version;
|
||||||
|
values["fieldListJSON"] = JSON.stringify(profile.fieldList);
|
||||||
|
values["savedBulkLoadProfileId"] = wrappedCurrentSavedBulkLoadProfile.get()?.values?.get("id");
|
||||||
|
values["layout"] = wrappedBulkLoadMapping.get().layout;
|
||||||
|
values["hasHeaderRow"] = wrappedBulkLoadMapping.get().hasHeaderRow;
|
||||||
|
|
||||||
|
values["mappedValuesJSON"] = JSON.stringify(mappedValues);
|
||||||
|
|
||||||
|
return ({maySubmit: !anyErrors, values});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!field)
|
||||||
|
{
|
||||||
|
//////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
// this happens like between steps - render empty rather than a flash of half-stuff //
|
||||||
|
//////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
return (<Box></Box>);
|
||||||
|
}
|
||||||
|
|
||||||
|
/***************************************************************************
|
||||||
|
**
|
||||||
|
***************************************************************************/
|
||||||
|
function mappedValueChanged(fileValue: string, newValue: any)
|
||||||
|
{
|
||||||
|
valueErrors[fileValue] = null;
|
||||||
|
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 (<Box>
|
||||||
|
|
||||||
|
<Box py="1rem" display="flex">
|
||||||
|
<SavedBulkLoadProfiles
|
||||||
|
metaData={metaData}
|
||||||
|
tableMetaData={tableMetaData}
|
||||||
|
tableStructure={tableStructure}
|
||||||
|
currentSavedBulkLoadProfileRecord={savedBulkLoadProfileRecord}
|
||||||
|
currentMapping={currentMapping}
|
||||||
|
allowSelectingProfile={false}
|
||||||
|
bulkLoadProfileOnChangeCallback={bulkLoadProfileOnChangeCallback}
|
||||||
|
fileDescription={fileDescription}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{
|
||||||
|
fileValues.map((fileValue, i) => (
|
||||||
|
<Box key={i} py="0.5rem" sx={{borderBottom: "0px solid lightgray", width: "100%", overflow: "auto"}}>
|
||||||
|
<Box display="grid" gridTemplateColumns="40% auto 60%" fontSize="1rem" gap="0.5rem">
|
||||||
|
<Box mt="0.5rem" textAlign="right">{fileValue}</Box>
|
||||||
|
<Box mt="0.625rem"><Icon>arrow_forward</Icon></Box>
|
||||||
|
<Box maxWidth="300px">
|
||||||
|
<QDynamicFormField
|
||||||
|
name={`${fieldFullName}.value.${i}`}
|
||||||
|
displayFormat={""}
|
||||||
|
label={""}
|
||||||
|
formFieldObject={formFields[i]}
|
||||||
|
type={formFields[i].type}
|
||||||
|
value={currentMapping.valueMappings[fieldFullName][fileValue]}
|
||||||
|
onChangeCallback={(newValue) => mappedValueChanged(fileValue, newValue)}
|
||||||
|
/>
|
||||||
|
{
|
||||||
|
valueErrors[fileValue] &&
|
||||||
|
<Box fontSize={"0.875rem"} mt={"-0.75rem"} color={colors.error.main}>
|
||||||
|
{valueErrors[fileValue]}
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</Box>);
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
export default BulkLoadValueMappingForm;
|
600
src/qqq/models/processes/BulkLoadModels.ts
Normal file
600
src/qqq/models/processes/BulkLoadModels.ts
Normal file
@ -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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
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: T;
|
||||||
|
|
||||||
|
/***************************************************************************
|
||||||
|
**
|
||||||
|
***************************************************************************/
|
||||||
|
constructor(t: T)
|
||||||
|
{
|
||||||
|
this.t = t;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/***************************************************************************
|
||||||
|
**
|
||||||
|
***************************************************************************/
|
||||||
|
public get(): T
|
||||||
|
{
|
||||||
|
return this.t;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/***************************************************************************
|
||||||
|
**
|
||||||
|
***************************************************************************/
|
||||||
|
public set(t: T)
|
||||||
|
{
|
||||||
|
this.t = t;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
227
src/qqq/utils/qqq/SavedBulkLoadProfileUtils.ts
Normal file
227
src/qqq/utils/qqq/SavedBulkLoadProfileUtils.ts
Normal file
@ -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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {BulkLoadField, BulkLoadMapping, BulkLoadTableStructure, FileDescription} from "qqq/models/processes/BulkLoadModels";
|
||||||
|
|
||||||
|
type FieldMapping = { [name: string]: BulkLoadField }
|
||||||
|
|
||||||
|
/***************************************************************************
|
||||||
|
** Utillity methods for working with saved bulk load profiles.
|
||||||
|
***************************************************************************/
|
||||||
|
export class SavedBulkLoadProfileUtils
|
||||||
|
{
|
||||||
|
|
||||||
|
/***************************************************************************
|
||||||
|
**
|
||||||
|
***************************************************************************/
|
||||||
|
private static diffFieldContents = (fileDescription: FileDescription, baseFieldsMap: FieldMapping, compareFieldsMap: FieldMapping, orderedFieldArray: BulkLoadField[]): string[] =>
|
||||||
|
{
|
||||||
|
const rs: string[] = [];
|
||||||
|
|
||||||
|
for (let bulkLoadField of orderedFieldArray)
|
||||||
|
{
|
||||||
|
const fieldName = bulkLoadField.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;
|
||||||
|
};
|
||||||
|
|
||||||
|
}
|
Reference in New Issue
Block a user