CE-1955 - Initial checkin of qfmd support for bulk-load

This commit is contained in:
2024-11-25 10:48:00 -06:00
parent 3fc4e37c12
commit f2b41532d4
9 changed files with 3668 additions and 0 deletions

View File

@ -0,0 +1,600 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord";
export type ValueType = "defaultValue" | "column";
/***************************************************************************
** model of a single field that's part of a bulk-load profile/mapping
***************************************************************************/
export class BulkLoadField
{
field: QFieldMetaData;
tableStructure: BulkLoadTableStructure;
valueType: ValueType;
columnIndex?: number;
headerName?: string = null;
defaultValue?: any = null;
doValueMapping: boolean = false;
wideLayoutIndexPath: number[] = [];
error: string = null;
key: string;
/***************************************************************************
**
***************************************************************************/
constructor(field: QFieldMetaData, tableStructure: BulkLoadTableStructure, valueType: ValueType = "column", columnIndex?: number, headerName?: string, defaultValue?: any, doValueMapping?: boolean, wideLayoutIndexPath: number[] = [])
{
this.field = field;
this.tableStructure = tableStructure;
this.valueType = valueType;
this.columnIndex = columnIndex;
this.headerName = headerName;
this.defaultValue = defaultValue;
this.doValueMapping = doValueMapping;
this.wideLayoutIndexPath = wideLayoutIndexPath;
this.key = new Date().getTime().toString();
}
/***************************************************************************
**
***************************************************************************/
public static clone(source: BulkLoadField): BulkLoadField
{
return (new BulkLoadField(source.field, source.tableStructure, source.valueType, source.columnIndex, source.headerName, source.defaultValue, source.doValueMapping, source.wideLayoutIndexPath));
}
/***************************************************************************
**
***************************************************************************/
public getQualifiedName(): string
{
if (this.tableStructure.isMain)
{
return this.field.name;
}
return this.tableStructure.associationPath + "." + this.field.name;
}
/***************************************************************************
**
***************************************************************************/
public getQualifiedNameWithWideSuffix(): string
{
let wideLayoutSuffix = "";
if (this.wideLayoutIndexPath.length > 0)
{
wideLayoutSuffix = "." + this.wideLayoutIndexPath.map(i => i + 1).join(".");
}
if (this.tableStructure.isMain)
{
return this.field.name + wideLayoutSuffix;
}
return this.tableStructure.associationPath + "." + this.field.name + wideLayoutSuffix;
}
/***************************************************************************
**
***************************************************************************/
public getKey(): string
{
let wideLayoutSuffix = "";
if (this.wideLayoutIndexPath.length > 0)
{
wideLayoutSuffix = "." + this.wideLayoutIndexPath.map(i => i + 1).join(".");
}
if (this.tableStructure.isMain)
{
return this.field.name + wideLayoutSuffix + this.key;
}
return this.tableStructure.associationPath + "." + this.field.name + wideLayoutSuffix + this.key;
}
/***************************************************************************
**
***************************************************************************/
public getQualifiedLabel(): string
{
let wideLayoutSuffix = "";
if (this.wideLayoutIndexPath.length > 0)
{
wideLayoutSuffix = " (" + this.wideLayoutIndexPath.map(i => i + 1).join(", ") + ")";
}
if (this.tableStructure.isMain)
{
return this.field.label + wideLayoutSuffix;
}
return this.tableStructure.label + ": " + this.field.label + wideLayoutSuffix;
}
/***************************************************************************
**
***************************************************************************/
public isMany(): boolean
{
return this.tableStructure && this.tableStructure.isMany;
}
}
/***************************************************************************
** this is a type defined in qqq backend - a representation of a bulk-load
** table - e.g., how it fits into qqq - and of note - how child / association
** tables are nested too.
***************************************************************************/
export interface BulkLoadTableStructure
{
isMain: boolean;
isMany: boolean;
tableName: string;
label: string;
associationPath: string;
fields: QFieldMetaData[];
associations: BulkLoadTableStructure[];
}
/*******************************************************************************
** this is the internal data structure that the UI works with - but notably,
** is not how we send it to the backend or how backend saves profiles -- see
** BulkLoadProfile for that.
*******************************************************************************/
export class BulkLoadMapping
{
fields: { [qualifiedName: string]: BulkLoadField } = {};
fieldsByTablePrefix: { [prefix: string]: { [qualifiedFieldName: string]: BulkLoadField } } = {};
tablesByPath: { [path: string]: BulkLoadTableStructure } = {};
requiredFields: BulkLoadField[] = [];
additionalFields: BulkLoadField[] = [];
unusedFields: BulkLoadField[] = [];
valueMappings: { [fieldName: string]: { [fileValue: string]: any } } = {};
hasHeaderRow: boolean;
layout: string;
/***************************************************************************
**
***************************************************************************/
constructor(tableStructure: BulkLoadTableStructure)
{
if (tableStructure)
{
this.processTableStructure(tableStructure);
if (!tableStructure.associations)
{
this.layout = "FLAT";
}
}
this.hasHeaderRow = true;
}
/***************************************************************************
**
***************************************************************************/
private processTableStructure(tableStructure: BulkLoadTableStructure)
{
const prefix = tableStructure.isMain ? "" : tableStructure.associationPath;
this.fieldsByTablePrefix[prefix] = {};
this.tablesByPath[prefix] = tableStructure;
for (let field of tableStructure.fields)
{
// todo delete this - backend should only give it to us if editable: if (field.isEditable)
{
const bulkLoadField = new BulkLoadField(field, tableStructure);
const qualifiedName = bulkLoadField.getQualifiedName();
this.fields[qualifiedName] = bulkLoadField;
this.fieldsByTablePrefix[prefix][qualifiedName] = bulkLoadField;
if (tableStructure.isMain && field.isRequired)
{
this.requiredFields.push(bulkLoadField);
}
else
{
this.unusedFields.push(bulkLoadField);
}
}
}
for (let associatedTableStructure of tableStructure.associations ?? [])
{
this.processTableStructure(associatedTableStructure);
}
}
/***************************************************************************
** take a saved bulk load profile - and convert it into a working bulkLoadMapping
** for the frontend to use!
***************************************************************************/
public static fromSavedProfileRecord(tableStructure: BulkLoadTableStructure, profileRecord: QRecord): BulkLoadMapping
{
const bulkLoadProfile = JSON.parse(profileRecord.values.get("mappingJson")) as BulkLoadProfile;
return BulkLoadMapping.fromBulkLoadProfile(tableStructure, bulkLoadProfile);
}
/***************************************************************************
** take a saved bulk load profile - and convert it into a working bulkLoadMapping
** for the frontend to use!
***************************************************************************/
public static fromBulkLoadProfile(tableStructure: BulkLoadTableStructure, bulkLoadProfile: BulkLoadProfile): BulkLoadMapping
{
const bulkLoadMapping = new BulkLoadMapping(tableStructure);
if (bulkLoadProfile.version == "v1")
{
bulkLoadMapping.hasHeaderRow = bulkLoadProfile.hasHeaderRow;
bulkLoadMapping.layout = bulkLoadProfile.layout;
////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// function to get a bulkLoadMapping field by its (full) name - whether that's in the required fields list, //
// or it's an additional field, in which case, we'll go through the addField method to move what list it's in //
////////////////////////////////////////////////////////////////////////////////////////////////////////////////
function getBulkLoadFieldMovingFromUnusedToActiveIfNeeded(bulkLoadMapping: BulkLoadMapping, name: string): BulkLoadField
{
let wideIndex: number = null;
if (name.match(/,\d+$/))
{
wideIndex = Number(name.match(/\d+$/));
name = name.replace(/,\d+$/, "");
}
for (let field of bulkLoadMapping.requiredFields)
{
if (field.getQualifiedName() == name)
{
return (field);
}
}
for (let field of bulkLoadMapping.unusedFields)
{
if (field.getQualifiedName() == name)
{
const addedField = bulkLoadMapping.addField(field, wideIndex);
return (addedField);
}
}
}
//////////////////////////////////////////////////////////////////
// loop over fields in the profile - adding them to the mapping //
//////////////////////////////////////////////////////////////////
for (let bulkLoadProfileField of ((bulkLoadProfile.fieldList ?? []) as BulkLoadProfileField[]))
{
const bulkLoadField = getBulkLoadFieldMovingFromUnusedToActiveIfNeeded(bulkLoadMapping, bulkLoadProfileField.fieldName);
if (!bulkLoadField)
{
console.log(`Couldn't find bulk-load-field by name from profile record [${bulkLoadProfileField.fieldName}]`);
continue;
}
if ((bulkLoadProfileField.columnIndex != null && bulkLoadProfileField.columnIndex != undefined) || (bulkLoadProfileField.headerName != null && bulkLoadProfileField.headerName != undefined))
{
bulkLoadField.valueType = "column";
bulkLoadField.doValueMapping = bulkLoadProfileField.doValueMapping;
bulkLoadField.headerName = bulkLoadProfileField.headerName;
bulkLoadField.columnIndex = bulkLoadProfileField.columnIndex;
if (bulkLoadProfileField.valueMappings)
{
bulkLoadMapping.valueMappings[bulkLoadProfileField.fieldName] = {};
for (let fileValue in bulkLoadProfileField.valueMappings)
{
////////////////////////////////////////////////////
// frontend wants string values here, so, string. //
////////////////////////////////////////////////////
bulkLoadMapping.valueMappings[bulkLoadProfileField.fieldName][String(fileValue)] = bulkLoadProfileField.valueMappings[fileValue];
}
}
}
else
{
bulkLoadField.valueType = "defaultValue";
bulkLoadField.defaultValue = bulkLoadProfileField.defaultValue;
}
}
return (bulkLoadMapping);
}
else
{
throw ("Unexpected version for bulk load profile: " + bulkLoadProfile.version);
}
}
/***************************************************************************
** take a working bulkLoadMapping from the frontend, and convert it to a
** BulkLoadProfile for the backend / for us to save.
***************************************************************************/
public toProfile(): { haveErrors: boolean, profile: BulkLoadProfile }
{
let haveErrors = false;
const profile = new BulkLoadProfile();
profile.version = "v1";
profile.hasHeaderRow = this.hasHeaderRow;
profile.layout = this.layout;
for (let bulkLoadField of [...this.requiredFields, ...this.additionalFields])
{
let fullFieldName = (bulkLoadField.tableStructure.isMain ? "" : bulkLoadField.tableStructure.associationPath + ".") + bulkLoadField.field.name;
if (bulkLoadField.wideLayoutIndexPath != null && bulkLoadField.wideLayoutIndexPath != undefined && bulkLoadField.wideLayoutIndexPath.length)
{
fullFieldName += "," + bulkLoadField.wideLayoutIndexPath.join(".");
}
bulkLoadField.error = null;
if (bulkLoadField.valueType == "column")
{
if (bulkLoadField.columnIndex == undefined || bulkLoadField.columnIndex == null)
{
haveErrors = true;
bulkLoadField.error = "You must select a column.";
}
else
{
const field: BulkLoadProfileField = {fieldName: fullFieldName, columnIndex: bulkLoadField.columnIndex, headerName: bulkLoadField.headerName, doValueMapping: bulkLoadField.doValueMapping};
if (this.valueMappings[fullFieldName])
{
field.valueMappings = this.valueMappings[fullFieldName];
}
profile.fieldList.push(field);
}
}
else if (bulkLoadField.valueType == "defaultValue")
{
if (bulkLoadField.defaultValue == undefined || bulkLoadField.defaultValue == null || bulkLoadField.defaultValue == "")
{
haveErrors = true;
bulkLoadField.error = "A value is required.";
}
else
{
profile.fieldList.push({fieldName: fullFieldName, defaultValue: bulkLoadField.defaultValue});
}
}
}
return {haveErrors, profile};
}
/***************************************************************************
**
***************************************************************************/
public addField(bulkLoadField: BulkLoadField, specifiedWideIndex?: number): BulkLoadField
{
if (bulkLoadField.isMany() && this.layout == "WIDE")
{
let index: number;
if (specifiedWideIndex != null && specifiedWideIndex != undefined)
{
index = specifiedWideIndex;
}
else
{
index = 0;
///////////////////////////////////////////////////////////
// count how many copies of this field there are already //
///////////////////////////////////////////////////////////
for (let existingField of [...this.requiredFields, ...this.additionalFields])
{
if (existingField.getQualifiedName() == bulkLoadField.getQualifiedName())
{
index++;
}
}
}
const cloneField = BulkLoadField.clone(bulkLoadField);
cloneField.wideLayoutIndexPath = [index];
this.additionalFields.push(cloneField);
return (cloneField);
}
else
{
this.additionalFields.push(bulkLoadField);
return (bulkLoadField);
}
}
/***************************************************************************
**
***************************************************************************/
public removeField(toRemove: BulkLoadField): void
{
const newAdditionalFields: BulkLoadField[] = [];
for (let bulkLoadField of this.additionalFields)
{
if (bulkLoadField.getQualifiedName() != toRemove.getQualifiedName())
{
newAdditionalFields.push(bulkLoadField);
}
}
this.additionalFields = newAdditionalFields;
}
}
/***************************************************************************
** meta-data about the file that the user uploaded
***************************************************************************/
export class FileDescription
{
headerValues: string[];
headerLetters: string[];
bodyValuesPreview: string[][];
// todo - just get this from the profile always - it's not part of the file per-se
hasHeaderRow: boolean = true;
/***************************************************************************
**
***************************************************************************/
constructor(headerValues: string[], headerLetters: string[], bodyValuesPreview: string[][])
{
this.headerValues = headerValues;
this.headerLetters = headerLetters;
this.bodyValuesPreview = bodyValuesPreview;
}
/***************************************************************************
**
***************************************************************************/
public setHasHeaderRow(hasHeaderRow: boolean)
{
this.hasHeaderRow = hasHeaderRow;
}
/***************************************************************************
**
***************************************************************************/
public getColumnNames(): string[]
{
if (this.hasHeaderRow)
{
return this.headerValues;
}
else
{
return this.headerLetters.map(l => `Column ${l}`);
}
}
/***************************************************************************
**
***************************************************************************/
public getPreviewValues(columnIndex: number): string[]
{
if (columnIndex == undefined)
{
return [];
}
if (this.hasHeaderRow)
{
return (this.bodyValuesPreview[columnIndex]);
}
else
{
return ([this.headerValues[columnIndex], ...this.bodyValuesPreview[columnIndex]]);
}
}
}
/***************************************************************************
** this (BulkLoadProfile & ...Field) is the model of what we save, and is
** also what we submit to the backend during the process.
***************************************************************************/
export class BulkLoadProfile
{
version: string;
fieldList: BulkLoadProfileField[] = [];
hasHeaderRow: boolean;
layout: string;
}
type BulkLoadProfileField =
{
fieldName: string,
columnIndex?: number,
headerName?: string,
defaultValue?: any,
doValueMapping?: boolean,
valueMappings?: { [fileValue: string]: any }
};
/***************************************************************************
** In the bulk load forms, we have some forward-ref callback functions, and
** they like to capture/retain a reference when those functions get defined,
** so we had some trouble updating objects in those functions.
**
** We "solved" this by creating instances of this class, which get captured,
** so then we can replace the wrapped object, and have a better time...
***************************************************************************/
export class Wrapper<T>
{
t: T;
/***************************************************************************
**
***************************************************************************/
constructor(t: T)
{
this.t = t;
}
/***************************************************************************
**
***************************************************************************/
public get(): T
{
return this.t;
}
/***************************************************************************
**
***************************************************************************/
public set(t: T)
{
this.t = t;
}
}