Skip to content

Commit bd1267d

Browse files
committed
added ui:required support for overriding required status from uiSchema
ui:required: true adds the field to validation and shows the required indicator. ui:required: false hides the indicator but schema validation still runs. Emits console.warn when ui:required: false is used on a schema-required field without ui:initialValue or ui:emptyValue.
1 parent b190d45 commit bd1267d

8 files changed

Lines changed: 281 additions & 4 deletions

File tree

packages/core/src/components/Form.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Component, ElementType, FormEvent, ReactNode, Ref, RefObject, createRef } from 'react';
22
import {
3+
augmentSchemaWithUiRequired,
34
createSchemaUtils,
45
CustomValidator,
56
deepEquals,
@@ -720,9 +721,10 @@ export default class Form<
720721
const schemaUtils = altSchemaUtils ? altSchemaUtils : this.state.schemaUtils;
721722
const { customValidate, transformErrors, uiSchema } = this.props;
722723
const resolvedSchema = retrievedSchema ?? schemaUtils.retrieveSchema(schema, formData);
724+
const effectiveSchema = augmentSchemaWithUiRequired<T, S>(resolvedSchema, uiSchema);
723725
return schemaUtils
724726
.getValidator()
725-
.validateFormData(formData, resolvedSchema, customValidate, transformErrors, uiSchema);
727+
.validateFormData(formData, effectiveSchema, customValidate, transformErrors, uiSchema);
726728
}
727729

728730
/** Renders any errors contained in the `state` in using the `ErrorList`, if not disabled by `showErrorList`. */

packages/core/src/components/fields/LayoutGridField.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -681,6 +681,8 @@ function LayoutGridFieldComponent<T = any, S extends StrictRJSFSchema = RJSFSche
681681
// the `Form` via the prop passed to `LayoutGridField` we need to make sure the uiSchema always has a true value
682682
// when it is needed
683683
const { fieldUiSchema, uiReadonly } = computeFieldUiSchema<T, S, F>(name, uiProps, uiSchema, isReadonly, readonly);
684+
const fieldUiOptions = getUiOptions<T, S, F>(fieldUiSchema);
685+
const effectiveRequired = fieldUiOptions.required !== undefined ? Boolean(fieldUiOptions.required) : isRequired;
684686

685687
return (
686688
<Field
@@ -691,7 +693,7 @@ function LayoutGridFieldComponent<T = any, S extends StrictRJSFSchema = RJSFSche
691693
}
692694
{...otherProps}
693695
name={name}
694-
required={isRequired}
696+
required={effectiveRequired}
695697
readonly={uiReadonly}
696698
schema={schema}
697699
uiSchema={fieldUiSchema}

packages/core/src/components/fields/SchemaField.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,13 @@ function SchemaFieldRender<T = any, S extends StrictRJSFSchema = RJSFSchema, F e
135135
const FieldComponent = getFieldComponent<T, S, F>(schema, uiOptions, registry);
136136
const disabled = Boolean(uiOptions.disabled ?? props.disabled);
137137
const readonly = Boolean(uiOptions.readonly ?? (props.readonly || props.schema.readOnly || schema.readOnly));
138+
const effectiveRequired = uiOptions.required !== undefined ? Boolean(uiOptions.required) : required;
139+
if (uiOptions.required === false && required && uiOptions.initialValue === undefined && uiOptions.emptyValue === undefined) {
140+
console.warn(
141+
`ui:required is false for a schema-required field "${name}" but no ui:initialValue or ui:emptyValue is set. ` +
142+
'The UI will show this field as optional but schema validation will still fail if left empty.',
143+
);
144+
}
138145
const uiSchemaHideError = uiOptions.hideError;
139146
// Set hideError to the value provided in the uiSchema, otherwise stick with the prop to propagate to children
140147
const hideError = uiSchemaHideError === undefined ? props.hideError : Boolean(uiSchemaHideError);
@@ -167,7 +174,7 @@ function SchemaFieldRender<T = any, S extends StrictRJSFSchema = RJSFSchema, F e
167174
);
168175
}
169176
// When the anyOf/oneOf is an optional data control render AND it does not have form data, hide the label
170-
const isOptionalRender = shouldRenderOptionalField<T, S, F>(registry, schema, required, uiSchema);
177+
const isOptionalRender = shouldRenderOptionalField<T, S, F>(registry, schema, effectiveRequired, uiSchema);
171178
const hasFormData = isFormDataAvailable<T>(formData);
172179
displayLabel = displayLabel && (!isOptionalRender || hasFormData);
173180
fieldPathIdProps = {
@@ -274,7 +281,7 @@ function SchemaFieldRender<T = any, S extends StrictRJSFSchema = RJSFSchema, F e
274281
onKeyRename,
275282
onKeyRenameBlur,
276283
onRemoveProperty,
277-
required,
284+
required: effectiveRequired,
278285
disabled,
279286
readonly,
280287
hideError,

packages/core/test/uiSchema.test.tsx

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2865,4 +2865,100 @@ describe('uiSchema', () => {
28652865
expect(node.querySelectorAll("input[placeholder='Node name']")).toHaveLength(5);
28662866
});
28672867
});
2868+
2869+
describe('ui:required', () => {
2870+
it('shows required asterisk on non-required field when ui:required is true', () => {
2871+
const schema: RJSFSchema = {
2872+
type: 'object',
2873+
properties: {
2874+
foo: { type: 'string' },
2875+
},
2876+
};
2877+
const uiSchema: UiSchema = {
2878+
foo: { 'ui:required': true },
2879+
};
2880+
const { node } = createFormComponent({ schema, uiSchema });
2881+
expect(node.querySelector('.rjsf-field-string label span.required')).not.toBeNull();
2882+
});
2883+
2884+
it('hides required asterisk on schema-required field when ui:required is false', () => {
2885+
const schema: RJSFSchema = {
2886+
type: 'object',
2887+
required: ['foo'],
2888+
properties: {
2889+
foo: { type: 'string' },
2890+
},
2891+
};
2892+
const uiSchema: UiSchema = {
2893+
foo: { 'ui:required': false, 'ui:initialValue': 'fallback' },
2894+
};
2895+
const { node } = createFormComponent({ schema, uiSchema });
2896+
expect(node.querySelector('.rjsf-field-string label span.required')).toBeNull();
2897+
});
2898+
2899+
it('produces validation error when ui:required is true and field is empty', () => {
2900+
const schema: RJSFSchema = {
2901+
type: 'object',
2902+
properties: {
2903+
foo: { type: 'string' },
2904+
},
2905+
};
2906+
const uiSchema: UiSchema = {
2907+
foo: { 'ui:required': true },
2908+
};
2909+
const { node, onSubmit } = createFormComponent({ schema, uiSchema });
2910+
submitForm(node);
2911+
expect(onSubmit).not.toHaveBeenCalled();
2912+
});
2913+
2914+
it('does not suppress schema validation when ui:required is false', () => {
2915+
const schema: RJSFSchema = {
2916+
type: 'object',
2917+
required: ['foo'],
2918+
properties: {
2919+
foo: { type: 'string' },
2920+
},
2921+
};
2922+
const uiSchema: UiSchema = {
2923+
foo: { 'ui:required': false },
2924+
};
2925+
const { node, onSubmit } = createFormComponent({ schema, uiSchema });
2926+
submitForm(node);
2927+
expect(onSubmit).not.toHaveBeenCalled();
2928+
});
2929+
2930+
it('emits console.warn for ui:required false without initialValue or emptyValue', () => {
2931+
const schema: RJSFSchema = {
2932+
type: 'object',
2933+
required: ['foo'],
2934+
properties: {
2935+
foo: { type: 'string' },
2936+
},
2937+
};
2938+
const uiSchema: UiSchema = {
2939+
foo: { 'ui:required': false },
2940+
};
2941+
createFormComponent({ schema, uiSchema });
2942+
expect(consoleWarnSpy).toHaveBeenCalledWith(
2943+
expect.stringContaining('ui:required is false for a schema-required field'),
2944+
);
2945+
});
2946+
2947+
it('does not emit console.warn for ui:required false with initialValue set', () => {
2948+
const schema: RJSFSchema = {
2949+
type: 'object',
2950+
required: ['foo'],
2951+
properties: {
2952+
foo: { type: 'string' },
2953+
},
2954+
};
2955+
const uiSchema: UiSchema = {
2956+
foo: { 'ui:required': false, 'ui:initialValue': 'x' },
2957+
};
2958+
createFormComponent({ schema, uiSchema });
2959+
expect(consoleWarnSpy).not.toHaveBeenCalledWith(
2960+
expect.stringContaining('ui:required is false for a schema-required field'),
2961+
);
2962+
});
2963+
});
28682964
});
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import get from 'lodash/get';
2+
3+
import getUiOptions from './getUiOptions';
4+
import { FormContextType, RJSFSchema, StrictRJSFSchema, UiSchema } from './types';
5+
6+
/** Recursively walks a schema and uiSchema, adding fields marked `ui:required: true` to the schema's `required`
7+
* arrays. Returns a new schema without mutating the original.
8+
*
9+
* @param schema - The schema to augment
10+
* @param [uiSchema] - The uiSchema containing ui:required overrides
11+
* @returns A new schema with ui:required fields added to required arrays
12+
*/
13+
export default function augmentSchemaWithUiRequired<
14+
T = any,
15+
S extends StrictRJSFSchema = RJSFSchema,
16+
F extends FormContextType = any,
17+
>(schema: S, uiSchema?: UiSchema<T, S, F>): S {
18+
if (!uiSchema || schema.type !== 'object' || !schema.properties) {
19+
return schema;
20+
}
21+
22+
const existingRequired = schema.required || [];
23+
const additionalRequired: string[] = [];
24+
let propertiesChanged = false;
25+
const newProperties: Record<string, unknown> = {};
26+
27+
for (const key of Object.keys(schema.properties)) {
28+
const fieldUiSchema = get(uiSchema, [key]);
29+
const propertySchema = schema.properties[key] as S;
30+
31+
if (fieldUiSchema) {
32+
const { required: uiRequired } = getUiOptions<T, S, F>(fieldUiSchema);
33+
if (uiRequired === true && !existingRequired.includes(key)) {
34+
additionalRequired.push(key);
35+
}
36+
}
37+
38+
// Recurse into nested objects
39+
if (propertySchema && propertySchema.type === 'object' && fieldUiSchema) {
40+
const augmented = augmentSchemaWithUiRequired<T, S, F>(propertySchema, fieldUiSchema);
41+
if (augmented !== propertySchema) {
42+
propertiesChanged = true;
43+
newProperties[key] = augmented;
44+
continue;
45+
}
46+
}
47+
newProperties[key] = propertySchema;
48+
}
49+
50+
if (additionalRequired.length === 0 && !propertiesChanged) {
51+
return schema;
52+
}
53+
54+
return {
55+
...schema,
56+
...(propertiesChanged ? { properties: newProperties } : {}),
57+
...(additionalRequired.length > 0 ? { required: [...existingRequired, ...additionalRequired] } : {}),
58+
};
59+
}

packages/utils/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import allowAdditionalItems from './allowAdditionalItems';
22
import asNumber from './asNumber';
3+
import augmentSchemaWithUiRequired from './augmentSchemaWithUiRequired';
34
import canExpand from './canExpand';
45
import createErrorHandler from './createErrorHandler';
56
import createSchemaUtils from './createSchemaUtils';
@@ -97,6 +98,7 @@ export {
9798
allowAdditionalItems,
9899
ariaDescribedByIds,
99100
asNumber,
101+
augmentSchemaWithUiRequired,
100102
buttonId,
101103
canExpand,
102104
createErrorHandler,

packages/utils/src/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1075,6 +1075,8 @@ type UIOptionsBaseType<T = any, S extends StrictRJSFSchema = RJSFSchema, F exten
10751075
emptyValue?: any;
10761076
/** Pre-fills the field on initial render and after reset, takes priority over schema default */
10771077
initialValue?: any;
1078+
/** Overrides the schema's required for a field: true adds required, false hides the indicator */
1079+
required?: boolean;
10781080
/** Will disable any of the enum options specified in the array (by value) */
10791081
enumDisabled?: EnumValue[];
10801082
/** Allows a user to provide a list of labels for enum values in the schema.
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { augmentSchemaWithUiRequired, RJSFSchema, UiSchema } from '../src';
2+
3+
describe('augmentSchemaWithUiRequired', () => {
4+
it('returns the same schema when no uiSchema is provided', () => {
5+
const schema: RJSFSchema = {
6+
type: 'object',
7+
properties: { foo: { type: 'string' } },
8+
};
9+
expect(augmentSchemaWithUiRequired(schema)).toBe(schema);
10+
});
11+
12+
it('returns the same schema when no ui:required fields exist', () => {
13+
const schema: RJSFSchema = {
14+
type: 'object',
15+
properties: { foo: { type: 'string' } },
16+
};
17+
const uiSchema: UiSchema = { foo: { 'ui:widget': 'textarea' } };
18+
expect(augmentSchemaWithUiRequired(schema, uiSchema)).toBe(schema);
19+
});
20+
21+
it('adds ui:required: true field to required array', () => {
22+
const schema: RJSFSchema = {
23+
type: 'object',
24+
properties: { foo: { type: 'string' }, bar: { type: 'string' } },
25+
};
26+
const uiSchema: UiSchema = { foo: { 'ui:required': true } };
27+
const result = augmentSchemaWithUiRequired(schema, uiSchema);
28+
expect(result.required).toEqual(['foo']);
29+
expect(result).not.toBe(schema);
30+
});
31+
32+
it('does not duplicate existing required fields', () => {
33+
const schema: RJSFSchema = {
34+
type: 'object',
35+
required: ['foo'],
36+
properties: { foo: { type: 'string' } },
37+
};
38+
const uiSchema: UiSchema = { foo: { 'ui:required': true } };
39+
const result = augmentSchemaWithUiRequired(schema, uiSchema);
40+
expect(result).toBe(schema);
41+
});
42+
43+
it('preserves existing required and adds new ones', () => {
44+
const schema: RJSFSchema = {
45+
type: 'object',
46+
required: ['foo'],
47+
properties: { foo: { type: 'string' }, bar: { type: 'string' } },
48+
};
49+
const uiSchema: UiSchema = { bar: { 'ui:required': true } };
50+
const result = augmentSchemaWithUiRequired(schema, uiSchema);
51+
expect(result.required).toEqual(['foo', 'bar']);
52+
});
53+
54+
it('handles nested objects', () => {
55+
const schema: RJSFSchema = {
56+
type: 'object',
57+
properties: {
58+
address: {
59+
type: 'object',
60+
properties: {
61+
city: { type: 'string' },
62+
},
63+
},
64+
},
65+
};
66+
const uiSchema: UiSchema = {
67+
address: { city: { 'ui:required': true } },
68+
};
69+
const result = augmentSchemaWithUiRequired(schema, uiSchema);
70+
expect((result.properties!.address as RJSFSchema).required).toEqual(['city']);
71+
});
72+
73+
it('does not mutate the original schema', () => {
74+
const schema: RJSFSchema = {
75+
type: 'object',
76+
properties: { foo: { type: 'string' } },
77+
};
78+
const uiSchema: UiSchema = { foo: { 'ui:required': true } };
79+
augmentSchemaWithUiRequired(schema, uiSchema);
80+
expect(schema.required).toBeUndefined();
81+
});
82+
83+
it('returns same schema when nested object has uiSchema but no ui:required changes', () => {
84+
const schema: RJSFSchema = {
85+
type: 'object',
86+
properties: {
87+
address: {
88+
type: 'object',
89+
properties: {
90+
city: { type: 'string' },
91+
},
92+
},
93+
},
94+
};
95+
const uiSchema: UiSchema = {
96+
address: { city: { 'ui:widget': 'textarea' } },
97+
};
98+
const result = augmentSchemaWithUiRequired(schema, uiSchema);
99+
expect(result).toBe(schema);
100+
});
101+
102+
it('returns schema as-is for non-object types', () => {
103+
const schema: RJSFSchema = { type: 'string' };
104+
const uiSchema: UiSchema = { 'ui:required': true };
105+
expect(augmentSchemaWithUiRequired(schema, uiSchema)).toBe(schema);
106+
});
107+
});

0 commit comments

Comments
 (0)