Skip to content

Commit

Permalink
fix: Added support for anyOf/oneOf in uiSchema
Browse files Browse the repository at this point in the history
Fixes rjsf-team#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
  • Loading branch information
heath-freenome committed Jan 19, 2024
1 parent f31bef1 commit b836735
Show file tree
Hide file tree
Showing 7 changed files with 785 additions and 219 deletions.
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand All @@ -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

Expand Down
40 changes: 34 additions & 6 deletions packages/core/src/components/fields/MultiSchemaField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 */
Expand Down Expand Up @@ -167,7 +170,7 @@ class AnyOfField<T = any, S extends StrictRJSFSchema = RJSFSchema, F extends For
const displayLabel = schemaUtils.getDisplayLabel(schema, uiSchema, globalUiOptions);

const option = selectedOption >= 0 ? retrievedOptions[selectedOption] || null : null;
let optionSchema: S;
let optionSchema: S | undefined | null;

if (option) {
// merge top level required field
Expand All @@ -176,14 +179,39 @@ class AnyOfField<T = any, S extends StrictRJSFSchema = RJSFSchema, F extends For
optionSchema = required ? (mergeSchemas({ required }, option) as S) : option;
}

// First we will check to see if there is an anyOf/oneOf override for the UI schema
let optionsUiSchema: UiSchema<T, S, F>[] = [];
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<T, S, F>(optionsUiSchema[index]);
return {
label: uiTitle || translateString(translateEnum, translateParams.concat(String(index + 1))),
value: index,
};
});

return (
<div className='panel panel-default panel-body'>
Expand All @@ -210,7 +238,7 @@ class AnyOfField<T = any, S extends StrictRJSFSchema = RJSFSchema, F extends For
hideLabel={!displayLabel}
/>
</div>
{option !== null && <_SchemaField {...this.props} schema={optionSchema!} />}
{optionSchema && <_SchemaField {...this.props} schema={optionSchema} uiSchema={optionUiSchema} />}
</div>
);
}
Expand Down
177 changes: 144 additions & 33 deletions packages/core/test/anyOf.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -1262,50 +1313,110 @@ 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' },
},
},
},
};

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', () => {
Expand Down

0 comments on commit b836735

Please sign in to comment.