Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

fix: Added support for anyOf/oneOf in uiSchema #4055

Merged
merged 1 commit into from
Jan 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading