Merge branch 'develop' into import-relations-mapping

This commit is contained in:
Ahmed Bouhuolia
2024-04-01 03:01:24 +02:00
committed by GitHub
10 changed files with 338 additions and 27 deletions

View File

@@ -2,12 +2,12 @@ import { Inject, Service } from 'typedi';
import * as R from 'ramda'; import * as R from 'ramda';
import bluebird from 'bluebird'; import bluebird from 'bluebird';
import { isUndefined, get, chain, toArray, pickBy, castArray } from 'lodash'; import { isUndefined, get, chain, toArray, pickBy, castArray } from 'lodash';
import { Knex } from 'knex';
import { ImportMappingAttr, ResourceMetaFieldsMap } from './interfaces'; import { ImportMappingAttr, ResourceMetaFieldsMap } from './interfaces';
import { parseBoolean } from '@/utils'; import { trimObject, parseBoolean } from './_utils';
import { trimObject } from './_utils';
import { Account, Item } from '@/models'; import { Account, Item } from '@/models';
import ResourceService from '../Resource/ResourceService'; import ResourceService from '../Resource/ResourceService';
import { Knex } from 'knex'; import { multiNumberParse } from '@/utils/multi-number-parse';
const CurrencyParsingDTOs = 10; const CurrencyParsingDTOs = 10;
@@ -17,7 +17,8 @@ export class ImportFileDataTransformer {
private resource: ResourceService; private resource: ResourceService;
/** /**
* * Parses the given sheet data before passing to the service layer.
* based on the mapped fields and the each field type .
* @param {number} tenantId - * @param {number} tenantId -
* @param {} * @param {}
*/ */
@@ -98,7 +99,7 @@ export class ImportFileDataTransformer {
// Parses the boolean value. // Parses the boolean value.
if (fields[key].fieldType === 'boolean') { if (fields[key].fieldType === 'boolean') {
_value = parseBoolean(value, false); _value = parseBoolean(value);
// Parses the enumeration value. // Parses the enumeration value.
} else if (field.fieldType === 'enumeration') { } else if (field.fieldType === 'enumeration') {
@@ -109,7 +110,7 @@ export class ImportFileDataTransformer {
_value = get(option, 'key'); _value = get(option, 'key');
// Parses the numeric value. // Parses the numeric value.
} else if (fields[key].fieldType === 'number') { } else if (fields[key].fieldType === 'number') {
_value = parseFloat(value); _value = multiNumberParse(value);
// Parses the relation value. // Parses the relation value.
} else if (field.fieldType === 'relation') { } else if (field.fieldType === 'relation') {
const relationModel = resourceModel.relationMappings[field.relationKey]; const relationModel = resourceModel.relationMappings[field.relationKey];

View File

@@ -1,12 +1,11 @@
import { Inject, Service } from 'typedi'; import { Inject, Service } from 'typedi';
import HasTenancyService from '../Tenancy/TenancyService'; import HasTenancyService from '../Tenancy/TenancyService';
import { sanitizeResourceName } from './_utils'; import { sanitizeResourceName, validateSheetEmpty } from './_utils';
import ResourceService from '../Resource/ResourceService'; import ResourceService from '../Resource/ResourceService';
import { IModelMetaField } from '@/interfaces'; import { IModelMetaField } from '@/interfaces';
import { ImportFileCommon } from './ImportFileCommon'; import { ImportFileCommon } from './ImportFileCommon';
import { ImportFileDataValidator } from './ImportFileDataValidator'; import { ImportFileDataValidator } from './ImportFileDataValidator';
import { ImportFileUploadPOJO } from './interfaces'; import { ImportFileUploadPOJO } from './interfaces';
import { ServiceError } from '@/exceptions';
@Service() @Service()
export class ImportFileUploadService { export class ImportFileUploadService {
@@ -51,6 +50,10 @@ export class ImportFileUploadService {
// Parse the buffer file to array data. // Parse the buffer file to array data.
const sheetData = this.importFileCommon.parseXlsxSheet(buffer); const sheetData = this.importFileCommon.parseXlsxSheet(buffer);
// Throws service error if the sheet data is empty.
validateSheetEmpty(sheetData);
const sheetColumns = this.importFileCommon.parseSheetColumns(sheetData); const sheetColumns = this.importFileCommon.parseSheetColumns(sheetData);
const coumnsStringified = JSON.stringify(sheetColumns); const coumnsStringified = JSON.stringify(sheetColumns);

View File

@@ -6,11 +6,13 @@ import {
first, first,
isUndefined, isUndefined,
pickBy, pickBy,
isEmpty,
} from 'lodash'; } from 'lodash';
import pluralize from 'pluralize'; import pluralize from 'pluralize';
import { ResourceMetaFieldsMap } from './interfaces'; import { ResourceMetaFieldsMap } from './interfaces';
import { IModelMetaField } from '@/interfaces'; import { IModelMetaField } from '@/interfaces';
import moment from 'moment'; import moment from 'moment';
import { ServiceError } from '@/exceptions';
export const ERRORS = { export const ERRORS = {
RESOURCE_NOT_IMPORTABLE: 'RESOURCE_NOT_IMPORTABLE', RESOURCE_NOT_IMPORTABLE: 'RESOURCE_NOT_IMPORTABLE',
@@ -20,6 +22,7 @@ export const ERRORS = {
IMPORT_FILE_NOT_MAPPED: 'IMPORT_FILE_NOT_MAPPED', IMPORT_FILE_NOT_MAPPED: 'IMPORT_FILE_NOT_MAPPED',
INVALID_MAP_DATE_FORMAT: 'INVALID_MAP_DATE_FORMAT', INVALID_MAP_DATE_FORMAT: 'INVALID_MAP_DATE_FORMAT',
MAP_DATE_FORMAT_NOT_DEFINED: 'MAP_DATE_FORMAT_NOT_DEFINED', MAP_DATE_FORMAT_NOT_DEFINED: 'MAP_DATE_FORMAT_NOT_DEFINED',
IMPORTED_SHEET_EMPTY: 'IMPORTED_SHEET_EMPTY',
}; };
export function trimObject(obj) { export function trimObject(obj) {
@@ -143,3 +146,38 @@ export const getUniqueImportableValue = (
return defaultTo(objectDTO[uniqueImportableKey], ''); return defaultTo(objectDTO[uniqueImportableKey], '');
}; };
/**
* Throws service error the given sheet is empty.
* @param {Array<any>} sheetData
*/
export const validateSheetEmpty = (sheetData: Array<any>) => {
if (isEmpty(sheetData)) {
throw new ServiceError(ERRORS.IMPORTED_SHEET_EMPTY);
}
const booleanValuesRepresentingTrue: string[] = ['true', 'yes', 'y', 't', '1'];
const booleanValuesRepresentingFalse: string[] = ['false', 'no', 'n', 'f', '0'];
/**
* Parses the given string value to boolean.
* @param {string} value
* @returns {string|null}
*/
export const parseBoolean = (value: string): boolean | null => {
const normalizeValue = (value: string): string =>
value.toString().trim().toLowerCase();
const normalizedValue = normalizeValue(value);
const valuesRepresentingTrue =
booleanValuesRepresentingTrue.map(normalizeValue);
const valueRepresentingFalse =
booleanValuesRepresentingFalse.map(normalizeValue);
if (valuesRepresentingTrue.includes(normalizedValue)) {
return true;
} else if (valueRepresentingFalse.includes(normalizedValue)) {
return false;
}
return null;
};

View File

@@ -0,0 +1,51 @@
import { assert } from 'chai';
import { multiNumberParse } from './multi-number-parse';
const correctNumbers = [
{ actual: '10.5', expected: 10.5 },
{ actual: '10,5', expected: 10.5 },
{ actual: '1.235,76', expected: 1235.76 },
{ actual: '2,543.56', expected: 2543.56 },
{ actual: '10 654.1234', expected: 10654.1234 },
{ actual: '2.654$10', expected: 2654.1 },
{ actual: '5.435.123,645', expected: 5435123.645 },
{ actual: '2,566,765.234', expected: 2566765.234 },
{ actual: '2,432,123$23', expected: 2432123.23 },
{ actual: '2,45EUR', expected: 2.45 },
{ actual: '4.78€', expected: 4.78 },
{ actual: '28', expected: 28 },
{ actual: '-48', expected: -48 },
{ actual: '39USD', expected: 39 },
// Some negative numbers
{ actual: '-2,543.56', expected: -2543.56 },
{ actual: '-10 654.1234', expected: -10654.1234 },
{ actual: '-2.654$10', expected: -2654.1 },
];
const incorrectNumbers = [
'10 345,234.21', // too many different separators
'1.123.234,534,234', // impossible to detect where's the decimal separator
'10.4,2', // malformed digit groups
'1.123.2', // also malformed digit groups
];
describe('Test numbers', () => {
correctNumbers.forEach((item) => {
it(`"${item.actual}" should return ${item.expected}`, (done) => {
const parsed = multiNumberParse(item.actual);
assert.isNotNaN(parsed);
assert.equal(parsed, item.expected);
done();
});
});
incorrectNumbers.forEach((item) => {
it(`"${item}" should return NaN`, (done) => {
assert.isNaN(numberParse(item));
done();
});
});
});

View File

@@ -0,0 +1,130 @@
const validGrouping = (integerPart, sep) =>
integerPart.split(sep).reduce((acc, group, idx) => {
if (idx > 0) {
return acc && group.length === 3;
}
return acc && group.length;
}, true);
export const multiNumberParse = (number: number | string, standardDecSep = '.') => {
// if it's a number already, this is going to be easy...
if (typeof number === 'number') {
return number;
}
// check validity of parameters
if (!number || typeof number !== 'string') {
throw new TypeError('number must be a string');
}
if (typeof standardDecSep !== 'string' || standardDecSep.length !== 1) {
throw new TypeError('standardDecSep must be a single character string');
}
// check if negative
const negative = number[0] === '-';
// strip unnecessary chars
const stripped = number
// get rid of trailing non-numbers
.replace(/[^\d]+$/, '')
// get rid of the signal
.slice(negative ? 1 : 0);
// analyze separators
const separators = (stripped.match(/[^\d]/g) || []).reduce(
(acc, sep, idx) => {
const sepChr = `str_${sep.codePointAt(0)}`;
const cnt = ((acc[sepChr] || {}).cnt || 0) + 1;
return {
...acc,
[sepChr]: {
sep,
cnt,
lastIdx: idx,
},
};
},
{}
);
// check correctness of separators
const sepKeys = Object.keys(separators);
if (!sepKeys.length) {
// no separator, that's easy-peasy
return parseInt(stripped, 10) * (negative ? -1 : 1);
}
if (sepKeys.length > 2) {
// there's more than 2 separators, that's wrong
return Number.NaN;
}
if (sepKeys.length > 1) {
// there's two separators, that's ok by now
let sep1 = separators[sepKeys[0]];
let sep2 = separators[sepKeys[1]];
if (sep1.lastIdx > sep2.lastIdx) {
// swap
[sep1, sep2] = [sep2, sep1];
}
// if more than one separator appears more than once, that's wrong
if (sep1.cnt > 1 && sep2.cnt > 1) {
return Number.NaN;
}
// check if the last separator is the single one
if (sep2.cnt > 1) {
return Number.NaN;
}
// check the groupings
const [integerPart] = stripped.split(sep2.sep);
if (!validGrouping(integerPart, sep1.sep)) {
return Number.NaN;
}
// ok, we got here! let's handle it
return (
parseFloat(stripped.split(sep1.sep).join('').replace(sep2.sep, '.')) *
(negative ? -1 : 1)
);
}
// ok, only one separator, which is nice
const sep = separators[sepKeys[0]];
if (sep.cnt > 1) {
// there's more than one separator, which means it's integer
// let's check the groupings
if (!validGrouping(stripped, sep.sep)) {
return Number.NaN;
}
// it's valid, let's return an integer
return parseInt(stripped.split(sep.sep).join(''), 10) * (negative ? -1 : 1);
}
// just one separator, let's check last group
const groups = stripped.split(sep.sep);
if (groups[groups.length - 1].length === 3) {
// ok, we're in ambiguous territory here
if (sep.sep !== standardDecSep) {
// it's an integer
return (
parseInt(stripped.split(sep.sep).join(''), 10) * (negative ? -1 : 1)
);
}
}
// well, it looks like it's a simple float
return parseFloat(stripped.replace(sep.sep, '.')) * (negative ? -1 : 1);
};

View File

@@ -0,0 +1,53 @@
import type { ReactNode } from 'react';
import { useState, createContext, useContext } from 'react';
interface AlertsManagerContextValue {
alerts: (string | number)[];
showAlert: (alert: string | number) => void;
hideAlert: (alert: string | number) => void;
hideAlerts: () => void;
isAlertActive: (alert: string | number) => boolean;
findAlert: (alert: string | number) => string | number | undefined;
}
const AlertsManagerContext = createContext<AlertsManagerContextValue>(
{} as AlertsManagerContextValue,
);
export function AlertsManager({ children }: { children: ReactNode }) {
const [alerts, setAlerts] = useState<(string | number)[]>([]);
const showAlert = (type: string | number): void => {
setAlerts([...alerts, type]);
};
const hideAlert = (type: string | number): void => {
alerts.filter((t) => t !== type);
};
const hideAlerts = (): void => {
setAlerts([]);
};
const isAlertActive = (type: string | number): boolean => {
return alerts.some((t) => t === type);
};
const findAlert = (type: string | number): number | string | undefined => {
return alerts.find((t) => t === type);
};
return (
<AlertsManagerContext.Provider
value={{
alerts,
showAlert,
hideAlert,
hideAlerts,
isAlertActive,
findAlert,
}}
>
{children}
</AlertsManagerContext.Provider>
);
}
export const useAlertsManager = () => useContext(AlertsManagerContext);

View File

@@ -3,8 +3,11 @@ import { Field } from 'formik';
import { Box, Group, Stack } from '@/components'; import { Box, Group, Stack } from '@/components';
import styles from './ImportDropzone.module.css'; import styles from './ImportDropzone.module.css';
import { ImportDropzoneField } from './ImportDropzoneFile'; import { ImportDropzoneField } from './ImportDropzoneFile';
import { useAlertsManager } from './AlertsManager';
export function ImportDropzone() { export function ImportDropzone() {
const { hideAlerts } = useAlertsManager();
return ( return (
<Stack spacing={0}> <Stack spacing={0}>
<Field id={'file'} name={'file'} type="file"> <Field id={'file'} name={'file'} type="file">
@@ -12,6 +15,7 @@ export function ImportDropzone() {
<ImportDropzoneField <ImportDropzoneField
value={form.file} value={form.file}
onChange={(file) => { onChange={(file) => {
hideAlerts();
form.setFieldValue('file', file); form.setFieldValue('file', file);
}} }}
/> />

View File

@@ -5,7 +5,8 @@ import { Intent } from '@blueprintjs/core';
import { Formik, Form, FormikHelpers } from 'formik'; import { Formik, Form, FormikHelpers } from 'formik';
import * as Yup from 'yup'; import * as Yup from 'yup';
import { useImportFileContext } from './ImportFileProvider'; import { useImportFileContext } from './ImportFileProvider';
import { ImportStepperStep } from './_types'; import { ImportAlert, ImportStepperStep } from './_types';
import { useAlertsManager } from './AlertsManager';
const initialValues = { const initialValues = {
file: null, file: null,
@@ -28,6 +29,7 @@ export function ImportFileUploadForm({
formikProps, formikProps,
formProps, formProps,
}: ImportFileUploadFormProps) { }: ImportFileUploadFormProps) {
const { showAlert, hideAlerts } = useAlertsManager();
const { mutateAsync: uploadImportFile } = useImportFileUpload(); const { mutateAsync: uploadImportFile } = useImportFileUpload();
const { const {
resource, resource,
@@ -42,6 +44,7 @@ export function ImportFileUploadForm({
values: ImportFileUploadValues, values: ImportFileUploadValues,
{ setSubmitting }: FormikHelpers<ImportFileUploadValues>, { setSubmitting }: FormikHelpers<ImportFileUploadValues>,
) => { ) => {
hideAlerts();
if (!values.file) return; if (!values.file) return;
setSubmitting(true); setSubmitting(true);
@@ -69,6 +72,9 @@ export function ImportFileUploadForm({
message: 'The extenstion of uploaded file is not supported.', message: 'The extenstion of uploaded file is not supported.',
}); });
} }
if (data.errors.find((er) => er.type === 'IMPORTED_SHEET_EMPTY')) {
showAlert(ImportAlert.IMPORTED_SHEET_EMPTY);
}
setSubmitting(false); setSubmitting(false);
}); });
}; };

View File

@@ -1,5 +1,5 @@
// @ts-nocheck // @ts-nocheck
import { Classes } from '@blueprintjs/core'; import { Callout, Classes, Intent } from '@blueprintjs/core';
import { Stack } from '@/components'; import { Stack } from '@/components';
import { ImportDropzone } from './ImportDropzone'; import { ImportDropzone } from './ImportDropzone';
import { ImportSampleDownload } from './ImportSampleDownload'; import { ImportSampleDownload } from './ImportSampleDownload';
@@ -7,29 +7,50 @@ import { ImportFileUploadForm } from './ImportFileUploadForm';
import { ImportFileUploadFooterActions } from './ImportFileFooterActions'; import { ImportFileUploadFooterActions } from './ImportFileFooterActions';
import { ImportFileContainer } from './ImportFileContainer'; import { ImportFileContainer } from './ImportFileContainer';
import { useImportFileContext } from './ImportFileProvider'; import { useImportFileContext } from './ImportFileProvider';
import { AlertsManager, useAlertsManager } from './AlertsManager';
import { ImportAlert } from './_types';
function ImportFileUploadCallouts() {
const { isAlertActive } = useAlertsManager();
return (
<>
{isAlertActive(ImportAlert.IMPORTED_SHEET_EMPTY) && (
<Callout intent={Intent.DANGER} icon={null}>
The imported sheet is empty.
</Callout>
)}
</>
);
}
export function ImportFileUploadStep() { export function ImportFileUploadStep() {
const { exampleDownload } = useImportFileContext(); const { exampleDownload } = useImportFileContext();
return ( return (
<ImportFileUploadForm> <AlertsManager>
<ImportFileContainer> <ImportFileUploadForm>
<p <ImportFileContainer>
className={Classes.TEXT_MUTED} <p
style={{ marginBottom: 18, lineHeight: 1.6 }} className={Classes.TEXT_MUTED}
> style={{ marginBottom: 18, lineHeight: 1.6 }}
Download a sample file and compare it with your import file to ensure >
it is properly formatted. It's not necessary for the columns to be in Download a sample file and compare it with your import file to
the same order, you can map them later. ensure it is properly formatted. It's not necessary for the columns
</p> to be in the same order, you can map them later.
</p>
<Stack spacing={40}> <Stack>
<ImportDropzone /> <ImportFileUploadCallouts />
{exampleDownload && <ImportSampleDownload />}
</Stack>
</ImportFileContainer>
<ImportFileUploadFooterActions /> <Stack spacing={40}>
</ImportFileUploadForm> <ImportDropzone />
{exampleDownload && <ImportSampleDownload />}
</Stack>
</Stack>
</ImportFileContainer>
<ImportFileUploadFooterActions />
</ImportFileUploadForm>
</AlertsManager>
); );
} }

View File

@@ -3,3 +3,7 @@ export enum ImportStepperStep {
Mapping = 1, Mapping = 1,
Preview = 2, Preview = 2,
} }
export enum ImportAlert {
IMPORTED_SHEET_EMPTY = 'IMPORTED_SHEET_EMPTY'
}