diff --git a/CHANGELOG.md b/CHANGELOG.md index 37b43d8c39..737f999edd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,20 +21,34 @@ should change the heading of the (upcoming) version to include a major version b - Updated `ArrayField` to move errors in the errorSchema when the position of array items changes for the insert and copy cases. -## @rjsf/mui +## @rjsf/material-ui - Removed an unnecessary `Grid` container component in the `ArrayFieldTemplate` component that wrapped the `ArrayFieldItemTemplate`, fixing [#3863](https://github.com/rjsf-team/react-jsonschema-form/issues/3863) +- Fixed an issue where `SelectWidget` switches from controlled to uncontrolled when `enumOptions` does not include a value, fixing [#3844](https://github.com/rjsf-team/react-jsonschema-form/issues/3844) -## @rjsf/material-ui +## @rjsf/mui - Removed an unnecessary `Grid` container component in the `ArrayFieldTemplate` component that wrapped the `ArrayFieldItemTemplate`, fixing [#3863](https://github.com/rjsf-team/react-jsonschema-form/issues/3863) +- Fixed an issue where `SelectWidget` switches from controlled to uncontrolled when `enumOptions` does not include a value, fixing [#3844](https://github.com/rjsf-team/react-jsonschema-form/issues/3844) ## @rjsf/utils - Added `getOptionMatchingSimpleDiscriminator()` function - `getMatchingOption` and `getClosestMatchingOption` now bypass `validator.isValid()` calls when simple discriminator is provided, fixing [#3692](https://github.com/rjsf-team/react-jsonschema-form/issues/3692) - Fix data type in `FieldTemplateProps['onChange']` +- Updated `retrieveSchema()` to properly resolve references inside of `properties` and array `items` while also dealing with recursive `$ref`s, fixing [#3761](https://github.com/rjsf-team/react-jsonschema-form/issues/3761) + - Updated `schemaParser()` and `getClosestMatchingOption()` to pass the new `recursiveRef` parameter added to internal `retrieveSchema()` APIs +- Added/updated all the necessary tests to restore the `100%` test coverage that was lost when updating to Jest 29 + - Updated `getDefaultFormState()` to remove an unnecessary check for `formData` being an object since it is always guaranteed to be one, thereby allowing full testing coverage + +## @rjsf/validator-ajv8 + +- Updated the `validator` and `precompiledValidator` tests to the restore `100%` coverage that was lost when updating to Jest 29 + - Updated `isValid()` for the `validator` commenting out an if condition that was preventing `100%` coverage, with a TODO to fix it later + +## Dev / docs / playground +- Added the `@types/jest` as a global `devDependency` so that developer tools properly recognize the jest function types # 5.13.0 diff --git a/package-lock.json b/package-lock.json index b52d4e979d..39d4e6190b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,7 @@ "@babel/eslint-parser": "^7.22.15", "@nrwl/nx-cloud": "^15.3.5", "@types/estree": "^1.0.1", + "@types/jest": "^29.5.5", "@types/node": "^18.17.14", "@types/prettier": "^2.7.3", "@types/react": "^17.0.65", @@ -8899,6 +8900,16 @@ "@types/istanbul-lib-report": "*" } }, + "node_modules/@types/jest": { + "version": "29.5.5", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.5.tgz", + "integrity": "sha512-ebylz2hnsWR9mYvmBFbXJXr+33UPc4+ZdxyDXh5w0FlPBTfCVN3wPL+kuOiQt3xvrK419v7XWeAs+AeOksafXg==", + "dev": true, + "dependencies": { + "expect": "^29.0.0", + "pretty-format": "^29.0.0" + } + }, "node_modules/@types/jsdom": { "version": "20.0.1", "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-20.0.1.tgz", diff --git a/package.json b/package.json index dd8502d5bd..236592e031 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "@babel/eslint-parser": "^7.22.15", "@nrwl/nx-cloud": "^15.3.5", "@types/estree": "^1.0.1", + "@types/jest": "^29.5.5", "@types/node": "^18.17.14", "@types/prettier": "^2.7.3", "@types/react": "^17.0.65", diff --git a/packages/utils/jest.config.js b/packages/utils/jest.config.js index 22cb9f7053..ba654509b0 100644 --- a/packages/utils/jest.config.js +++ b/packages/utils/jest.config.js @@ -10,8 +10,7 @@ module.exports = { coveragePathIgnorePatterns: ['/node_modules/', '/test'], coverageThreshold: { global: { - // todo: dropped from 100 after jest 29 - branches: 98, + branches: 100, functions: 100, lines: 100, statements: 100, diff --git a/packages/utils/src/parser/schemaParser.ts b/packages/utils/src/parser/schemaParser.ts index 70151f06a0..0badc0e8f8 100644 --- a/packages/utils/src/parser/schemaParser.ts +++ b/packages/utils/src/parser/schemaParser.ts @@ -22,12 +22,13 @@ function parseSchema(validator, schema, rootSchema, undefined, true); + const recurseRefs: string[] = []; + const schemas = retrieveSchemaInternal(validator, schema, rootSchema, undefined, true, recurseRefs); schemas.forEach((schema) => { const sameSchemaIndex = recurseList.findIndex((item) => isEqual(item, schema)); if (sameSchemaIndex === -1) { recurseList.push(schema); - const allOptions = resolveAnyOrOneOfSchemas(validator, schema, rootSchema, true); + const allOptions = resolveAnyOrOneOfSchemas(validator, schema, rootSchema, true, recurseRefs); allOptions.forEach((s) => { if (PROPERTIES_KEY in s && s[PROPERTIES_KEY]) { forEach(schema[PROPERTIES_KEY], (value) => { diff --git a/packages/utils/src/schema/getClosestMatchingOption.ts b/packages/utils/src/schema/getClosestMatchingOption.ts index b93a7e2e3c..c81ce42c70 100644 --- a/packages/utils/src/schema/getClosestMatchingOption.ts +++ b/packages/utils/src/schema/getClosestMatchingOption.ts @@ -147,7 +147,7 @@ export default function getClosestMatchingOption< ): number { // First resolve any refs in the options const resolvedOptions = options.map((option) => { - return resolveAllReferences(option, rootSchema); + return resolveAllReferences(option, rootSchema, []); }); const simpleDiscriminatorMatch = getOptionMatchingSimpleDiscriminator(formData, options, discriminatorField); diff --git a/packages/utils/src/schema/getDefaultFormState.ts b/packages/utils/src/schema/getDefaultFormState.ts index d7a45239ca..88464a8217 100644 --- a/packages/utils/src/schema/getDefaultFormState.ts +++ b/packages/utils/src/schema/getDefaultFormState.ts @@ -185,7 +185,7 @@ export function computeDefaults(refName, rootSchema); } } else if (DEPENDENCIES_KEY in schema) { - const resolvedSchema = resolveDependencies(validator, schema, rootSchema, false, formData); + const resolvedSchema = resolveDependencies(validator, schema, rootSchema, false, [], formData); schemaToCompute = resolvedSchema[0]; // pick the first element from resolve dependencies } else if (isFixedItems(schema)) { defaults = (schema.items! as S[]).map((itemSchema: S, idx: number) => @@ -287,16 +287,13 @@ export function computeDefaults !schema.properties || !schema.properties[key]) .forEach((key) => keys.add(key)); } - let formDataRequired: string[]; - if (isObject(formData)) { - formDataRequired = []; - Object.keys(formData as GenericObjectType) - .filter((key) => !schema.properties || !schema.properties[key]) - .forEach((key) => { - keys.add(key); - formDataRequired.push(key); - }); - } + const formDataRequired: string[] = []; + Object.keys(formData as GenericObjectType) + .filter((key) => !schema.properties || !schema.properties[key]) + .forEach((key) => { + keys.add(key); + formDataRequired.push(key); + }); keys.forEach((key) => { const computedDefault = computeDefaults(validator, additionalPropertiesSchema as S, { rootSchema, diff --git a/packages/utils/src/schema/retrieveSchema.ts b/packages/utils/src/schema/retrieveSchema.ts index 8d00d02f33..b0e50fb547 100644 --- a/packages/utils/src/schema/retrieveSchema.ts +++ b/packages/utils/src/schema/retrieveSchema.ts @@ -1,4 +1,5 @@ import get from 'lodash/get'; +import isEqual from 'lodash/isEqual'; import set from 'lodash/set'; import times from 'lodash/times'; import transform from 'lodash/transform'; @@ -59,6 +60,7 @@ export function resolveCondition(validator, then as S, rootSchema, formData, expandAllBranches) + retrieveSchemaInternal(validator, then as S, rootSchema, formData, expandAllBranches, recurseList) ); } if (otherwise && typeof otherwise !== 'boolean') { schemas = schemas.concat( - retrieveSchemaInternal(validator, otherwise as S, rootSchema, formData, expandAllBranches) + retrieveSchemaInternal(validator, otherwise as S, rootSchema, formData, expandAllBranches, recurseList) ); } } else { const conditionalSchema = conditionValue ? then : otherwise; if (conditionalSchema && typeof conditionalSchema !== 'boolean') { schemas = schemas.concat( - retrieveSchemaInternal(validator, conditionalSchema as S, rootSchema, formData, expandAllBranches) + retrieveSchemaInternal( + validator, + conditionalSchema as S, + rootSchema, + formData, + expandAllBranches, + recurseList + ) ); } } @@ -89,7 +98,7 @@ export function resolveCondition mergeSchemas(resolvedSchemaLessConditional, s) as S); } return resolvedSchemas.flatMap((s) => - retrieveSchemaInternal(validator, s, rootSchema, formData, expandAllBranches) + retrieveSchemaInternal(validator, s, rootSchema, formData, expandAllBranches, recurseList) ); } @@ -141,20 +150,45 @@ export function resolveSchema(validator, schema, rootSchema, expandAllBranches, formData); + const updatedSchemas = resolveReference( + validator, + schema, + rootSchema, + expandAllBranches, + recurseList, + formData + ); + if (updatedSchemas.length > 1 || updatedSchemas[0] !== schema) { + // return the updatedSchemas array if it has either multiple schemas within it + // OR the first schema is not the same as the original schema + return updatedSchemas; } if (DEPENDENCIES_KEY in schema) { - const resolvedSchemas = resolveDependencies(validator, schema, rootSchema, expandAllBranches, formData); + const resolvedSchemas = resolveDependencies( + validator, + schema, + rootSchema, + expandAllBranches, + recurseList, + formData + ); return resolvedSchemas.flatMap((s) => { - return retrieveSchemaInternal(validator, s, rootSchema, formData, expandAllBranches); + return retrieveSchemaInternal(validator, s, rootSchema, formData, expandAllBranches, recurseList); }); } if (ALL_OF_KEY in schema && Array.isArray(schema.allOf)) { const allOfSchemaElements: S[][] = schema.allOf.map((allOfSubschema) => - retrieveSchemaInternal(validator, allOfSubschema as S, rootSchema, formData, expandAllBranches) + retrieveSchemaInternal( + validator, + allOfSubschema as S, + rootSchema, + formData, + expandAllBranches, + recurseList + ) ); const allPermutations = getAllPermutationsOfXxxOf(allOfSchemaElements); return allPermutations.map((permutation) => ({ ...schema, allOf: permutation })); @@ -163,8 +197,9 @@ export function resolveSchema($ref, rootSchema); - // Update referenced schema definition with local schema properties. - return retrieveSchemaInternal( - validator, - { ...refSchema, ...localSchema }, - rootSchema, - formData, - expandAllBranches - ); + const updatedSchema = resolveAllReferences(schema, rootSchema, recurseList); + if (updatedSchema !== schema) { + // Only call this if the schema was actually changed by the `resolveAllReferences()` function + return retrieveSchemaInternal( + validator, + updatedSchema, + rootSchema, + formData, + expandAllBranches, + recurseList + ); + } + return [schema]; } -/** Resolves all references within a schema's properties and array items. +/** Resolves all references within the schema itself as well as any of its properties and array items. * * @param schema - The schema for which resolving all references is desired * @param rootSchema - The root schema that will be forwarded to all the APIs - * @returns - given schema will all references resolved + * @param recurseList - List of $refs already resolved to prevent recursion + * @returns - given schema will all references resolved or the original schema if no internal `$refs` were resolved */ -export function resolveAllReferences(schema: S, rootSchema: S): S { +export function resolveAllReferences( + schema: S, + rootSchema: S, + recurseList: string[] +): S { + if (!isObject(schema)) { + return schema; + } let resolvedSchema: S = schema; // resolve top level ref if (REF_KEY in resolvedSchema) { const { $ref, ...localSchema } = resolvedSchema; + // Check for a recursive reference and stop the loop + if (recurseList.includes($ref!)) { + return resolvedSchema; + } + recurseList.push($ref!); // Retrieve the referenced schema definition. const refSchema = findSchemaDefinition($ref, rootSchema); resolvedSchema = { ...refSchema, ...localSchema }; @@ -215,7 +265,7 @@ export function resolveAllReferences(sc const updatedProps = transform( resolvedSchema[PROPERTIES_KEY]!, (result, value, key: string) => { - result[key] = resolveAllReferences(value as S, rootSchema); + result[key] = resolveAllReferences(value as S, rootSchema, recurseList); }, {} as RJSFSchema ); @@ -227,10 +277,13 @@ export function resolveAllReferences(sc !Array.isArray(resolvedSchema.items) && typeof resolvedSchema.items !== 'boolean' ) { - resolvedSchema = { ...resolvedSchema, items: resolveAllReferences(resolvedSchema.items as S, rootSchema) }; + resolvedSchema = { + ...resolvedSchema, + items: resolveAllReferences(resolvedSchema.items as S, rootSchema, recurseList), + }; } - return resolvedSchema; + return isEqual(schema, resolvedSchema) ? schema : resolvedSchema; } /** Creates new 'properties' items for each key in the `formData` @@ -310,15 +363,36 @@ export function retrieveSchemaInternal< T = any, S extends StrictRJSFSchema = RJSFSchema, F extends FormContextType = any ->(validator: ValidatorType, schema: S, rootSchema: S, rawFormData?: T, expandAllBranches = false): S[] { +>( + validator: ValidatorType, + schema: S, + rootSchema: S, + rawFormData?: T, + expandAllBranches = false, + recurseList: string[] = [] +): S[] { if (!isObject(schema)) { return [{} as S]; } - const resolvedSchemas = resolveSchema(validator, schema, rootSchema, expandAllBranches, rawFormData); + const resolvedSchemas = resolveSchema( + validator, + schema, + rootSchema, + expandAllBranches, + recurseList, + rawFormData + ); return resolvedSchemas.flatMap((s: S) => { let resolvedSchema = s; if (IF_KEY in resolvedSchema) { - return resolveCondition(validator, resolvedSchema, rootSchema, expandAllBranches, rawFormData as T); + return resolveCondition( + validator, + resolvedSchema, + rootSchema, + expandAllBranches, + recurseList, + rawFormData as T + ); } if (ALL_OF_KEY in resolvedSchema) { // resolve allOf schemas @@ -362,7 +436,14 @@ export function resolveAnyOrOneOfSchemas< T = any, S extends StrictRJSFSchema = RJSFSchema, F extends FormContextType = any ->(validator: ValidatorType, schema: S, rootSchema: S, expandAllBranches: boolean, rawFormData?: T) { +>( + validator: ValidatorType, + schema: S, + rootSchema: S, + expandAllBranches: boolean, + recurseList: string[], + rawFormData?: T +) { let anyOrOneOf: S[] | undefined; const { oneOf, anyOf, ...remaining } = schema; if (Array.isArray(oneOf)) { @@ -375,7 +456,7 @@ export function resolveAnyOrOneOfSchemas< const formData = rawFormData === undefined && expandAllBranches ? ({} as T) : rawFormData; const discriminator = getDiscriminatorFieldFromSchema(schema); anyOrOneOf = anyOrOneOf.map((s) => { - return resolveAllReferences(s, rootSchema); + return resolveAllReferences(s, rootSchema, recurseList); }); // Call this to trigger the set of isValid() calls that the schema parser will need const option = getFirstMatchingOption(validator, formData, anyOrOneOf, rootSchema, discriminator); @@ -403,6 +484,7 @@ export function resolveDependencies - processDependencies(validator, dependencies, resolvedSchema, rootSchema, expandAllBranches, formData) + processDependencies( + validator, + dependencies, + resolvedSchema, + rootSchema, + expandAllBranches, + recurseList, + formData + ) ); } @@ -437,6 +528,7 @@ export function processDependencies - processDependencies(validator, remainingDependencies, schema, rootSchema, expandAllBranches, formData) + processDependencies( + validator, + remainingDependencies, + schema, + rootSchema, + expandAllBranches, + recurseList, + formData + ) ); } return schemas; @@ -513,6 +614,7 @@ export function withDependentSchema( @@ -520,7 +622,8 @@ export function withDependentSchema { const { oneOf, ...dependentSchema } = dependent; @@ -534,7 +637,7 @@ export function withDependentSchema(validator, subschema as S, rootSchema, expandAllBranches, formData); + return resolveReference(validator, subschema as S, rootSchema, expandAllBranches, recurseList, formData); }); const allPermutations = getAllPermutationsOfXxxOf(resolvedOneOfs); return allPermutations.flatMap((resolvedOneOf) => @@ -545,6 +648,7 @@ export function withDependentSchema { @@ -608,7 +713,8 @@ export function withExactlyOneSubschema< dependentSchema, rootSchema, formData, - expandAllBranches + expandAllBranches, + recurseList ); return schemas.map((s) => mergeSchemas(schema, s) as S); }); diff --git a/packages/utils/test/getWidget.test.tsx b/packages/utils/test/getWidget.test.tsx index f392d9c2f4..15afc73ca4 100644 --- a/packages/utils/test/getWidget.test.tsx +++ b/packages/utils/test/getWidget.test.tsx @@ -92,6 +92,10 @@ describe('getWidget()', () => { expect(() => getWidget(schema, 'blabla')).toThrowError(`No widget for type 'object'`); }); + it('should fail if schema `type` has no widget property', () => { + expect(() => getWidget(subschema, 'blabla')).toThrowError(`No widget 'blabla' for type 'boolean'`); + }); + it('should fail if schema has no type property', () => { expect(() => getWidget({}, 'blabla')).toThrowError(`No widget 'blabla' for type 'undefined'`); }); diff --git a/packages/utils/test/parser/ParserValidator.test.ts b/packages/utils/test/parser/ParserValidator.test.ts index 7d7944c8e1..3a9ada681a 100644 --- a/packages/utils/test/parser/ParserValidator.test.ts +++ b/packages/utils/test/parser/ParserValidator.test.ts @@ -62,6 +62,9 @@ describe('ParserValidator', () => { [TINY_HASH]: { ...TINY_SCHEMA, [ID_KEY]: TINY_HASH }, }); }); + it('calling isValid() with TINY_SCHEMA again returns false, and tests other branch', () => { + expect(validator.isValid(TINY_SCHEMA, undefined, RECURSIVE_REF)).toBe(false); + }); it('calling isValid() with ID_SCHEMA returns false', () => { expect(validator.isValid(ID_SCHEMA, undefined, RECURSIVE_REF)).toBe(false); }); diff --git a/packages/utils/test/schema/getDefaultFormStateTest.ts b/packages/utils/test/schema/getDefaultFormStateTest.ts index b844bdc5f6..d35e238c27 100644 --- a/packages/utils/test/schema/getDefaultFormStateTest.ts +++ b/packages/utils/test/schema/getDefaultFormStateTest.ts @@ -1,5 +1,9 @@ import { createSchemaUtils, getDefaultFormState, RJSFSchema } from '../../src'; -import { computeDefaults } from '../../src/schema/getDefaultFormState'; +import { + AdditionalItemsHandling, + computeDefaults, + getInnerSchemaForArrayItem, +} from '../../src/schema/getDefaultFormState'; import { RECURSIVE_REF, RECURSIVE_REF_ALLOF } from '../testUtils/testData'; import { TestValidatorType } from './types'; @@ -15,6 +19,9 @@ export default function getDefaultFormStateTest(testValidator: TestValidatorType it('throws error when schema is not an object', () => { expect(() => getDefaultFormState(testValidator, null as unknown as RJSFSchema)).toThrowError('Invalid schema:'); }); + it('getInnerSchemaForArrayItem() item of type boolean returns empty schema', () => { + expect(getInnerSchemaForArrayItem({ items: [true] }, AdditionalItemsHandling.Ignore, 0)).toEqual({}); + }); describe('computeDefaults()', () => { it('test computeDefaults that is passed a schema with a ref', () => { const schema: RJSFSchema = { @@ -286,6 +293,41 @@ export default function getDefaultFormStateTest(testValidator: TestValidatorType }, }); }); + it('test an object with additionalProperties type object with no defaults and non-object formdata', () => { + const schema: RJSFSchema = { + type: 'object', + properties: { + test: { + title: 'Test', + type: 'object', + properties: { + foo: { + type: 'string', + }, + }, + additionalProperties: { + type: 'object', + properties: { + host: { + title: 'Host', + type: 'string', + }, + port: { + title: 'Port', + type: 'integer', + }, + }, + }, + }, + }, + }; + expect( + computeDefaults(testValidator, schema, { + rootSchema: schema, + rawFormData: {}, + }) + ).toEqual({}); + }); it('test computeDefaults handles an invalid property schema', () => { const schema: RJSFSchema = { type: 'object', diff --git a/packages/utils/test/schema/retrieveSchemaTest.ts b/packages/utils/test/schema/retrieveSchemaTest.ts index 0592413478..d0ac187197 100644 --- a/packages/utils/test/schema/retrieveSchemaTest.ts +++ b/packages/utils/test/schema/retrieveSchemaTest.ts @@ -268,6 +268,70 @@ export default function retrieveSchemaTest(testValidator: TestValidatorType) { ...(RECURSIVE_REF_ALLOF.definitions!['@enum'] as RJSFSchema), }); }); + it('should `resolve` refs inside of a properties key with bad property', () => { + const schema: RJSFSchema = { + type: 'object', + properties: { + firstName: 'some mame' as unknown as RJSFSchema, + }, + }; + const rootSchema: RJSFSchema = { + type: 'object', + }; + expect(retrieveSchema(testValidator, schema, rootSchema)).toEqual(schema); + }); + it('should `resolve` refs inside of a properties key', () => { + const entity: RJSFSchema = { + type: 'string', + title: 'Entity', + }; + const schema: RJSFSchema = { + type: 'object', + properties: { + entity: { + $ref: '#/definitions/entity', + }, + }, + }; + const rootSchema: RJSFSchema = { + type: 'object', + definitions: { + entity, + }, + }; + expect(retrieveSchema(testValidator, schema, rootSchema)).toEqual({ + type: 'object', + properties: { + entity: { + ...entity, + }, + }, + }); + }); + it('should `resolve` refs inside of an items key', () => { + const entity: RJSFSchema = { + type: 'string', + title: 'Entity', + }; + const schema: RJSFSchema = { + type: 'array', + items: { + $ref: '#/definitions/entity', + }, + }; + const rootSchema: RJSFSchema = { + type: 'object', + definitions: { + entity, + }, + }; + expect(retrieveSchema(testValidator, schema, rootSchema)).toEqual({ + type: 'array', + items: { + ...entity, + }, + }); + }); describe('property dependencies', () => { describe('false condition', () => { it('should not add required properties', () => { @@ -302,6 +366,24 @@ export default function retrieveSchemaTest(testValidator: TestValidatorType) { required: ['b'], }); }); + it('should not define required properties, when the dependency is a boolean', () => { + const schema: RJSFSchema = { + ...PROPERTY_DEPENDENCIES, + required: undefined, + dependencies: { + a: true, + }, + }; + const rootSchema: RJSFSchema = { definitions: {} }; + const formData = { a: '1' }; + expect(retrieveSchema(testValidator, schema, rootSchema, formData)).toEqual({ + type: 'object', + properties: { + a: { type: 'string' }, + b: { type: 'integer' }, + }, + }); + }); }); describe('when required is defined', () => { @@ -1088,7 +1170,7 @@ export default function retrieveSchemaTest(testValidator: TestValidatorType) { { properties: undefined }, { properties: { foo: { type: 'string' } } }, ]; - expect(withExactlyOneSubschema(testValidator, schema, schema, 'bar', oneOf, false)).toEqual([schema]); + expect(withExactlyOneSubschema(testValidator, schema, schema, 'bar', oneOf, false, [])).toEqual([schema]); }); }); describe('stubExistingAdditionalProperties()', () => { @@ -1292,7 +1374,7 @@ export default function retrieveSchemaTest(testValidator: TestValidatorType) { describe('resolveAnyOrOneOfSchemas()', () => { it('resolves anyOf with $ref for single element, merging schemas', () => { const anyOfSchema: RJSFSchema = SUPER_SCHEMA.properties?.multi as RJSFSchema; - expect(resolveAnyOrOneOfSchemas(testValidator, anyOfSchema, SUPER_SCHEMA, false)).toEqual([ + expect(resolveAnyOrOneOfSchemas(testValidator, anyOfSchema, SUPER_SCHEMA, false, [])).toEqual([ { ...(SUPER_SCHEMA.definitions?.foo as RJSFSchema), title: 'multi', @@ -1301,7 +1383,7 @@ export default function retrieveSchemaTest(testValidator: TestValidatorType) { }); it('resolves oneOf with $ref for expandedAll elements, merging schemas', () => { const oneOfSchema: RJSFSchema = SUPER_SCHEMA.properties?.single as RJSFSchema; - expect(resolveAnyOrOneOfSchemas(testValidator, oneOfSchema, SUPER_SCHEMA, true)).toEqual([ + expect(resolveAnyOrOneOfSchemas(testValidator, oneOfSchema, SUPER_SCHEMA, true, [])).toEqual([ { ...(SUPER_SCHEMA.definitions?.choice1 as RJSFSchema), required: ['choice', 'more'], @@ -1347,7 +1429,7 @@ export default function retrieveSchemaTest(testValidator: TestValidatorType) { }, }, }; - expect(resolveAnyOrOneOfSchemas(testValidator, schema, rootSchema, true)).toEqual([ + expect(resolveAnyOrOneOfSchemas(testValidator, schema, rootSchema, true, [])).toEqual([ { type: 'object', properties: { @@ -1374,7 +1456,7 @@ export default function retrieveSchemaTest(testValidator: TestValidatorType) { describe('resolveCondition()', () => { it('returns both conditions with expandAll', () => { expect( - resolveCondition(testValidator, SCHEMA_WITH_SINGLE_CONDITION, SCHEMA_WITH_SINGLE_CONDITION, true) + resolveCondition(testValidator, SCHEMA_WITH_SINGLE_CONDITION, SCHEMA_WITH_SINGLE_CONDITION, true, []) ).toEqual([ { type: 'object', @@ -1392,6 +1474,30 @@ export default function retrieveSchemaTest(testValidator: TestValidatorType) { }, ]); }); + it('returns neither condition with expandAll, using boolean based then/else', () => { + const schema: RJSFSchema = { + type: 'object', + properties: { + country: { + default: 'United States of America', + enum: ['United States of America', 'Canada'], + }, + }, + if: { + properties: { country: { const: 'United States of America' } }, + }, + then: false, + else: true, + }; + expect(resolveCondition(testValidator, schema, schema, true, [])).toEqual([ + { + type: 'object', + properties: { + ...SCHEMA_WITH_SINGLE_CONDITION.properties, + }, + }, + ]); + }); }); }); } diff --git a/packages/utils/test/schema/sanitizeDataForNewSchemaTest.ts b/packages/utils/test/schema/sanitizeDataForNewSchemaTest.ts index 902d168716..a6d85d965b 100644 --- a/packages/utils/test/schema/sanitizeDataForNewSchemaTest.ts +++ b/packages/utils/test/schema/sanitizeDataForNewSchemaTest.ts @@ -444,5 +444,17 @@ export default function sanitizeDataForNewSchemaTest(testValidator: TestValidato }) ).toEqual({ foo: undefined }); }); + it('returns formData when the new schema has field that is not in the old schema', () => { + const oldSchema: RJSFSchema = { + type: 'object', + properties: {}, + }; + const newSchema: RJSFSchema = { + type: 'object', + properties: { foo: { type: 'object' } }, + }; + const formData = { foo: '1' }; + expect(schemaUtils.sanitizeDataForNewSchema(newSchema, oldSchema, formData)).toEqual(formData); + }); }); } diff --git a/packages/utils/test/testUtils/testData.ts b/packages/utils/test/testUtils/testData.ts index ea81b7a944..9b930b674a 100644 --- a/packages/utils/test/testUtils/testData.ts +++ b/packages/utils/test/testUtils/testData.ts @@ -358,6 +358,7 @@ export const ERROR_MAPPER = { '': 'root error', foo: 'foo error', list: 'list error', + noMessage: '', 'list.0': 'list 0 error', 'list.1': 'list 1 error', nested: 'nested error', @@ -377,7 +378,10 @@ export const TEST_FORM_DATA = { export const TEST_ERROR_SCHEMA: ErrorSchema = reduce( ERROR_MAPPER, (builder: ErrorSchemaBuilder, value, key) => { - return builder.addErrors(value, key === '' ? undefined : key); + if (value) { + return builder.addErrors(value, key === '' ? undefined : key); + } + return builder; }, new ErrorSchemaBuilder() ).ErrorSchema; @@ -391,6 +395,17 @@ export const TEST_ERROR_LIST: RJSFValidationError[] = reduce( [] ); +export const TEST_ERROR_LIST_OUTPUT: RJSFValidationError[] = reduce( + ERROR_MAPPER, + (list: RJSFValidationError[], value, key) => { + if (value) { + list.push({ property: `.${key}`, message: value, stack: `.${key} ${value}` }); + } + return list; + }, + [] +); + export const SUPER_SCHEMA: RJSFSchema = deepFreeze({ [ID_KEY]: 'super-schema', definitions: { diff --git a/packages/utils/test/toErrorList.test.ts b/packages/utils/test/toErrorList.test.ts index 3fc8e12659..cc468df01d 100644 --- a/packages/utils/test/toErrorList.test.ts +++ b/packages/utils/test/toErrorList.test.ts @@ -1,11 +1,17 @@ import { toErrorList } from '../src'; -import { TEST_ERROR_LIST, TEST_ERROR_SCHEMA } from './testUtils/testData'; +import { TEST_ERROR_LIST_OUTPUT, TEST_ERROR_SCHEMA } from './testUtils/testData'; describe('toErrorList()', () => { it('returns empty array when nothing is passed', () => { expect(toErrorList()).toEqual([]); }); + it('Returns an empty array when an empty object is provided', () => { + expect(toErrorList({})).toEqual([]); + }); + it('Returns an empty array when an object with a non-plain child object is provided', () => { + expect(toErrorList({ nonObject: new Error('non-object') })).toEqual([]); + }); it('Returns the expected list of errors when given an ErrorSchema', () => { - expect(toErrorList(TEST_ERROR_SCHEMA)).toEqual(TEST_ERROR_LIST); + expect(toErrorList(TEST_ERROR_SCHEMA)).toEqual(TEST_ERROR_LIST_OUTPUT); }); }); diff --git a/packages/validator-ajv8/jest.config.js b/packages/validator-ajv8/jest.config.js index 22cb9f7053..ba654509b0 100644 --- a/packages/validator-ajv8/jest.config.js +++ b/packages/validator-ajv8/jest.config.js @@ -10,8 +10,7 @@ module.exports = { coveragePathIgnorePatterns: ['/node_modules/', '/test'], coverageThreshold: { global: { - // todo: dropped from 100 after jest 29 - branches: 98, + branches: 100, functions: 100, lines: 100, statements: 100, diff --git a/packages/validator-ajv8/src/validator.ts b/packages/validator-ajv8/src/validator.ts index fdb1be8d20..e7298db9a3 100644 --- a/packages/validator-ajv8/src/validator.ts +++ b/packages/validator-ajv8/src/validator.ts @@ -134,9 +134,10 @@ export default class AJV8Validator(schema) as S; const schemaId = schemaWithIdRefPrefix[ID_KEY] ?? hashForSchema(schemaWithIdRefPrefix); let compiledValidator: ValidateFunction | undefined; diff --git a/packages/validator-ajv8/test/precompiledValidator.test.ts b/packages/validator-ajv8/test/precompiledValidator.test.ts index 3b4953e693..55e3c83286 100644 --- a/packages/validator-ajv8/test/precompiledValidator.test.ts +++ b/packages/validator-ajv8/test/precompiledValidator.test.ts @@ -7,6 +7,7 @@ import { RJSFValidationError, UiSchema, JUNK_OPTION_ID, + retrieveSchema, } from '@rjsf/utils'; import AJV8PrecompiledValidator from '../src/precompiledValidator'; @@ -30,8 +31,11 @@ describe('AJV8PrecompiledValidator', () => { describe('default options', () => { // Use the AJV8PrecompiledValidator let validator: AJV8PrecompiledValidator; + let resolvedRootSchema: RJSFSchema; beforeAll(() => { validator = new AJV8PrecompiledValidator(validateFns, rootSchema); + // Since the root schema is retrieved in core before calling the validator emulate that here + resolvedRootSchema = retrieveSchema(validator, rootSchema, rootSchema); }); describe('validator.isValid()', () => { it('should return true if the data is valid against the schema', () => { @@ -129,7 +133,7 @@ describe('AJV8PrecompiledValidator', () => { let errors: RJSFValidationError[]; beforeAll(() => { - const result = validator.validateFormData({ foo: '42' }, rootSchema); + const result = validator.validateFormData({ foo: '42' }, resolvedRootSchema); errors = result.errors; }); @@ -142,7 +146,7 @@ describe('AJV8PrecompiledValidator', () => { let errorSchema: ErrorSchema; beforeAll(() => { - const result = validator.validateFormData({ foo: 42 }, rootSchema); + const result = validator.validateFormData({ foo: 42 }, resolvedRootSchema); errors = result.errors; errorSchema = result.errorSchema; }); @@ -159,7 +163,7 @@ describe('AJV8PrecompiledValidator', () => { describe('Validating multipleOf with a float', () => { let errors: RJSFValidationError[]; beforeAll(() => { - const result = validator.validateFormData({ price: 1.05 }, rootSchema); + const result = validator.validateFormData({ price: 1.05 }, resolvedRootSchema); errors = result.errors; }); it('should not return an error', () => { @@ -170,7 +174,7 @@ describe('AJV8PrecompiledValidator', () => { let errors: RJSFValidationError[]; let errorSchema: ErrorSchema; beforeAll(() => { - const result = validator.validateFormData({ price: 0.14 }, rootSchema); + const result = validator.validateFormData({ price: 0.14 }, resolvedRootSchema); errors = result.errors; errorSchema = result.errorSchema; }); @@ -194,7 +198,7 @@ describe('AJV8PrecompiledValidator', () => { describe('formData is provided at top level', () => { beforeAll(() => { const formData = { passwords: { pass1: 'a', pass2: 'b' } }; - const result = validator.validateFormData(formData, rootSchema); + const result = validator.validateFormData(formData, resolvedRootSchema); errors = result.errors; errorSchema = result.errorSchema; }); @@ -205,7 +209,7 @@ describe('AJV8PrecompiledValidator', () => { describe('formData is not provided at top level', () => { beforeAll(() => { const formData = { passwords: { pass1: 'a' } }; - const result = validator.validateFormData(formData, rootSchema); + const result = validator.validateFormData(formData, resolvedRootSchema); errors = result.errors; errorSchema = result.errorSchema; }); @@ -223,7 +227,7 @@ describe('AJV8PrecompiledValidator', () => { let errors: RJSFValidationError[]; beforeAll(() => { - const result = validator.validateFormData({ anything: { foo: '42' } }, rootSchema); + const result = validator.validateFormData({ anything: { foo: '42' } }, resolvedRootSchema); errors = result.errors; }); @@ -236,7 +240,7 @@ describe('AJV8PrecompiledValidator', () => { let errorSchema: ErrorSchema; beforeAll(() => { - const result = validator.validateFormData({ anything: { foo: 42 } }, rootSchema); + const result = validator.validateFormData({ anything: { foo: 42 } }, resolvedRootSchema); errors = result.errors; errorSchema = result.errorSchema; }); @@ -263,7 +267,13 @@ describe('AJV8PrecompiledValidator', () => { transformErrors = jest.fn((errors: RJSFValidationError[]) => { return [Object.assign({}, errors[0], { message: newErrorMessage })]; }); - const result = validator.validateFormData({ name: 42 }, rootSchema, undefined, transformErrors, uiSchema); + const result = validator.validateFormData( + { name: 42 }, + resolvedRootSchema, + undefined, + transformErrors, + uiSchema + ); errors = result.errors; }); @@ -295,7 +305,7 @@ describe('AJV8PrecompiledValidator', () => { describe('formData is provided and passes custom validation', () => { beforeAll(() => { const formData = { passwords: { pass1: 'a', pass2: 'a' } }; - const result = validator.validateFormData(formData, rootSchema, validate, undefined, uiSchema); + const result = validator.validateFormData(formData, resolvedRootSchema, validate, undefined, uiSchema); errors = result.errors; errorSchema = result.errorSchema; }); @@ -309,7 +319,7 @@ describe('AJV8PrecompiledValidator', () => { describe('formData is provided, but fails custom validation', () => { beforeAll(() => { const formData = { passwords: { pass1: 'a', pass2: 'b' } }; - const result = validator.validateFormData(formData, rootSchema, validate, undefined, uiSchema); + const result = validator.validateFormData(formData, resolvedRootSchema, validate, undefined, uiSchema); errors = result.errors; errorSchema = result.errorSchema; }); @@ -328,7 +338,7 @@ describe('AJV8PrecompiledValidator', () => { describe('formData is missing data', () => { beforeAll(() => { const formData = { passwords: { pass1: 'a' } }; - const result = validator.validateFormData(formData, rootSchema, validate); + const result = validator.validateFormData(formData, resolvedRootSchema, validate); errors = result.errors; errorSchema = result.errorSchema; }); @@ -349,21 +359,21 @@ describe('AJV8PrecompiledValidator', () => { const formData = { dataUrlWithName: 'data:text/plain;name=file1.txt;base64,x=', }; - const result = validator.validateFormData(formData, rootSchema); + const result = validator.validateFormData(formData, resolvedRootSchema); expect(result.errors).toHaveLength(0); }); it('Data-Url without name is accepted', () => { const formData = { dataUrlWithName: 'data:text/plain;base64,x=', }; - const result = validator.validateFormData(formData, rootSchema); + const result = validator.validateFormData(formData, resolvedRootSchema); expect(result.errors).toHaveLength(0); }); it('Data-Url with bad data generates error', () => { const formData = { dataUrlWithName: 'x=', }; - const result = validator.validateFormData(formData, rootSchema); + const result = validator.validateFormData(formData, resolvedRootSchema); expect(result.errors).toHaveLength(1); expect(result.errorSchema.dataUrlWithName!.__errors).toHaveLength(1); expect(result.errorSchema.dataUrlWithName!.__errors![0]).toEqual('must match format "data-url"'); @@ -373,16 +383,19 @@ describe('AJV8PrecompiledValidator', () => { }); describe('validator.validateFormData(), custom options, and localizer', () => { let validator: AJV8PrecompiledValidator; + let resolvedRootSchema: RJSFSchema; let localizer: Localizer; beforeAll(() => { localizer = jest.fn().mockImplementation(); validator = new AJV8PrecompiledValidator(validateOptionsFns, rootSchema, localizer); + // Since the root schema is retrieved in core before calling the validator emulate that here + resolvedRootSchema = retrieveSchema(validator, rootSchema, rootSchema); }); describe('validating using single custom meta schema', () => { let errors: RJSFValidationError[]; beforeAll(() => { (localizer as jest.Mock).mockClear(); - const result = validator.validateFormData({ foo: 42 }, rootSchema); + const result = validator.validateFormData({ foo: 42 }, resolvedRootSchema); errors = result.errors; }); it('should return 1 error about formData', () => { @@ -412,14 +425,14 @@ describe('AJV8PrecompiledValidator', () => { }); describe('validating using custom string formats', () => { it('should not return a validation error if proper string format is used', () => { - const result = validator.validateFormData({ phone: '800-555-2368' }, rootSchema); + const result = validator.validateFormData({ phone: '800-555-2368' }, resolvedRootSchema); expect(result.errors).toHaveLength(0); }); describe('validating using a custom formats', () => { let errors: RJSFValidationError[]; beforeAll(() => { - const result = validator.validateFormData({ phone: '800.555.2368' }, rootSchema); + const result = validator.validateFormData({ phone: '800.555.2368' }, resolvedRootSchema); errors = result.errors; }); it('should return 1 error about formData', () => { diff --git a/packages/validator-ajv8/test/validator.test.ts b/packages/validator-ajv8/test/validator.test.ts index 3494527441..ae67f5f79b 100644 --- a/packages/validator-ajv8/test/validator.test.ts +++ b/packages/validator-ajv8/test/validator.test.ts @@ -94,7 +94,6 @@ describe('AJV8Validator', () => { // @ts-expect-error - accessing private Ajv instance to verify compilation happens once const addSchemaSpy = jest.spyOn(validator.ajv, 'addSchema'); - addSchemaSpy.mockClear(); // Call isValid twice with the same schema validator.isValid(schema, formData, rootSchema);