diff --git a/src/FileUtils/AgGridUtils.js b/src/FileUtils/AgGridUtils.js index 37082e7..5c56a39 100644 --- a/src/FileUtils/AgGridUtils.js +++ b/src/FileUtils/AgGridUtils.js @@ -54,16 +54,6 @@ const _buildNumberColumnsError = (rowLineNumber, expectedCols, row) => { return new PanelError(errorSummary, errorLoc, errorContext); }; -const _buildTypeError = (type, rowLineNumber, colIndex, colsData, value, expected) => { - const errorSummary = `Incorrect ${type} value`; - const errorLoc = `Line ${rowLineNumber}, Column ${colIndex + 1} ("${colsData[colIndex].field}")`; - const errorContext = `${errorSummary} (${errorLoc})\n` + `Incorrect value : "${value}" for type ${type}`; - if (!expected || expected.length === 0) { - return new PanelError(errorSummary, errorLoc, errorContext); - } - return new PanelError(errorSummary, errorLoc, errorContext + '\n' + expected); -}; - const DEFAULT_CSV_EXPORT_OPTIONS = { colSep: ',', dateFormat: 'yyyy-MM-dd', @@ -82,16 +72,6 @@ const _forgeColumnsCountError = (row, rowLineNumber, expectedCols, errors) => { if (row.length !== expectedCols.length) errors.push(_buildNumberColumnsError(rowLineNumber, expectedCols, row)); }; -const _forgeTypeError = (value, rowLineNumber, type, options, colsData, colIndex) => { - let expected = ''; - if (type === 'enum') { - expected = `Expected values: [${options.enumValues.join()}]`; - } else if (type === 'date') { - expected = `Expected format: ${options.dateFormat}`; - } - return _buildTypeError(type, rowLineNumber, colIndex, colsData, value, expected); -}; - const _getColTypeFromTypeArray = (typeArray) => { if (!typeArray || typeArray.length === 0) { return 'string'; // Fall back to default type @@ -120,13 +100,20 @@ const _validateFormat = (rows, hasHeader, cols, options) => { const colType = colsData[colIndex].type; if (colType && rowCell !== undefined) { // use of cellEditorParams is deprecated - const enumValues = colsData[colIndex]?.enumValues ?? colsData[colIndex]?.cellEditorParams?.enumValues; - const colOptions = { ...options, enumValues }; + const colOptions = { + ...options, + enumValues: colsData[colIndex]?.enumValues ?? colsData[colIndex]?.cellEditorParams?.enumValues, + minValue: colsData[colIndex]?.minValue, + maxValue: colsData[colIndex]?.maxValue, + }; const acceptsEmptyFields = // use of cellEditorParams is deprecated colsData[colIndex].acceptsEmptyFields ?? colsData[colIndex].cellEditorParams?.acceptsEmptyFields ?? false; - if (!ValidationUtils.isValid(rowCell, colType, colOptions, acceptsEmptyFields)) { - errors.push(_forgeTypeError(rowCell, rowIndex + 1, colType, colOptions, colsData, colIndex)); + const validationResult = ValidationUtils.isValid(rowCell, colType, colOptions, acceptsEmptyFields); + if (validationResult !== true) { + const { summary: errorSummary, context: errorContext } = validationResult; + const errorLoc = `Line ${rowIndex + 1}, Column ${colIndex + 1} ("${colsData[colIndex].field}")`; + errors.push(new PanelError(errorSummary, errorLoc, errorContext)); } } } diff --git a/src/FileUtils/__test__/CustomersData.js b/src/FileUtils/__test__/CustomersData.js index 8569143..198c481 100644 --- a/src/FileUtils/__test__/CustomersData.js +++ b/src/FileUtils/__test__/CustomersData.js @@ -73,7 +73,7 @@ export const CUSTOMERS_COLS = [ { headerName: 'identity', children: [ - { field: 'birthday', type: ['date'], minValue: '1900-01-01', maxValue: new Date().toISOString() }, + { field: 'birthday', type: ['date'], minValue: new Date('1900-01-01'), maxValue: new Date('2030-01-01') }, { field: 'height', type: ['number'], minValue: 0, maxValue: 2.5 }, ], }, @@ -88,7 +88,7 @@ export const CUSTOMERS_COLS_DEPRECATED = [ type: ['enum'], cellEditorParams: { enumValues: ['AppleJuice', 'Beer', 'OrangeJuice', 'Wine'] }, }, - { field: 'birthday', type: ['date'], minValue: '1900-01-01', maxValue: new Date().toISOString() }, + { field: 'birthday', type: ['date'], minValue: new Date('1900-01-01'), maxValue: new Date('2030-01-01') }, { field: 'height', type: ['number'], minValue: 0, maxValue: 2.5 }, ]; @@ -210,6 +210,8 @@ export const INVALID_CUSTOMERS_ROWS = [ ['Bob', '15', '1', 'AppleJuice', 'bad_date', '1.70'], ['Bob', '15', '0', 'AppleJuice', '01/01/2000', 'bad_number'], ['Bob', 'bad_int', 'bad_bool', 'bad_enum', 'bad_date', 'bad_number'], + ['Bob', '-1', 'yes', 'AppleJuice', '01/01/1899', '-0.1'], // minValue not respected + ['Bob', '999', 'yes', 'AppleJuice', '01/01/2999', '99.9'], // maxValue not respected ['Bob', '15', 'yes', 'AppleJuice', '01/01/2000', '1.70'], ]; @@ -221,64 +223,55 @@ export const EXPECTED_ERRORS_WITH_HEADER = [ 'Expected data format : "name,age,canDrinkAlcohol,favoriteDrink,birthday,height"\n' + 'Incorrect Row : "MissingColumns"' ), - new Error( - 'Incorrect int value', - 'Line 3, Column 2 ("age")', - 'Incorrect int value (Line 3, Column 2 ("age"))\nIncorrect value : "bad_int" for type int' - ), + new Error('Incorrect int value', 'Line 3, Column 2 ("age")', 'Incorrect value: "bad_int" for type int'), new Error( 'Incorrect bool value', 'Line 4, Column 3 ("canDrinkAlcohol")', - 'Incorrect bool value (Line 4, Column 3 ("canDrinkAlcohol"))\nIncorrect value : "bad_bool" for type bool' + 'Incorrect value: "bad_bool" for type bool' ), new Error( 'Incorrect enum value', 'Line 5, Column 4 ("favoriteDrink")', - 'Incorrect enum value (Line 5, Column 4 ("favoriteDrink"))\n' + - 'Incorrect value : "bad_enum" for type enum\n' + - 'Expected values: [AppleJuice,Beer,OrangeJuice,Wine]' + 'Incorrect value: "bad_enum" for type enum\n' + 'Expected values: [AppleJuice,Beer,OrangeJuice,Wine]' ), new Error( 'Incorrect date value', 'Line 6, Column 5 ("birthday")', - 'Incorrect date value (Line 6, Column 5 ("birthday"))\n' + - 'Incorrect value : "bad_date" for type date\n' + - 'Expected format: dd/MM/yyyy' - ), - new Error( - 'Incorrect number value', - 'Line 7, Column 6 ("height")', - 'Incorrect number value (Line 7, Column 6 ("height"))\nIncorrect value : "bad_number" for type number' - ), - new Error( - 'Incorrect int value', - 'Line 8, Column 2 ("age")', - 'Incorrect int value (Line 8, Column 2 ("age"))\nIncorrect value : "bad_int" for type int' + 'Incorrect value: "bad_date" for type date\n' + 'Expected format: dd/MM/yyyy' ), + new Error('Incorrect number value', 'Line 7, Column 6 ("height")', 'Incorrect value: "bad_number" for type number'), + new Error('Incorrect int value', 'Line 8, Column 2 ("age")', 'Incorrect value: "bad_int" for type int'), new Error( 'Incorrect bool value', 'Line 8, Column 3 ("canDrinkAlcohol")', - 'Incorrect bool value (Line 8, Column 3 ("canDrinkAlcohol"))\nIncorrect value : "bad_bool" for type bool' + 'Incorrect value: "bad_bool" for type bool' ), new Error( 'Incorrect enum value', 'Line 8, Column 4 ("favoriteDrink")', - 'Incorrect enum value (Line 8, Column 4 ("favoriteDrink"))\n' + - 'Incorrect value : "bad_enum" for type enum\n' + - 'Expected values: [AppleJuice,Beer,OrangeJuice,Wine]' + 'Incorrect value: "bad_enum" for type enum\n' + 'Expected values: [AppleJuice,Beer,OrangeJuice,Wine]' ), new Error( 'Incorrect date value', 'Line 8, Column 5 ("birthday")', - 'Incorrect date value (Line 8, Column 5 ("birthday"))\n' + - 'Incorrect value : "bad_date" for type date\n' + - 'Expected format: dd/MM/yyyy' + 'Incorrect value: "bad_date" for type date\n' + 'Expected format: dd/MM/yyyy' + ), + new Error('Incorrect number value', 'Line 8, Column 6 ("height")', 'Incorrect value: "bad_number" for type number'), + // Min/max values errors + new Error('Value out of range', 'Line 9, Column 2 ("age")', 'Value "-1" should be greater than 0'), + new Error( + 'Value out of range', + 'Line 9, Column 5 ("birthday")', + 'Value "01/01/1899" should be greater than 01/01/1900' ), + new Error('Value out of range', 'Line 9, Column 6 ("height")', 'Value "-0.1" should be greater than 0'), + new Error('Value out of range', 'Line 10, Column 2 ("age")', 'Value "999" should be less than 120'), new Error( - 'Incorrect number value', - 'Line 8, Column 6 ("height")', - 'Incorrect number value (Line 8, Column 6 ("height"))\nIncorrect value : "bad_number" for type number' + 'Value out of range', + 'Line 10, Column 5 ("birthday")', + 'Value "01/01/2999" should be less than 01/01/2030' ), + new Error('Value out of range', 'Line 10, Column 6 ("height")', 'Value "99.9" should be less than 2.5'), ]; export const EXPECTED_ERRORS_WITHOUT_COLS = [ @@ -298,64 +291,51 @@ export const EXPECTED_ERRORS_WITHOUT_HEADER = [ 'Expected data format : "name,age,canDrinkAlcohol,favoriteDrink,birthday,height"\n' + 'Incorrect Row : "MissingColumns"' ), - new Error( - 'Incorrect int value', - 'Line 2, Column 2 ("age")', - 'Incorrect int value (Line 2, Column 2 ("age"))\nIncorrect value : "bad_int" for type int' - ), + new Error('Incorrect int value', 'Line 2, Column 2 ("age")', 'Incorrect value: "bad_int" for type int'), new Error( 'Incorrect bool value', 'Line 3, Column 3 ("canDrinkAlcohol")', - 'Incorrect bool value (Line 3, Column 3 ("canDrinkAlcohol"))\nIncorrect value : "bad_bool" for type bool' + 'Incorrect value: "bad_bool" for type bool' ), new Error( 'Incorrect enum value', 'Line 4, Column 4 ("favoriteDrink")', - 'Incorrect enum value (Line 4, Column 4 ("favoriteDrink"))\n' + - 'Incorrect value : "bad_enum" for type enum\n' + - 'Expected values: [AppleJuice,Beer,OrangeJuice,Wine]' + 'Incorrect value: "bad_enum" for type enum\n' + 'Expected values: [AppleJuice,Beer,OrangeJuice,Wine]' ), new Error( 'Incorrect date value', 'Line 5, Column 5 ("birthday")', - 'Incorrect date value (Line 5, Column 5 ("birthday"))\n' + - 'Incorrect value : "bad_date" for type date\n' + - 'Expected format: dd/MM/yyyy' - ), - new Error( - 'Incorrect number value', - 'Line 6, Column 6 ("height")', - 'Incorrect number value (Line 6, Column 6 ("height"))\nIncorrect value : "bad_number" for type number' - ), - new Error( - 'Incorrect int value', - 'Line 7, Column 2 ("age")', - 'Incorrect int value (Line 7, Column 2 ("age"))\nIncorrect value : "bad_int" for type int' + 'Incorrect value: "bad_date" for type date\n' + 'Expected format: dd/MM/yyyy' ), + new Error('Incorrect number value', 'Line 6, Column 6 ("height")', 'Incorrect value: "bad_number" for type number'), + new Error('Incorrect int value', 'Line 7, Column 2 ("age")', 'Incorrect value: "bad_int" for type int'), new Error( 'Incorrect bool value', 'Line 7, Column 3 ("canDrinkAlcohol")', - 'Incorrect bool value (Line 7, Column 3 ("canDrinkAlcohol"))\nIncorrect value : "bad_bool" for type bool' + 'Incorrect value: "bad_bool" for type bool' ), new Error( 'Incorrect enum value', 'Line 7, Column 4 ("favoriteDrink")', - 'Incorrect enum value (Line 7, Column 4 ("favoriteDrink"))\n' + - 'Incorrect value : "bad_enum" for type enum\n' + - 'Expected values: [AppleJuice,Beer,OrangeJuice,Wine]' + 'Incorrect value: "bad_enum" for type enum\n' + 'Expected values: [AppleJuice,Beer,OrangeJuice,Wine]' ), new Error( 'Incorrect date value', 'Line 7, Column 5 ("birthday")', - 'Incorrect date value (Line 7, Column 5 ("birthday"))\n' + - 'Incorrect value : "bad_date" for type date\n' + - 'Expected format: dd/MM/yyyy' + 'Incorrect value: "bad_date" for type date\n' + 'Expected format: dd/MM/yyyy' ), + new Error('Incorrect number value', 'Line 7, Column 6 ("height")', 'Incorrect value: "bad_number" for type number'), + // Min/max values errors + new Error('Value out of range', 'Line 8, Column 2 ("age")', 'Value "-1" should be greater than 0'), new Error( - 'Incorrect number value', - 'Line 7, Column 6 ("height")', - 'Incorrect number value (Line 7, Column 6 ("height"))\nIncorrect value : "bad_number" for type number' + 'Value out of range', + 'Line 8, Column 5 ("birthday")', + 'Value "01/01/1899" should be greater than 01/01/1900' ), + new Error('Value out of range', 'Line 8, Column 6 ("height")', 'Value "-0.1" should be greater than 0'), + new Error('Value out of range', 'Line 9, Column 2 ("age")', 'Value "999" should be less than 120'), + new Error('Value out of range', 'Line 9, Column 5 ("birthday")', 'Value "01/01/2999" should be less than 01/01/2030'), + new Error('Value out of range', 'Line 9, Column 6 ("height")', 'Value "99.9" should be less than 2.5'), ]; export const EXPECTED_CUSTOM_CSV_OUTPUT = diff --git a/src/ValidationUtils/ValidationUtils.js b/src/ValidationUtils/ValidationUtils.js index 37e4f0f..20822a2 100644 --- a/src/ValidationUtils/ValidationUtils.js +++ b/src/ValidationUtils/ValidationUtils.js @@ -2,6 +2,22 @@ // Licensed under the MIT license. import validator from 'validator'; import { DateUtils } from '../DateUtils'; +import { Error as PanelError } from '../models'; + +const forgeTypeError = (value, type, options) => { + let expected; + if (type === 'enum') expected = `Expected values: [${options.enumValues.join()}]`; + else if (type === 'date') expected = `Expected format: ${options?.dateFormat}`; + + const error = new PanelError(`Incorrect ${type} value`, null, `Incorrect value: "${value}" for type ${type}`); + if (expected) error.context += '\n' + expected; + return error; +}; + +const forgeConfigError = (errorContext) => { + console.warn(`Configuration error: ${errorContext}`); + return { summary: 'Configuration error', context: errorContext }; +}; const isBool = (dataStr) => { return validator.isBoolean(dataStr, { loose: true }); @@ -27,34 +43,75 @@ const isString = (data) => { return typeof data === 'string'; }; +const isInRange = (value, minValue, maxValue) => { + if (value == null) return null; + + let errorMessage; + if (minValue != null && value < minValue) errorMessage = `Value "${value}" should be greater than ${minValue}`; + if (maxValue != null && value > maxValue) errorMessage = `Value "${value}" should be less than ${maxValue}`; + + if (errorMessage == null) return true; + return new PanelError(`Value out of range`, null, errorMessage); +}; + +const castToDate = (dateOrStrValue, dateFormat) => { + if (dateOrStrValue == null) return; + if (dateOrStrValue instanceof Date) return dateOrStrValue; + if (typeof dateOrStrValue !== 'string') { + console.warn(`Configuration error: ${dateOrStrValue} is not a string nor a Date.`); + return; + } + if (!isDate(dateOrStrValue, dateFormat)) { + console.warn(`Configuration error: ${forgeTypeError(dateOrStrValue, 'date', { dateFormat }).context}.`); + return; + } + + return DateUtils.parse(dateOrStrValue, dateFormat); +}; + +const isDateInRange = (value, minValue, maxValue, dateFormat) => { + const minDate = castToDate(minValue, dateFormat); + const maxDate = castToDate(maxValue, dateFormat); + const format = DateUtils.format; + if (value == null) return null; + if (dateFormat == null) return forgeConfigError("Missing option dateFormat, can't perform date validation."); + + let errorMessage; + if (minDate != null && value < minDate) + errorMessage = `Value "${format(value, dateFormat)}" should be greater than ${format(minDate, dateFormat)}`; + if (maxDate != null && value > maxDate) + errorMessage = `Value "${format(value, dateFormat)}" should be less than ${format(maxDate, dateFormat)}`; + + if (errorMessage == null) return true; + return new PanelError(`Value out of range`, null, errorMessage); +}; + const isValid = (dataStr, type, options, canBeEmpty = false) => { if (canBeEmpty && dataStr === '') { return true; } switch (type) { case 'bool': - return isBool(dataStr); - case 'date': - if (!options.dateFormat) { - console.error("Missing option dateFormat, can't perform date validation."); - return false; - } - return isDate(dataStr, options.dateFormat); + return isBool(dataStr) || forgeTypeError(dataStr, type, options); + case 'date': { + if (!options?.dateFormat) return forgeConfigError("Missing option dateFormat, can't perform date validation."); + if (!isDate(dataStr, options?.dateFormat)) return forgeTypeError(dataStr, type, options); + const valueAsDate = DateUtils.parse(dataStr, options?.dateFormat); + return isDateInRange(valueAsDate, options?.minValue, options?.maxValue, options?.dateFormat); + } case 'enum': - if (!options.enumValues) { - console.error("Missing option enumValues, can't perform enum validation."); - return false; - } - return isEnum(dataStr, options.enumValues); + if (!options.enumValues) return forgeConfigError("Missing option enumValues, can't perform enum validation."); + return isEnum(dataStr, options.enumValues) || forgeTypeError(dataStr, type, options); case 'int': - return isInt(dataStr); + if (!isInt(dataStr)) return forgeTypeError(dataStr, type, options); + return isInRange(Number(dataStr), options?.minValue, options?.maxValue); case 'number': - return isNumber(dataStr); + if (!isNumber(dataStr)) return forgeTypeError(dataStr, type, options); + return isInRange(Number(dataStr), options?.minValue, options?.maxValue); case 'string': - return isString(dataStr); + return isString(dataStr) || forgeTypeError(dataStr, type, options); default: - console.error(`Unknown type "${type}", can't perform type validation.`); - return false; + return forgeConfigError(`Unknown type "${type}", can't perform type validation.`); } }; diff --git a/src/ValidationUtils/__test__/ValidationUtils.spec.js b/src/ValidationUtils/__test__/ValidationUtils.spec.js index 0f77091..3b4499a 100644 --- a/src/ValidationUtils/__test__/ValidationUtils.spec.js +++ b/src/ValidationUtils/__test__/ValidationUtils.spec.js @@ -1,6 +1,8 @@ // Copyright (c) Cosmo Tech. // Licensed under the MIT license. import { ValidationUtils } from '..'; +import { DateUtils } from '../../DateUtils'; +import { Error } from '../../models'; describe('isBool', () => { test.each` @@ -97,7 +99,7 @@ describe('isNumber', () => { }); }); -describe('isValid', () => { +describe('isValid detects values with wrong types', () => { const options = { dateFormat: 'dd/MM/yyyy', enumValues: ['A', 'B', 'C'] }; test.each` dataStr | type | expectedRes @@ -138,7 +140,76 @@ describe('isValid', () => { ${'False'} | ${'enum'} | ${false} ${'False'} | ${'bool'} | ${true} `('$dataStr is of type $type must be $expectedRes', ({ dataStr, type, expectedRes }) => { + let additionalContext; + if (type === 'date') additionalContext = `Expected format: ${options.dateFormat}`; + else if (type === 'enum') additionalContext = `Expected values: [${options.enumValues.join()}]`; + + const expectedError = new Error(`Incorrect ${type} value`, null, `Incorrect value: "${dataStr}" for type ${type}`); + if (additionalContext) expectedError.context += '\n' + additionalContext; + const res = ValidationUtils.isValid(dataStr, type, options); - expect(res).toStrictEqual(expectedRes); + expect(res).toStrictEqual(expectedRes || expectedError); }); }); + +describe('isValid accepts values that are not out of range', () => { + const options = { dateFormat: 'dd/MM/yyyy', enumValues: ['A', 'B', 'C'] }; + const minDateStr = '01/01/2000'; + const maxDateStr = '31/12/2000'; + const minDate = new Date(minDateStr); + const maxDate = new Date(maxDateStr); + test.each` + dataStr | type | minValue | maxValue + ${'10'} | ${'int'} | ${-4} | ${10} + ${'1'} | ${'int'} | ${-4} | ${10} + ${'-1'} | ${'int'} | ${-4} | ${10} + ${'-4'} | ${'int'} | ${-4} | ${10} + ${'-999'} | ${'int'} | ${undefined} | ${10} + ${'999'} | ${'int'} | ${-4} | ${undefined} + ${'1.5'} | ${'number'} | ${-1.5} | ${1.5} + ${'1.2'} | ${'number'} | ${-1.5} | ${1.5} + ${'-1.2'} | ${'number'} | ${-1.5} | ${1.5} + ${'-1.5'} | ${'number'} | ${-1.5} | ${1.5} + ${'-999.9'} | ${'number'} | ${undefined} | ${1.5} + ${'999.9'} | ${'number'} | ${-1.5} | ${undefined} + ${'01/01/2000'} | ${'date'} | ${minDate} | ${maxDate} + ${'31/12/2000'} | ${'date'} | ${minDate} | ${maxDate} + ${'01/01/2000'} | ${'date'} | ${minDateStr} | ${maxDateStr} + ${'31/12/2000'} | ${'date'} | ${minDateStr} | ${maxDateStr} + ${'01/01/1800'} | ${'date'} | ${undefined} | ${maxDate} + ${'31/12/2999'} | ${'date'} | ${minDate} | ${undefined} + `( + '"out of range" error for $dataStr must be "$expectedError"', + ({ dataStr, type, minValue, maxValue, expectedError }) => { + const res = ValidationUtils.isValid(dataStr, type, { ...options, minValue, maxValue }); + expect(res).toStrictEqual(true); + } + ); +}); + +describe('isValid rejects values out of range', () => { + const options = { dateFormat: 'dd/MM/yyyy', enumValues: ['A', 'B', 'C'] }; + const minStr = '01/01/2000'; + const maxStr = '31/12/2000'; + const min = DateUtils.parse(minStr, options.dateFormat); + const max = DateUtils.parse(maxStr, options.dateFormat); + test.each` + dataStr | type | minValue | maxValue | expectedError + ${'-6'} | ${'int'} | ${-4} | ${10} | ${'Value "-6" should be greater than -4'} + ${'11'} | ${'int'} | ${-4} | ${10} | ${'Value "11" should be less than 10'} + ${'-1.6'} | ${'number'} | ${-1.5} | ${1.5} | ${'Value "-1.6" should be greater than -1.5'} + ${'1.6'} | ${'number'} | ${-1.5} | ${1.5} | ${'Value "1.6" should be less than 1.5'} + ${'31/12/1999'} | ${'date'} | ${min} | ${max} | ${'Value "31/12/1999" should be greater than 01/01/2000'} + ${'01/01/2001'} | ${'date'} | ${min} | ${max} | ${'Value "01/01/2001" should be less than 31/12/2000'} + ${'31/12/1999'} | ${'date'} | ${minStr} | ${maxStr} | ${'Value "31/12/1999" should be greater than 01/01/2000'} + ${'01/01/2001'} | ${'date'} | ${minStr} | ${maxStr} | ${'Value "01/01/2001" should be less than 31/12/2000'} + `( + '"out of range" error for $dataStr must be "$expectedError"', + ({ dataStr, type, minValue, maxValue, expectedError }) => { + const res = ValidationUtils.isValid(dataStr, type, { ...options, minValue, maxValue }); + expect(res).not.toEqual(true); + expect(res.summary).toStrictEqual('Value out of range'); + expect(res.context).toStrictEqual(expectedError); + } + ); +});