Skip to content

Bug: AJV $data reference in const property in schema treated as default/const value. #4431

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Jan 8, 2025
Merged
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ should change the heading of the (upcoming) version to include a major version b

- Fixed issue with formData not updating when dependencies change, fixing [#4325](https://github.com/rjsf-team/react-jsonschema-form/issues/4325)
- Fixed issue with assigning default values to formData with deeply nested required properties, fixing [#4399](https://github.com/rjsf-team/react-jsonschema-form/issues/4399)
- Fix for AJV [$data](https://ajv.js.org/guide/combining-schemas.html#data-reference) reference in const property in schema treated as default/const value. The issue is mentioned in [#4361](https://github.com/rjsf-team/react-jsonschema-form/issues/4361).

# 5.23.2

Expand Down
43 changes: 43 additions & 0 deletions packages/core/test/ObjectField.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,49 @@ describe('ObjectField', () => {
);
});

it('should validate AJV $data reference ', () => {
const schema = {
type: 'object',
properties: {
email: {
type: 'string',
title: 'E-mail',
format: 'email',
},
emailConfirm: {
type: 'string',
const: {
$data: '/email',
},
title: 'Confirm e-mail',
format: 'email',
},
},
};
const { node, rerender } = createFormComponent({
schema,
formData: {
email: '[email protected]',
emailConfirm: '[email protected]',
},
liveValidate: true,
});

const errorMessages = node.querySelectorAll('#root_emailConfirm__error');
expect(errorMessages).to.have.length(1);

rerender({
schema,
formData: {
email: '[email protected]',
emailConfirm: '[email protected]',
},
liveValidate: true,
});

expect(node.querySelectorAll('#root_foo__error')).to.have.length(0);
});

it('Check that when formData changes, the form should re-validate', () => {
const { node, rerender } = createFormComponent({
schema,
Expand Down
2 changes: 2 additions & 0 deletions packages/playground/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,11 @@ const esV8Validator = customizeValidator({}, localize_es);
const AJV8_2019 = customizeValidator({ AjvClass: Ajv2019 });
const AJV8_2020 = customizeValidator({ AjvClass: Ajv2020 });
const AJV8_DISC = customizeValidator({ ajvOptionsOverrides: { discriminator: true } });
const AJV8_DATA_REF = customizeValidator({ ajvOptionsOverrides: { $data: true } });

const validators: PlaygroundProps['validators'] = {
AJV8: v8Validator,
'AJV8 $data reference': AJV8_DATA_REF,
'AJV8 (discriminator)': AJV8_DISC,
AJV8_es: esV8Validator,
AJV8_2019,
Expand Down
3 changes: 2 additions & 1 deletion packages/playground/src/components/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,7 @@ export default function Header({
uiSchema,
theme,
liveSettings,
validator,
})
);

Expand All @@ -314,7 +315,7 @@ export default function Header({
setShareURL(null);
console.error(error);
}
}, [formData, liveSettings, schema, theme, uiSchema, setShareURL]);
}, [formData, liveSettings, schema, theme, uiSchema, validator, setShareURL]);

return (
<div className='page-header'>
Expand Down
2 changes: 2 additions & 0 deletions packages/playground/src/components/Playground.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ export default function Playground({ themes, validators }: PlaygroundProps) {
theme: dataTheme = theme,
extraErrors,
liveSettings,
validator,
...rest
} = data;

Expand All @@ -85,6 +86,7 @@ export default function Playground({ themes, validators }: PlaygroundProps) {
setTheme(theTheme);
setShowForm(true);
setLiveSettings(liveSettings);
setValidator(validator);
setOtherFormProps({ fields, templates, ...rest });
},
[theme, onThemeSelected, themes]
Expand Down
4 changes: 3 additions & 1 deletion packages/playground/src/samples/Sample.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { FormProps } from '@rjsf/core';

export type Sample = Omit<FormProps, 'validator'>;
export interface Sample extends Omit<FormProps, 'validator'> {
validator: string;
}
17 changes: 17 additions & 0 deletions packages/utils/src/constIsAjvDataReference.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { CONST_KEY, getSchemaType, isObject } from './';
import { RJSFSchema, StrictRJSFSchema } from './types';
import { JSONSchema7Type } from 'json-schema';
import isString from 'lodash/isString';

/**
* Checks if the schema const property value is an AJV $data reference
* and the current schema is not an object or array
*
* @param schema - The schema to check if the const is an AJV $data reference
* @returns - true if the schema const property value is an AJV $data reference otherwise false.
*/
export default function constIsAjvDataReference<S extends StrictRJSFSchema = RJSFSchema>(schema: S): boolean {
const schemaConst = schema[CONST_KEY] as JSONSchema7Type & { $data: string };
const schemaType = getSchemaType<S>(schema);
return isObject(schemaConst) && isString(schemaConst?.$data) && schemaType !== 'object' && schemaType !== 'array';
}
12 changes: 9 additions & 3 deletions packages/utils/src/schema/getDefaultFormState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import isSelect from './isSelect';
import retrieveSchema, { resolveDependencies } from './retrieveSchema';
import isConstant from '../isConstant';
import { JSONSchema7Object } from 'json-schema';
import constIsAjvDataReference from '../constIsAjvDataReference';
import isEqual from 'lodash/isEqual';
import optionsList from '../optionsList';

Expand Down Expand Up @@ -213,8 +214,12 @@ export function computeDefaults<T = any, S extends StrictRJSFSchema = RJSFSchema
let experimental_dfsb_to_compute = experimental_defaultFormStateBehavior;
let updatedRecurseList = _recurseList;

if (schema[CONST_KEY] && experimental_defaultFormStateBehavior?.constAsDefaults !== 'never') {
defaults = schema.const as unknown as T;
if (
schema[CONST_KEY] &&
experimental_defaultFormStateBehavior?.constAsDefaults !== 'never' &&
!constIsAjvDataReference(schema)
) {
defaults = schema[CONST_KEY] as unknown as T;
} else if (isObject(defaults) && isObject(schema.default)) {
// For object defaults, only override parent defaults that are defined in
// schema.default.
Expand Down Expand Up @@ -431,7 +436,8 @@ export function getObjectDefaults<T = any, S extends StrictRJSFSchema = RJSFSche
const hasParentConst = isObject(parentConst) && (parentConst as JSONSchema7Object)[key] !== undefined;
const hasConst =
((isObject(propertySchema) && CONST_KEY in propertySchema) || hasParentConst) &&
experimental_defaultFormStateBehavior?.constAsDefaults !== 'never';
experimental_defaultFormStateBehavior?.constAsDefaults !== 'never' &&
!constIsAjvDataReference(propertySchema);
// Compute the defaults for this node, with the parent defaults we might
// have from a previous run: defaults[key].
const computedDefault = computeDefaults<T, S, F>(validator, propertySchema, {
Expand Down
53 changes: 53 additions & 0 deletions packages/utils/test/constIsAjvDataReference.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { RJSFSchema } from 'src';
import constIsAjvDataReference from '../src/constIsAjvDataReference';

describe('constIsAjvDataReference()', () => {
describe('check if schema contains $data reference', () => {
it('should return true when the const property contains a $data reference', () => {
const schema: RJSFSchema = {
type: 'string',
const: {
$data: '/email',
},
title: 'Confirm e-mail',
format: 'email',
};
expect(constIsAjvDataReference(schema)).toEqual(true);
});

it('should return false when the const property does not contain a $data reference', () => {
const schema: RJSFSchema = {
type: 'string',
const: 'hello world',
};
expect(constIsAjvDataReference(schema)).toEqual(false);
});

it('Should return false when the const property is not present in the schema', () => {
const schema: RJSFSchema = {
type: 'string',
};
expect(constIsAjvDataReference(schema)).toEqual(false);
});

it('Should return false when the $data reference is at the object level.', () => {
const schema: RJSFSchema = {
type: 'object',
properties: {
$data: {
type: 'string',
},
},
const: {
$data: 'Hello World!',
},
};
expect(constIsAjvDataReference(schema)).toEqual(false);
});

it('should return false when the schema is invalid', () => {
const schema = 'hello world' as unknown as RJSFSchema;
expect(constIsAjvDataReference(schema)).toEqual(false);
});
});
});
96 changes: 96 additions & 0 deletions packages/utils/test/schema/getDefaultFormStateTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2113,6 +2113,102 @@ export default function getDefaultFormStateTest(testValidator: TestValidatorType
expect(ensureFormDataMatchingSchema(testValidator, schema, schema, 'a')).toEqual('a');
});
});
describe('AJV $data reference in const property in schema should not be treated as default/const value', () => {
let schema: RJSFSchema;
it('test nested object with $data in the schema', () => {
schema = {
type: 'object',
properties: {
email: {
type: 'string',
title: 'E-mail',
format: 'email',
},
emailConfirm: {
type: 'string',
const: {
$data: '/email',
},
title: 'Confirm e-mail',
format: 'email',
},
nestedObject: {
type: 'object',
properties: {
nestedEmail: {
type: 'string',
title: 'E-mail',
format: 'email',
},
nestedEmailConfirm: {
type: 'string',
title: 'Confirm e-mail',
const: {
$data: '/nestedObject/nestedEmail',
},
format: 'email',
},
},
},
nestedObjectConfirm: {
type: 'object',
properties: {
nestedEmailConfirm: {
type: 'string',
title: 'Confirm e-mail',
const: {
$data: '/nestedObject/nestedEmail',
},
format: 'email',
},
},
},
arrayConfirm: {
type: 'array',
items: {
type: 'string',
title: 'Confirm e-mail',
const: {
$data: '/nestedObject/nestedEmail',
},
format: 'email',
},
},
},
};
expect(
computeDefaults(testValidator, schema, {
rootSchema: schema,
})
).toEqual({
arrayConfirm: [],
});
});
it('test nested object with $data in the schema and emptyObjectFields set to populateRequiredDefaults', () => {
expect(
computeDefaults(testValidator, schema, {
rootSchema: schema,
experimental_defaultFormStateBehavior: { emptyObjectFields: 'populateRequiredDefaults' },
})
).toEqual({});
});
it('test nested object with $data in the schema and emptyObjectFields set to skipEmptyDefaults', () => {
expect(
computeDefaults(testValidator, schema, {
rootSchema: schema,
experimental_defaultFormStateBehavior: { emptyObjectFields: 'skipEmptyDefaults' },
})
).toEqual({});
});
it('test nested object with $data in the schema and emptyObjectFields set to skipDefaults', () => {
expect(
computeDefaults(testValidator, schema, {
rootSchema: schema,
experimental_defaultFormStateBehavior: { emptyObjectFields: 'skipDefaults' },
})
).toEqual({});
});
});
describe('default form state behavior: ignore min items unless required', () => {
it('should return empty data for an optional array property with minItems', () => {
const schema: RJSFSchema = {
Expand Down
Loading