CE-1955 Bulk load bugs & usability improvements

This commit is contained in:
2024-12-27 14:58:40 -06:00
parent d793c23861
commit 3ef2d64327
4 changed files with 349 additions and 30 deletions

View File

@ -21,9 +21,10 @@
import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
import {Checkbox, FormControlLabel, Radio} from "@mui/material";
import {Checkbox, FormControlLabel, Radio, Tooltip} from "@mui/material";
import Autocomplete from "@mui/material/Autocomplete";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import Icon from "@mui/material/Icon";
import IconButton from "@mui/material/IconButton";
import RadioGroup from "@mui/material/RadioGroup";
@ -45,6 +46,27 @@ interface BulkLoadMappingFieldProps
forceParentUpdate?: () => void,
}
const xIconButtonSX =
{
border: `1px solid ${colors.grayLines.main} !important`,
borderRadius: "0.5rem",
textTransform: "none",
fontSize: "1rem",
fontWeight: "400",
width: "30px",
minWidth: "30px",
height: "2rem",
minHeight: "2rem",
paddingLeft: 0,
paddingRight: 0,
marginRight: "0.5rem",
marginTop: "0.5rem",
color: colors.error.main,
"&:hover": {color: colors.error.main},
"&:focus": {color: colors.error.main},
"&:focus:not(:hover)": {color: colors.error.main},
};
const qController = Client.getInstance();
/***************************************************************************
@ -212,7 +234,9 @@ export default function BulkLoadFileMappingField({bulkLoadField, isRequired, rem
<Box display="flex" alignItems="flex-start">
{
(!isRequired) && <IconButton onClick={() => removeFieldCallback()} sx={{pt: "0.75rem"}}><Icon fontSize="small">remove_circle</Icon></IconButton>
(!isRequired) && <Tooltip placement="bottom" title="Remove this field from your mapping.">
<Button sx={xIconButtonSX} onClick={() => removeFieldCallback()}><Icon>clear</Icon></Button>
</Tooltip>
}
<Box pt="0.625rem">
{bulkLoadField.getQualifiedLabel()}
@ -265,7 +289,7 @@ export default function BulkLoadFileMappingField({bulkLoadField, isRequired, rem
</Box>
{
bulkLoadField.error &&
<Box fontSize={smallerFontSize} color={colors.error.main} ml="145px">
<Box fontSize={smallerFontSize} color={colors.error.main} ml="145px" className="bulkLoadFieldError">
{bulkLoadField.error}
</Box>
}

View File

@ -47,7 +47,7 @@ export default function BulkLoadFileMappingFields({bulkLoadMapping, fileDescript
{
const [, forceUpdate] = useReducer((x) => x + 1, 0);
const [forceRerender, setForceRerender] = useState(0);
const [forceHierarchyAutoCompleteRerender, setForceHierarchyAutoCompleteRerender] = useState(0);
////////////////////////////////////////////
// build list of fields that can be added //
@ -98,8 +98,9 @@ export default function BulkLoadFileMappingFields({bulkLoadMapping, fileDescript
setAddFieldsDisableStates(newDisableStates);
setTooltips(newTooltips);
setForceHierarchyAutoCompleteRerender(forceHierarchyAutoCompleteRerender + 1);
}, [bulkLoadMapping]);
}, [bulkLoadMapping, bulkLoadMapping.layout]);
///////////////////////////////////////////////
@ -140,9 +141,6 @@ export default function BulkLoadFileMappingFields({bulkLoadMapping, fileDescript
***************************************************************************/
function removeField(bulkLoadField: BulkLoadField)
{
// addFieldsToggleStates[bulkLoadField.getQualifiedName()] = false;
// setAddFieldsToggleStates(Object.assign({}, addFieldsToggleStates));
addFieldsDisableStates[bulkLoadField.getQualifiedName()] = false;
setAddFieldsDisableStates(Object.assign({}, addFieldsDisableStates));
@ -160,7 +158,7 @@ export default function BulkLoadFileMappingFields({bulkLoadMapping, fileDescript
bulkLoadMapping.removeField(bulkLoadField);
forceUpdate();
forceParentUpdate();
setForceRerender(forceRerender + 1);
setForceHierarchyAutoCompleteRerender(forceHierarchyAutoCompleteRerender + 1);
}
/***************************************************************************
@ -297,7 +295,7 @@ export default function BulkLoadFileMappingFields({bulkLoadMapping, fileDescript
isModeSelectOne
keepOpenAfterSelectOne
handleSelectedOption={handleAddField}
forceRerender={forceRerender}
forceRerender={forceHierarchyAutoCompleteRerender}
disabledStates={addFieldsDisableStates}
tooltips={tooltips}
/>

View File

@ -26,10 +26,12 @@ import {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstan
import {QProcessMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QProcessMetaData";
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord";
import {Badge, Icon} from "@mui/material";
import Autocomplete from "@mui/material/Autocomplete";
import Box from "@mui/material/Box";
import Grid from "@mui/material/Grid";
import TextField from "@mui/material/TextField";
import Tooltip from "@mui/material/Tooltip/Tooltip";
import {useFormikContext} from "formik";
import {DynamicFormFieldLabel} from "qqq/components/forms/DynamicForm";
import QDynamicFormField from "qqq/components/forms/DynamicFormField";
@ -126,6 +128,14 @@ const BulkLoadFileMappingForm = forwardRef(({processValues, tableMetaData, metaD
}
setFieldErrors(fieldErrors);
if(haveProfileErrors)
{
setTimeout(() =>
{
document.querySelector(".bulkLoadFieldError")?.scrollIntoView({behavior: "smooth", block: "center", inline: "center"});
}, 250);
}
return {maySubmit: !haveProfileErrors && !haveLocalErrors, values};
}
};
@ -224,7 +234,11 @@ const BulkLoadFileMappingForm = forwardRef(({processValues, tableMetaData, metaD
<BulkLoadFileMappingFields
bulkLoadMapping={bulkLoadMapping}
fileDescription={fileDescription}
forceParentUpdate={() => forceUpdate()}
forceParentUpdate={() =>
{
setRerenderHeader(rerenderHeader + 1);
forceUpdate();
}}
/>
</Box>
@ -293,7 +307,7 @@ function BulkLoadMappingHeader({fileDescription, fileName, bulkLoadMapping, fiel
***************************************************************************/
function layoutChanged(event: any, newValue: any)
{
bulkLoadMapping.layout = newValue ? newValue.id : null;
bulkLoadMapping.switchLayout(newValue ? newValue.id : null);
fieldErrors.layout = null;
forceParentUpdate();
}
@ -322,7 +336,7 @@ function BulkLoadMappingHeader({fileDescription, fileName, bulkLoadMapping, fiel
<h5>File Details</h5>
<Box ml="1rem">
<ProcessViewForm fields={viewFields} values={viewValues} columns={2} />
<BulkLoadMappingFilePreview fileDescription={fileDescription} />
<BulkLoadMappingFilePreview fileDescription={fileDescription} bulkLoadMapping={bulkLoadMapping} />
<Grid container pt="1rem">
<Grid item xs={12} md={6}>
<DynamicFormFieldLabel name={hasHeaderRowFormField.name} label={`${hasHeaderRowFormField.label} *`} />
@ -347,6 +361,7 @@ function BulkLoadMappingHeader({fileDescription, fileName, bulkLoadMapping, fiel
getOptionLabel={(option) => typeof (option) == "string" ? option : (option?.label ?? "")}
isOptionEqualToValue={(option, value) => option == null && value == null || option.id == value.id}
renderOption={(props, option, state) => (<li {...props}>{option?.label ?? ""}</li>)}
disableClearable
sx={{"& .MuiOutlinedInput-root": {padding: "0"}}}
/>
{
@ -366,13 +381,14 @@ function BulkLoadMappingHeader({fileDescription, fileName, bulkLoadMapping, fiel
interface BulkLoadMappingFilePreviewProps
{
fileDescription: FileDescription;
fileDescription: FileDescription,
bulkLoadMapping?: BulkLoadMapping
}
/***************************************************************************
** private subcomponent - the file-preview section of the bulk load file mapping screen.
***************************************************************************/
function BulkLoadMappingFilePreview({fileDescription}: BulkLoadMappingFilePreviewProps): JSX.Element
function BulkLoadMappingFilePreview({fileDescription, bulkLoadMapping}: BulkLoadMappingFilePreviewProps): JSX.Element
{
const rows: number[] = [];
for (let i = 0; i < fileDescription.bodyValuesPreview[0].length; i++)
@ -380,25 +396,135 @@ function BulkLoadMappingFilePreview({fileDescription}: BulkLoadMappingFilePrevie
rows.push(i);
}
/***************************************************************************
**
***************************************************************************/
function getValue(i: number, j: number)
{
const value = fileDescription.bodyValuesPreview[j][i];
if (value == null)
{
return "";
}
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// this was useful at one point in time when we had an object coming back for xlsx files with many different data types //
// we'd see a .string attribute, which would have the value we'd want to show. not using it now, but keep in case //
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// @ts-ignore
if (value && value.string)
{
// @ts-ignore
return (value.string);
}
return `${value}`;
}
/***************************************************************************
**
***************************************************************************/
function getHeaderColor(count: number): string
{
if (count > 0)
{
return "blue";
}
return "black";
}
/***************************************************************************
**
***************************************************************************/
function getCursor(count: number): string
{
if (count > 0)
{
return "pointer";
}
return "default";
}
/***************************************************************************
**
***************************************************************************/
function getColumnTooltip(fields: BulkLoadField[])
{
return (<Box>
This column is mapped to the field{fields.length == 1 ? "" : "s"}:
<ul style={{marginLeft: "1rem"}}>
{fields.map((field, i) => <li key={i}>{field.getQualifiedLabel()}</li>)}
</ul>
</Box>);
}
return (
<Box sx={{"& table, & td": {border: "1px solid black", borderCollapse: "collapse", padding: "0 0.25rem", fontSize: "0.875rem", whiteSpace: "nowrap"}}}>
<Box sx={{width: "100%", overflow: "auto"}}>
<table cellSpacing="0" width="100%">
<thead>
<tr style={{backgroundColor: "#d3d3d3"}}>
<tr style={{backgroundColor: "#d3d3d3", height: "1.75rem"}}>
<td></td>
{fileDescription.headerLetters.map((letter) => <td key={letter} style={{textAlign: "center"}}>{letter}</td>)}
{fileDescription.headerLetters.map((letter, index) =>
{
const fields = bulkLoadMapping.getFieldsForColumnIndex(index);
const count = fields.length;
return (<td key={letter} style={{textAlign: "center", color: getHeaderColor(count), cursor: getCursor(count)}}>
<>
{
count > 0 &&
<Tooltip title={getColumnTooltip(fields)} placement="top" enterDelay={500}>
<Box>
{letter}
<Badge badgeContent={count} variant={"standard"} color="secondary" sx={{marginTop: ".75rem"}}><Icon></Icon></Badge>
</Box>
</Tooltip>
}
{
count == 0 && <Box>{letter}</Box>
}
</>
</td>);
})}
</tr>
</thead>
<tbody>
<tr>
<td style={{backgroundColor: "#d3d3d3", textAlign: "center"}}>1</td>
{fileDescription.headerValues.map((value) => <td key={value} style={{backgroundColor: fileDescription.hasHeaderRow ? "#ebebeb" : ""}}>{value}</td>)}
{fileDescription.headerValues.map((value, index) =>
{
const fields = bulkLoadMapping.getFieldsForColumnIndex(index);
const count = fields.length;
const tdStyle = {color: getHeaderColor(count), cursor: getCursor(count), backgroundColor: ""};
if(fileDescription.hasHeaderRow)
{
tdStyle.backgroundColor = "#ebebeb";
if(count > 0)
{
return <td key={value} style={tdStyle}>
<Tooltip title={getColumnTooltip(fields)} placement="top" enterDelay={500}><Box>{value}</Box></Tooltip>
</td>
}
else
{
return <td key={value} style={tdStyle}>{value}</td>
}
}
else
{
return <td key={value} style={tdStyle}>{value}</td>
}
}
)}
</tr>
{rows.map((i) => (
<tr key={i}>
<td style={{backgroundColor: "#d3d3d3", textAlign: "center"}}>{i + 2}</td>
{fileDescription.headerLetters.map((letter, j) => <td key={j}>{fileDescription.bodyValuesPreview[j][i]}</td>)}
{fileDescription.headerLetters.map((letter, j) => <td key={j}>{getValue(i, j)}</td>)}
</tr>
))}
</tbody>

View File

@ -21,6 +21,7 @@
import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
import {QFieldType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldType";
import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord";
export type ValueType = "defaultValue" | "column";
@ -422,18 +423,23 @@ export class BulkLoadMapping
}
else
{
index = 0;
///////////////////////////////////////////////////////////
// count how many copies of this field there are already //
///////////////////////////////////////////////////////////
///////////////////////////////////////////////
// find the max index for this field already //
///////////////////////////////////////////////
let maxIndex = -1;
for (let existingField of [...this.requiredFields, ...this.additionalFields])
{
if (existingField.getQualifiedName() == bulkLoadField.getQualifiedName())
{
index++;
const thisIndex = existingField.wideLayoutIndexPath[0]
if (thisIndex != null && thisIndex != undefined && thisIndex > maxIndex)
{
maxIndex = thisIndex;
}
}
}
index = maxIndex + 1;
}
const cloneField = BulkLoadField.clone(bulkLoadField);
cloneField.wideLayoutIndexPath = [index];
@ -455,7 +461,7 @@ export class BulkLoadMapping
const newAdditionalFields: BulkLoadField[] = [];
for (let bulkLoadField of this.additionalFields)
{
if (bulkLoadField.getQualifiedName() != toRemove.getQualifiedName())
if (bulkLoadField.getQualifiedNameWithWideSuffix() != toRemove.getQualifiedNameWithWideSuffix())
{
newAdditionalFields.push(bulkLoadField);
}
@ -463,6 +469,107 @@ export class BulkLoadMapping
this.additionalFields = newAdditionalFields;
}
/***************************************************************************
**
***************************************************************************/
public switchLayout(newLayout: string): void
{
const newAdditionalFields: BulkLoadField[] = [];
let anyChanges = false;
if ("WIDE" != newLayout)
{
////////////////////////////////////////////////////////////////////////////////////////////////////////
// if going to a layout other than WIDE, make sure there aren't any fields with a wideLayoutIndexPath //
////////////////////////////////////////////////////////////////////////////////////////////////////////
const namesWhereOneWideLayoutIndexHasBeenFound: { [name: string]: boolean } = {};
for (let existingField of this.additionalFields)
{
if (existingField.wideLayoutIndexPath.length > 0)
{
const name = existingField.getQualifiedName();
if (namesWhereOneWideLayoutIndexHasBeenFound[name])
{
/////////////////////////////////////////////////////////////////////////////////////////////////////////
// in this case, we're on like the 2nd or 3rd instance of, say, Line Item: SKU - so - just discard it. //
/////////////////////////////////////////////////////////////////////////////////////////////////////////
anyChanges = true;
}
else
{
////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// else, this is the 1st instance of, say, Line Item: SKU - so mark that we've found it - and keep this field //
// (that is, put it in the new array), but with no index path //
////////////////////////////////////////////////////////////////////////////////////////////////////////////////
namesWhereOneWideLayoutIndexHasBeenFound[name] = true;
const newField = BulkLoadField.clone(existingField);
newField.wideLayoutIndexPath = [];
newAdditionalFields.push(newField)
anyChanges = true;
}
}
else
{
//////////////////////////////////////////////////////
// else, non-wide-path fields, just get added as-is //
//////////////////////////////////////////////////////
newAdditionalFields.push(existingField)
}
}
}
else
{
///////////////////////////////////////////////////////////////////////////////////////////////
// if going to WIDE layout, then any field from a child table needs a wide-layout-index-path //
///////////////////////////////////////////////////////////////////////////////////////////////
for (let existingField of this.additionalFields)
{
if (existingField.tableStructure.isMain)
{
////////////////////////////////////////////
// fields from main table come over as-is //
////////////////////////////////////////////
newAdditionalFields.push(existingField)
}
else
{
/////////////////////////////////////////////////////////////////////////////////////////////
// fields from child tables get a wideLayoutIndexPath (and we're assuming just 1 for each) //
/////////////////////////////////////////////////////////////////////////////////////////////
const newField = BulkLoadField.clone(existingField);
newField.wideLayoutIndexPath = [0];
newAdditionalFields.push(newField)
anyChanges = true;
}
}
}
if (anyChanges)
{
this.additionalFields = newAdditionalFields;
}
}
/***************************************************************************
**
***************************************************************************/
public getFieldsForColumnIndex(i: number): BulkLoadField[]
{
const rs: BulkLoadField[] = [];
for (let field of [...this.requiredFields, ...this.additionalFields])
{
if(field.valueType == "column" && field.columnIndex == i)
{
rs.push(field);
}
}
return (rs);
}
}
@ -517,21 +624,85 @@ export class FileDescription
/***************************************************************************
**
***************************************************************************/
public getPreviewValues(columnIndex: number): string[]
public getPreviewValues(columnIndex: number, fieldType?: QFieldType): string[]
{
if (columnIndex == undefined)
{
return [];
}
if (this.hasHeaderRow)
function getTypedValue(value: any): string
{
return (this.bodyValuesPreview[columnIndex]);
}
else
if(value == null)
{
return ([this.headerValues[columnIndex], ...this.bodyValuesPreview[columnIndex]]);
return "";
}
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// this was useful at one point in time when we had an object coming back for xlsx files with many different data types //
// we'd see a .string attribute, which would have the value we'd want to show. not using it now, but keep in case //
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
if (value && value.string)
{
switch (fieldType)
{
case QFieldType.BOOLEAN:
{
return value.bool;
}
case QFieldType.STRING:
case QFieldType.TEXT:
case QFieldType.HTML:
case QFieldType.PASSWORD:
{
return value.string;
}
case QFieldType.INTEGER:
case QFieldType.LONG:
{
return value.integer;
}
case QFieldType.DECIMAL:
{
return value.decimal;
}
case QFieldType.DATE:
{
return value.date;
}
case QFieldType.TIME:
{
return value.time;
}
case QFieldType.DATE_TIME:
{
return value.dateTime;
}
case QFieldType.BLOB:
return ""; // !!
}
}
return (`${value}`);
}
const valueArray: string[] = [];
if (!this.hasHeaderRow)
{
const typedValue = getTypedValue(this.headerValues[columnIndex])
valueArray.push(typedValue == null ? "" : `${typedValue}`);
}
for (let value of this.bodyValuesPreview[columnIndex])
{
const typedValue = getTypedValue(value)
valueArray.push(typedValue == null ? "" : `${typedValue}`);
}
return (valueArray);
}
}