mirror of
https://github.com/Kingsrook/qqq-frontend-material-dashboard.git
synced 2025-07-22 23:28:44 +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;
|
Reference in New Issue
Block a user