From b836735983e412a101d862bc2f27e45d2e85745f Mon Sep 17 00:00:00 2001 From: Heath Chiavettone Date: Fri, 19 Jan 2024 15:39:59 -0800 Subject: [PATCH 1/2] fix: Added support for anyOf/oneOf in uiSchema Fixes #4039 by updating `MultiSchemaField` to properly support `anyOf`/`oneOf` arrays in the `uiSchema` - In `@rjsf/utils`: Improved documentation and typescript ignores in tests related to `base64` from previous PR - In `@rjsf/core`: Updated `MultiSchemaField` to support `anyOf`/`oneOf` arrays in the `uiSchema` - Updated the tests to verify the new feature - In `docs`: Added documentation to the `uiSchema.md` file describing how to use the new feature - Updated the `CHANGELOG.md` accordingly --- CHANGELOG.md | 5 + .../components/fields/MultiSchemaField.tsx | 40 +- packages/core/test/anyOf.test.jsx | 177 ++++- packages/core/test/oneOf.test.jsx | 686 +++++++++++++----- packages/docs/docs/api-reference/uiSchema.md | 84 +++ packages/utils/src/base64.ts | 6 +- packages/utils/test/base64.test.ts | 6 +- 7 files changed, 785 insertions(+), 219 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b059aa5e1e..c4ddfb47fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,10 @@ should change the heading of the (upcoming) version to include a major version b # 5.16.2 +## @rjsf/core + +- Added support for `anyOf`/`oneOf` in `uiSchema`s in the `MultiSchemaField`, fixing [#4039](https://github.com/rjsf-team/react-jsonschema-form/issues/4039) + ## @rjsf/utils - [4024](https://github.com/rjsf-team/react-jsonschema-form/issues/4024) Added `base64` to support `encoding` @@ -27,6 +31,7 @@ should change the heading of the (upcoming) version to include a major version b - [4024](https://github.com/rjsf-team/react-jsonschema-form/issues/4024) Updated the base64 references from (`atob` and `btoa`) to invoke the functions from the new `base64` object in `@rjsf/utils`. +- Updated the `uiSchema.md` documentation to describe how to use the new `anyOf`/`oneOf` support # 5.16.1 diff --git a/packages/core/src/components/fields/MultiSchemaField.tsx b/packages/core/src/components/fields/MultiSchemaField.tsx index cf5d4600cd..c58b080424 100644 --- a/packages/core/src/components/fields/MultiSchemaField.tsx +++ b/packages/core/src/components/fields/MultiSchemaField.tsx @@ -3,6 +3,7 @@ import get from 'lodash/get'; import isEmpty from 'lodash/isEmpty'; import omit from 'lodash/omit'; import { + ANY_OF_KEY, deepEquals, ERRORS_KEY, FieldProps, @@ -11,9 +12,11 @@ import { getUiOptions, getWidget, mergeSchemas, + ONE_OF_KEY, RJSFSchema, StrictRJSFSchema, TranslatableString, + UiSchema, } from '@rjsf/utils'; /** Type used for the state of the `AnyOfField` component */ @@ -167,7 +170,7 @@ class AnyOfField= 0 ? retrievedOptions[selectedOption] || null : null; - let optionSchema: S; + let optionSchema: S | undefined | null; if (option) { // merge top level required field @@ -176,14 +179,39 @@ class AnyOfField[] = []; + if (ONE_OF_KEY in schema && uiSchema && ONE_OF_KEY in uiSchema) { + if (Array.isArray(uiSchema[ONE_OF_KEY])) { + optionsUiSchema = uiSchema[ONE_OF_KEY]; + } else { + console.warn(`uiSchema.oneOf is not an array for "${title || name}"`); + } + } else if (ANY_OF_KEY in schema && uiSchema && ANY_OF_KEY in uiSchema) { + if (Array.isArray(uiSchema[ANY_OF_KEY])) { + optionsUiSchema = uiSchema[ANY_OF_KEY]; + } else { + console.warn(`uiSchema.anyOf is not an array for "${title || name}"`); + } + } + // Then we pick the one that matches the selected option index, if one exists otherwise default to the main uiSchema + let optionUiSchema = uiSchema; + if (selectedOption >= 0 && optionsUiSchema.length > selectedOption) { + optionUiSchema = optionsUiSchema[selectedOption]; + } + const translateEnum: TranslatableString = title ? TranslatableString.TitleOptionPrefix : TranslatableString.OptionPrefix; const translateParams = title ? [title] : []; - const enumOptions = retrievedOptions.map((opt: { title?: string }, index: number) => ({ - label: opt.title || translateString(translateEnum, translateParams.concat(String(index + 1))), - value: index, - })); + const enumOptions = retrievedOptions.map((opt: { title?: string }, index: number) => { + // Also see if there is an override title in the uiSchema for each option, otherwise use the title from the option + const { title: uiTitle = opt.title } = getUiOptions(optionsUiSchema[index]); + return { + label: uiTitle || translateString(translateEnum, translateParams.concat(String(index + 1))), + value: index, + }; + }); return (
@@ -210,7 +238,7 @@ class AnyOfField
- {option !== null && <_SchemaField {...this.props} schema={optionSchema!} />} + {optionSchema && <_SchemaField {...this.props} schema={optionSchema} uiSchema={optionUiSchema} />} ); } diff --git a/packages/core/test/anyOf.test.jsx b/packages/core/test/anyOf.test.jsx index 5e15171294..ca213e3626 100644 --- a/packages/core/test/anyOf.test.jsx +++ b/packages/core/test/anyOf.test.jsx @@ -57,8 +57,6 @@ describe('anyOf', () => { schema, }); - console.log(node.innerHTML); - expect(node.querySelectorAll('select')).to.have.length.of(1); expect(node.querySelector('select').id).eql('root__anyof_select'); expect(node.querySelectorAll('span.required')).to.have.length.of(1); @@ -92,8 +90,6 @@ describe('anyOf', () => { schema, }); - console.log(node.innerHTML); - expect(node.querySelectorAll('select')).to.have.length.of(1); expect(node.querySelector('select').id).eql('root__anyof_select'); expect(node.querySelectorAll('span.required')).to.have.length.of(2); @@ -1139,6 +1135,61 @@ describe('anyOf', () => { Simulate.change(strInputs[1], { target: { value: 'bar' } }); expect(strInputs[1].value).eql('bar'); }); + it('should correctly render mixed types for anyOf inside array items', () => { + const schema = { + type: 'object', + properties: { + items: { + type: 'array', + items: { + anyOf: [ + { + type: 'string', + }, + { + type: 'object', + properties: { + foo: { + type: 'integer', + }, + bar: { + type: 'string', + }, + }, + }, + ], + }, + }, + }, + }; + + const { node } = createFormComponent({ + schema, + }); + + expect(node.querySelector('.array-item-add button')).not.eql(null); + + Simulate.click(node.querySelector('.array-item-add button')); + + const $select = node.querySelector('select'); + expect($select).not.eql(null); + Simulate.change($select, { + target: { value: $select.options[1].value }, + }); + + expect(node.querySelectorAll('input#root_items_0_foo')).to.have.length.of(1); + expect(node.querySelectorAll('input#root_items_0_bar')).to.have.length.of(1); + }); + }); + + describe('definitions', () => { + beforeEach(() => { + sandbox = createSandbox(); + sandbox.stub(console, 'warn'); + }); + afterEach(() => { + sandbox.restore(); + }); it('should correctly set the label of the options', () => { const schema = { @@ -1262,29 +1313,30 @@ describe('anyOf', () => { expect($select.options[2].text).eql('Baz'); }); - it('should correctly render mixed types for anyOf inside array items', () => { + it('should correctly set the label of the options, with uiSchema-based titles, for each anyOf option', () => { const schema = { type: 'object', - properties: { - items: { - type: 'array', - items: { - anyOf: [ - { - type: 'string', - }, - { - type: 'object', - properties: { - foo: { - type: 'integer', - }, - bar: { - type: 'string', - }, - }, - }, - ], + anyOf: [ + { + title: 'Foo', + properties: { + foo: { type: 'string' }, + }, + }, + { + properties: { + bar: { type: 'string' }, + }, + }, + { + $ref: '#/definitions/baz', + }, + ], + definitions: { + baz: { + title: 'Baz', + properties: { + baz: { type: 'string' }, }, }, }, @@ -1292,20 +1344,79 @@ describe('anyOf', () => { const { node } = createFormComponent({ schema, + uiSchema: { + anyOf: [ + { + 'ui:title': 'Custom foo', + }, + { + 'ui:title': 'Custom bar', + }, + { + 'ui:title': 'Custom baz', + }, + ], + }, }); + const $select = node.querySelector('select'); - expect(node.querySelector('.array-item-add button')).not.eql(null); + expect($select.options[0].text).eql('Custom foo'); + expect($select.options[1].text).eql('Custom bar'); + expect($select.options[2].text).eql('Custom baz'); - Simulate.click(node.querySelector('.array-item-add button')); + // Also verify the uiSchema was passed down to the underlying widget by confirming the lable (in the legend) + // matches the selected option's title + expect($select.value).eql('0'); + const inputLabel = node.querySelector('legend#root__title'); + expect(inputLabel.innerHTML).eql($select.options[$select.value].text); + }); - const $select = node.querySelector('select'); - expect($select).not.eql(null); - Simulate.change($select, { - target: { value: $select.options[1].value }, + it('should warn when the anyOf in the uiSchema is not an array, and pass the base uiSchema down', () => { + const schema = { + type: 'object', + anyOf: [ + { + title: 'Foo', + properties: { + foo: { type: 'string' }, + }, + }, + { + properties: { + bar: { type: 'string' }, + }, + }, + { + $ref: '#/definitions/baz', + }, + ], + definitions: { + baz: { + title: 'Baz', + properties: { + baz: { type: 'string' }, + }, + }, + }, + }; + + const { node } = createFormComponent({ + schema, + uiSchema: { + 'ui:title': 'My Title', + anyOf: { 'ui:title': 'UiSchema title' }, + }, }); - expect(node.querySelectorAll('input#root_items_0_foo')).to.have.length.of(1); - expect(node.querySelectorAll('input#root_items_0_bar')).to.have.length.of(1); + expect(console.warn.calledWithMatch(/uiSchema.anyOf is not an array for "My Title"/)).to.be.true; + + const $select = node.querySelector('select'); + + // Also verify the base uiSchema was passed down to the underlying widget by confirming the label (in the legend) + // matches the selected option's title + expect($select.value).eql('0'); + const inputLabel = node.querySelector('legend#root__title'); + expect(inputLabel.innerHTML).eql('My Title'); }); it('should correctly infer the selected option based on value', () => { diff --git a/packages/core/test/oneOf.test.jsx b/packages/core/test/oneOf.test.jsx index 45ae7301dd..3f11c921bc 100644 --- a/packages/core/test/oneOf.test.jsx +++ b/packages/core/test/oneOf.test.jsx @@ -58,8 +58,6 @@ describe('oneOf', () => { schema, }); - console.log(node.innerHTML); - expect(node.querySelectorAll('select')).to.have.length.of(1); expect(node.querySelector('select').id).eql('root__oneof_select'); expect(node.querySelectorAll('span.required')).to.have.length.of(1); @@ -93,8 +91,6 @@ describe('oneOf', () => { schema, }); - console.log(node.innerHTML); - expect(node.querySelectorAll('select')).to.have.length.of(1); expect(node.querySelector('select').id).eql('root__oneof_select'); expect(node.querySelectorAll('span.required')).to.have.length.of(2); @@ -871,9 +867,165 @@ describe('oneOf', () => { expect(node.querySelectorAll('input#root_items_0_foo')).to.have.length.of(1); expect(node.querySelectorAll('input#root_items_0_bar')).to.have.length.of(1); }); + + it('should not change the selected option when switching order of items for oneOf inside array items', () => { + const schema = { + type: 'object', + properties: { + items: { + type: 'array', + items: { + type: 'object', + oneOf: [ + { + properties: { + foo: { + type: 'string', + }, + }, + }, + { + properties: { + bar: { + type: 'string', + }, + }, + }, + ], + }, + }, + }, + }; + + const { node } = createFormComponent({ + schema, + formData: { + items: [ + {}, + { + bar: 'defaultbar', + }, + ], + }, + }); + + let selects = node.querySelectorAll('select'); + expect(selects[0].value).eql('0'); + expect(selects[1].value).eql('1'); + + const moveUpBtns = node.querySelectorAll('.array-item-move-up'); + Simulate.click(moveUpBtns[1]); + + selects = node.querySelectorAll('select'); + expect(selects[0].value).eql('1'); + expect(selects[1].value).eql('0'); + }); + + it('should correctly update inputs for oneOf inside array items after being moved down', () => { + const schema = { + type: 'object', + properties: { + items: { + type: 'array', + items: { + type: 'object', + oneOf: [ + { + properties: { + foo: { + type: 'string', + }, + }, + }, + { + properties: { + bar: { + type: 'string', + }, + }, + }, + ], + }, + }, + }, + }; + + const { node } = createFormComponent({ + schema, + formData: { + items: [{}, {}], + }, + }); + + const moveDownBtns = node.querySelectorAll('.array-item-move-down'); + Simulate.click(moveDownBtns[0]); + + const strInputs = node.querySelectorAll('fieldset .field-string input[type=text]'); + + Simulate.change(strInputs[1], { target: { value: 'bar' } }); + expect(strInputs[1].value).eql('bar'); + }); + + it('should infer the value of an array with nested oneOfs properly', () => { + // From https://github.com/rjsf-team/react-jsonschema-form/issues/2944 + const schema = { + type: 'array', + items: { + oneOf: [ + { + properties: { + lorem: { + type: 'string', + }, + }, + required: ['lorem'], + }, + { + properties: { + ipsum: { + oneOf: [ + { + properties: { + day: { + type: 'string', + }, + }, + }, + { + properties: { + night: { + type: 'string', + }, + }, + }, + ], + }, + }, + required: ['ipsum'], + }, + ], + }, + }; + const { node } = createFormComponent({ + schema, + formData: [{ ipsum: { night: 'nicht' } }], + }); + const outerOneOf = node.querySelector('select#root_0__oneof_select'); + expect(outerOneOf.value).eql('1'); + const innerOneOf = node.querySelector('select#root_0_ipsum__oneof_select'); + expect(innerOneOf.value).eql('1'); + }); }); describe('definitions', () => { + beforeEach(() => { + sandbox = createSandbox(); + sandbox.stub(console, 'warn'); + }); + afterEach(() => { + sandbox.restore(); + }); + it('should handle the $ref keyword correctly', () => { const schema = { definitions: { @@ -1055,226 +1207,412 @@ describe('oneOf', () => { expect($select.options[1].text).eql('My Title option 2'); expect($select.options[2].text).eql('Baz'); }); - }); - it('should correctly infer the selected option based on value', () => { - const schema = { - $ref: '#/definitions/any', - definitions: { - chain: { - type: 'object', - title: 'Chain', - properties: { - id: { - enum: ['chain'], + it('should correctly set the label of the options, with uiSchema-based titles, for each oneOf option', () => { + const schema = { + type: 'object', + oneOf: [ + { + title: 'Foo', + properties: { + foo: { type: 'string' }, }, - components: { - type: 'array', - items: { $ref: '#/definitions/any' }, + }, + { + properties: { + bar: { type: 'string' }, }, }, - }, - - map: { - type: 'object', - title: 'Map', - properties: { - id: { enum: ['map'] }, - fn: { $ref: '#/definitions/any' }, + { + $ref: '#/definitions/baz', }, - }, - - to_absolute: { - type: 'object', - title: 'To Absolute', - properties: { - id: { enum: ['to_absolute'] }, - base_url: { type: 'string' }, + ], + definitions: { + baz: { + title: 'Baz', + properties: { + baz: { type: 'string' }, + }, }, }, + }; - transform: { - type: 'object', - title: 'Transform', - properties: { - id: { enum: ['transform'] }, - property_key: { type: 'string' }, - transformer: { $ref: '#/definitions/any' }, - }, - }, - any: { + const { node } = createFormComponent({ + schema, + uiSchema: { oneOf: [ - { $ref: '#/definitions/chain' }, - { $ref: '#/definitions/map' }, - { $ref: '#/definitions/to_absolute' }, - { $ref: '#/definitions/transform' }, + { + 'ui:title': 'Custom foo', + }, + { + 'ui:title': 'Custom bar', + }, + { + 'ui:title': 'Custom baz', + }, ], }, - }, - }; + }); + const $select = node.querySelector('select'); - const { node } = createFormComponent({ - schema, - formData: { - id: 'chain', - components: [ + expect($select.options[0].text).eql('Custom foo'); + expect($select.options[1].text).eql('Custom bar'); + expect($select.options[2].text).eql('Custom baz'); + + // Also verify the uiSchema was passed down to the underlying widget by confirming the lable (in the legend) + // matches the selected option's title + expect($select.value).eql('0'); + const inputLabel = node.querySelector('legend#root__title'); + expect(inputLabel.innerHTML).eql($select.options[$select.value].text); + }); + + it('should warn when the oneOf in the uiSchema is not an array, and pass the base uiSchema down', () => { + const schema = { + type: 'object', + oneOf: [ { - id: 'map', - fn: { - id: 'transform', - property_key: 'uri', - transformer: { - id: 'to_absolute', - base_url: 'http://localhost', - }, + title: 'Foo', + properties: { + foo: { type: 'string' }, }, }, + { + properties: { + bar: { type: 'string' }, + }, + }, + { + $ref: '#/definitions/baz', + }, ], - }, + definitions: { + baz: { + title: 'Baz', + properties: { + baz: { type: 'string' }, + }, + }, + }, + }; + + const { node } = createFormComponent({ + schema, + uiSchema: { + 'ui:title': 'My Title', + oneOf: { 'ui:title': 'UiSchema title' }, + }, + }); + + expect(console.warn.calledWithMatch(/uiSchema.oneOf is not an array for "My Title"/)).to.be.true; + + const $select = node.querySelector('select'); + + // Also verify the base uiSchema was passed down to the underlying widget by confirming the label (in the legend) + // matches the selected option's title + expect($select.value).eql('0'); + const inputLabel = node.querySelector('legend#root__title'); + expect(inputLabel.innerHTML).eql('My Title'); }); - const rootId = node.querySelector('select#root_id'); - expect(getSelectedOptionValue(rootId)).eql('chain'); - const componentId = node.querySelector('select#root_components_0_id'); - expect(getSelectedOptionValue(componentId)).eql('map'); + it('should correctly render mixed types for oneOf inside array items', () => { + const schema = { + type: 'object', + properties: { + items: { + type: 'array', + items: { + oneOf: [ + { + type: 'string', + }, + { + type: 'object', + properties: { + foo: { + type: 'integer', + }, + bar: { + type: 'string', + }, + }, + }, + ], + }, + }, + }, + }; - const fnId = node.querySelector('select#root_components_0_fn_id'); - expect(getSelectedOptionValue(fnId)).eql('transform'); + const { node } = createFormComponent({ + schema, + }); - const transformerId = node.querySelector('select#root_components_0_fn_transformer_id'); - expect(getSelectedOptionValue(transformerId)).eql('to_absolute'); - }); + expect(node.querySelector('.array-item-add button')).not.eql(null); - it('should infer the value of an array with nested oneOfs properly', () => { - // From https://github.com/rjsf-team/react-jsonschema-form/issues/2944 - const schema = { - type: 'array', - items: { - oneOf: [ - { + Simulate.click(node.querySelector('.array-item-add button')); + + const $select = node.querySelector('select'); + expect($select).not.eql(null); + Simulate.change($select, { + target: { value: $select.options[1].value }, + }); + + expect(node.querySelectorAll('input#root_items_0_foo')).to.have.length.of(1); + expect(node.querySelectorAll('input#root_items_0_bar')).to.have.length.of(1); + }); + + it('should correctly infer the selected option based on value', () => { + const schema = { + $ref: '#/definitions/any', + definitions: { + chain: { + type: 'object', + title: 'Chain', properties: { - lorem: { - type: 'string', + id: { + enum: ['chain'], + }, + components: { + type: 'array', + items: { $ref: '#/definitions/any' }, }, }, - required: ['lorem'], }, - { + + map: { + type: 'object', + title: 'Map', properties: { - ipsum: { - oneOf: [ - { - properties: { - day: { - type: 'string', + id: { enum: ['map'] }, + fn: { $ref: '#/definitions/any' }, + }, + }, + + to_absolute: { + type: 'object', + title: 'To Absolute', + properties: { + id: { enum: ['to_absolute'] }, + base_url: { type: 'string' }, + }, + }, + + transform: { + type: 'object', + title: 'Transform', + properties: { + id: { enum: ['transform'] }, + property_key: { type: 'string' }, + transformer: { $ref: '#/definitions/any' }, + }, + }, + any: { + oneOf: [ + { $ref: '#/definitions/chain' }, + { $ref: '#/definitions/map' }, + { $ref: '#/definitions/to_absolute' }, + { $ref: '#/definitions/transform' }, + ], + }, + }, + }; + + const { node } = createFormComponent({ + schema, + formData: { + id: 'chain', + components: [ + { + id: 'map', + fn: { + id: 'transform', + property_key: 'uri', + transformer: { + id: 'to_absolute', + base_url: 'http://localhost', + }, + }, + }, + ], + }, + }); + + const rootId = node.querySelector('select#root_id'); + expect(getSelectedOptionValue(rootId)).eql('chain'); + const componentId = node.querySelector('select#root_components_0_id'); + expect(getSelectedOptionValue(componentId)).eql('map'); + + const fnId = node.querySelector('select#root_components_0_fn_id'); + expect(getSelectedOptionValue(fnId)).eql('transform'); + + const transformerId = node.querySelector('select#root_components_0_fn_transformer_id'); + expect(getSelectedOptionValue(transformerId)).eql('to_absolute'); + }); + + it('should update formData to remove unnecessary data when oneOf option changes', () => { + const schema = { + title: 'UFO Sightings', + type: 'object', + required: ['craftTypes'], + properties: { + craftTypes: { + type: 'array', + minItems: 1, + uniqueItems: true, + title: 'Type of UFO', + items: { + oneOf: [ + { + title: 'Cigar Shaped', + type: 'object', + required: ['daysOfYear'], + properties: { + name: { + type: 'string', + title: 'What do you call it?', + }, + daysOfYear: { + type: 'array', + minItems: 1, + uniqueItems: true, + title: 'What days of the year did you see it?', + items: { + type: 'number', + title: 'Day', }, }, }, - { - properties: { - night: { + }, + { + title: 'Round', + type: 'object', + required: ['keywords'], + properties: { + title: { + type: 'string', + title: 'What should we call it?', + }, + keywords: { + type: 'array', + minItems: 1, + uniqueItems: true, + title: 'List of keywords related to the sighting', + items: { type: 'string', + title: 'Keyword', }, }, }, - ], - }, + }, + ], }, - required: ['ipsum'], }, - ], - }, - }; - const { node } = createFormComponent({ - schema, - formData: [{ ipsum: { night: 'nicht' } }], + }, + }; + const { node, onChange } = createFormComponent({ + schema, + }); + + // Added an empty array initially + sinon.assert.calledWithMatch(onChange.lastCall, { + formData: { craftTypes: [{ daysOfYear: [undefined] }] }, + }); + + const select = node.querySelector('select#root_craftTypes_0__oneof_select'); + + Simulate.change(select, { + target: { value: select.options[1].value }, + }); + + sinon.assert.calledWithMatch(onChange.lastCall, { + formData: { + craftTypes: [{ keywords: [undefined], title: undefined, daysOfYear: undefined }], + }, + }); }); - const outerOneOf = node.querySelector('select#root_0__oneof_select'); - expect(outerOneOf.value).eql('1'); - const innerOneOf = node.querySelector('select#root_0_ipsum__oneof_select'); - expect(innerOneOf.value).eql('1'); }); - it('should update formData to remove unnecessary data when one of option changes', () => { + + describe('hideError works with oneOf', () => { const schema = { - title: 'UFO Sightings', type: 'object', - required: ['craftTypes'], properties: { - craftTypes: { - type: 'array', - minItems: 1, - uniqueItems: true, - title: 'Type of UFO', - items: { - oneOf: [ - { - title: 'Cigar Shaped', - type: 'object', - required: ['daysOfYear'], - properties: { - name: { - type: 'string', - title: 'What do you call it?', - }, - daysOfYear: { - type: 'array', - minItems: 1, - uniqueItems: true, - title: 'What days of the year did you see it?', - items: { - type: 'number', - title: 'Day', - }, - }, - }, - }, - { - title: 'Round', - type: 'object', - required: ['keywords'], - properties: { - title: { - type: 'string', - title: 'What should we call it?', - }, - keywords: { - type: 'array', - minItems: 1, - uniqueItems: true, - title: 'List of keywords related to the sighting', - items: { - type: 'string', - title: 'Keyword', - }, - }, - }, - }, - ], - }, + userId: { + oneOf: [ + { + type: 'number', + }, + { + type: 'string', + }, + ], }, }, }; - const { node, onChange } = createFormComponent({ - schema, - }); + function customValidate(formData, errors) { + errors.userId.addError('test'); + return errors; + } - // Added an empty array initially - sinon.assert.calledWithMatch(onChange.lastCall, { - formData: { craftTypes: [{ daysOfYear: [undefined] }] }, - }); + it('should show error on options with different types', () => { + const { node } = createFormComponent({ + schema, + customValidate, + }); - const select = node.querySelector('select#root_craftTypes_0__oneof_select'); + Simulate.change(node.querySelector('input#root_userId'), { + target: { value: 12345 }, + }); + Simulate.submit(node); - Simulate.change(select, { - target: { value: select.options[1].value }, + let inputs = node.querySelectorAll('.form-group.field-error input[type=number]'); + expect(inputs[0].id).eql('root_userId'); + + const $select = node.querySelector('select'); + + Simulate.change($select, { + target: { value: $select.options[1].value }, + }); + + Simulate.change(node.querySelector('input#root_userId'), { + target: { value: 'Lorem ipsum dolor sit amet' }, + }); + Simulate.submit(node); + + inputs = node.querySelectorAll('.form-group.field-error input[type=text]'); + expect(inputs[0].id).eql('root_userId'); }); + it('should NOT show error on options with different types when hideError: true', () => { + const { node } = createFormComponent({ + schema, + uiSchema: { + 'ui:hideError': true, + }, + customValidate, + }); - sinon.assert.calledWithMatch(onChange.lastCall, { - formData: { - craftTypes: [{ keywords: [undefined], title: undefined, daysOfYear: undefined }], - }, + Simulate.change(node.querySelector('input#root_userId'), { + target: { value: 12345 }, + }); + Simulate.submit(node); + + let inputs = node.querySelectorAll('.form-group.field-error input[type=number]'); + expect(inputs).to.have.length.of(0); + + const $select = node.querySelector('select'); + + Simulate.change($select, { + target: { value: $select.options[1].value }, + }); + + Simulate.change(node.querySelector('input#root_userId'), { + target: { value: 'Lorem ipsum dolor sit amet' }, + }); + Simulate.submit(node); + + inputs = node.querySelectorAll('.form-group.field-error input[type=text]'); + expect(inputs).to.have.length.of(0); }); }); + describe('OpenAPI discriminator support', () => { const schema = { type: 'object', diff --git a/packages/docs/docs/api-reference/uiSchema.md b/packages/docs/docs/api-reference/uiSchema.md index 1787a4804f..4250b61837 100644 --- a/packages/docs/docs/api-reference/uiSchema.md +++ b/packages/docs/docs/api-reference/uiSchema.md @@ -454,6 +454,90 @@ const uiSchema = { }; ``` +## Using uiSchema with oneOf, anyOf + +### anyOf + +The uiSchema will work with elements inside an `anyOf` as long as the uiSchema defines the `anyOf` key at the same level as the `anyOf` within the `schema`. +Because the `anyOf` in the `schema` is an array, so must be the one in the `uiSchema`. +If you want to override the titles of the first two elements within the `anyOf` list you would do the following: + +```ts +import { RJSFSchema, UiSchema } from '@rjsf/utils'; + +const schema: RJSFSchema = { + type: 'object', + anyOf: [ + { + title: 'Strings', + type: 'string', + }, + { + title: 'Numbers', + type: 'number', + }, + { + title: 'Booleans', + type: 'boolean', + }, + ], +}; + +const uiSchema: UiSchema = { + anyOf: [ + { + 'ui:title': 'Custom String Title', + }, + { + 'ui:title': 'Custom Number Title', + }, + ], +}; +``` + +> NOTE: Because the third element in the `schema` does not have an associated element in the `uiSchema`, it will keep its original title. + +### oneOf + +The uiSchema will work with elements inside an `oneOf` as long as the uiSchema defines the `oneOf` key at the same level as the `oneOf` within the `schema`. +Because the `oneOf` in the `schema` is an array, so must be the one in the `uiSchema`. +If you want to override the titles of the first two elements within the `oneOf` list you would do the following: + +```ts +import { RJSFSchema, UiSchema } from '@rjsf/utils'; + +const schema: RJSFSchema = { + type: 'object', + oneOf: [ + { + title: 'Strings', + type: 'string', + }, + { + title: 'Numbers', + type: 'number', + }, + { + title: 'Booleans', + type: 'boolean', + }, + ], +}; + +const uiSchema: UiSchema = { + oneOf: [ + { + 'ui:title': 'Custom String Title', + }, + { + 'ui:title': 'Custom Number Title', + }, + ], +}; +``` + +> NOTE: Because the third element in the `schema` does not have an associated element in the `uiSchema`, it will keep its original title. + ## Theme Options - [AntD Customization](themes/antd/uiSchema.md) diff --git a/packages/utils/src/base64.ts b/packages/utils/src/base64.ts index 2540cc682f..23f0f3a3b2 100644 --- a/packages/utils/src/base64.ts +++ b/packages/utils/src/base64.ts @@ -1,6 +1,8 @@ /** - * An object that provides base64 encoding and decoding functions using the utf-8 charset to support the characters outside the latin1 range - * By default, btoa() and atob() only support the latin1 character range. + * An object that provides base64 encoding and decoding functions using the utf-8 charset to support the characters + * outside the latin1 range. By default, btoa() and atob() only support the latin1 character range. + * + * This is built as an on-the-fly executed function to support testing the node vs browser implementations */ const base64 = (function () { // If we are in the browser, we can use the built-in TextEncoder and TextDecoder diff --git a/packages/utils/test/base64.test.ts b/packages/utils/test/base64.test.ts index 6eedfc1825..2131c730c5 100644 --- a/packages/utils/test/base64.test.ts +++ b/packages/utils/test/base64.test.ts @@ -45,11 +45,9 @@ describe('browser behavior', () => { }); // restore the TextEncoder and TextDecoder to undefined afterAll(() => { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore + // @ts-expect-error The TextEncoder type is not allowed to be undefined, but we do need to do it for tests global.TextEncoder = undefined; - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore + // @ts-expect-error The TextDecoder type is not allowed to be undefined, but we do need to do it for tests global.TextDecoder = undefined; }); it('should successfully create a base64 object and encode/decode string in browser', () => { From e15bccc0ecd110b618ba8154cfcab12d50ad1e2a Mon Sep 17 00:00:00 2001 From: Heath Chiavettone Date: Fri, 26 Jan 2024 14:12:33 -0800 Subject: [PATCH 2/2] Fix checkbox with 0 as a value was unselectable in antd Fixed #4067 by properly dealing with enums that have 0 as a value - In `@rjsf/utils`: Updated `enumOptionsValueForIndex()` to filter against `emptyValue` rather than just truthy - Updated the tests to verify the bug and then validate the fix - Updated the `CHANGELOG.md` accordingly --- CHANGELOG.md | 7 +++---- packages/utils/src/enumOptionsValueForIndex.ts | 7 ++++++- packages/utils/test/enumOptionsValueForIndex.test.ts | 6 +++++- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c4ddfb47fd..5417256288 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,13 +24,12 @@ should change the heading of the (upcoming) version to include a major version b ## @rjsf/utils -- [4024](https://github.com/rjsf-team/react-jsonschema-form/issues/4024) Added `base64` to support `encoding` - and `decoding` using the `UTF-8` charset to support the characters out of the `Latin1` range. +- [#4024](https://github.com/rjsf-team/react-jsonschema-form/issues/4024) Added `base64` to support `encoding` and `decoding` using the `UTF-8` charset to support the characters out of the `Latin1` range. +- Updated `enumOptionsValueForIndex()` to fix issue that filtered enum options with a value that was 0, fixing [#4067](https://github.com/rjsf-team/react-jsonschema-form/issues/4067) ## Dev / docs / playground -- [4024](https://github.com/rjsf-team/react-jsonschema-form/issues/4024) Updated the base64 references from (`atob` - and `btoa`) to invoke the functions from the new `base64` object in `@rjsf/utils`. +- [#4024](https://github.com/rjsf-team/react-jsonschema-form/issues/4024) Updated the base64 references from (`atob` and `btoa`) to invoke the functions from the new `base64` object in `@rjsf/utils`. - Updated the `uiSchema.md` documentation to describe how to use the new `anyOf`/`oneOf` support # 5.16.1 diff --git a/packages/utils/src/enumOptionsValueForIndex.ts b/packages/utils/src/enumOptionsValueForIndex.ts index 1bfdf69ba8..5c02e2ddfa 100644 --- a/packages/utils/src/enumOptionsValueForIndex.ts +++ b/packages/utils/src/enumOptionsValueForIndex.ts @@ -17,7 +17,12 @@ export default function enumOptionsValueForIndex['value'] ): EnumOptionsType['value'] | EnumOptionsType['value'][] | undefined { if (Array.isArray(valueIndex)) { - return valueIndex.map((index) => enumOptionsValueForIndex(index, allEnumOptions)).filter((val) => val); + return ( + valueIndex + .map((index) => enumOptionsValueForIndex(index, allEnumOptions)) + // Since the recursive call returns `emptyValue` when we get a bad option, only filter those out + .filter((val) => val !== emptyValue) + ); } // So Number(null) and Number('') both return 0, so use emptyValue for those two values const index = valueIndex === '' || valueIndex === null ? -1 : Number(valueIndex); diff --git a/packages/utils/test/enumOptionsValueForIndex.test.ts b/packages/utils/test/enumOptionsValueForIndex.test.ts index cadb3e16f2..0c56cf26d9 100644 --- a/packages/utils/test/enumOptionsValueForIndex.test.ts +++ b/packages/utils/test/enumOptionsValueForIndex.test.ts @@ -1,5 +1,5 @@ import { enumOptionsValueForIndex } from '../src'; -import { ALL_OPTIONS } from './testUtils/testData'; +import { ALL_OPTIONS, FALSY_OPTIONS } from './testUtils/testData'; const EMPTY_VALUE = 'empty'; @@ -38,4 +38,8 @@ describe('enumOptionsValueForIndex()', () => { const expected = [ALL_OPTIONS[2].value, ALL_OPTIONS[1].value]; expect(enumOptionsValueForIndex([2, 1], ALL_OPTIONS)).toEqual(expected); }); + it('keeps falsy values in the options', () => { + const expected = [FALSY_OPTIONS[1].value]; + expect(enumOptionsValueForIndex([1], FALSY_OPTIONS)).toEqual(expected); + }); });