diff --git a/.changeset/grumpy-experts-chew.md b/.changeset/grumpy-experts-chew.md new file mode 100644 index 0000000..d36f955 --- /dev/null +++ b/.changeset/grumpy-experts-chew.md @@ -0,0 +1,5 @@ +--- +"@croz/nrich-form-configuration-core": major +--- + +Upgrade yup to v1.4.0, add custom deep merge function for merging yup schemas diff --git a/libs/form-configuration/core/package.json b/libs/form-configuration/core/package.json index 30f7577..ff4c5e0 100644 --- a/libs/form-configuration/core/package.json +++ b/libs/form-configuration/core/package.json @@ -5,7 +5,7 @@ "author": "CROZ", "bugs": "https://github.com/croz-ltd/nrich-frontend/issues", "dependencies": { - "yup": "^0.32.11", + "yup": "^1.4.0", "zustand": "^4.4.7" }, "devDependencies": { @@ -18,6 +18,7 @@ "eslint": "^8.2.0", "eslint-config-nrich": "*", "jest": "^28.1.0", + "lodash": "^4.17.21", "msw": "^0.48.1", "react": "^18.1.0", "react-dom": "^18.1.0", diff --git a/libs/form-configuration/core/src/converter/FormConfigurationValidationConverter.ts b/libs/form-configuration/core/src/converter/FormConfigurationValidationConverter.ts index 35ba0d0..c73c90a 100644 --- a/libs/form-configuration/core/src/converter/FormConfigurationValidationConverter.ts +++ b/libs/form-configuration/core/src/converter/FormConfigurationValidationConverter.ts @@ -15,6 +15,7 @@ * */ +import _mergeWith from "lodash/mergeWith"; import * as yup from "yup"; import { ConstrainedPropertyClientValidatorConfiguration, ConstrainedPropertyConfiguration, ValidatorConverter } from "../api"; @@ -35,8 +36,7 @@ export class FormConfigurationValidationConverter { }, { supports: (configuration) => ["Size", "Length"].includes(configuration.name), - convert: (configuration, validator) => validator.min(configuration.argumentMap.min, configuration.errorMessage) - .max(configuration.argumentMap.max, configuration.errorMessage), + convert: (configuration, validator) => validator.min(configuration.argumentMap.min, configuration.errorMessage).max(configuration.argumentMap.max, configuration.errorMessage), }, { supports: (configuration) => ["Pattern"].includes(configuration.name), @@ -84,14 +84,15 @@ export class FormConfigurationValidationConverter { return; } - const validator = property.validatorList.reduce((previousValidator, validatorConfiguration) => this.applyConverter(validatorConfiguration, previousValidator), yupValidation().nullable()); + const validator = property.validatorList + .reduce((previousValidator, validatorConfiguration) => this.applyConverter(validatorConfiguration, previousValidator), yupValidation().default(undefined).nullable()); const [propertyName, restOfPathList] = FormConfigurationValidationConverter.convertPath(property.path); if (restOfPathList.length > 0) { const currentPathSchema = [...restOfPathList].reverse() - .reduce((currentShape, path) => ({ [path]: yup.object().shape(currentShape) }), { [propertyName]: validator }); + .reduce((currentShape, path) => ({ [path]: yup.object().shape(currentShape).default(undefined).nullable() }), { [propertyName]: validator }); - schema = schema.concat(yup.object().shape(currentPathSchema)); + schema = this.mergeSchemas(schema, yup.object().shape(currentPathSchema)); } else { const currentPropertySchema = yup.object().shape({ [propertyName]: validator }); @@ -103,6 +104,53 @@ export class FormConfigurationValidationConverter { return schema; } + // Function to recursively merge two Yup schemas + mergeSchemas(schema1: yup.ObjectSchema, schema2: yup.ObjectSchema) { + // Recursive helper function to merge two schema objects + const mergeObjects = (obj1, obj2) => { + const merged = { ...obj1 }; + + Object.keys(obj2).forEach((key) => { + if (Object.prototype.hasOwnProperty.call(merged, key)) { + // If both properties are objects, merge recursively + if (obj1[key].type === "object" && obj2[key].type === "object") { + merged[key] = this.mergeSchemas(obj1[key], obj2[key]); + merged[key].spec = _mergeWith(obj1[key].spec, obj2[key].spec, (field1, field2) => (typeof field1 === "boolean" ? field1 && field2 : field1 ?? field2)); + } + else if (obj1[key].type === "array" && obj2[key].type === "array") { + if (obj1[key].innerType.type === "object" && obj2[key].innerType.type === "object") { + merged[key] = yup.array().of(this.mergeSchemas(obj1[key].innerType, obj2[key].innerType)); + } + else { + merged[key] = yup.array().of(obj2[key].innerType); + } + } + else { + merged[key] = obj2[key]; + } + } + else { + // Otherwise, add the property to the merged object + merged[key] = obj2[key]; + } + }); + + return merged; + }; + + // Extract the fields of schema1 + const fields1 = schema1.fields; + + // Extract the fields of schema2 + const fields2 = schema2.fields; + + // Merge the fields recursively + const mergedFields = mergeObjects(fields1, fields2); + + // Create a new merged schema + return yup.object().shape(mergedFields); + } + private applyConverter(validatorConfiguration: ConstrainedPropertyClientValidatorConfiguration, validator: any): any { const converter = this.resolveConverter(validatorConfiguration); let resolvedValidator = validator; diff --git a/libs/form-configuration/core/test/converter/FormConfigurationValidationConverter.test.ts b/libs/form-configuration/core/test/converter/FormConfigurationValidationConverter.test.ts index 8cf40b1..6d8d890 100644 --- a/libs/form-configuration/core/test/converter/FormConfigurationValidationConverter.test.ts +++ b/libs/form-configuration/core/test/converter/FormConfigurationValidationConverter.test.ts @@ -15,6 +15,8 @@ * */ +import * as yup from "yup"; + import { FormConfigurationValidationConverter } from "../../src/converter"; import { createComplexValidationList, createCustomValidationList, createNestedValidationList, createSimpleNullableValidationList, createSimpleValidationList, invalidValidationConfiguration, @@ -130,3 +132,142 @@ describe("@croz/nrich-form-configuration-core/FormConfigurationValidationConvert expect(result.isValidSync({ username: null })).toBe(true); }); }); + +it.each([ + [ + { + schema1: yup.object().shape({ firstName: yup.string().required() }), + schema2: yup.object().shape({ lastName: yup.string() }), + }, + { + expectedResult: yup.object().shape({ + firstName: yup.string().required(), + lastName: yup.string(), + }), + }, + ], + + [ + { + schema1: yup.object().shape({ firstName: yup.string().required() }), + schema2: yup.object().shape({}), + }, + { + expectedResult: yup.object().shape({ firstName: yup.string().required() }), + }, + ], + + [ + { + schema1: yup.object().shape({ user: yup.object().shape({ username: yup.string(), address: yup.object().shape({ street: yup.string().required() }) }) }), + schema2: yup.object().shape({ id: yup.number() }), + }, + { + expectedResult: yup.object().shape({ id: yup.number(), user: yup.object().shape({ username: yup.string(), address: yup.object().shape({ street: yup.string().required() }) }) }), + }, + ], + + [ + { + schema1: yup.object().shape({ user: yup.object().shape({ username: yup.string(), address: yup.object().shape({ street: yup.string().required() }) }) }), + schema2: yup.object().shape({ user: yup.object().shape({ address: yup.object().shape({ city: yup.string().required() }) }) }), + }, + { + expectedResult: yup.object().shape({ + user: yup.object().shape({ + username: yup.string(), + address: yup.object().shape({ + street: yup.string().required(), + city: yup.string().required(), + }), + }), + }), + }, + ], + + [ + { + schema1: yup.object().shape({ user: yup.object().shape({ username: yup.string(), address: yup.object().shape({ street: yup.object().shape({ streetName: yup.string() }) }) }) }), + schema2: yup.object().shape({ user: yup.object().shape({ address: yup.object().shape({ city: yup.string().required() }) }) }), + }, + { + expectedResult: yup.object().shape({ + user: yup.object().shape({ + username: yup.string(), + address: yup.object().shape({ + street: yup.object().shape({ + streetName: yup.string(), + }), + city: yup.string().required(), + }), + }), + }), + }, + ], + + [ + { + schema1: yup.object().shape({ user: yup.object().shape({ username: yup.string() }) }), + schema2: yup.object().shape({ user: yup.object().shape({ address: yup.object().shape({ city: yup.string().required() }).default(undefined).nullable() }) }), + }, + { + expectedResult: yup.object().shape({ + user: yup.object().shape({ + username: yup.string(), + address: yup.object().shape({ + city: yup.string().required(), + }).default(undefined).nullable(), + }), + }), + }, + ], + + [ + { + schema1: yup.object().shape({ firstName: yup.string().required() }), + schema2: yup.object().shape({ firstName: yup.string() }), + }, + { expectedResult: yup.object().shape({ firstName: yup.string() }) }, + ], + + [ + { + schema1: yup.object().shape({ todos: yup.array().of(yup.string()) }), + schema2: yup.object().shape({ todos: yup.array().of(yup.number()) }), + }, + { expectedResult: yup.object().shape({ todos: yup.array().of(yup.number()) }) }, + ], + + [ + { + schema1: yup.object().shape({ todos: yup.array().of(yup.string()) }), + schema2: yup.object().shape({ todos: yup.array().of(yup.object().shape({ name: yup.string() })) }), + }, + { expectedResult: yup.object().shape({ todos: yup.array().of(yup.object().shape({ name: yup.string() })) }) }, + ], + + [ + { + schema1: yup.object().shape({ todos: yup.array().of(yup.object().shape({ name: yup.string() })) }), + schema2: yup.object().shape({ todos: yup.array().of(yup.string()) }), + }, + { expectedResult: yup.object().shape({ todos: yup.array().of(yup.string()) }) }, + ], + + [ + { + schema1: yup.object().shape({ test: yup.object().shape({ prop: yup.string().required() }).required() }), + schema2: yup.object().shape({ test: yup.object().shape({ prop2: yup.string() }).optional() }), + }, + { expectedResult: yup.object().shape({ test: yup.object().shape({ prop: yup.string().required(), prop2: yup.string() }).required() }) }, + ], +])("should merge schemas %p correctly, and get result %p", (schemas, expectedResult) => { + // given + const converter = new FormConfigurationValidationConverter(); + + // when + const mergedSchema = converter.mergeSchemas(schemas.schema1, schemas.schema2); + + // then + expect(mergedSchema.describe()).toEqual(expectedResult.expectedResult.describe()); +}); diff --git a/yarn.lock b/yarn.lock index 46f0ef3..dbe7c0c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -378,7 +378,7 @@ __metadata: languageName: node linkType: hard -"@babel/runtime@npm:^7.10.2, @babel/runtime@npm:^7.10.4, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.15.4, @babel/runtime@npm:^7.18.3, @babel/runtime@npm:^7.18.9, @babel/runtime@npm:^7.20.1, @babel/runtime@npm:^7.5.5, @babel/runtime@npm:^7.8.7, @babel/runtime@npm:^7.9.2": +"@babel/runtime@npm:^7.10.2, @babel/runtime@npm:^7.10.4, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.18.3, @babel/runtime@npm:^7.18.9, @babel/runtime@npm:^7.20.1, @babel/runtime@npm:^7.5.5, @babel/runtime@npm:^7.8.7, @babel/runtime@npm:^7.9.2": version: 7.20.1 resolution: "@babel/runtime@npm:7.20.1" dependencies: @@ -703,12 +703,13 @@ __metadata: eslint: ^8.2.0 eslint-config-nrich: "*" jest: ^28.1.0 + lodash: ^4.17.21 msw: ^0.48.1 react: ^18.1.0 react-dom: ^18.1.0 tsup: ^6.5.0 whatwg-fetch: ^3.6.2 - yup: ^0.32.11 + yup: ^1.4.0 zustand: ^4.4.7 peerDependencies: react: ">=16.8.0" @@ -1867,13 +1868,6 @@ __metadata: languageName: node linkType: hard -"@types/lodash@npm:^4.14.175": - version: 4.14.189 - resolution: "@types/lodash@npm:4.14.189" - checksum: 096d1e0954794fb76bd6a0ea443067f02004b53e3c59ac6e3fed88817a1468fc2e6614abdf44341f5e0fdff5f9a3c94a934cc9ff7126e07efbcd94355a21fe45 - languageName: node - linkType: hard - "@types/minimist@npm:^1.2.0": version: 1.2.2 resolution: "@types/minimist@npm:1.2.2" @@ -6222,13 +6216,6 @@ __metadata: languageName: node linkType: hard -"lodash-es@npm:^4.17.21": - version: 4.17.21 - resolution: "lodash-es@npm:4.17.21" - checksum: 05cbffad6e2adbb331a4e16fbd826e7faee403a1a04873b82b42c0f22090f280839f85b95393f487c1303c8a3d2a010048bf06151a6cbe03eee4d388fb0a12d2 - languageName: node - linkType: hard - "lodash.memoize@npm:4.x": version: 4.1.2 resolution: "lodash.memoize@npm:4.1.2" @@ -6667,13 +6654,6 @@ __metadata: languageName: node linkType: hard -"nanoclone@npm:^0.2.1": - version: 0.2.1 - resolution: "nanoclone@npm:0.2.1" - checksum: 96b2954e22f70561f41e20d69856266c65583c2a441dae108f1dc71b716785d2c8038dac5f1d5e92b117aed3825f526b53139e2e5d6e6db8a77cfa35b3b8bf40 - languageName: node - linkType: hard - "natural-compare-lite@npm:^1.4.0": version: 1.4.0 resolution: "natural-compare-lite@npm:1.4.0" @@ -7284,10 +7264,10 @@ __metadata: languageName: node linkType: hard -"property-expr@npm:^2.0.4": - version: 2.0.5 - resolution: "property-expr@npm:2.0.5" - checksum: 4ebe82ce45aaf1527e96e2ab84d75d25217167ec3ff6378cf83a84fb4abc746e7c65768a79d275881602ae82f168f9a6dfaa7f5e331d0fcc83d692770bcce5f1 +"property-expr@npm:^2.0.5": + version: 2.0.6 + resolution: "property-expr@npm:2.0.6" + checksum: 89977f4bb230736c1876f460dd7ca9328034502fd92e738deb40516d16564b850c0bbc4e052c3df88b5b8cd58e51c93b46a94bea049a3f23f4a022c038864cab languageName: node linkType: hard @@ -8288,6 +8268,13 @@ __metadata: languageName: node linkType: hard +"tiny-case@npm:^1.0.3": + version: 1.0.3 + resolution: "tiny-case@npm:1.0.3" + checksum: 3f7a30c39d5b0e1bc097b0b271bec14eb5b836093db034f35a0de26c14422380b50dc12bfd37498cf35b192f5df06f28a710712c87ead68872a9e37ad6f6049d + languageName: node + linkType: hard + "tmp@npm:^0.0.33": version: 0.0.33 resolution: "tmp@npm:0.0.33" @@ -9221,18 +9208,15 @@ __metadata: languageName: node linkType: hard -"yup@npm:^0.32.11": - version: 0.32.11 - resolution: "yup@npm:0.32.11" +"yup@npm:^1.4.0": + version: 1.4.0 + resolution: "yup@npm:1.4.0" dependencies: - "@babel/runtime": ^7.15.4 - "@types/lodash": ^4.14.175 - lodash: ^4.17.21 - lodash-es: ^4.17.21 - nanoclone: ^0.2.1 - property-expr: ^2.0.4 + property-expr: ^2.0.5 + tiny-case: ^1.0.3 toposort: ^2.0.2 - checksum: 43a16786b47cc910fed4891cebdd89df6d6e31702e9462e8f969c73eac88551ce750732608012201ea6b93802c8847cb0aa27b5d57370640f4ecf30f9f97d4b0 + type-fest: ^2.19.0 + checksum: 20a2ee0c1e891979ca16b34805b3a3be9ab4bea6ea3d2f9005b998b4dc992d0e4d7b53e5f4d8d9423420046630fb44fdf0ecf7e83bc34dd83392bca046c5229d languageName: node linkType: hard