diff --git a/CHANGELOG.md b/CHANGELOG.md index a5084b626f..2b41ff3c81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,47 @@ it according to semantic versioning. For example, if your PR adds a breaking cha should change the heading of the (upcoming) version to include a major version bump. --> + +# 6.0.0-beta.14 + +## @rjsf/core + +- Added support for dynamic UI schema in array fields - the `items` property in `uiSchema` can now accept a function that returns a UI schema based on the array item's data, index, and form context ([#4706](https://github.com/rjsf-team/react-jsonschema-form/pull/4706)) +- Fixed checkbox widget to use current value instead of event target in onFocus/onBlur handlers, fixing [#4704](https://github.com/rjsf-team/react-jsonschema-form/issues/4704) + +## @rjsf/utils + +- Updated `UiSchema` type to support dynamic array item UI schemas - the `items` property can now be either a `UiSchema` object or a function that returns a `UiSchema` ([#4706](https://github.com/rjsf-team/react-jsonschema-form/pull/4706)) + +## @rjsf/chakra-ui + +- Fixed checkbox widget to use current value instead of event target in onFocus/onBlur handlers, fixing [#4704](https://github.com/rjsf-team/react-jsonschema-form/issues/4704) + +## @rjsf/daisyui + +- Fixed checkbox widget to use current value instead of event target in onFocus/onBlur handlers, fixing [#4704](https://github.com/rjsf-team/react-jsonschema-form/issues/4704) + +## @rjsf/fluentui-rc + +- Fixed checkbox widget to use current value instead of event target in onFocus/onBlur handlers, fixing [#4704](https://github.com/rjsf-team/react-jsonschema-form/issues/4704) + +## @rjsf/mui + +- Fixed checkbox widget to use current value instead of event target in onFocus/onBlur handlers, fixing [#4704](https://github.com/rjsf-team/react-jsonschema-form/issues/4704) + +## @rjsf/primereact + +- Fixed checkbox widget to use current value instead of event target in onFocus/onBlur handlers, fixing [#4704](https://github.com/rjsf-team/react-jsonschema-form/issues/4704) + +## @rjsf/semantic-ui + +- Fixed checkbox widget to use current value instead of event target in onFocus/onBlur handlers, fixing [#4704](https://github.com/rjsf-team/react-jsonschema-form/issues/4704) + +## Dev / docs / playground + +- Added comprehensive documentation for dynamic UI schema feature with TypeScript examples ([#4706](https://github.com/rjsf-team/react-jsonschema-form/pull/4706)) +- Updated array documentation to reference the new dynamic UI schema capabilities ([#4706](https://github.com/rjsf-team/react-jsonschema-form/pull/4706)) + # 6.0.0-beta.13 ## @rjsf/shadcn diff --git a/packages/chakra-ui/src/CheckboxWidget/CheckboxWidget.tsx b/packages/chakra-ui/src/CheckboxWidget/CheckboxWidget.tsx index 6099b47ca3..a439203b2e 100644 --- a/packages/chakra-ui/src/CheckboxWidget/CheckboxWidget.tsx +++ b/packages/chakra-ui/src/CheckboxWidget/CheckboxWidget.tsx @@ -48,8 +48,8 @@ export default function CheckboxWidget< const description = options.description || schema.description; const _onChange = ({ checked }: CheckboxCheckedChangeDetails) => onChange(checked); - const _onBlur = ({ target }: FocusEvent) => onBlur(id, target && target.value); - const _onFocus = ({ target }: FocusEvent) => onFocus(id, target && target.value); + const _onBlur = ({ target }: FocusEvent) => onBlur(id, target && target.checked); + const _onFocus = ({ target }: FocusEvent) => onFocus(id, target && target.checked); const chakraProps = getChakra({ uiSchema }); diff --git a/packages/core/src/components/fields/ArrayField.tsx b/packages/core/src/components/fields/ArrayField.tsx index 72825d2a03..0b6d87aac5 100644 --- a/packages/core/src/components/fields/ArrayField.tsx +++ b/packages/core/src/components/fields/ArrayField.tsx @@ -22,7 +22,7 @@ import cloneDeep from 'lodash/cloneDeep'; import get from 'lodash/get'; import isObject from 'lodash/isObject'; import set from 'lodash/set'; -import { nanoid } from 'nanoid'; +import uniqueId from 'lodash/uniqueId'; /** Type used to represent the keyed form data used in the state */ type KeyedFormDataType = { key: string; item: T }; @@ -37,7 +37,7 @@ type ArrayFieldState = { /** Used to generate a unique ID for an element in a row */ function generateRowId() { - return nanoid(); + return uniqueId('rjsf-array-item-'); } /** Converts the `formData` into `KeyedFormDataType` data, using the `generateRowId()` function to create the key @@ -423,6 +423,39 @@ class ArrayField, + item: T, + index: number, + formContext: F, + ): UiSchema | undefined { + if (typeof uiSchema.items === 'function') { + try { + // Call the function with item data, index, and form context + // TypeScript now correctly infers the types thanks to the ArrayElement type in UiSchema + const result = uiSchema.items(item, index, formContext); + // Only use the result if it's truthy + return result as UiSchema; + } catch (e) { + console.error(`Error executing dynamic uiSchema.items function for item at index ${index}:`, e); + // Fall back to undefined to allow the field to still render + return undefined; + } + } else { + // Static object case - preserve undefined to maintain backward compatibility + return uiSchema.items as UiSchema | undefined; + } + } + /** Renders the `ArrayField` depending on the specific needs of the schema and uischema elements */ render() { @@ -500,6 +533,10 @@ class ArrayField) : undefined; const itemIdPrefix = idSchema.$id + idSeparator + index; const itemIdSchema = schemaUtils.toIdSchema(itemSchema, itemIdPrefix, itemCast, idPrefix, idSeparator); + + // Compute the item UI schema using the helper method + const itemUiSchema = this.computeItemUiSchema(uiSchema, item, index, formContext); + return this.renderArrayFieldItem({ key, index, @@ -512,7 +549,7 @@ class ArrayField | undefined; + if (additional) { + // For additional items, use additionalItems uiSchema + itemUiSchema = uiSchema.additionalItems as UiSchema; + } else { + // For fixed items, uiSchema.items can be an array, a function, or a single object + if (Array.isArray(uiSchema.items)) { + itemUiSchema = uiSchema.items[index] as UiSchema; + } else { + // Use the helper method for function or static object cases + itemUiSchema = this.computeItemUiSchema(uiSchema, item, index, formContext); + } + } const itemErrorSchema = errorSchema ? (errorSchema[index] as ErrorSchema) : undefined; return this.renderArrayFieldItem({ @@ -811,7 +857,7 @@ class ArrayField; + itemUiSchema: UiSchema | undefined; itemIdSchema: IdSchema; itemErrorSchema?: ErrorSchema; autofocus?: boolean; diff --git a/packages/core/test/ArrayField.test.jsx b/packages/core/test/ArrayField.test.jsx index 6794e23272..86b87166ce 100644 --- a/packages/core/test/ArrayField.test.jsx +++ b/packages/core/test/ArrayField.test.jsx @@ -3382,4 +3382,407 @@ describe('ArrayField', () => { expect(errorMessages).to.have.length(0); }); }); + + describe('Dynamic uiSchema.items function', () => { + it('should support static uiSchema.items object for backward compatibility', () => { + const schema = { + type: 'array', + items: { + type: 'object', + properties: { + name: { type: 'string' }, + age: { type: 'number' }, + }, + }, + }; + + const uiSchema = { + items: { + name: { + 'ui:widget': 'textarea', + }, + }, + }; + + const formData = [ + { name: 'John', age: 30 }, + { name: 'Jane', age: 25 }, + ]; + + const { node } = createFormComponent({ schema, uiSchema, formData }); + + // Should render textareas for name fields based on static uiSchema + const textareas = node.querySelectorAll('textarea'); + expect(textareas).to.have.length(2); + }); + + it('should call dynamic uiSchema.items function with correct parameters', () => { + const schema = { + type: 'array', + items: { + type: 'object', + properties: { + name: { type: 'string' }, + role: { type: 'string' }, + }, + }, + }; + + const dynamicUiSchemaFunction = sinon.spy((itemData) => { + return { + name: { + 'ui:widget': itemData.role === 'admin' ? 'textarea' : 'text', + }, + }; + }); + + const uiSchema = { + items: dynamicUiSchemaFunction, + }; + + const formData = [ + { name: 'John', role: 'admin' }, + { name: 'Jane', role: 'user' }, + ]; + + const formContext = { testContext: 'value' }; + + createFormComponent({ schema, uiSchema, formData, formContext }); + + // Should be called twice (once for each array item) + expect(dynamicUiSchemaFunction.callCount).to.equal(2); + + // Check first call + expect(dynamicUiSchemaFunction.firstCall.args[0]).to.deep.equal({ name: 'John', role: 'admin' }); + expect(dynamicUiSchemaFunction.firstCall.args[1]).to.equal(0); + expect(dynamicUiSchemaFunction.firstCall.args[2]).to.deep.equal({ testContext: 'value' }); + + // Check second call + expect(dynamicUiSchemaFunction.secondCall.args[0]).to.deep.equal({ name: 'Jane', role: 'user' }); + expect(dynamicUiSchemaFunction.secondCall.args[1]).to.equal(1); + expect(dynamicUiSchemaFunction.secondCall.args[2]).to.deep.equal({ testContext: 'value' }); + }); + + it('should apply dynamic uiSchema correctly based on item data', () => { + const schema = { + type: 'array', + items: { + type: 'object', + properties: { + name: { type: 'string' }, + priority: { type: 'string', enum: ['high', 'normal', 'low'] }, + }, + }, + }; + + const uiSchema = { + items: (itemData) => { + if (itemData.priority === 'high') { + return { + name: { + 'ui:widget': 'textarea', + 'ui:options': { + rows: 5, + }, + }, + priority: { + 'ui:widget': 'select', + 'ui:classNames': 'priority-high', + }, + }; + } + return { + name: { + 'ui:widget': 'text', + }, + }; + }, + }; + + const formData = [ + { name: 'Critical Task', priority: 'high' }, + { name: 'Regular Task', priority: 'normal' }, + ]; + + const { node } = createFormComponent({ schema, uiSchema, formData }); + + // First item should have textarea due to high priority + const firstItemTextarea = node.querySelectorAll('.rjsf-array-item')[0].querySelector('textarea'); + expect(firstItemTextarea).to.exist; + expect(firstItemTextarea.rows).to.equal(5); + + // Second item should have text input + const secondItemInput = node.querySelectorAll('.rjsf-array-item')[1].querySelector('input[type="text"]'); + expect(secondItemInput).to.exist; + + // High priority item should have custom className + const highPrioritySelect = node.querySelector('.priority-high select'); + expect(highPrioritySelect).to.exist; + }); + + it('should handle errors in dynamic uiSchema function gracefully', () => { + const schema = { + type: 'array', + items: { + type: 'object', + properties: { + name: { type: 'string' }, + }, + }, + }; + + const consoleErrorStub = sinon.stub(console, 'error'); + + const uiSchema = { + items: (itemData, index) => { + if (index === 1) { + throw new Error('Test error'); + } + return { + name: { + 'ui:widget': 'textarea', + }, + }; + }, + }; + + const formData = [{ name: 'First' }, { name: 'Second' }, { name: 'Third' }]; + + const { node } = createFormComponent({ schema, uiSchema, formData }); + + // Should log error for second item + expect(consoleErrorStub.calledWith('Error executing dynamic uiSchema.items function for item at index 1:')).to.be + .true; + + // All items should still render (with fallback for errored item) + const arrayItems = node.querySelectorAll('.rjsf-array-item'); + expect(arrayItems).to.have.length(3); + + // First and third items should have textareas + expect(arrayItems[0].querySelector('textarea')).to.exist; + expect(arrayItems[2].querySelector('textarea')).to.exist; + + // Second item should fall back to default text input + expect(arrayItems[1].querySelector('input[type="text"]')).to.exist; + expect(arrayItems[1].querySelector('textarea')).to.not.exist; + + consoleErrorStub.restore(); + }); + + it('should handle errors in dynamic uiSchema function gracefully for fixed arrays', () => { + const schema = { + type: 'array', + items: [{ type: 'string' }, { type: 'string' }], + }; + + const consoleErrorStub = sinon.stub(console, 'error'); + + const uiSchema = { + items: (itemData, index) => { + if (index === 1) { + throw new Error('Test error in fixed array'); + } + return { 'ui:widget': 'textarea' }; + }, + }; + + const formData = ['First', 'Second']; + const { node } = createFormComponent({ schema, uiSchema, formData }); + + // Should log error for second item + expect(consoleErrorStub.calledWith('Error executing dynamic uiSchema.items function for item at index 1:')).to.be + .true; + + // All items should still render + const arrayItems = node.querySelectorAll('.rjsf-array-item'); + expect(arrayItems).to.have.length(2); + + // First item should have textarea + expect(arrayItems[0].querySelector('textarea')).to.exist; + + // Second item should fall back to default text input + expect(arrayItems[1].querySelector('input[type="text"]')).to.exist; + expect(arrayItems[1].querySelector('textarea')).to.not.exist; + + consoleErrorStub.restore(); + }); + + it('should handle falsy return values from dynamic uiSchema function', () => { + const schema = { + type: 'array', + items: { + type: 'object', + properties: { + name: { type: 'string' }, + visible: { type: 'boolean' }, + }, + }, + }; + + const uiSchema = { + items: (itemData) => { + // Return null/undefined for items where visible is false + if (!itemData.visible) { + return null; + } + return { + name: { + 'ui:widget': 'textarea', + }, + }; + }, + }; + + const formData = [ + { name: 'Visible Item', visible: true }, + { name: 'Hidden Item', visible: false }, + ]; + + const { node } = createFormComponent({ schema, uiSchema, formData }); + + // Both items should render + const arrayItems = node.querySelectorAll('.rjsf-array-item'); + expect(arrayItems).to.have.length(2); + + // First item should have textarea + expect(arrayItems[0].querySelector('textarea')).to.exist; + + // Second item should have default input (falsy return handled gracefully) + expect(arrayItems[1].querySelector('input[type="text"]')).to.exist; + expect(arrayItems[1].querySelector('textarea')).to.not.exist; + }); + + it('should work with empty arrays', () => { + const schema = { + type: 'array', + items: { + type: 'object', + properties: { + name: { type: 'string' }, + }, + }, + }; + + const dynamicUiSchemaFunction = sinon.spy(() => ({ + name: { + 'ui:widget': 'textarea', + }, + })); + + const uiSchema = { + items: dynamicUiSchemaFunction, + }; + + const formData = []; + + const { node } = createFormComponent({ schema, uiSchema, formData }); + + // Function should not be called for empty array + expect(dynamicUiSchemaFunction.callCount).to.equal(0); + + // Should still render the add button + const addButton = node.querySelector('.rjsf-array-item-add button'); + expect(addButton).to.exist; + }); + + it('should update dynamically when array items are added', () => { + const schema = { + type: 'array', + items: { + type: 'object', + properties: { + name: { type: 'string' }, + }, + }, + }; + + let callCount = 0; + const uiSchema = { + items: (itemData, index) => { + callCount++; + return { + name: { + 'ui:widget': 'textarea', + 'ui:placeholder': `Item ${index + 1}`, + }, + }; + }, + }; + + const formData = [{ name: 'First' }]; + + const { node } = createFormComponent({ schema, uiSchema, formData }); + + // Initial render should call function once + expect(callCount).to.equal(1); + + // Add a new item + const addButton = node.querySelector('.rjsf-array-item-add button'); + act(() => { + fireEvent.click(addButton); + }); + + // Should now have called function for both items (3 total: 1 initial + 2 for re-render) + expect(callCount).to.be.at.least(3); + + // Check placeholders are set correctly + const textareas = node.querySelectorAll('textarea'); + expect(textareas).to.have.length(2); + expect(textareas[0].placeholder).to.equal('Item 1'); + expect(textareas[1].placeholder).to.equal('Item 2'); + }); + + it('should work with nested arrays', () => { + const schema = { + type: 'array', + items: { + type: 'object', + properties: { + title: { type: 'string' }, + tags: { + type: 'array', + items: { type: 'string' }, + }, + }, + }, + }; + + const uiSchema = { + items: (itemData, index) => ({ + title: { + 'ui:widget': index === 0 ? 'textarea' : 'text', + }, + tags: { + items: { + 'ui:widget': 'text', + 'ui:placeholder': 'Tag', + }, + }, + }), + }; + + const formData = [ + { title: 'First Post', tags: ['react', 'form'] }, + { title: 'Second Post', tags: ['javascript'] }, + ]; + + const { node } = createFormComponent({ schema, uiSchema, formData }); + + // First item title should be textarea + const firstItemTitle = node + .querySelectorAll('.rjsf-array-item')[0] + .querySelector('.rjsf-field-object .rjsf-field-string:first-of-type textarea'); + expect(firstItemTitle).to.exist; + + // Second item title should be text input + const secondItemTitle = node + .querySelectorAll('.rjsf-array-item')[1] + .querySelector('.rjsf-field-object .rjsf-field-string:first-of-type input[type="text"]'); + expect(secondItemTitle).to.exist; + + // Verify that tag inputs exist + const tagInputs = node.querySelectorAll('.rjsf-field-array .rjsf-field-array input[type="text"]'); + expect(tagInputs.length).to.be.at.least(3); // 2 tags in first item + 1 tag in second item + }); + }); }); diff --git a/packages/core/test/test_utils.js b/packages/core/test/test_utils.js index 9011286850..85633d432c 100644 --- a/packages/core/test/test_utils.js +++ b/packages/core/test/test_utils.js @@ -16,8 +16,11 @@ export function createComponent(Component, props) { const comp = ; const { container, rerender } = render(comp); - const rerenderFunction = (props) => - rerender(); + const rerenderFunction = (newProps) => { + // For Form components, ensure validator is always passed + const propsWithValidator = Component === Form && !newProps.validator ? { validator, ...newProps } : newProps; + return rerender(); + }; const node = findDOMNode(container).firstElementChild; return { comp, node, onChange, onError, onSubmit, rerender: rerenderFunction }; diff --git a/packages/daisyui/src/widgets/CheckboxWidget/CheckboxWidget.tsx b/packages/daisyui/src/widgets/CheckboxWidget/CheckboxWidget.tsx index 0d8c63641f..4471a8b465 100644 --- a/packages/daisyui/src/widgets/CheckboxWidget/CheckboxWidget.tsx +++ b/packages/daisyui/src/widgets/CheckboxWidget/CheckboxWidget.tsx @@ -42,7 +42,7 @@ export default function CheckboxWidget< /** Handle focus events */ - const handleFocus = useCallback(() => { + const handleFocus: React.FocusEventHandler = useCallback(() => { if (onFocus) { onFocus(id, value); } @@ -50,7 +50,7 @@ export default function CheckboxWidget< /** Handle blur events */ - const handleBlur = useCallback(() => { + const handleBlur: React.FocusEventHandler = useCallback(() => { if (onBlur) { onBlur(id, value); } diff --git a/packages/daisyui/test/__snapshots__/Array.test.tsx.snap b/packages/daisyui/test/__snapshots__/Array.test.tsx.snap index abd9864b38..6cfdc5c83d 100644 --- a/packages/daisyui/test/__snapshots__/Array.test.tsx.snap +++ b/packages/daisyui/test/__snapshots__/Array.test.tsx.snap @@ -33045,7 +33045,6 @@ exports[`array fields fixed array 1`] = ` "type": "string", } } - uiSchema={{}} /> } disabled={false} @@ -36696,7 +36695,6 @@ exports[`array fields fixed array 1`] = ` "type": "number", } } - uiSchema={{}} /> } disabled={false} @@ -77283,7 +77281,6 @@ exports[`with title and description fixed array 1`] = ` "type": "string", } } - uiSchema={{}} /> } disabled={false} @@ -80951,7 +80948,6 @@ exports[`with title and description fixed array 1`] = ` "type": "number", } } - uiSchema={{}} /> } disabled={false} @@ -166330,7 +166326,6 @@ exports[`with title and description with global label off fixed array 1`] = ` "type": "string", } } - uiSchema={{}} /> } disabled={false} @@ -169985,7 +169980,6 @@ exports[`with title and description with global label off fixed array 1`] = ` "type": "number", } } - uiSchema={{}} /> } disabled={false} diff --git a/packages/docs/docs/api-reference/dynamic-ui-schema-examples.md b/packages/docs/docs/api-reference/dynamic-ui-schema-examples.md new file mode 100644 index 0000000000..adabf9d99f --- /dev/null +++ b/packages/docs/docs/api-reference/dynamic-ui-schema-examples.md @@ -0,0 +1,294 @@ +# Dynamic uiSchema Examples + +## Backward Compatibility Examples + +### Example 1: Traditional Static uiSchema (No Changes Required) + +```typescript +import { UiSchema } from '@rjsf/utils'; + +// This continues to work exactly as before +const uiSchema: UiSchema = { + guests: { + items: { + name: { 'ui:placeholder': 'Enter guest name' }, + age: { 'ui:widget': 'updown' }, + relationship: { 'ui:widget': 'select' }, + }, + }, +}; +``` + +### Example 2: Dynamic uiSchema with Function + +```typescript +import { UiSchema, FormContextType } from '@rjsf/utils'; + +interface GuestData { + name?: string; + age?: number; + relationship?: 'child' | 'adult' | 'senior'; + guardianName?: string; + mealPreference?: string; +} + +// New functionality - dynamic UI based on item data +const uiSchema: UiSchema = { + guests: { + items: (itemData: GuestData | undefined, index: number, formContext?: FormContextType): UiSchema => { + // Note: For newly added items, `itemData` will be undefined or contain default values. + // Using optional chaining (`?.`) is recommended to handle this case gracefully. + + // Base UI schema for all items + const baseUiSchema: UiSchema = { + name: { 'ui:placeholder': `Guest ${index + 1} name` }, + relationship: { 'ui:widget': 'select' }, + }; + + // Conditionally modify UI based on data + if (itemData?.relationship === 'child') { + return { + ...baseUiSchema, + age: { + 'ui:widget': 'updown', + 'ui:help': 'Age is required for children', + 'ui:options': { min: 0, max: 17 }, + }, + guardianName: { + 'ui:placeholder': 'Parent/Guardian name', + }, + mealPreference: { 'ui:widget': 'hidden' }, + }; + } + + if (itemData?.relationship === 'adult') { + return { + ...baseUiSchema, + age: { 'ui:widget': 'hidden' }, + guardianName: { 'ui:widget': 'hidden' }, + mealPreference: { + 'ui:widget': 'select', + 'ui:placeholder': 'Select meal preference', + }, + }; + } + + // Default for new items or unknown relationships + return baseUiSchema; + }, + }, +}; +``` + +### Example 3: Using Form Context + +```typescript +import { UiSchema, FormContextType } from '@rjsf/utils'; + +interface ParticipantData { + name?: string; + email?: string; + workshop?: string; +} + +interface MyFormContext extends FormContextType { + eventType?: 'conference' | 'workshop' | 'webinar'; +} + +const uiSchema: UiSchema = { + participants: { + items: (itemData: ParticipantData | undefined, index: number, formContext?: MyFormContext): UiSchema => { + // Access form-wide settings + const isConference = formContext?.eventType === 'conference'; + + return { + name: { 'ui:placeholder': 'Participant name' }, + email: { 'ui:widget': 'email' }, + // Show workshop selection only for conference events + workshop: isConference ? { 'ui:widget': 'select' } : { 'ui:widget': 'hidden' }, + }; + }, + }, +}; +``` + +### Example 4: Falsy Return Values + +```typescript +import { UiSchema, FormContextType } from '@rjsf/utils'; + +interface ItemData { + needsCustomUI?: boolean; + field1?: string; + field2?: string; +} + +const uiSchema: UiSchema = { + items: { + items: (itemData: ItemData | undefined, index: number): UiSchema | null | undefined => { + // Only apply custom UI to specific items + if (itemData?.needsCustomUI) { + return { + field1: { 'ui:widget': 'textarea' }, + field2: { 'ui:help': 'This item needs special attention' }, + }; + } + + // Return null or undefined to use default UI rendering + // This is useful for conditionally applying custom UI + return null; + }, + }, +}; +``` + +### Example 5: Dynamic UI for Fixed Arrays + +For fixed/tuple arrays (where schema.items is an array), the dynamic function can be applied to each position: + +```typescript +import { RJSFSchema, UiSchema } from '@rjsf/utils'; + +interface DetailsData { + age?: number; + role?: string; +} + +const schema: RJSFSchema = { + type: 'array', + items: [ + { type: 'string', title: 'First Name' }, + { type: 'string', title: 'Last Name' }, + { type: 'object', title: 'Details', properties: { age: { type: 'number' }, role: { type: 'string' } } }, + ], +}; + +const uiSchema: UiSchema = { + items: [ + { 'ui:placeholder': 'Enter first name' }, // Static UI for first item + { 'ui:placeholder': 'Enter last name' }, // Static UI for second item + // Dynamic UI for third item based on its data + (itemData: DetailsData | undefined, index: number): UiSchema => { + if (itemData?.role === 'admin') { + return { + age: { 'ui:widget': 'hidden' }, + role: { 'ui:help': 'Admin role selected' }, + }; + } + return { + age: { 'ui:widget': 'updown' }, + role: { 'ui:widget': 'select' }, + }; + }, + ], +}; +``` + +## Schema Example + +```typescript +import { RJSFSchema } from '@rjsf/utils'; + +const schema: RJSFSchema = { + type: 'object', + properties: { + guests: { + type: 'array', + items: { + type: 'object', + properties: { + name: { type: 'string', title: 'Name' }, + age: { type: 'number', title: 'Age' }, + relationship: { + type: 'string', + title: 'Relationship', + enum: ['adult', 'child', 'senior'], + }, + guardianName: { type: 'string', title: 'Guardian Name' }, + mealPreference: { + type: 'string', + title: 'Meal Preference', + enum: ['vegetarian', 'vegan', 'standard', 'gluten-free'], + }, + }, + required: ['name', 'relationship'], + }, + }, + }, +}; +``` + +## Key Benefits + +1. **Backward Compatible**: Existing forms with object-based `uiSchema.items` continue to work without any changes +2. **Progressive Enhancement**: Developers can opt-in to dynamic behavior when needed +3. **Flexible**: Access to item data, index, and form context enables complex UI logic +4. **Safe**: Built-in error handling prevents the entire form from crashing if your function throws an error. When an error occurs for a specific item, it will be caught and logged to the developer console, and the UI for that item will fall back to the default rendering. This ensures the rest of the form remains functional while making debugging easier. +5. **On-Demand Execution**: The function is executed on-demand during the render cycle. However, as it runs for each array item, performance should be carefully managed for large lists (see Performance Considerations below). + +## Key Behaviors + +- **Falsy Returns**: If your function returns a falsy value (e.g., `null` or `undefined`), the UI for that specific item will fall back to its default rendering. This allows you to conditionally apply custom UI only when needed. +- **Error Handling**: If your function throws an error, it will be caught and logged to the console. The form will continue to work, using default UI for the affected item. +- **New Items**: When a new item is added to the array, `itemData` will be `undefined` or contain default values from the schema. Always use optional chaining (`?.`) to safely access properties. + +## Performance Considerations + +When using dynamic `uiSchema.items` functions, keep in mind: + +- The function is executed **on every render** for **each array item** +- For large arrays, this can impact performance if the function performs expensive operations +- Best practices: + - Keep the function logic lightweight and fast + - Avoid heavy computations or external API calls within the function + - Consider memoizing results if the same inputs produce the same outputs + - For complex logic, pre-compute values and store them in formContext or component state + +Example of a performance-optimized approach: + +```typescript +import React, { PropsWithChildren, useMemo } from 'react'; +import Form from '@rjsf/core'; +import { RJSFSchema, UiSchema, FormContextType, IChangeEvent } from '@rjsf/utils'; + +interface ItemData { + type?: string; + field?: any; +} + +interface ExpensiveDataConfig { + widget: string; +} + +interface MyFormProps { + schema: RJSFSchema; + formData?: any; +} + +// In your React component that renders the form: +function MyFormComponent({ schema, formData }: PropsWithChildren) { + // Pre-compute expensive data once, and only re-compute if dependencies change + const expensiveData = useMemo>(() => computeExpensiveData(), [/* dependencies */]); + + const defaultConfig: ExpensiveDataConfig = { widget: 'text' }; + + // Define the uiSchema inside the component so it can access the memoized data + const uiSchema: UiSchema = { + myArrayField: { // Target your specific array field + items: (itemData: ItemData | undefined, index: number, formContext?: FormContextType): UiSchema => { + // Use the pre-computed data - this is very fast + const config = expensiveData[itemData?.type || ''] || defaultConfig; + + return { + field: { 'ui:widget': config.widget } + }; + } + } + }; + + return
; +} + +// Placeholder for the expensive computation function +declare function computeExpensiveData(): Record; +``` diff --git a/packages/docs/docs/api-reference/uiSchema.md b/packages/docs/docs/api-reference/uiSchema.md index c87677a8f4..e1229c466e 100644 --- a/packages/docs/docs/api-reference/uiSchema.md +++ b/packages/docs/docs/api-reference/uiSchema.md @@ -100,7 +100,7 @@ The `ui:options` property cannot be nested inside itself and thus is the last ex All the properties that follow can be specified in the `uiSchema` in either of the two equivalent ways. -NOTE: The properties specific to array items can be found [here](../json-schema/arrays.md#array-item-uiSchema-options) +> NOTE: The properties specific to array items can be found [here](../json-schema/arrays.md#array-item-uiSchema-options). For advanced dynamic UI schema capabilities for array items, see the [Dynamic UI Schema Examples](./dynamic-ui-schema-examples.md). ### widget diff --git a/packages/docs/docs/json-schema/arrays.md b/packages/docs/docs/json-schema/arrays.md index e8a4e1262b..34112a7cd6 100644 --- a/packages/docs/docs/json-schema/arrays.md +++ b/packages/docs/docs/json-schema/arrays.md @@ -67,6 +67,8 @@ const uiSchema = { render(, document.getElementById('app')); ``` +> NOTE: The `items` property in `uiSchema` can also accept a function for dynamic UI schema generation based on array item data. See [Dynamic UI Schema Examples](../api-reference/dynamic-ui-schema-examples.md) for advanced use cases. + ## The `additionalItems` keyword The `additionalItems` keyword allows the user to add additional items of a given schema. For example: @@ -276,7 +278,7 @@ const widgets = { render( , - document.getElementById('app') + document.getElementById('app'), ); ``` diff --git a/packages/docs/docs/migration-guides/v6.x upgrade guide.md b/packages/docs/docs/migration-guides/v6.x upgrade guide.md index 94d83db863..92d0b5240c 100644 --- a/packages/docs/docs/migration-guides/v6.x upgrade guide.md +++ b/packages/docs/docs/migration-guides/v6.x upgrade guide.md @@ -379,3 +379,25 @@ Three new validator-based utility functions are available in `@rjsf/utils`: - `findFieldInSchema(validator: ValidatorType, rootSchema: S, path: string | string[], schema: S, formData?: T, experimental_customMergeAllOf?: Experimental_CustomMergeAllOf): FoundFieldType`: Finds the field specified by the `path` within the root or recursed `schema` - `findSelectedOptionInXxxOf(validator: ValidatorType, rootSchema: S, schema: S, fallbackField: string,xxx: 'anyOf' | 'oneOf', formData?: T, experimental_customMergeAllOf?: Experimental_CustomMergeAllOf): S | undefined`: Finds the option that matches the selector field in the `schema` or undefined if nothing is selected - `getFromSchema(validator: ValidatorType, rootSchema: S, schema: S, path: string | string[], defaultValue: T | S, experimental_customMergeAllOf?: Experimental_CustomMergeAllOf): T | S`: Helper that acts like lodash's `get` but additionally retrieves `$ref`s as needed to get the path for schemas + +### Dynamic UI Schema for Array Items + +RJSF 6.x introduces a new feature that allows dynamic UI schema generation for array items. +The `items` property in a `uiSchema` can now accept a function that returns a UI schema based on the array item's data, index, and form context. + +```typescript +const uiSchema: UiSchema = { + myArrayField: { + items: (itemData, index, formContext) => { + // Return different UI schemas based on item data + if (itemData?.type === 'special') { + return { 'ui:widget': 'textarea' }; + } + return { 'ui:widget': 'text' }; + }, + }, +}; +``` + +This feature is fully backward compatible - existing forms using object-based `uiSchema.items` will continue to work without changes. +See the [Dynamic UI Schema Examples](../api-reference/dynamic-ui-schema-examples.md) documentation for comprehensive examples and usage patterns. diff --git a/packages/fluentui-rc/src/CheckboxWidget/CheckboxWidget.tsx b/packages/fluentui-rc/src/CheckboxWidget/CheckboxWidget.tsx index e2788e54eb..df58ffff5f 100644 --- a/packages/fluentui-rc/src/CheckboxWidget/CheckboxWidget.tsx +++ b/packages/fluentui-rc/src/CheckboxWidget/CheckboxWidget.tsx @@ -48,8 +48,8 @@ export default function CheckboxWidget< const required = schemaRequiresTrueValue(schema); const _onChange = ({ target: { checked } }: ChangeEvent) => onChange(checked); - const _onBlur = ({ target }: FocusEvent) => onBlur(id, target && target.value); - const _onFocus = ({ target }: FocusEvent) => onFocus(id, target && target.value); + const _onBlur = ({ target }: FocusEvent) => onBlur(id, target && target.checked); + const _onFocus = ({ target }: FocusEvent) => onFocus(id, target && target.checked); const description = options.description ?? schema.description; return ( diff --git a/packages/mui/src/CheckboxWidget/CheckboxWidget.tsx b/packages/mui/src/CheckboxWidget/CheckboxWidget.tsx index 6669ac8d29..3c6d73a236 100644 --- a/packages/mui/src/CheckboxWidget/CheckboxWidget.tsx +++ b/packages/mui/src/CheckboxWidget/CheckboxWidget.tsx @@ -1,4 +1,3 @@ -import { FocusEvent } from 'react'; import Checkbox from '@mui/material/Checkbox'; import FormControlLabel from '@mui/material/FormControlLabel'; import { @@ -50,8 +49,8 @@ export default function CheckboxWidget< const required = schemaRequiresTrueValue(schema); const _onChange = (_: any, checked: boolean) => onChange(checked); - const _onBlur = ({ target }: FocusEvent) => onBlur(id, target && target.value); - const _onFocus = ({ target }: FocusEvent) => onFocus(id, target && target.value); + const _onBlur: React.FocusEventHandler = () => onBlur(id, value); + const _onFocus: React.FocusEventHandler = () => onFocus(id, value); const description = options.description ?? schema.description; return ( diff --git a/packages/primereact/src/ArrayFieldTemplate/ArrayFieldTemplate.tsx b/packages/primereact/src/ArrayFieldTemplate/ArrayFieldTemplate.tsx index b6a5f9e68c..babda7c9c9 100644 --- a/packages/primereact/src/ArrayFieldTemplate/ArrayFieldTemplate.tsx +++ b/packages/primereact/src/ArrayFieldTemplate/ArrayFieldTemplate.tsx @@ -65,7 +65,11 @@ export default function ArrayFieldTemplate< required={required} registry={registry} /> -
(schema) ? '' : ' sortable-form-fields'}`}> +
(schema) ? '' : ' sortable-form-fields'}`} + > (schema); - const _onChange = (e: CheckboxChangeEvent) => onChange && onChange(e.checked); - const _onBlur = () => onBlur && onBlur(id, value); - const _onFocus = () => onFocus && onFocus(id, value); const checked = value === 'true' || value === true; + const _onChange = (e: CheckboxChangeEvent) => onChange && onChange(e.checked); + const _onBlur: React.FocusEventHandler = () => onBlur && onBlur(id, value); + const _onFocus: React.FocusEventHandler = () => onFocus && onFocus(id, value); const description = options.description ?? schema.description; const primeProps = (options.prime || {}) as object; diff --git a/packages/primereact/test/__snapshots__/Array.test.tsx.snap b/packages/primereact/test/__snapshots__/Array.test.tsx.snap index b7cbbf1373..fa4a10bd26 100644 --- a/packages/primereact/test/__snapshots__/Array.test.tsx.snap +++ b/packages/primereact/test/__snapshots__/Array.test.tsx.snap @@ -37,15 +37,15 @@ exports[`array fields array 1`] = ` data-pc-section="root" formContext={{}} formData={[]} - id={null} + id="root" onClick={null} style={{}} >
(schema); - const _onChange = (_: FormEvent, data: CheckboxProps) => onChange && onChange(data.checked); - const _onBlur = () => onBlur && onBlur(id, value); - const _onFocus = () => onFocus && onFocus(id, value); const checked = value == 'true' || value == true; + const _onChange = (_: FormEvent, data: CheckboxProps) => onChange && onChange(data.checked); + const _onBlur: React.FocusEventHandler = () => onBlur && onBlur(id, value); + const _onFocus: React.FocusEventHandler = () => onFocus && onFocus(id, value); const description = options.description ?? schema.description; return ( diff --git a/packages/utils/src/createSchemaUtils.ts b/packages/utils/src/createSchemaUtils.ts index d3d1d21571..f588861df9 100644 --- a/packages/utils/src/createSchemaUtils.ts +++ b/packages/utils/src/createSchemaUtils.ts @@ -60,7 +60,7 @@ class SchemaUtils, ) { - if (rootSchema[SCHEMA_KEY] === JSON_SCHEMA_DRAFT_2020_12) { + if (rootSchema && rootSchema[SCHEMA_KEY] === JSON_SCHEMA_DRAFT_2020_12) { this.rootSchema = makeAllReferencesAbsolute(rootSchema, get(rootSchema, ID_KEY, '#')); } else { this.rootSchema = rootSchema; @@ -102,9 +102,12 @@ class SchemaUtils, ): boolean { + // If either validator or rootSchema are falsy, return false to prevent the creation + // of a new SchemaUtilsType with incomplete properties. if (!validator || !rootSchema) { return false; } + return ( this.validator !== validator || !deepEquals(this.rootSchema, rootSchema) || diff --git a/packages/utils/src/getUiOptions.ts b/packages/utils/src/getUiOptions.ts index 3038174dcb..4891cb4571 100644 --- a/packages/utils/src/getUiOptions.ts +++ b/packages/utils/src/getUiOptions.ts @@ -13,6 +13,10 @@ export default function getUiOptions = {}, globalOptions: GlobalUISchemaOptions = {}, ): UIOptionsType { + // Handle null or undefined uiSchema + if (!uiSchema) { + return { ...globalOptions }; + } return Object.keys(uiSchema) .filter((key) => key.indexOf('ui:') === 0) .reduce( diff --git a/packages/utils/src/schema/getDisplayLabel.ts b/packages/utils/src/schema/getDisplayLabel.ts index be5c0afca1..6acdb93561 100644 --- a/packages/utils/src/schema/getDisplayLabel.ts +++ b/packages/utils/src/schema/getDisplayLabel.ts @@ -52,10 +52,10 @@ export default function getDisplayLabel< if (schemaType === 'object') { displayLabel = false; } - if (schemaType === 'boolean' && !uiSchema[UI_WIDGET_KEY]) { + if (schemaType === 'boolean' && uiSchema && !uiSchema[UI_WIDGET_KEY]) { displayLabel = false; } - if (uiSchema[UI_FIELD_KEY]) { + if (uiSchema && uiSchema[UI_FIELD_KEY]) { displayLabel = false; } return displayLabel; diff --git a/packages/utils/src/types.ts b/packages/utils/src/types.ts index 411bc741c7..b4e9138f94 100644 --- a/packages/utils/src/types.ts +++ b/packages/utils/src/types.ts @@ -1005,6 +1005,13 @@ export type UIOptionsType< [key: string]: boolean | number | string | object | any[] | null | undefined; }; +/** + * A utility type that extracts the element type from an array type. + * If the type is not an array, it returns the type itself as a safe fallback. + * Handles both standard arrays and readonly arrays. + */ +export type ArrayElement = A extends readonly (infer E)[] ? E : A; + /** Type describing the well-known properties of the `UiSchema` while also supporting all user defined properties, * starting with `ui:`. */ @@ -1030,6 +1037,13 @@ export type UiSchema< 'ui:fieldReplacesAnyOrOneOf'?: boolean; /** An object that contains all the potential UI options in a single object */ 'ui:options'?: UIOptionsType; + /** The uiSchema for items in an array. Can be an object for a uniform uiSchema across all items (current behavior), + * or a function that returns a dynamic uiSchema based on the item's data and index. + * When using a function, it receives the item data, index, and optionally the form context as parameters. + */ + items?: + | UiSchema, S, F> + | ((itemData: ArrayElement, index: number, formContext?: F) => UiSchema, S, F>); }; /** A `CustomValidator` function takes in a `formData`, `errors` and `uiSchema` objects and returns the given `errors` diff --git a/packages/utils/test/getUiOptions.test.ts b/packages/utils/test/getUiOptions.test.ts index 72bbefb812..483488bd3a 100644 --- a/packages/utils/test/getUiOptions.test.ts +++ b/packages/utils/test/getUiOptions.test.ts @@ -57,6 +57,12 @@ describe('getUiOptions()', () => { it('returns empty options with no uiSchema', () => { expect(getUiOptions()).toEqual({}); }); + it('returns globalOptions when uiSchema is undefined', () => { + expect(getUiOptions(undefined, globalOptions)).toEqual(globalOptions); + }); + it('returns globalOptions when uiSchema is null', () => { + expect(getUiOptions(null as any, globalOptions)).toEqual(globalOptions); + }); it('returns array object as options', () => { expect(getUiOptions(uiSchema.arrayObject, globalOptions)).toEqual(results.arrayObject); expect(consoleErrorSpy).not.toHaveBeenCalled();