Compare commits

..

13 Commits

Author SHA1 Message Date
7c7b9a3dbf initial checkin of support of bulk load with file 2025-07-17 12:37:54 -05:00
1dce760934 Merge pull request #91 from Kingsrook/feature/criteria-paster-tests
added tests around filter criteria paster tool
2025-07-08 14:34:29 -05:00
ff4683af1f added tests around filter criteria paster tool 2025-07-08 13:53:02 -05:00
ab4be1d5af fix to hotfix, observe chips as well to handle paste 2025-06-30 23:55:42 -05:00
0d7e76df6c hotfix on number chip validity, text fix 2025-06-27 12:17:52 -05:00
d41f5f8339 added clarifying comment 2025-06-20 13:22:29 -05:00
4d30eb3060 Merge pull request #90 from Kingsrook/feature/search-possible-values-by-label
Feature/search possible values by label
2025-06-18 10:23:13 -05:00
d4a675e952 updated to include the unique count of valid values 2025-06-06 19:17:10 -05:00
633c97b710 fix when no helpContent avaliable 2025-06-03 17:27:11 -05:00
c70ef3dae8 feedback from review session 2025-06-02 16:39:27 -05:00
5c69ae666c added ability to search for possible value data using the PVS labels, rather than just the ids, updated the values paster widget thingy to use this change to make pvs requests in a paginated manner 2025-05-27 15:17:57 -05:00
2e5aba6c16 Merge tag 'version-0.25.0' into dev
Tag release
2025-05-20 07:52:28 -05:00
cbcb3b505e Update for next development version 2025-05-20 07:06:37 -05:00
22 changed files with 5133 additions and 19827 deletions

23618
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -6,7 +6,7 @@
"@auth0/auth0-react": "1.10.2",
"@emotion/react": "11.7.1",
"@emotion/styled": "11.6.0",
"@kingsrook/qqq-frontend-core": "1.0.119",
"@kingsrook/qqq-frontend-core": "1.0.122",
"@mui/icons-material": "5.4.1",
"@mui/material": "5.11.1",
"@mui/styles": "5.11.1",
@ -35,9 +35,9 @@
"html-react-parser": "1.4.8",
"html-to-text": "^9.0.5",
"http-proxy-middleware": "2.0.6",
"lodash": "4.17.21",
"jwt-decode": "3.1.2",
"oidc-client-ts": "2.4.1",
"react-oidc-context": "2.3.1",
"rapidoc": "9.3.4",
"react": "18.0.0",
"react-ace": "10.1.0",
@ -51,6 +51,7 @@
"react-github-btn": "1.2.1",
"react-google-drive-picker": "^1.2.0",
"react-markdown": "9.0.1",
"react-oidc-context": "2.3.1",
"react-router-dom": "6.2.1",
"react-router-hash-link": "2.4.3",
"react-table": "7.7.0",

View File

@ -29,7 +29,7 @@
<packaging>jar</packaging>
<properties>
<revision>0.25.0</revision>
<revision>0.26.0-SNAPSHOT</revision>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>

View File

@ -23,8 +23,10 @@ import {Chip} from "@mui/material";
import TextField from "@mui/material/TextField";
import {makeStyles} from "@mui/styles";
import Downshift from "downshift";
import {debounce} from "lodash";
import {arrayOf, func, string} from "prop-types";
import React, {useEffect, useState} from "react";
import Client from "qqq/utils/qqq/Client";
import React, {useEffect, useRef, useState} from "react";
const useStyles = makeStyles((theme: any) => ({
chip: {
@ -34,21 +36,107 @@ const useStyles = makeStyles((theme: any) => ({
function ChipTextField({...props})
{
const qController = Client.getInstance();
const classes = useStyles();
const {handleChipChange, label, chipType, disabled, placeholder, chipData, multiline, rows, ...other} = props;
const {table, field, handleChipChange, label, chipType, disabled, placeholder, chipData, multiline, rows, ...other} = props;
const [inputValue, setInputValue] = useState("");
const [chips, setChips] = useState([]);
const [chipColors, setChipColors] = useState([]);
const [chipValidity, setChipValidity] = useState([] as boolean[]);
const [chipPVSIds, setChipPVSIds] = useState([] as any[]);
const [isMakingRequest, setIsMakingRequest] = useState(false);
////////////////////////////////////////////////////////////////////
// these refs are used for the async api call for possible values //
////////////////////////////////////////////////////////////////////
const chipsRef = useRef<string[]>([]);
/////////////////////////////////////////////////////////////////////////////////////////////
// use debounce library to not flood server as user types, wait a second before requesting //
/////////////////////////////////////////////////////////////////////////////////////////////
async function fetchPVSLabelsAndColorChips()
{
//////////////////////////////////////////////////////////
// make a request for the possible value labels (chips) //
//////////////////////////////////////////////////////////
setIsMakingRequest(true);
const currentChips = chipsRef.current;
setChipColors([]);
///////////////////////////////////////////////////////////////////////////////
// Determine chip colors based on whether each chip value appears in results //
///////////////////////////////////////////////////////////////////////////////
const newChipColors = [] as string[];
const chipValidity = [] as boolean[];
const chipPVSIds = [] as any[];
////////////////////////////////////////////////////////////////////////////
// make the request for all 'chips' with pagination to handle large sizes //
////////////////////////////////////////////////////////////////////////////
const BATCH_SIZE = 250;
for (let i = 0; i < currentChips.length; i += BATCH_SIZE)
{
const batch = currentChips.slice(i, i + BATCH_SIZE);
const page = await qController.possibleValues(
table.name,
null,
field.name,
"",
null,
batch
);
for (let j = 0; j < batch.length; j++)
{
let found = false;
for (let k = 0; k < page.length; k++)
{
const result = page[k];
if (result.label.toLowerCase() === batch[j].toLowerCase())
{
chipPVSIds.push(result.id);
newChipColors.push("info");
chipValidity.push(true);
found = true;
break;
}
}
if (!found)
{
chipPVSIds.push(null);
chipValidity.push(false);
newChipColors.push("error");
}
}
}
setChipPVSIds(chipPVSIds);
setChipColors(newChipColors);
setChipValidity(chipValidity);
setIsMakingRequest(false);
}
const debouncedApiCall = useRef(debounce(fetchPVSLabelsAndColorChips, 500)).current;
useEffect(() =>
{
setChips(chipData);
}, [chipData]);
chipsRef.current = chipData;
determineChipColors();
if (chipType !== "pvs")
{
const currentChipValidity = chips.map((chip, i) =>
(chipType !== "number" || !Number.isNaN(Number(chips[i])))
);
setChipValidity(currentChipValidity);
}
}, [JSON.stringify(chipData), chips]);
useEffect(() =>
{
handleChipChange(chips);
}, [chips, handleChipChange]);
handleChipChange(isMakingRequest, chipValidity, chipPVSIds);
}, [chipValidity, chipPVSIds, isMakingRequest]);
function handleKeyDown(event: any)
{
@ -64,13 +152,16 @@ function ChipTextField({...props})
setInputValue("");
return;
}
if (!event.target.value.replace(/\s/g, "").length) return;
if (!event.target.value.replace(/\s/g, "").length)
{
return;
}
setInputValue("");
newChipList.push(event.target.value.trim());
setChips(newChipList);
setInputValue("");
}
else if (chips.length && !inputValue.length && event.key === "Backspace" )
else if (chips.length && !inputValue.length && event.key === "Backspace")
{
setChips(chips.slice(0, chips.length - 1));
}
@ -87,18 +178,26 @@ function ChipTextField({...props})
setChips(newChipList);
}
const handleDelete = (item: any) => () =>
{
const newChipList = [...chips];
newChipList.splice(newChipList.indexOf(item), 1);
setChips(newChipList);
};
function handleInputChange(event: { target: { value: React.SetStateAction<string>; }; })
{
setInputValue(event.target.value);
}
function determineChipColors(): any
{
if (chipType === "pvs")
{
debouncedApiCall();
}
else
{
const newChipColors = chips.map((chip, i) =>
(chipType !== "number" || !Number.isNaN(Number(chips[i]))) ? "info" : "error"
);
setChipColors(newChipColors);
}
}
return (
<React.Fragment>
@ -116,7 +215,7 @@ function ChipTextField({...props})
});
// @ts-ignore
return (
<div id="chip-text-field-container" style={{flexWrap: "wrap", display:"flex"}}>
<div id="chip-text-field-container" style={{flexWrap: "wrap", display: "flex"}}>
<TextField
sx={{width: "99%"}}
disabled={disabled}
@ -125,16 +224,16 @@ function ChipTextField({...props})
startAdornment:
<div style={{overflowY: "auto", overflowX: "hidden", margin: "-10px", width: "calc(100% + 20px)", padding: "10px", marginBottom: "-20px", height: "calc(100% + 10px)"}}>
{
chips.map((item, i) => (
chips.map((item, index) => (
<Chip
color={(chipType !== "number" || ! Number.isNaN(Number(item))) ? "info" : "error"}
key={`${item}-${i}`}
onChange={determineChipColors}
color={chipColors[index]}
key={`${item}-${index}`}
variant="outlined"
tabIndex={-1}
label={item}
className={classes.chip}
/>
))
}
</div>,
@ -158,6 +257,7 @@ function ChipTextField({...props})
</React.Fragment>
);
}
ChipTextField.defaultProps = {
chipData: []
};
@ -166,4 +266,4 @@ ChipTextField.propTypes = {
chipData: arrayOf(string)
};
export default ChipTextField
export default ChipTextField;

View File

@ -20,17 +20,18 @@
*/
import {QPossibleValue} from "@kingsrook/qqq-frontend-core/lib/model/QPossibleValue";
import {Box, InputAdornment, InputLabel} from "@mui/material";
import {InputAdornment, InputLabel} from "@mui/material";
import Box from "@mui/material/Box";
import Switch from "@mui/material/Switch";
import {ErrorMessage, Field, useFormikContext} from "formik";
import DynamicFormUtils from "qqq/components/forms/DynamicFormUtils";
import DynamicSelect from "qqq/components/forms/DynamicSelect";
import React, {useMemo, useState} from "react";
import AceEditor from "react-ace";
import colors from "qqq/assets/theme/base/colors";
import BooleanFieldSwitch from "qqq/components/forms/BooleanFieldSwitch";
import DynamicFormUtils from "qqq/components/forms/DynamicFormUtils";
import DynamicSelect from "qqq/components/forms/DynamicSelect";
import MDInput from "qqq/components/legacy/MDInput";
import MDTypography from "qqq/components/legacy/MDTypography";
import React, {useMemo, useState} from "react";
import AceEditor from "react-ace";
import {flushSync} from "react-dom";
// Declaring props types for FormField
@ -83,10 +84,10 @@ function QDynamicFormField({
if (placeholder)
{
inputProps.placeholder = placeholder
inputProps.placeholder = placeholder;
}
if(backgroundColor)
if (backgroundColor)
{
inputProps.sx = {
"&.MuiInputBase-root": {
@ -124,7 +125,7 @@ function QDynamicFormField({
{
onChange.onChange = (e: any) =>
{
if(isToUpperCase || isToLowerCase)
if (isToUpperCase || isToLowerCase)
{
const beforeStart = e.target.selectionStart;
const beforeEnd = e.target.selectionEnd;
@ -141,7 +142,7 @@ function QDynamicFormField({
newValue = newValue.toLowerCase();
}
setFieldValue(name, newValue);
if(onChangeCallback)
if (onChangeCallback)
{
onChangeCallback(newValue);
}
@ -153,7 +154,7 @@ function QDynamicFormField({
input.setSelectionRange(beforeStart, beforeEnd);
}
}
else if(onChangeCallback)
else if (onChangeCallback)
{
onChangeCallback(e.currentTarget.value);
}
@ -165,15 +166,15 @@ function QDynamicFormField({
***************************************************************************/
function dynamicSelectOnChange(newValue?: QPossibleValue)
{
if(onChangeCallback)
if (onChangeCallback)
{
onChangeCallback(newValue == null ? null : newValue.id)
onChangeCallback(newValue == null ? null : newValue.id);
}
}
let field;
let getsBulkEditHtmlLabel = true;
if(formFieldObject.possibleValueProps)
if (formFieldObject.possibleValueProps)
{
field = (<DynamicSelect
name={name}
@ -186,7 +187,7 @@ function QDynamicFormField({
onChange={dynamicSelectOnChange}
// otherValues={otherValuesMap}
useCase="form"
/>)
/>);
}
else if (type === "checkbox")
{
@ -220,7 +221,7 @@ function QDynamicFormField({
onChange={(value: string, event: any) =>
{
setFieldValue(name, value, false);
if(onChangeCallback)
if (onChangeCallback)
{
onChangeCallback(value);
}

View File

@ -174,7 +174,7 @@ function DynamicSelect({fieldPossibleValueProps, overrideId, name, fieldLabel, i
const filterInlinePossibleValues = (searchTerm: string, possibleValues: QPossibleValue[]): QPossibleValue[] =>
{
return possibleValues.filter(pv => pv.label?.toLowerCase().startsWith(searchTerm));
}
};
/***************************************************************************
@ -182,15 +182,15 @@ function DynamicSelect({fieldPossibleValueProps, overrideId, name, fieldLabel, i
***************************************************************************/
const loadResults = async (): Promise<QPossibleValue[]> =>
{
if(possibleValues)
if (possibleValues)
{
return filterInlinePossibleValues(searchTerm, possibleValues)
return filterInlinePossibleValues(searchTerm, possibleValues);
}
else
{
return await qController.possibleValues(tableName, processName, possibleValueSourceName ?? fieldName, searchTerm ?? "", null, otherValues, useCase);
}
return await qController.possibleValues(tableName, processName, possibleValueSourceName ?? fieldName, searchTerm ?? "", null, null, otherValues, useCase);
}
};
/***************************************************************************

View File

@ -184,9 +184,9 @@ function EntityForm(props: Props): JSX.Element
///////////////////////////////////////////////////////////////////////////////////////
// copy values from specified fields in the parent record down into the child record //
///////////////////////////////////////////////////////////////////////////////////////
if(widgetData.defaultValuesForNewChildRecordsFromParentFields)
if (widgetData.defaultValuesForNewChildRecordsFromParentFields)
{
for(let childField in widgetData.defaultValuesForNewChildRecordsFromParentFields)
for (let childField in widgetData.defaultValuesForNewChildRecordsFromParentFields)
{
const parentField = widgetData.defaultValuesForNewChildRecordsFromParentFields[childField];
defaultValues[childField] = formValues[parentField];
@ -278,21 +278,21 @@ function EntityForm(props: Props): JSX.Element
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// build a map of display values for the new record, specifically, for any possible-values that need translated. //
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
const displayValues: {[fieldName: string]: string} = {};
if(childTableName && values)
const displayValues: { [fieldName: string]: string } = {};
if (childTableName && values)
{
///////////////////////////////////////////////////////////////////////////////////////////////////////
// this function internally memoizes, so, we could potentially avoid an await here, but, seems ok... //
///////////////////////////////////////////////////////////////////////////////////////////////////////
const childTableMetaData = await qController.loadTableMetaData(childTableName)
const childTableMetaData = await qController.loadTableMetaData(childTableName);
for (let key in values)
{
const value = values[key];
const field = childTableMetaData.fields.get(key);
if(field.possibleValueSourceName)
if (field.possibleValueSourceName)
{
const possibleValues = await qController.possibleValues(childTableName, null, field.name, null, [value], objectToMap(values), "form")
if(possibleValues && possibleValues.length > 0)
const possibleValues = await qController.possibleValues(childTableName, null, field.name, null, [value], null, objectToMap(values), "form");
if (possibleValues && possibleValues.length > 0)
{
displayValues[key] = possibleValues[0].label;
}
@ -516,13 +516,12 @@ function EntityForm(props: Props): JSX.Element
}
/***************************************************************************
**
***************************************************************************/
function objectToMap(object: { [key: string]: any }): Map<string, any>
{
if(object == null)
if (object == null)
{
return (null);
}
@ -532,7 +531,7 @@ function EntityForm(props: Props): JSX.Element
{
rs.set(key, object[key]);
}
return rs
return rs;
}
@ -667,7 +666,7 @@ function EntityForm(props: Props): JSX.Element
const defaultValue = (defaultValues && defaultValues[fieldName]) ? defaultValues[fieldName] : fieldMetaData.defaultValue;
if (defaultValue && fieldMetaData.possibleValueSourceName)
{
const results: QPossibleValue[] = await qController.possibleValues(tableName, null, fieldName, null, [initialValues[fieldName]], objectToMap(initialValues), "form");
const results: QPossibleValue[] = await qController.possibleValues(tableName, null, fieldName, null, [initialValues[fieldName]], null, objectToMap(initialValues), "form");
if (results && results.length > 0)
{
defaultDisplayValues.set(fieldName, results[0].label);

View File

@ -59,7 +59,8 @@ interface Props
bulkLoadProfileOnChangeCallback?: (record: QRecord | null) => void,
allowSelectingProfile?: boolean,
fileDescription?: FileDescription,
bulkLoadProfileResetToSuggestedMappingCallback?: () => void
bulkLoadProfileResetToSuggestedMappingCallback?: () => void,
isBulkEdit?: boolean;
}
SavedBulkLoadProfiles.defaultProps = {
@ -72,7 +73,7 @@ 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
function SavedBulkLoadProfiles({metaData, tableMetaData, tableStructure, currentSavedBulkLoadProfileRecord, bulkLoadProfileOnChangeCallback, currentMapping, allowSelectingProfile, fileDescription, bulkLoadProfileResetToSuggestedMappingCallback, isBulkEdit}: Props): JSX.Element
{
const [yourSavedBulkLoadProfiles, setYourSavedBulkLoadProfiles] = useState([] as QRecord[]);
const [bulkLoadProfilesSharedWithYou, setBulkLoadProfilesSharedWithYou] = useState([] as QRecord[]);
@ -142,6 +143,7 @@ function SavedBulkLoadProfiles({metaData, tableMetaData, tableStructure, current
const formData = new FormData();
formData.append("tableName", tableMetaData.name);
formData.append("isBulkEdit", isBulkEdit.toString());
const savedBulkLoadProfiles = await makeSavedBulkLoadProfileRequest("querySavedBulkLoadProfile", formData);
const yourSavedBulkLoadProfiles: QRecord[] = [];
@ -212,7 +214,7 @@ function SavedBulkLoadProfiles({metaData, tableMetaData, tableStructure, current
break;
case RESET_TO_SUGGESTION:
setSavePopupOpen(false);
if(bulkLoadProfileResetToSuggestedMappingCallback)
if (bulkLoadProfileResetToSuggestedMappingCallback)
{
bulkLoadProfileResetToSuggestedMappingCallback();
}
@ -265,6 +267,7 @@ function SavedBulkLoadProfiles({metaData, tableMetaData, tableStructure, current
const bulkLoadProfile = currentMapping.toProfile();
const mappingJson = JSON.stringify(bulkLoadProfile.profile);
formData.append("mappingJson", mappingJson);
formData.append("isBulkEdit", isBulkEdit.toString());
if (isSaveAsAction || isRenameAction || currentSavedBulkLoadProfileRecord == null)
{
@ -389,6 +392,7 @@ function SavedBulkLoadProfiles({metaData, tableMetaData, tableStructure, current
return (savedBulkLoadProfiles);
}
const bulkAction = isBulkEdit ? "Edit" : "Load";
const hasStorePermission = metaData?.processes.has("storeSavedBulkLoadProfile");
const hasDeletePermission = metaData?.processes.has("deleteSavedBulkLoadProfile");
const hasQueryPermission = metaData?.processes.has("querySavedBulkLoadProfile");
@ -428,15 +432,15 @@ function SavedBulkLoadProfiles({metaData, tableMetaData, tableStructure, current
PaperProps={{style: {maxHeight: "calc(100vh - 200px)", minWidth: menuWidth}}}
>
{
<MenuItem sx={{width: menuWidth}} disabled style={{opacity: "initial"}}><b>Bulk Load Profile Actions</b></MenuItem>
<MenuItem sx={{width: menuWidth}} disabled style={{opacity: "initial"}}><b>Bulk {bulkAction} 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>
<span>You are using the bulk {bulkAction.toLowerCase()} 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 {bulkAction.toLowerCase()} profile.<br /><br />You can save your profile on this screen.</span>
}
</MenuItem>
}
@ -456,7 +460,7 @@ function SavedBulkLoadProfiles({metaData, tableMetaData, tableStructure, current
}
{
hasStorePermission && currentSavedBulkLoadProfileRecord != null &&
<Tooltip {...menuTooltipAttribs} title={notOwnerTooltipText ?? "Change the name for this saved bulk load profile."}>
<Tooltip {...menuTooltipAttribs} title={notOwnerTooltipText ?? "Change the name for this saved bulk {bulkAction.toLowerCase()} profile."}>
<span>
<MenuItem disabled={currentSavedBulkLoadProfileRecord === null || disabledBecauseNotOwner} onClick={() => handleDropdownOptionClick(RENAME_OPTION)}>
<ListItemIcon><Icon>edit</Icon></ListItemIcon>
@ -467,7 +471,7 @@ function SavedBulkLoadProfiles({metaData, tableMetaData, tableStructure, current
}
{
hasStorePermission && currentSavedBulkLoadProfileRecord != null &&
<Tooltip {...menuTooltipAttribs} title="Save a new copy this bulk load profile, with a different name, separate from the original.">
<Tooltip {...menuTooltipAttribs} title="Save a new copy this bulk {bulkAction.toLowerCase()} profile, with a different name, separate from the original.">
<span>
<MenuItem disabled={currentSavedBulkLoadProfileRecord === null} onClick={() => handleDropdownOptionClick(DUPLICATE_OPTION)}>
<ListItemIcon><Icon>content_copy</Icon></ListItemIcon>
@ -478,7 +482,7 @@ function SavedBulkLoadProfiles({metaData, tableMetaData, tableStructure, current
}
{
hasDeletePermission && currentSavedBulkLoadProfileRecord != null &&
<Tooltip {...menuTooltipAttribs} title={notOwnerTooltipText ?? "Delete this saved bulk load profile."}>
<Tooltip {...menuTooltipAttribs} title={notOwnerTooltipText ?? "Delete this saved bulk {bulkAction.toLowerCase()} profile."}>
<span>
<MenuItem disabled={currentSavedBulkLoadProfileRecord === null || disabledBecauseNotOwner} onClick={() => handleDropdownOptionClick(DELETE_OPTION)}>
<ListItemIcon><Icon>delete</Icon></ListItemIcon>
@ -489,11 +493,11 @@ function SavedBulkLoadProfiles({metaData, tableMetaData, tableStructure, current
}
{
allowSelectingProfile &&
<Tooltip {...menuTooltipAttribs} title="Create a new blank bulk load profile for this table, removing all mappings.">
<Tooltip {...menuTooltipAttribs} title="Create a new blank bulk {bulkAction.toLowerCase()} profile for this table, removing all mappings.">
<span>
<MenuItem onClick={() => handleDropdownOptionClick(CLEAR_OPTION)}>
<ListItemIcon><Icon>monitor</Icon></ListItemIcon>
New Bulk Load Profile
New Bulk {bulkAction} Profile
</MenuItem>
</span>
</Tooltip>
@ -504,7 +508,7 @@ function SavedBulkLoadProfiles({metaData, tableMetaData, tableStructure, current
{
<Divider />
}
<MenuItem disabled style={{"opacity": "initial"}}><b>Your Saved Bulk Load Profiles</b></MenuItem>
<MenuItem disabled style={{"opacity": "initial"}}><b>Your Saved Bulk {bulkAction} Profiles</b></MenuItem>
{
yourSavedBulkLoadProfiles && yourSavedBulkLoadProfiles.length > 0 ? (
yourSavedBulkLoadProfiles.map((record: QRecord, index: number) =>
@ -514,11 +518,11 @@ function SavedBulkLoadProfiles({metaData, tableMetaData, tableStructure, current
)
) : (
<MenuItem disabled sx={{opacity: "1 !important"}}>
<i>You do not have any saved bulk load profiles for this table.</i>
<i>You do not have any saved bulk {bulkAction.toLowerCase()} profiles for this table.</i>
</MenuItem>
)
}
<MenuItem disabled style={{"opacity": "initial"}}><b>Bulk Load Profiles Shared with you</b></MenuItem>
<MenuItem disabled style={{"opacity": "initial"}}><b>Bulk {bulkAction} Profiles Shared with you</b></MenuItem>
{
bulkLoadProfilesSharedWithYou && bulkLoadProfilesSharedWithYou.length > 0 ? (
bulkLoadProfilesSharedWithYou.map((record: QRecord, index: number) =>
@ -528,7 +532,7 @@ function SavedBulkLoadProfiles({metaData, tableMetaData, tableStructure, current
)
) : (
<MenuItem disabled sx={{opacity: "1 !important"}}>
<i>You do not have any bulk load profiles shared with you for this table.</i>
<i>You do not have any bulk {bulkAction.toLowerCase()} profiles shared with you for this table.</i>
</MenuItem>
)
}
@ -537,7 +541,7 @@ function SavedBulkLoadProfiles({metaData, tableMetaData, tableStructure, current
</Menu>
);
let buttonText = "Saved Bulk Load Profiles";
let buttonText = `Saved Bulk ${bulkAction} Profiles`;
let buttonBackground = "none";
let buttonBorder = colors.grayLines.main;
let buttonColor = colors.gray.main;
@ -639,13 +643,13 @@ function SavedBulkLoadProfiles({metaData, tableMetaData, tableStructure, current
<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>
<li>You are not using a saved bulk {bulkAction.toLowerCase()} 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&hellip;</Button>
<Button disableRipple={true} sx={linkButtonStyle} onClick={() => handleDropdownOptionClick(SAVE_OPTION)}>Save Bulk {bulkAction} Profile As&hellip;</Button>
</Tooltip>
{/* vertical rule */}
@ -716,20 +720,20 @@ function SavedBulkLoadProfiles({metaData, tableMetaData, tableStructure, current
{
currentSavedBulkLoadProfileRecord ? (
isDeleteAction ? (
<DialogTitle id="alert-dialog-title">Delete Bulk Load Profile</DialogTitle>
<DialogTitle id="alert-dialog-title">Delete Bulk {bulkAction} Profile</DialogTitle>
) : (
isSaveAsAction ? (
<DialogTitle id="alert-dialog-title">Save Bulk Load Profile As</DialogTitle>
<DialogTitle id="alert-dialog-title">Save Bulk {bulkAction} Profile As</DialogTitle>
) : (
isRenameAction ? (
<DialogTitle id="alert-dialog-title">Rename Bulk Load Profile</DialogTitle>
<DialogTitle id="alert-dialog-title">Rename Bulk {bulkAction} Profile</DialogTitle>
) : (
<DialogTitle id="alert-dialog-title">Update Existing Bulk Load Profile</DialogTitle>
<DialogTitle id="alert-dialog-title">Update Existing Bulk {bulkAction} Profile</DialogTitle>
)
)
)
) : (
<DialogTitle id="alert-dialog-title">Save New Bulk Load Profile</DialogTitle>
<DialogTitle id="alert-dialog-title">Save New Bulk {bulkAction} Profile</DialogTitle>
)
}
<DialogContent sx={{width: "500px"}}>
@ -743,15 +747,15 @@ function SavedBulkLoadProfiles({metaData, tableMetaData, tableStructure, current
<Box>
{
isSaveAsAction ? (
<Box mb={3}>Enter a name for this new saved bulk load profile.</Box>
<Box mb={3}>Enter a name for this new saved bulk {bulkAction.toLowerCase()} profile.</Box>
) : (
<Box mb={3}>Enter a new name for this saved bulk load profile.</Box>
<Box mb={3}>Enter a new name for this saved bulk {bulkAction.toLowerCase()} profile.</Box>
)
}
<TextField
autoFocus
name="custom-delimiter-value"
placeholder="Bulk Load Profile Name"
placeholder={`Bulk ${bulkAction} Profile Name`}
inputProps={{width: "100%", maxLength: 100}}
value={savedBulkLoadProfileNameInputValue}
sx={{width: "100%"}}
@ -764,9 +768,9 @@ function SavedBulkLoadProfiles({metaData, tableMetaData, tableStructure, current
</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 delete the bulk {bulkAction.toLowerCase()} profile {`'${currentSavedBulkLoadProfileRecord?.values.get("label")}'`}?</Box>
) : (
<Box>Are you sure you want to update the bulk load profile {`'${currentSavedBulkLoadProfileRecord?.values.get("label")}'`}?</Box>
<Box>Are you sure you want to update the bulk {bulkAction.toLowerCase()} profile {`'${currentSavedBulkLoadProfileRecord?.values.get("label")}'`}?</Box>
)
)
}

View File

@ -26,7 +26,6 @@ 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";
@ -44,6 +43,7 @@ interface BulkLoadMappingFieldProps
removeFieldCallback?: () => void,
fileDescription: FileDescription,
forceParentUpdate?: () => void,
isBulkEdit?: boolean
}
const xIconButtonSX =
@ -72,7 +72,7 @@ const qController = Client.getInstance();
/***************************************************************************
** row for a single field on the bulk load mapping screen.
***************************************************************************/
export default function BulkLoadFileMappingField({bulkLoadField, isRequired, removeFieldCallback, fileDescription, forceParentUpdate}: BulkLoadMappingFieldProps): JSX.Element
export default function BulkLoadFileMappingField({bulkLoadField, isRequired, removeFieldCallback, fileDescription, forceParentUpdate, isBulkEdit}: BulkLoadMappingFieldProps): JSX.Element
{
const columnNames = fileDescription.getColumnNames();
@ -94,7 +94,7 @@ export default function BulkLoadFileMappingField({bulkLoadField, isRequired, rem
// deal with dynamically loading the initial default value for a possible value... //
/////////////////////////////////////////////////////////////////////////////////////
let actuallyDoingInitialLoadOfPossibleValue = doingInitialLoadOfPossibleValue;
if(dynamicField.possibleValueProps && bulkLoadField.defaultValue && !doingInitialLoadOfPossibleValue && !everDidInitialLoadOfPossibleValue)
if (dynamicField.possibleValueProps && bulkLoadField.defaultValue && !doingInitialLoadOfPossibleValue && !everDidInitialLoadOfPossibleValue)
{
actuallyDoingInitialLoadOfPossibleValue = true;
setDoingInitialLoadOfPossibleValue(true);
@ -104,7 +104,7 @@ export default function BulkLoadFileMappingField({bulkLoadField, isRequired, rem
{
try
{
const possibleValues = await qController.possibleValues(bulkLoadField.tableStructure.tableName, null, fieldMetaData.name, null, [bulkLoadField.defaultValue], undefined, "filter");
const possibleValues = await qController.possibleValues(bulkLoadField.tableStructure.tableName, null, fieldMetaData.name, null, [bulkLoadField.defaultValue], undefined, null, "filter");
if (possibleValues && possibleValues.length > 0)
{
setPossibleValueInitialDisplayValue(possibleValues[0].label);
@ -114,9 +114,9 @@ export default function BulkLoadFileMappingField({bulkLoadField, isRequired, rem
setPossibleValueInitialDisplayValue(null);
}
}
catch(e)
catch (e)
{
console.log(`Error loading possible value: ${e}`)
console.log(`Error loading possible value: ${e}`);
}
actuallyDoingInitialLoadOfPossibleValue = false;
@ -124,7 +124,7 @@ export default function BulkLoadFileMappingField({bulkLoadField, isRequired, rem
})();
}
if(dynamicField.possibleValueProps && possibleValueInitialDisplayValue)
if (dynamicField.possibleValueProps && possibleValueInitialDisplayValue)
{
dynamicField.possibleValueProps.initialDisplayValue = possibleValueInitialDisplayValue;
}
@ -134,11 +134,11 @@ export default function BulkLoadFileMappingField({bulkLoadField, isRequired, rem
// don't allow duplicates //
//////////////////////////////////////////////////////
const columnOptions: { value: number, label: string }[] = [];
const usedLabels: {[label: string]: boolean} = {};
const usedLabels: { [label: string]: boolean } = {};
for (let i = 0; i < columnNames.length; i++)
{
const label = columnNames[i];
if(!usedLabels[label])
if (!usedLabels[label])
{
columnOptions.push({label: label, value: i});
usedLabels[label] = true;
@ -148,9 +148,9 @@ export default function BulkLoadFileMappingField({bulkLoadField, isRequired, rem
//////////////////////////////////////////////////////////////////////
// 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)
if (bulkLoadField.columnIndex != null && bulkLoadField.columnIndex != undefined && selectedColumn.label && columnNames[bulkLoadField.columnIndex] != selectedColumn.label)
{
setSelectedColumn({label: columnNames[bulkLoadField.columnIndex], value: bulkLoadField.columnIndex})
setSelectedColumn({label: columnNames[bulkLoadField.columnIndex], value: bulkLoadField.columnIndex});
setSelectedColumnInputValue(columnNames[bulkLoadField.columnIndex]);
}
@ -228,6 +228,17 @@ export default function BulkLoadFileMappingField({bulkLoadField, isRequired, rem
forceParentUpdate && forceParentUpdate();
}
/***************************************************************************
**
***************************************************************************/
function clearIfEmptyChanged(value: boolean)
{
bulkLoadField.clearIfEmpty = value;
forceParentUpdate && forceParentUpdate();
}
/***************************************************************************
**
***************************************************************************/
@ -314,8 +325,11 @@ export default function BulkLoadFileMappingField({bulkLoadField, isRequired, rem
<Box ml="1rem">
{
valueType == "column" && <>
<Box>
<Box display="flex" alignItems="center" sx={{height: "45px"}}>
<FormControlLabel value="mapValues" control={<Checkbox size="small" defaultChecked={bulkLoadField.doValueMapping} onChange={(event, checked) => mapValuesChanged(checked)} />} label={"Map values"} sx={{minWidth: "140px", whiteSpace: "nowrap"}} />
{
isBulkEdit && !isRequired && <FormControlLabel value="clearIfEmpty" control={<Checkbox size="small" defaultChecked={bulkLoadField.clearIfEmpty} onChange={(event, checked) => clearIfEmptyChanged(checked)} />} label={"Clear if empty"} sx={{minWidth: "140px", whiteSpace: "nowrap"}} />
}
</Box>
<Box fontSize={mainFontSize} mt="0.5rem">
Preview Values: <span style={{color: "gray"}}>{(fileDescription.getPreviewValues(selectedColumn?.value) ?? [""]).join(", ")}</span>

View File

@ -33,6 +33,7 @@ interface BulkLoadMappingFieldsProps
bulkLoadMapping: BulkLoadMapping,
fileDescription: FileDescription,
forceParentUpdate?: () => void,
isBulkEdit?: boolean
}
@ -43,7 +44,7 @@ const ALREADY_ADDED_FIELD_TOOLTIP = "This field has already been added to your m
/***************************************************************************
** The section of the bulk load mapping screen with all the fields.
***************************************************************************/
export default function BulkLoadFileMappingFields({bulkLoadMapping, fileDescription, forceParentUpdate}: BulkLoadMappingFieldsProps): JSX.Element
export default function BulkLoadFileMappingFields({bulkLoadMapping, fileDescription, forceParentUpdate, isBulkEdit}: BulkLoadMappingFieldsProps): JSX.Element
{
const [, forceUpdate] = useReducer((x) => x + 1, 0);
@ -254,11 +255,16 @@ export default function BulkLoadFileMappingFields({bulkLoadMapping, fileDescript
return (
<>
<h5>Required Fields</h5>
{isBulkEdit ? <h5>Key Fields</h5> : <h5>Required Fields</h5>}
<Box pl={"1rem"}>
{
bulkLoadMapping.requiredFields.length == 0 &&
(
isBulkEdit ?
<i style={{fontSize: "0.875rem"}}>Select table key fields to continue.</i>
:
<i style={{fontSize: "0.875rem"}}>There are no required fields in this table.</i>
)
}
{bulkLoadMapping.requiredFields.map((bulkLoadField) => (
<BulkLoadFileMappingField
@ -267,12 +273,13 @@ export default function BulkLoadFileMappingFields({bulkLoadMapping, fileDescript
bulkLoadField={bulkLoadField}
isRequired={true}
forceParentUpdate={forceParentUpdate}
isBulkEdit={isBulkEdit}
/>
))}
</Box>
<Box mt="1rem">
<h5>Additional Fields</h5>
{isBulkEdit ? <h5>Fields To Update</h5> : <h5>Additional Fields</h5>}
<Box pl={"1rem"}>
{bulkLoadMapping.additionalFields.map((bulkLoadField) => (
<BulkLoadFileMappingField
@ -282,6 +289,7 @@ export default function BulkLoadFileMappingFields({bulkLoadMapping, fileDescript
isRequired={false}
removeFieldCallback={() => removeField(bulkLoadField)}
forceParentUpdate={forceParentUpdate}
isBulkEdit={isBulkEdit}
/>
))}

View File

@ -36,15 +36,18 @@ import {useFormikContext} from "formik";
import colors from "qqq/assets/theme/base/colors";
import {DynamicFormFieldLabel} from "qqq/components/forms/DynamicForm";
import QDynamicFormField from "qqq/components/forms/DynamicFormField";
import DynamicFormUtils from "qqq/components/forms/DynamicFormUtils";
import MDTypography from "qqq/components/legacy/MDTypography";
import HelpContent from "qqq/components/misc/HelpContent";
import SavedBulkLoadProfiles from "qqq/components/misc/SavedBulkLoadProfiles";
import BulkLoadFileMappingFields from "qqq/components/processes/BulkLoadFileMappingFields";
import {BulkLoadField, BulkLoadMapping, BulkLoadProfile, BulkLoadTableStructure, FileDescription, Wrapper} from "qqq/models/processes/BulkLoadModels";
import {SubFormPreSubmitCallbackResultType} from "qqq/pages/processes/ProcessRun";
import React, {forwardRef, useImperativeHandle, useReducer, useState} from "react";
import Client from "qqq/utils/qqq/Client";
import React, {forwardRef, useEffect, useImperativeHandle, useReducer, useState} from "react";
import ProcessViewForm from "./ProcessViewForm";
const qController = Client.getInstance();
interface BulkLoadMappingFormProps
{
@ -73,13 +76,12 @@ const BulkLoadFileMappingForm = forwardRef(({processValues, tableMetaData, metaD
const [suggestedBulkLoadProfile] = useState(processValues.suggestedBulkLoadProfile as BulkLoadProfile);
const [tableStructure] = useState(processValues.tableStructure as BulkLoadTableStructure);
const [bulkLoadMapping, setBulkLoadMapping] = useState(BulkLoadMapping.fromBulkLoadProfile(tableStructure, processValues.bulkLoadProfile));
const [bulkLoadMapping, setBulkLoadMapping] = useState(BulkLoadMapping.fromBulkLoadProfile(tableStructure, processValues.bulkLoadProfile, processMetaData.name));
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);
/////////////////////////////////////////////////////////////////////////////////////////////////
@ -114,6 +116,8 @@ const BulkLoadFileMappingForm = forwardRef(({processValues, tableMetaData, metaD
values["savedBulkLoadProfileId"] = wrappedCurrentSavedBulkLoadProfile.get()?.values?.get("id");
values["layout"] = wrappedBulkLoadMapping.get().layout;
values["hasHeaderRow"] = wrappedBulkLoadMapping.get().hasHeaderRow;
values["isBulkEdit"] = wrappedBulkLoadMapping.get().isBulkEdit;
values["keyFields"] = wrappedBulkLoadMapping.get().keyFields;
let haveLocalErrors = false;
const fieldErrors: { [fieldName: string]: string } = {};
@ -130,7 +134,14 @@ const BulkLoadFileMappingForm = forwardRef(({processValues, tableMetaData, metaD
}
setFieldErrors(fieldErrors);
if(wrappedBulkLoadMapping.get().requiredFields.length == 0 && wrappedBulkLoadMapping.get().additionalFields.length == 0)
if (values["isBulkEdit"] && (values["keyFields"] == null || values["keyFields"] == undefined))
{
haveLocalErrors = true;
fieldErrors["keyFields"] = "This field is required.";
}
setFieldErrors(fieldErrors);
if (wrappedBulkLoadMapping.get().requiredFields.length == 0 && wrappedBulkLoadMapping.get().additionalFields.length == 0)
{
setNoMappedFieldsError("You must have at least 1 field.");
haveLocalErrors = true;
@ -141,7 +152,7 @@ const BulkLoadFileMappingForm = forwardRef(({processValues, tableMetaData, metaD
setNoMappedFieldsError(null);
}
if(haveProfileErrors)
if (haveProfileErrors)
{
setTimeout(() =>
{
@ -182,7 +193,7 @@ const BulkLoadFileMappingForm = forwardRef(({processValues, tableMetaData, metaD
***************************************************************************/
function bulkLoadProfileResetToSuggestedMappingCallback()
{
handleNewBulkLoadMapping(BulkLoadMapping.fromBulkLoadProfile(processValues.tableStructure, suggestedBulkLoadProfile));
handleNewBulkLoadMapping(BulkLoadMapping.fromBulkLoadProfile(processValues.tableStructure, suggestedBulkLoadProfile, processValues.name));
}
@ -201,6 +212,8 @@ const BulkLoadFileMappingForm = forwardRef(({processValues, tableMetaData, metaD
setBulkLoadMapping(newBulkLoadMapping);
wrappedBulkLoadMapping.set(newBulkLoadMapping);
setFieldValue("isBulkEdit", newBulkLoadMapping.isBulkEdit);
setFieldValue("keyFields", newBulkLoadMapping.keyFields);
setFieldValue("hasHeaderRow", newBulkLoadMapping.hasHeaderRow);
setFieldValue("layout", newBulkLoadMapping.layout);
@ -228,10 +241,13 @@ const BulkLoadFileMappingForm = forwardRef(({processValues, tableMetaData, metaD
bulkLoadProfileOnChangeCallback={bulkLoadProfileOnChangeCallback}
bulkLoadProfileResetToSuggestedMappingCallback={bulkLoadProfileResetToSuggestedMappingCallback}
fileDescription={fileDescription}
isBulkEdit={processValues.isBulkEdit}
/>
</Box>
<BulkLoadMappingHeader
tableMetaData={tableMetaData}
isBulkEdit={processValues.isBulkEdit}
key={rerenderHeader}
bulkLoadMapping={bulkLoadMapping}
fileDescription={fileDescription}
@ -245,6 +261,7 @@ const BulkLoadFileMappingForm = forwardRef(({processValues, tableMetaData, metaD
<Box mt="2rem">
<BulkLoadFileMappingFields
isBulkEdit={processValues.isBulkEdit}
bulkLoadMapping={bulkLoadMapping}
fileDescription={fileDescription}
forceParentUpdate={() =>
@ -267,6 +284,7 @@ export default BulkLoadFileMappingForm;
interface BulkLoadMappingHeaderProps
{
isBulkEdit?: boolean,
fileDescription: FileDescription,
fileName: string,
bulkLoadMapping?: BulkLoadMapping,
@ -275,13 +293,16 @@ interface BulkLoadMappingHeaderProps
forceParentUpdate?: () => void,
frontendStep: QFrontendStepMetaData,
processMetaData: QProcessMetaData,
tableMetaData: QTableMetaData,
}
/***************************************************************************
** private subcomponent - the header section of the bulk load file mapping screen.
***************************************************************************/
function BulkLoadMappingHeader({fileDescription, fileName, bulkLoadMapping, fieldErrors, tableStructure, forceParentUpdate, frontendStep, processMetaData}: BulkLoadMappingHeaderProps): JSX.Element
function BulkLoadMappingHeader({isBulkEdit, fileDescription, fileName, bulkLoadMapping, fieldErrors, tableStructure, forceParentUpdate, frontendStep, processMetaData, tableMetaData}: BulkLoadMappingHeaderProps): JSX.Element
{
const [dynamicField, setDynamicField] = useState(null);
const viewFields = [
new QFieldMetaData({name: "fileName", label: "File Name", type: "STRING"}),
new QFieldMetaData({name: "fileDetails", label: "File Details", type: "STRING"}),
@ -307,6 +328,36 @@ function BulkLoadMappingHeader({fileDescription, fileName, bulkLoadMapping, fiel
const selectedLayout = layoutOptions.filter(o => o.id == bulkLoadMapping.layout)[0] ?? null;
useEffect(() =>
{
(async () =>
{
if (isBulkEdit)
{
/////////////////////////////////////////////////////////////////////////
// if doing a bulk edit, the selected keyFields and set as the display //
/////////////////////////////////////////////////////////////////////////
const displayValues = new Map<string, string>;
if (bulkLoadMapping.keyFields)
{
const possibleValues = await qController.possibleValues(null, processMetaData.name, "tableKeyFields", bulkLoadMapping.keyFields, null);
console.log("Received possible values of: " + JSON.stringify(possibleValues));
displayValues.set("tableKeyFields", possibleValues[0].label);
}
const tableKeyFieldsField = processMetaData.frontendSteps.find(s => s.name == "fileMapping")?.formFields.find(f => f.name == "tableKeyFields");
const newDynamicField = DynamicFormUtils.getDynamicField(tableKeyFieldsField);
const dynamicFieldInObject: any = {};
dynamicFieldInObject[tableKeyFieldsField["name"]] = newDynamicField;
DynamicFormUtils.addPossibleValueProps(dynamicFieldInObject, [tableKeyFieldsField], null, processMetaData.name, displayValues);
keyFieldsChanged(bulkLoadMapping.keyFields);
setDynamicField(newDynamicField);
forceParentUpdate();
}
})();
}, [JSON.stringify(bulkLoadMapping)]);
/***************************************************************************
**
***************************************************************************/
@ -331,6 +382,61 @@ function BulkLoadMappingHeader({fileDescription, fileName, bulkLoadMapping, fiel
forceParentUpdate();
}
/***************************************************************************
**
***************************************************************************/
async function keyFieldsChanged(newValue: any)
{
fieldErrors.keyFields = null;
if (newValue && newValue.length > 0)
{
//////////////////////////////////////////////////////////
// validate that the fields in the key have been mapped //
//////////////////////////////////////////////////////////
console.log("Received key fields of: " + newValue);
const keyFields = newValue.split("|");
const unmappedKeyFields: string[] = [];
const requiredFields: BulkLoadField[] = [];
const additionalFields: BulkLoadField[] = [];
////////////////////////////////////////////////////////////////////////////////////////////////
// iterate over all fields in the table, when there are key fields found, make them required, //
// otherwise add them to addition fields //
////////////////////////////////////////////////////////////////////////////////////////////////
for (let bulkLoadField of [...bulkLoadMapping.requiredFields, ...bulkLoadMapping.additionalFields])
{
const qualifiedName = bulkLoadField.getQualifiedName();
const keyField = keyFields.find((k: string) => k == qualifiedName);
if (keyField)
{
requiredFields.push(bulkLoadField);
var fieldsByTablePrefix = bulkLoadMapping.fieldsByTablePrefix[""][keyField];
if (!fieldsByTablePrefix || fieldsByTablePrefix.columnIndex == null)
{
unmappedKeyFields.push(tableMetaData.fields.get(keyField).label);
}
}
else
{
additionalFields.push(bulkLoadField);
}
}
bulkLoadMapping.requiredFields = requiredFields;
bulkLoadMapping.additionalFields = additionalFields;
if (unmappedKeyFields.length > 0)
{
fieldErrors.keyFields = "The following key fields are not mapped: " + unmappedKeyFields.join(", ");
}
bulkLoadMapping.handleChangeToKeyFields(newValue);
}
forceParentUpdate();
}
/***************************************************************************
**
***************************************************************************/
@ -369,6 +475,9 @@ function BulkLoadMappingHeader({fileDescription, fileName, bulkLoadMapping, fiel
{getFormattedHelpContent("hasHeaderRow")}
</Grid>
<Grid item xs={12} md={6}>
{
!isBulkEdit ? (
<>
<DynamicFormFieldLabel name={"layout"} label={"File Layout *"} />
<Autocomplete
id={"layout"}
@ -390,6 +499,26 @@ function BulkLoadMappingHeader({fileDescription, fileName, bulkLoadMapping, fiel
</MDTypography>
}
{getFormattedHelpContent("layout")}
</>
) : (
<>
{
dynamicField &&
<>
<DynamicFormFieldLabel name={dynamicField.name} label={`${dynamicField.label} *`} />
<QDynamicFormField name={dynamicField.name} displayFormat={""} label={""} formFieldObject={dynamicField} type={"pvs"} value={bulkLoadMapping.keyFields} onChangeCallback={keyFieldsChanged} />
{
fieldErrors.keyFields &&
<MDTypography component="div" variant="caption" color="error" fontWeight="regular" mt="0.25rem">
{<div className="fieldErrorMessage">{fieldErrors.keyFields}</div>}
</MDTypography>
}
{getFormattedHelpContent("tableKeyFields")}
</>
}
</>
)
}
</Grid>
</Grid>
</Box>
@ -490,12 +619,12 @@ function BulkLoadMappingFilePreview({fileDescription, bulkLoadMapping}: BulkLoad
const fields = bulkLoadMapping.getFieldsForColumnIndex(index);
const count = fields.length;
let dupeWarning = <></>
if(fileDescription.hasHeaderRow && fileDescription.duplicateHeaderIndexes[index])
let dupeWarning = <></>;
if (fileDescription.hasHeaderRow && fileDescription.duplicateHeaderIndexes[index])
{
dupeWarning = <Tooltip title="This column header is a duplicate. Only the first occurrance of it will be used." placement="top" enterDelay={500}>
<Icon color="warning" sx={{p: "0.125rem", mr: "0.25rem"}}>warning</Icon>
</Tooltip>
</Tooltip>;
}
return (<td key={letter} style={{textAlign: "center", color: getHeaderColor(count), cursor: getCursor(count)}}>
@ -528,24 +657,24 @@ function BulkLoadMappingFilePreview({fileDescription, bulkLoadMapping}: BulkLoad
const count = fields.length;
const tdStyle = {color: getHeaderColor(count), cursor: getCursor(count), backgroundColor: ""};
if(fileDescription.hasHeaderRow)
if (fileDescription.hasHeaderRow)
{
tdStyle.backgroundColor = "#ebebeb";
if(count > 0)
if (count > 0)
{
return <td key={value} style={tdStyle}>
<Tooltip title={getColumnTooltip(fields)} placement="top" enterDelay={500}><Box>{value}</Box></Tooltip>
</td>
</td>;
}
else
{
return <td key={value} style={tdStyle}>{value}</td>
return <td key={value} style={tdStyle}>{value}</td>;
}
}
else
{
return <td key={value} style={tdStyle}>{value}</td>
return <td key={value} style={tdStyle}>{value}</td>;
}
}
)}

View File

@ -43,12 +43,12 @@ interface BulkLoadValueMappingFormProps
const BulkLoadProfileForm = forwardRef(({processValues, tableMetaData, metaData}: BulkLoadValueMappingFormProps, ref) =>
{
const savedBulkLoadProfileRecordProcessValue = processValues.savedBulkLoadProfileRecord;
const [savedBulkLoadProfileRecord, setSavedBulkLoadProfileRecord] = useState(savedBulkLoadProfileRecordProcessValue == null ? null : new QRecord(savedBulkLoadProfileRecordProcessValue))
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 [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));
@ -93,6 +93,7 @@ const BulkLoadProfileForm = forwardRef(({processValues, tableMetaData, metaData}
allowSelectingProfile={false}
fileDescription={fileDescription}
bulkLoadProfileOnChangeCallback={bulkLoadProfileOnChangeCallback}
isBulkEdit={processValues.isBulkEdit}
/>
</Box>

View File

@ -75,7 +75,7 @@ const BulkLoadValueMappingForm = forwardRef(({processValues, setActiveStepLabel,
*******************************************************************************/
function initializeCurrentBulkLoadMapping(): BulkLoadMapping
{
const bulkLoadMapping = BulkLoadMapping.fromBulkLoadProfile(tableStructure, bulkLoadProfile);
const bulkLoadMapping = BulkLoadMapping.fromBulkLoadProfile(tableStructure, bulkLoadProfile, processValues.name);
if (!bulkLoadMapping.valueMappings[fieldFullName])
{
@ -155,7 +155,7 @@ const BulkLoadValueMappingForm = forwardRef(({processValues, setActiveStepLabel,
function mappedValueChanged(fileValue: string, newValue: any)
{
valueErrors[fileValue] = null;
if(newValue == null)
if (newValue == null)
{
delete currentMapping.valueMappings[fieldFullName][fileValue];
}
@ -195,6 +195,7 @@ const BulkLoadValueMappingForm = forwardRef(({processValues, setActiveStepLabel,
allowSelectingProfile={false}
bulkLoadProfileOnChangeCallback={bulkLoadProfileOnChangeCallback}
fileDescription={fileDescription}
isBulkEdit={processValues.isBulkEdit}
/>
</Box>

View File

@ -19,6 +19,10 @@
* 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 {QPossibleValue} from "@kingsrook/qqq-frontend-core/lib/model/QPossibleValue";
import {FormControl, InputLabel, Select, SelectChangeEvent} from "@mui/material";
import Box from "@mui/material/Box";
import Card from "@mui/material/Card";
@ -28,20 +32,26 @@ import Modal from "@mui/material/Modal";
import TextField from "@mui/material/TextField";
import Tooltip from "@mui/material/Tooltip";
import Typography from "@mui/material/Typography";
import {GridFilterItem} from "@mui/x-data-grid-pro";
import React, {useEffect, useState} from "react";
import {QCancelButton, QSaveButton} from "qqq/components/buttons/DefaultButtons";
import ChipTextField from "qqq/components/forms/ChipTextField";
import HelpContent from "qqq/components/misc/HelpContent";
import {LoadingState} from "qqq/models/LoadingState";
import Client from "qqq/utils/qqq/Client";
import React, {useEffect, useReducer, useState} from "react";
interface Props
{
type: string;
onSave: (newValues: any[]) => void;
table?: QTableMetaData;
field?: QFieldMetaData;
}
FilterCriteriaPaster.defaultProps = {};
const qController = Client.getInstance();
function FilterCriteriaPaster({type, onSave}: Props): JSX.Element
function FilterCriteriaPaster({table, field, type, onSave}: Props): JSX.Element
{
enum Delimiter
{
@ -68,6 +78,12 @@ function FilterCriteriaPaster({type, onSave}: Props): JSX.Element
mainCardStyles.width = "60%";
mainCardStyles.minWidth = "500px";
///////////////////////////////////////////////////////////////////////////////////////////
// add a LoadingState object, in case the initial loads (of meta data and view) are slow //
///////////////////////////////////////////////////////////////////////////////////////////
const [, forceUpdate] = useReducer((x) => x + 1, 0);
const [pageLoadingState, _] = useState(new LoadingState(forceUpdate));
//x const [gridFilterItem, setGridFilterItem] = useState(props.item);
const [pasteModalIsOpen, setPasteModalIsOpen] = useState(false);
const [inputText, setInputText] = useState("");
@ -75,8 +91,13 @@ function FilterCriteriaPaster({type, onSave}: Props): JSX.Element
const [delimiterCharacter, setDelimiterCharacter] = useState("");
const [customDelimiterValue, setCustomDelimiterValue] = useState("");
const [chipData, setChipData] = useState(undefined);
const [uniqueCount, setUniqueCount] = useState(undefined);
const [chipValidity, setChipValidity] = useState([] as boolean[]);
const [chipPVSIds, setChipPVSIds] = useState([] as any[]);
const [detectedText, setDetectedText] = useState("");
const [errorText, setErrorText] = useState("");
const [saveDisabled, setSaveDisabled] = useState(true);
const [metaData, setMetaData] = useState(null as QInstance);
//////////////////////////////////////////////////////////////
// handler for when paste icon is clicked in 'any' operator //
@ -92,6 +113,7 @@ function FilterCriteriaPaster({type, onSave}: Props): JSX.Element
setDelimiter("");
setDelimiterCharacter("");
setChipData([]);
setChipValidity([]);
setInputText("");
setDetectedText("");
setCustomDelimiterValue("");
@ -106,17 +128,42 @@ function FilterCriteriaPaster({type, onSave}: Props): JSX.Element
const handleSaveClicked = () =>
{
////////////////////////////////////////
// if numeric remove any non-numerics //
////////////////////////////////////////
///////////////////////////////////////////////////////////////
// if numeric remove any non-numerics, or invalid pvs values //
///////////////////////////////////////////////////////////////
let saveData = [];
let usedLabels = new Map<any, boolean>();
for (let i = 0; i < chipData.length; i++)
{
if (type !== "number" || !Number.isNaN(Number(chipData[i])))
if (chipValidity[i] === true)
{
if (type === "pvs")
{
/////////////////////////////////////////////
// if already used this PVS label, skip it //
/////////////////////////////////////////////
if (usedLabels.get(chipData[i]) != null)
{
continue;
}
saveData.push(new QPossibleValue({id: chipPVSIds[i], label: chipData[i]}));
usedLabels.set(chipData[i], true);
}
else
{
saveData.push(chipData[i]);
}
}
}
//////////////////////////////////////////
// for pvs, sort by label before saving //
//////////////////////////////////////////
if (type === "pvs")
{
saveData.sort((a: QPossibleValue, b: QPossibleValue) => b.label.localeCompare(a.label));
}
onSave(saveData);
@ -214,6 +261,12 @@ function FilterCriteriaPaster({type, onSave}: Props): JSX.Element
useEffect(() =>
{
(async () =>
{
const metaData = await qController.loadMetaData();
setMetaData(metaData);
})();
let currentDelimiter = delimiter;
let currentDelimiterCharacter = delimiterCharacter;
@ -246,10 +299,16 @@ function FilterCriteriaPaster({type, onSave}: Props): JSX.Element
let parts = inputText.split(regex);
let chipData = [] as string[];
/////////////////////////////////////////////////////////////////
// use a map to keep track of the counts for each unique value //
/////////////////////////////////////////////////////////////////
const uniqueValuesMap: { [key: string]: number } = {};
///////////////////////////////////////////////////////
// if delimiter is empty string, dont split anything //
///////////////////////////////////////////////////////
setErrorText("");
let invalidCount = 0;
if (currentDelimiterCharacter !== "")
{
for (let i = 0; i < parts.length; i++)
@ -259,30 +318,58 @@ function FilterCriteriaPaster({type, onSave}: Props): JSX.Element
{
chipData.push(part);
///////////////////////////////////////////////////////////
// if numeric, check that first before pushing as a chip //
///////////////////////////////////////////////////////////
if (type === "number" && Number.isNaN(Number(part)))
////////////////////////////////////////////////////////////////
// if numeric or pvs, check validity and add to invalid count //
////////////////////////////////////////////////////////////////
if (chipValidity[i] != null && chipValidity[i] !== true)
{
setErrorText("Some values are not numbers");
if ((type === "number" && Number.isNaN(Number(part))) || type === "pvs")
{
invalidCount++;
}
}
else
{
let count = uniqueValuesMap[part] == null ? 0 : uniqueValuesMap[part];
uniqueValuesMap[part] = count + 1;
}
}
}
}
if (invalidCount > 0)
{
if (type === "number")
{
let suffix = invalidCount === 1 ? " value is not a number" : " values are not numbers";
setErrorText(invalidCount + suffix + " and will not be added to the filter");
}
else if (type === "pvs")
{
let suffix = invalidCount === 1 ? " value was" : " values were";
setErrorText(invalidCount + suffix + " not found and will not be added to the filter");
}
}
setUniqueCount(Object.keys(uniqueValuesMap).length);
setChipData(chipData);
}, [inputText, delimiterCharacter, customDelimiterValue, detectedText]);
}, [inputText, delimiterCharacter, customDelimiterValue, detectedText, chipValidity]);
const slotName = type === "pvs" ? "bulkAddFilterValuesPossibleValueSource" : "bulkAddFilterValues";
const helpRoles = ["QUERY_SCREEN"];
const formattedHelpContent = <HelpContent helpContents={metaData?.helpContent?.get(slotName)} roles={helpRoles} heading={null} helpContentKey={`instanceLevel:true;slot:${slotName}`} />;
return (
<Box>
<Tooltip title="Quickly add many values to your filter by pasting them from a spreadsheet or any other data source.">
<Icon onClick={handlePasteClick} fontSize="small" color="info" sx={{mx: 0.25, cursor: "pointer"}}>paste_content</Icon>
<Icon className="criteriaPasterButton" onClick={handlePasteClick} fontSize="small" color="info" sx={{mx: 0.25, cursor: "pointer"}}>paste_content</Icon>
</Tooltip>
{
pasteModalIsOpen &&
(
<Modal open={pasteModalIsOpen}>
<Box>
<Box sx={{position: "absolute", overflowY: "auto", width: "100%"}}>
<Box py={3} justifyContent="center" sx={{display: "flex", mt: 8}}>
<Card sx={mainCardStyles}>
@ -290,11 +377,13 @@ function FilterCriteriaPaster({type, onSave}: Props): JSX.Element
<Grid container>
<Grid item pr={3} xs={12} lg={12}>
<Typography variant="h5">Bulk Add Filter Values</Typography>
{
formattedHelpContent && <Box sx={{display: "flex", lineHeight: "1.7", textTransform: "none"}}>
<Typography sx={{display: "flex", lineHeight: "1.7", textTransform: "revert"}} variant="button">
Paste into the box on the left.
Review the filter values in the box on the right.
If the filter values are not what are expected, try changing the separator using the dropdown below.
{formattedHelpContent}
</Typography>
</Box>
}
</Grid>
</Grid>
</Box>
@ -302,6 +391,7 @@ function FilterCriteriaPaster({type, onSave}: Props): JSX.Element
<Grid item pr={3} xs={6} lg={6} sx={{width: "100%", display: "flex", flexDirection: "column", flexGrow: 1}}>
<FormControl sx={{m: 1, width: "100%"}}>
<TextField
className="criteriaPasterTextArea"
id="outlined-multiline-static"
label="PASTE TEXT"
multiline
@ -314,10 +404,25 @@ function FilterCriteriaPaster({type, onSave}: Props): JSX.Element
<Grid item xs={6} lg={6} sx={{display: "flex", flexGrow: 1}}>
<FormControl sx={{m: 1, width: "100%"}}>
<ChipTextField
handleChipChange={() =>
handleChipChange={(isMakingRequest: boolean, chipValidity: boolean[], chipPVSIds: any[]) =>
{
setErrorText("");
if (isMakingRequest)
{
pageLoadingState.setLoading();
}
else
{
pageLoadingState.setNotLoading();
}
setSaveDisabled(isMakingRequest);
setChipPVSIds(chipPVSIds);
setChipValidity(chipValidity);
}}
table={table}
field={field}
chipData={chipData}
chipValidity={chipValidity}
chipType={type}
multiline
fullWidth
@ -377,7 +482,7 @@ function FilterCriteriaPaster({type, onSave}: Props): JSX.Element
)}
</Box>
</Grid>
<Grid sx={{display: "flex", justifyContent: "flex-start", alignItems: "flex-start"}} item pl={1} xs={3} lg={3}>
<Grid sx={{display: "flex", justifyContent: "flex-start", alignItems: "flex-start"}} item pl={1} xs={4} lg={4}>
{
errorText && chipData.length > 0 && (
<Box sx={{display: "flex", justifyContent: "flex-start", alignItems: "flex-start"}}>
@ -386,11 +491,19 @@ function FilterCriteriaPaster({type, onSave}: Props): JSX.Element
</Box>
)
}
{
pageLoadingState.isLoadingSlow() && (
<Box sx={{display: "flex", justifyContent: "flex-start", alignItems: "flex-start"}}>
<Icon color="warning">warning</Icon>
<Typography sx={{paddingLeft: "4px", textTransform: "revert"}} variant="button">Loading...</Typography>
</Box>
)
}
</Grid>
<Grid sx={{display: "flex", justifyContent: "flex-end", alignItems: "flex-start"}} item pr={1} xs={3} lg={3}>
<Grid sx={{display: "flex", justifyContent: "flex-end", alignItems: "flex-start"}} item pr={1} xs={2} lg={2}>
{
chipData && chipData.length > 0 && (
<Typography sx={{textTransform: "revert"}} variant="button">{chipData.length.toLocaleString()} {chipData.length === 1 ? "value" : "values"}</Typography>
<Typography sx={{textTransform: "revert"}} variant="button">{chipData.length.toLocaleString()} {chipData.length === 1 ? "value" : "values"} {uniqueCount && `(${uniqueCount} unique)`}</Typography>
)
}
</Grid>
@ -401,12 +514,13 @@ function FilterCriteriaPaster({type, onSave}: Props): JSX.Element
onClickHandler={handleCancelClicked}
iconName="cancel"
disabled={false} />
<QSaveButton onClickHandler={handleSaveClicked} label="Add Filters" disabled={false} />
<QSaveButton onClickHandler={handleSaveClicked} label="Add Values" disabled={saveDisabled} />
</Grid>
</Box>
</Card>
</Box>
</Box>
</Box>
</Modal>
)

View File

@ -398,11 +398,12 @@ function FilterCriteriaRowValues({operatorOption, criteria, field, table, valueC
initialValues = criteria.values;
}
}
return <Box>
return <Box display="flex" alignItems="flex-end" className="multiValue">
<Box width={"100%"}>
<DynamicSelect
fieldPossibleValueProps={{tableName: table.name, fieldName: field.name, initialDisplayValue: null}}
overrideId={field.name + "-multi-" + criteria.id}
key={field.name + "-multi-" + criteria.id}
key={field.name + "-multi-" + criteria.id + "-" + criteria.values.length}
isMultiple
fieldLabel="Values"
initialValues={initialValues}
@ -412,6 +413,10 @@ function FilterCriteriaRowValues({operatorOption, criteria, field, table, valueC
variant="standard"
useCase="filter"
/>
</Box>
<Box>
<FilterCriteriaPaster table={table} field={field} type="pvs" onSave={(newValues: any[]) => saveNewPasterValues(newValues)} />
</Box>
</Box>;
}

View File

@ -29,9 +29,9 @@ 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 {QActionsMenuButton} from "qqq/components/buttons/DefaultButtons";
import React, {useState} from "react";
import {useNavigate} from "react-router-dom";
import {QActionsMenuButton} from "qqq/components/buttons/DefaultButtons";
interface QueryScreenActionMenuProps
{
@ -40,28 +40,28 @@ interface QueryScreenActionMenuProps
tableProcesses: QProcessMetaData[];
bulkLoadClicked: () => void;
bulkEditClicked: () => void;
bulkEditWithFileClicked: () => void;
bulkDeleteClicked: () => void;
processClicked: (process: QProcessMetaData) => void;
}
QueryScreenActionMenu.defaultProps = {
};
QueryScreenActionMenu.defaultProps = {};
export default function QueryScreenActionMenu({metaData, tableMetaData, tableProcesses, bulkLoadClicked, bulkEditClicked, bulkDeleteClicked, processClicked}: QueryScreenActionMenuProps): JSX.Element
export default function QueryScreenActionMenu({metaData, tableMetaData, tableProcesses, bulkLoadClicked, bulkEditClicked, bulkEditWithFileClicked, bulkDeleteClicked, processClicked}: QueryScreenActionMenuProps): JSX.Element
{
const [anchorElement, setAnchorElement] = useState(null)
const [anchorElement, setAnchorElement] = useState(null);
const navigate = useNavigate();
const openActionsMenu = (event: any) =>
{
setAnchorElement(event.currentTarget);
}
};
const closeActionsMenu = () =>
{
setAnchorElement(null);
}
};
const pushDividerIfNeeded = (menuItems: JSX.Element[]) =>
{
@ -75,7 +75,7 @@ export default function QueryScreenActionMenu({metaData, tableMetaData, tablePro
{
closeActionsMenu();
handler();
}
};
const menuItems: JSX.Element[] = [];
if (tableMetaData.capabilities.has(Capability.TABLE_INSERT) && tableMetaData.insertPermission)
@ -85,6 +85,7 @@ export default function QueryScreenActionMenu({metaData, tableMetaData, tablePro
if (tableMetaData.capabilities.has(Capability.TABLE_UPDATE) && tableMetaData.editPermission)
{
menuItems.push(<MenuItem key="bulkEdit" onClick={() => runSomething(bulkEditClicked)}><ListItemIcon><Icon>edit</Icon></ListItemIcon>Bulk Edit</MenuItem>);
menuItems.push(<MenuItem key="bulkEditWithFile" onClick={() => runSomething(bulkEditWithFileClicked)}><ListItemIcon><Icon>edit_note</Icon></ListItemIcon>Bulk Edit With File</MenuItem>);
}
if (tableMetaData.capabilities.has(Capability.TABLE_DELETE) && tableMetaData.deletePermission)
{
@ -130,5 +131,5 @@ export default function QueryScreenActionMenu({metaData, tableMetaData, tablePro
{menuItems}
</Menu>
</>
)
);
}

View File

@ -38,7 +38,7 @@ import XIcon from "qqq/components/query/XIcon";
import {QueryScreenUsage} from "qqq/pages/records/query/RecordQuery";
import FilterUtils from "qqq/utils/qqq/FilterUtils";
import TableUtils from "qqq/utils/qqq/TableUtils";
import React, {SyntheticEvent, useContext, useReducer, useState} from "react";
import React, {SyntheticEvent, useContext, useEffect, useReducer, useState} from "react";
export type CriteriaParamType = QFilterCriteriaWithId | null | "tooComplex";
@ -135,7 +135,7 @@ const getOperatorSelectedValue = (operatorOptions: OperatorOption[], criteria: Q
return (filteredOptions[0]);
}
if(return0thOptionInsteadOfNull)
if (return0thOptionInsteadOfNull)
{
console.log("Returning 0th operator instead of null - this isn't expected, but has been seen to happen - so here's some additional debugging:");
try
@ -144,7 +144,7 @@ const getOperatorSelectedValue = (operatorOptions: OperatorOption[], criteria: Q
console.log("Criteria: " + JSON.stringify(criteria));
console.log("Default Operator: " + JSON.stringify(defaultOperator));
}
catch(e)
catch (e)
{
console.log(`Error in debug output: ${e}`);
}
@ -186,6 +186,13 @@ export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData
//////////////////////
const [, forceUpdate] = useReducer((x) => x + 1, 0);
useEffect(() =>
{
//////////////////////////////////////////////////////////////////////////////
// was not seeing criteria changes take place until watching it stringified //
//////////////////////////////////////////////////////////////////////////////
setCriteria(criteria);
}, [JSON.stringify(criteria)]);
/*******************************************************************************
**

View File

@ -39,6 +39,7 @@ export class BulkLoadField
headerName?: string = null;
defaultValue?: any = null;
doValueMapping: boolean = false;
clearIfEmpty?: boolean = false;
wideLayoutIndexPath: number[] = [];
@ -51,7 +52,7 @@ export class BulkLoadField
/***************************************************************************
**
***************************************************************************/
constructor(field: QFieldMetaData, tableStructure: BulkLoadTableStructure, valueType: ValueType = "column", columnIndex?: number, headerName?: string, defaultValue?: any, doValueMapping?: boolean, wideLayoutIndexPath: number[] = [], error: string = null, warning: string = null)
constructor(field: QFieldMetaData, tableStructure: BulkLoadTableStructure, valueType: ValueType = "column", columnIndex?: number, headerName?: string, defaultValue?: any, doValueMapping?: boolean, wideLayoutIndexPath: number[] = [], error: string = null, warning: string = null, clearIfEmpty?: boolean)
{
this.field = field;
this.tableStructure = tableStructure;
@ -64,6 +65,7 @@ export class BulkLoadField
this.error = error;
this.warning = warning;
this.key = new Date().getTime().toString();
this.clearIfEmpty = clearIfEmpty ?? false;
}
@ -72,7 +74,7 @@ export class BulkLoadField
***************************************************************************/
public static clone(source: BulkLoadField): BulkLoadField
{
return (new BulkLoadField(source.field, source.tableStructure, source.valueType, source.columnIndex, source.headerName, source.defaultValue, source.doValueMapping, source.wideLayoutIndexPath, source.error, source.warning));
return (new BulkLoadField(source.field, source.tableStructure, source.valueType, source.columnIndex, source.headerName, source.defaultValue, source.doValueMapping, source.wideLayoutIndexPath, source.error, source.warning, source.clearIfEmpty));
}
@ -173,6 +175,9 @@ export interface BulkLoadTableStructure
associationPath: string;
fields: QFieldMetaData[];
associations: BulkLoadTableStructure[];
isBulkEdit: boolean;
possibleKeyFields: string[];
keyFields?: string;
}
@ -193,6 +198,8 @@ export class BulkLoadMapping
valueMappings: { [fieldName: string]: { [fileValue: string]: any } } = {};
isBulkEdit: boolean;
keyFields: string;
hasHeaderRow: boolean;
layout: string;
@ -211,6 +218,8 @@ export class BulkLoadMapping
}
}
this.isBulkEdit = tableStructure.isBulkEdit;
this.keyFields = tableStructure.keyFields;
this.hasHeaderRow = true;
}
@ -218,11 +227,13 @@ export class BulkLoadMapping
/***************************************************************************
**
***************************************************************************/
private processTableStructure(tableStructure: BulkLoadTableStructure)
public processTableStructure(tableStructure: BulkLoadTableStructure)
{
const prefix = tableStructure.isMain ? "" : tableStructure.associationPath;
this.fieldsByTablePrefix[prefix] = {};
this.tablesByPath[prefix] = tableStructure;
this.isBulkEdit = tableStructure.isBulkEdit;
this.keyFields = tableStructure.keyFields;
for (let field of tableStructure.fields)
{
@ -233,6 +244,27 @@ export class BulkLoadMapping
this.fields[qualifiedName] = bulkLoadField;
this.fieldsByTablePrefix[prefix][qualifiedName] = bulkLoadField;
if (this.isBulkEdit)
{
if (this.keyFields == null)
{
this.unusedFields.push(bulkLoadField);
}
else
{
const keyFields = this.keyFields.split("|");
if (keyFields.includes(qualifiedName))
{
this.requiredFields.push(bulkLoadField);
}
else
{
this.unusedFields.push(bulkLoadField);
}
}
}
else
{
if (tableStructure.isMain && field.isRequired)
{
this.requiredFields.push(bulkLoadField);
@ -243,6 +275,7 @@ export class BulkLoadMapping
}
}
}
}
for (let associatedTableStructure of tableStructure.associations ?? [])
{
@ -266,14 +299,16 @@ export class BulkLoadMapping
** 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
public static fromBulkLoadProfile(tableStructure: BulkLoadTableStructure, bulkLoadProfile: BulkLoadProfile, processName?: string): BulkLoadMapping
{
const bulkLoadMapping = new BulkLoadMapping(tableStructure);
if (bulkLoadProfile.version == "v1")
{
bulkLoadMapping.isBulkEdit = bulkLoadProfile.isBulkEdit;
bulkLoadMapping.hasHeaderRow = bulkLoadProfile.hasHeaderRow;
bulkLoadMapping.layout = bulkLoadProfile.layout;
bulkLoadMapping.keyFields = bulkLoadProfile.keyFields;
////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// function to get a bulkLoadMapping field by its (full) name - whether that's in the required fields list, //
@ -322,6 +357,7 @@ export class BulkLoadMapping
{
bulkLoadField.valueType = "column";
bulkLoadField.doValueMapping = bulkLoadProfileField.doValueMapping;
bulkLoadField.clearIfEmpty = bulkLoadProfileField.clearIfEmpty;
bulkLoadField.headerName = bulkLoadProfileField.headerName;
bulkLoadField.columnIndex = bulkLoadProfileField.columnIndex;
@ -344,6 +380,29 @@ export class BulkLoadMapping
}
}
if (!bulkLoadMapping.keyFields && tableStructure.possibleKeyFields?.length > 0)
{
////////////////////////////////////////////////////////////////////////////////////////////////
// look at each of the possible key fields, compare with the fields in the bulk load profile, //
// on the first one that matches, use that as the default bulk load mapping key field //
////////////////////////////////////////////////////////////////////////////////////////////////
for (let keyField of tableStructure.possibleKeyFields)
{
const parts = keyField.split("|");
const allPartsMatch = parts.every(part =>
(bulkLoadProfile.fieldList ?? []).some((field: BulkLoadProfileField) =>
field.fieldName === part
)
);
if (allPartsMatch)
{
bulkLoadMapping.keyFields = keyField;
break; // stop after the first valid match
}
}
}
return (bulkLoadMapping);
}
else
@ -365,6 +424,8 @@ export class BulkLoadMapping
profile.version = "v1";
profile.hasHeaderRow = this.hasHeaderRow;
profile.layout = this.layout;
profile.isBulkEdit = this.isBulkEdit;
profile.keyFields = this.keyFields;
for (let bulkLoadField of [...this.requiredFields, ...this.additionalFields])
{
@ -384,7 +445,7 @@ export class BulkLoadMapping
}
else
{
const field: BulkLoadProfileField = {fieldName: fullFieldName, columnIndex: bulkLoadField.columnIndex, headerName: bulkLoadField.headerName, doValueMapping: bulkLoadField.doValueMapping};
const field: BulkLoadProfileField = {fieldName: fullFieldName, columnIndex: bulkLoadField.columnIndex, headerName: bulkLoadField.headerName, doValueMapping: bulkLoadField.doValueMapping, clearIfEmpty: bulkLoadField.clearIfEmpty};
if (this.valueMappings[fullFieldName])
{
@ -576,6 +637,16 @@ export class BulkLoadMapping
return (rs);
}
/***************************************************************************
**
***************************************************************************/
public handleChangeToKeyFields(newKeyFields: any)
{
this.keyFields = newKeyFields;
}
/***************************************************************************
**
***************************************************************************/
@ -600,7 +671,7 @@ export class BulkLoadMapping
{
const newField = BulkLoadField.clone(field);
newField.columnIndex = null;
newField.warning = "This field was assigned to a column with a duplicated header"
newField.warning = "This field was assigned to a column with a duplicated header";
newRequiredFields.push(newField);
anyChangesToRequiredFields = true;
}
@ -616,7 +687,7 @@ export class BulkLoadMapping
{
const newField = BulkLoadField.clone(field);
newField.columnIndex = null;
newField.warning = "This field was assigned to a column with a duplicated header"
newField.warning = "This field was assigned to a column with a duplicated header";
newAdditionalFields.push(newField);
anyChangesToAdditionalFields = true;
}
@ -798,6 +869,8 @@ export class BulkLoadProfile
fieldList: BulkLoadProfileField[] = [];
hasHeaderRow: boolean;
layout: string;
isBulkEdit: boolean;
keyFields: string;
}
type BulkLoadProfileField =
@ -807,6 +880,7 @@ type BulkLoadProfileField =
headerName?: string,
defaultValue?: any,
doValueMapping?: boolean,
clearIfEmpty?: boolean,
valueMappings?: { [fileValue: string]: any }
};

View File

@ -1557,7 +1557,7 @@ const RecordQuery = forwardRef(({table, usage, isModal, isPreview, allowVariable
/*******************************************************************************
** function to open one of the bulk (insert/edit/delete) processes.
*******************************************************************************/
const openBulkProcess = (processNamePart: "Insert" | "Edit" | "Delete", processLabelPart: "Load" | "Edit" | "Delete") =>
const openBulkProcess = (processNamePart: "Insert" | "Edit" | "Delete" | "EditWithFile", processLabelPart: "Load" | "Edit" | "Delete" | "Edit With File") =>
{
const processList = allTableProcesses.filter(p => p.name.endsWith(`.bulk${processNamePart}`));
if (processList.length > 0)
@ -1593,6 +1593,15 @@ const RecordQuery = forwardRef(({table, usage, isModal, isPreview, allowVariable
};
/*******************************************************************************
** Event handler for the bulk-edit-with-file process being selected
*******************************************************************************/
const bulkEditWithFileClicked = () =>
{
openBulkProcess("EditWithFile", "Edit With File");
};
/*******************************************************************************
** Event handler for the bulk-delete process being selected
*******************************************************************************/
@ -2861,6 +2870,7 @@ const RecordQuery = forwardRef(({table, usage, isModal, isPreview, allowVariable
tableProcesses={tableProcesses}
bulkLoadClicked={bulkLoadClicked}
bulkEditClicked={bulkEditClicked}
bulkEditWithFileClicked={bulkEditWithFileClicked}
bulkDeleteClicked={bulkDeleteClicked}
processClicked={processClicked}
/>

View File

@ -133,7 +133,7 @@ class FilterUtils
}
else
{
values = await qController.possibleValues(fieldTable.name, null, field.name, "", values, undefined, "filter");
values = await qController.possibleValues(fieldTable.name, null, field.name, "", values, undefined, undefined, "filter");
}
}

View File

@ -103,6 +103,30 @@ public class QueryScreenLib
/*******************************************************************************
**
*******************************************************************************/
public void openCriteriaPasterAndPasteValues(String fieldName, List<String> values)
{
/////////////////////////////////////////////////////////////////////////////
// open the is any of criteria for given field and click the paster button //
/////////////////////////////////////////////////////////////////////////////
qSeleniumLib.waitForSelectorContaining("BUTTON", fieldName).click();
qSeleniumLib.waitForSelector("#criteriaOperator").click();
qSeleniumLib.waitForSelectorContaining("LI", "is any of").click();
qSeleniumLib.waitForMillis(250);
qSeleniumLib.waitForSelector(".criteriaPasterButton").click();
////////////////////////////////////////
// paste the values into the textarea //
////////////////////////////////////////
qSeleniumLib
.waitForSelector(".criteriaPasterTextArea textarea#outlined-multiline-static")
.sendKeys(String.join("\n", values));
}
/*******************************************************************************
**
*******************************************************************************/

View File

@ -22,13 +22,16 @@
package com.kingsrook.qqq.frontend.materialdashboard.selenium.tests.query;
import java.util.HashSet;
import java.util.List;
import java.util.stream.Collectors;
import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.QBaseSeleniumTest;
import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.QQQMaterialDashboardSelectors;
import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.QueryScreenLib;
import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.javalin.CapturedContext;
import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.javalin.QSeleniumJavalin;
import org.junit.jupiter.api.Test;
import org.openqa.selenium.WebElement;
import static org.assertj.core.api.Assertions.assertThat;
@ -200,6 +203,204 @@ public class QueryScreenTest extends QBaseSeleniumTest
/*******************************************************************************
**
*******************************************************************************/
@Test
void testCriteriaPasterHappyPath()
{
QueryScreenLib queryScreenLib = new QueryScreenLib(qSeleniumLib);
////////////////////////////
// go to the person page //
////////////////////////////
qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person", "Person");
queryScreenLib.waitForQueryToHaveRan();
//////////////////////////////////////
// open the paste values dialog UI //
//////////////////////////////////////
queryScreenLib.openCriteriaPasterAndPasteValues("id", List.of("1", "2", "3"));
///////////////////////////////////////////////////////////////
// wait for chips to appear in the filter values review box //
///////////////////////////////////////////////////////////////
assertFilterPasterChipCounts(3, 0);
///////////////////////////////////////////////
// confirm each chip has the blue color class //
///////////////////////////////////////////////
qSeleniumLib.waitForSelectorAll(".MuiChip-root", 3).forEach(chip ->
{
String classAttr = chip.getAttribute("class");
assertThat(classAttr).contains("MuiChip-colorInfo");
});
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void testCriteriaPasterInvalidValueValidation()
{
QueryScreenLib queryScreenLib = new QueryScreenLib(qSeleniumLib);
////////////////////////////
// go to the person page //
////////////////////////////
qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person", "Person");
queryScreenLib.waitForQueryToHaveRan();
//////////////////////////////////////
// open the paste values dialog UI //
//////////////////////////////////////
queryScreenLib.openCriteriaPasterAndPasteValues("id", List.of("1", "a", "3"));
//////////////////////////////////////////////////////
// check that chips match values and are classified //
//////////////////////////////////////////////////////
assertFilterPasterChipCounts(2, 1);
////////////////////////////////////////////////////////////////////
// confirm that an appropriate validation error message is shown //
////////////////////////////////////////////////////////////////////
WebElement errorMessage = qSeleniumLib.waitForSelectorContaining("span", "value is not a number");
assertThat(errorMessage.getText()).contains("value is not a number");
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void testCriteriaPasterDuplicateValueValidation()
{
QueryScreenLib queryScreenLib = new QueryScreenLib(qSeleniumLib);
////////////////////////////
// go to the person page //
////////////////////////////
qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person", "Person");
queryScreenLib.waitForQueryToHaveRan();
//////////////////////////////////////
// open the paste values dialog UI //
//////////////////////////////////////
List<String> pastedValues = List.of("1", "1", "1", "2", "2");
queryScreenLib.openCriteriaPasterAndPasteValues("id", pastedValues);
///////////////////////////////////////////////
// expected chip & uniqueness calculations //
///////////////////////////////////////////////
int totalCount = pastedValues.size(); // 5
int uniqueCount = new HashSet<>(pastedValues).size(); // 2
/////////////////////////////
// chips should show dupes //
/////////////////////////////
assertFilterPasterChipCounts(pastedValues.size(), 0);
////////////////////////////////////////////////////////////////
// counter text should match “5 values (2 unique)” (or alike) //
////////////////////////////////////////////////////////////////
String expectedCounter = totalCount + " values (" + uniqueCount + " unique)";
WebElement counterLabel = qSeleniumLib.waitForSelectorContaining("span", "unique");
assertThat(counterLabel.getText()).contains(expectedCounter);
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void testCriteriaPasterWithPVSHappyPath()
{
QueryScreenLib queryScreenLib = new QueryScreenLib(qSeleniumLib);
////////////////////////////
// go to the person page //
////////////////////////////
qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person", "Person");
queryScreenLib.waitForQueryToHaveRan();
//////////////////////////////////////
// open the paste values dialog UI //
//////////////////////////////////////
queryScreenLib.addBasicFilter("home city");
queryScreenLib.openCriteriaPasterAndPasteValues("home city", List.of("St. Louis", "chesterfield"));
qSeleniumLib.waitForSeconds(1);
///////////////////////////////////////////////////////////////
// wait for chips to appear in the filter values review box //
///////////////////////////////////////////////////////////////
assertFilterPasterChipCounts(2, 0);
///////////////////////////////////////////////
// confirm each chip has the blue color class //
///////////////////////////////////////////////
qSeleniumLib.waitForSelectorAll(".MuiChip-root", 2).forEach(chip ->
{
String classAttr = chip.getAttribute("class");
assertThat(classAttr).contains("MuiChip-colorInfo");
});
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void testCriteriaPasterWithPVSTwoGoodOneBadAndDupes()
{
QueryScreenLib queryScreenLib = new QueryScreenLib(qSeleniumLib);
////////////////////////////
// go to the person page //
////////////////////////////
qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/person", "Person");
queryScreenLib.waitForQueryToHaveRan();
//////////////////////////////////////
// open the paste values dialog UI //
//////////////////////////////////////
List<String> cities = List.of("St. Louis", "chesterfield", "Maryville", "st. louis", "st. louis", "chesterfield");
queryScreenLib.addBasicFilter("home city");
queryScreenLib.openCriteriaPasterAndPasteValues("home city", cities);
qSeleniumLib.waitForSeconds(1);
///////////////////////////////////////////////
// expected chip & uniqueness calculations //
///////////////////////////////////////////////
int totalCount = cities.size();
int uniqueCount = cities.stream().map(String::toLowerCase).collect(Collectors.toSet()).size();
///////////////////////////////////////////
// chips should show dupes and bad chips //
///////////////////////////////////////////
assertFilterPasterChipCounts(5, 1);
////////////////////////////////////////////////////////////////
// counter text should match “5 values (2 unique)” (or alike) //
////////////////////////////////////////////////////////////////
String expectedCounter = totalCount + " values (" + uniqueCount + " unique)";
WebElement counterLabel = qSeleniumLib.waitForSelectorContaining("span", "unique");
assertThat(counterLabel.getText()).contains(expectedCounter);
//////////////////////////////////////////
// assert the "value not found" warning //
//////////////////////////////////////////
WebElement warning = qSeleniumLib.waitForSelectorContaining("span", "was not found");
assertThat(warning.getText()).contains("1 value was not found and will not be added to the filter");
}
/*******************************************************************************
**
*******************************************************************************/
@ -273,4 +474,18 @@ public class QueryScreenTest extends QBaseSeleniumTest
queryScreenLib.clickAdvancedFilterClearIcon();
}
/*******************************************************************************
**
*******************************************************************************/
private void assertFilterPasterChipCounts(int expectedValid, int expectedInvalid)
{
List<WebElement> chips = qSeleniumLib.waitForSelectorAll(".MuiChip-root", expectedValid + expectedInvalid);
long validCount = chips.stream().filter(c -> c.getAttribute("class").contains("MuiChip-colorInfo")).count();
long errorCount = chips.stream().filter(c -> c.getAttribute("class").contains("MuiChip-colorError")).count();
assertThat(validCount).isEqualTo(expectedValid);
assertThat(errorCount).isEqualTo(expectedInvalid);
}
}