Skip to content

Commit f1640b5

Browse files
Feature: expanded FallbackField for object and array (#4857)
1 parent bdc063b commit f1640b5

File tree

8 files changed

+137
-48
lines changed

8 files changed

+137
-48
lines changed

CHANGELOG.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,10 @@ should change the heading of the (upcoming) version to include a major version b
3030
## @rjsf/core
3131

3232
- Fixed duplicate label and description rendering in `CheckboxWidget` by conditionally rendering them based on widget type
33-
- Updated `CheckboxWidget` to handle label and description rendering consistently
34-
- Modified `FieldTemplate` to skip label and description rendering for checkbox widgets, fixing ([#4742](https://github.com/rjsf-team/react-jsonschema-form/issues/4742))
33+
- Updated `CheckboxWidget` to handle label and description rendering consistently
34+
- Modified `FieldTemplate` to skip label and description rendering for checkbox widgets, fixing ([#4742](https://github.com/rjsf-team/react-jsonschema-form/issues/4742))
3535
- Updated `ObjectField` to change the removal of an additional property to defer the work to the `processPendingChange()` handler in `Form`, fixing [#4850](https://github.com/rjsf-team/react-jsonschema-form/issues/4850)
36+
- Updated `FallbackField` to support `object` and `array` types, and improved `ArrayField` so that it handles missing items properly with the fallback field
3637

3738
## @rjsf/fluentui-rc
3839

@@ -66,7 +67,6 @@ should change the heading of the (upcoming) version to include a major version b
6667
## @rjsf/utils
6768

6869
- Updated `getDefaultFormState()` to not save an undefined field value into an object when the type is `null` and `excludeObjectChildren` is provided, fixing [#4821](https://github.com/rjsf-team/react-jsonschema-form/issues/4821)
69-
- Added new `ADDITIONAL_PROPERTY_KEY_REMOVE` constant
7070

7171
## Dev / docs / playground
7272

packages/core/src/components/Form.tsx

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { Component, ElementType, FormEvent, ReactNode, Ref, RefObject, createRef } from 'react';
22
import {
3-
ADDITIONAL_PROPERTY_KEY_REMOVE,
43
createSchemaUtils,
54
CustomValidator,
65
deepEquals,
@@ -55,9 +54,7 @@ import _toPath from 'lodash/toPath';
5554
import _unset from 'lodash/unset';
5655

5756
import getDefaultRegistry from '../getDefaultRegistry';
58-
59-
/** Internal only symbol used by the `reset()` function to indicate that a reset operation is happening */
60-
const IS_RESET = Symbol('reset');
57+
import { ADDITIONAL_PROPERTY_KEY_REMOVE, IS_RESET } from './constants';
6158

6259
/** The properties that are passed to the `Form` */
6360
export interface FormProps<T = any, S extends StrictRJSFSchema = RJSFSchema, F extends FormContextType = any> {
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
/** Internal only symbol used by `Form` & `ObjectField` to mark an additional property element to be removed */
2+
export const ADDITIONAL_PROPERTY_KEY_REMOVE = Symbol('remove-this-key');
3+
4+
/** Internal only symbol used by the `reset()` function to indicate that a reset operation is happening */
5+
export const IS_RESET = Symbol('reset');

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

Lines changed: 45 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -153,9 +153,12 @@ function getNewFormDataRow<T = any, S extends StrictRJSFSchema = RJSFSchema, F e
153153
registry: Registry<T[], S, F>,
154154
schema: S,
155155
): T {
156-
const { schemaUtils } = registry;
156+
const { schemaUtils, globalFormOptions } = registry;
157157
let itemSchema = schema.items as S;
158-
if (isFixedItems(schema) && allowAdditionalItems(schema)) {
158+
if (globalFormOptions.useFallbackUiForUnsupportedType && !itemSchema) {
159+
// If we don't have itemSchema and useFallbackUiForUnsupportedType is on, use an empty schema
160+
itemSchema = {} as S;
161+
} else if (isFixedItems(schema) && allowAdditionalItems(schema)) {
159162
itemSchema = schema.additionalItems as S;
160163
}
161164
// Cast this as a T to work around schema utils being for T[] caused by the FieldProps<T[], S, F> call on the class
@@ -840,7 +843,7 @@ export default function ArrayField<T = any, S extends StrictRJSFSchema = RJSFSch
840843
props: FieldProps<T[], S, F>,
841844
) {
842845
const { schema, uiSchema, errorSchema, fieldPathId, registry, formData, onChange } = props;
843-
const { schemaUtils, translateString } = registry;
846+
const { globalFormOptions, schemaUtils, translateString } = registry;
844847
const { keyedFormData, updateKeyedFormData } = useKeyedFormData<T>(formData);
845848
// All the children will use childFieldPathId if present in the props, falling back to the fieldPathId
846849
const childFieldPathId = props.childFieldPathId ?? fieldPathId;
@@ -1027,43 +1030,56 @@ export default function ArrayField<T = any, S extends StrictRJSFSchema = RJSFSch
10271030
[onChange, childFieldPathId],
10281031
);
10291032

1030-
if (!(ITEMS_KEY in schema)) {
1031-
const uiOptions = getUiOptions<T[], S, F>(uiSchema);
1032-
const UnsupportedFieldTemplate = getTemplate<'UnsupportedFieldTemplate', T[], S, F>(
1033-
'UnsupportedFieldTemplate',
1034-
registry,
1035-
uiOptions,
1036-
);
1037-
1038-
return (
1039-
<UnsupportedFieldTemplate
1040-
schema={schema}
1041-
fieldPathId={fieldPathId}
1042-
reason={translateString(TranslatableString.MissingItems)}
1043-
registry={registry}
1044-
/>
1045-
);
1046-
}
1047-
const arrayProps = {
1033+
const arrayAsMultiProps: ArrayAsFieldProps<T[], S, F> = {
1034+
...props,
1035+
formData,
1036+
fieldPathId: childFieldPathId,
1037+
onSelectChange: onSelectChange,
1038+
};
1039+
const arrayProps: InternalArrayFieldProps<T, S, F> = {
1040+
...props,
10481041
handleAddItem,
10491042
handleCopyItem,
10501043
handleRemoveItem,
10511044
handleReorderItems,
10521045
keyedFormData,
10531046
onChange: handleChange,
10541047
};
1055-
if (schemaUtils.isMultiSelect(schema)) {
1048+
if (!(ITEMS_KEY in schema)) {
1049+
if (!globalFormOptions.useFallbackUiForUnsupportedType) {
1050+
const uiOptions = getUiOptions<T[], S, F>(uiSchema);
1051+
const UnsupportedFieldTemplate = getTemplate<'UnsupportedFieldTemplate', T[], S, F>(
1052+
'UnsupportedFieldTemplate',
1053+
registry,
1054+
uiOptions,
1055+
);
1056+
1057+
return (
1058+
<UnsupportedFieldTemplate
1059+
schema={schema}
1060+
fieldPathId={fieldPathId}
1061+
reason={translateString(TranslatableString.MissingItems)}
1062+
registry={registry}
1063+
/>
1064+
);
1065+
}
1066+
// Add an items schema with type as undefined so it triggers FallbackField later on
1067+
const fallbackSchema = { ...schema, [ITEMS_KEY]: { type: undefined } };
1068+
arrayAsMultiProps.schema = fallbackSchema;
1069+
arrayProps.schema = fallbackSchema;
1070+
}
1071+
if (schemaUtils.isMultiSelect(arrayAsMultiProps.schema)) {
10561072
// If array has enum or uniqueItems set to true, call renderMultiSelect() to render the default multiselect widget or a custom widget, if specified.
1057-
return <ArrayAsMultiSelect<T, S, F> {...props} fieldPathId={childFieldPathId} onSelectChange={onSelectChange} />;
1073+
return <ArrayAsMultiSelect<T, S, F> {...arrayAsMultiProps} />;
10581074
}
10591075
if (isCustomWidget<T[], S, F>(uiSchema)) {
1060-
return <ArrayAsCustomWidget<T, S, F> {...props} fieldPathId={childFieldPathId} onSelectChange={onSelectChange} />;
1076+
return <ArrayAsCustomWidget<T, S, F> {...arrayAsMultiProps} />;
10611077
}
1062-
if (isFixedItems(schema)) {
1063-
return <FixedArray<T, S, F> {...props} {...arrayProps} />;
1078+
if (isFixedItems(arrayAsMultiProps.schema)) {
1079+
return <FixedArray<T, S, F> {...arrayProps} />;
10641080
}
1065-
if (schemaUtils.isFilesArray(schema, uiSchema)) {
1066-
return <ArrayAsFiles {...props} fieldPathId={childFieldPathId} onSelectChange={onSelectChange} />;
1081+
if (schemaUtils.isFilesArray(arrayAsMultiProps.schema, uiSchema)) {
1082+
return <ArrayAsFiles<T, S, F> {...arrayAsMultiProps} />;
10671083
}
1068-
return <NormalArray<T, S, F> {...props} {...arrayProps} />;
1084+
return <NormalArray<T, S, F> {...arrayProps} />;
10691085
}

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

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import { JSONSchema7TypeName } from 'json-schema';
2121
function getFallbackTypeSelectionSchema(title: string): RJSFSchema {
2222
return {
2323
type: 'string',
24-
enum: ['string', 'number', 'boolean'],
24+
enum: ['string', 'number', 'boolean', 'object', 'array'],
2525
default: 'string',
2626
title: title,
2727
};
@@ -36,6 +36,9 @@ function getTypeOfFormData(formData: any): JSONSchema7TypeName {
3636
if (dataType === 'string' || dataType === 'number' || dataType === 'boolean') {
3737
return dataType;
3838
}
39+
if (dataType === 'object') {
40+
return Array.isArray(formData) ? 'array' : 'object';
41+
}
3942
// Treat everything else as a string
4043
return 'string';
4144
}
@@ -106,20 +109,14 @@ export default function FallbackField<
106109
};
107110

108111
if (!globalFormOptions.useFallbackUiForUnsupportedType) {
112+
const { reason = translateString(TranslatableString.UnknownFieldType, [String(schema.type)]) } = props;
109113
const UnsupportedFieldTemplate = getTemplate<'UnsupportedFieldTemplate', T, S, F>(
110114
'UnsupportedFieldTemplate',
111115
registry,
112116
uiOptions,
113117
);
114118

115-
return (
116-
<UnsupportedFieldTemplate
117-
schema={schema}
118-
fieldPathId={fieldPathId}
119-
reason={translateString(TranslatableString.UnknownFieldType, [String(schema.type)])}
120-
registry={registry}
121-
/>
122-
);
119+
return <UnsupportedFieldTemplate schema={schema} fieldPathId={fieldPathId} reason={reason} registry={registry} />;
123120
}
124121

125122
const FallbackFieldTemplate = getTemplate<'FallbackFieldTemplate', T, S, F>(
@@ -151,7 +148,18 @@ export default function FallbackField<
151148
required={required}
152149
/>
153150
}
154-
schemaField={<SchemaField {...props} schema={{ type, title: translateString(TranslatableString.Value) } as S} />}
151+
schemaField={
152+
<SchemaField
153+
{...props}
154+
schema={
155+
{
156+
type,
157+
title: translateString(TranslatableString.Value),
158+
...(type === 'object' && { additionalProperties: true }),
159+
} as S
160+
}
161+
/>
162+
}
155163
/>
156164
);
157165
}

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { FocusEvent, useCallback, useState } from 'react';
22
import {
3-
ADDITIONAL_PROPERTY_KEY_REMOVE,
43
ADDITIONAL_PROPERTY_FLAG,
54
ANY_OF_KEY,
65
getTemplate,
@@ -30,6 +29,8 @@ import has from 'lodash/has';
3029
import isObject from 'lodash/isObject';
3130
import set from 'lodash/set';
3231

32+
import { ADDITIONAL_PROPERTY_KEY_REMOVE } from '../constants';
33+
3334
/** Returns a flag indicating whether the `name` field is required in the object schema
3435
*
3536
* @param schema - The schema to check

packages/core/test/Form.test.jsx

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,69 @@ describeRepeated('Form common', (createFormComponent) => {
145145
},
146146
'root_unknownProperty',
147147
);
148+
149+
// Change the fallback type to 'boolean'
150+
fireEvent.change(node.querySelector('select'), { target: { value: 2 } });
151+
optionValue = node.querySelector('select').value;
152+
optionText = node.querySelector(`select option[value="${optionValue}"]`).textContent;
153+
expect(optionText).to.equal('boolean');
154+
expect(node.querySelector('input[type=checkbox]')).to.exist;
155+
expect(node.querySelector('input[type=checkbox]').checked).to.equal(true);
156+
// Verify formData was casted to number
157+
sinon.assert.calledWithMatch(
158+
onChange.lastCall,
159+
{
160+
formData: {
161+
unknownProperty: true,
162+
},
163+
schema,
164+
},
165+
'root_unknownProperty',
166+
);
167+
168+
// Change the fallback type to 'object'
169+
fireEvent.change(node.querySelector('select'), { target: { value: 3 } });
170+
optionValue = node.querySelector('select').value;
171+
optionText = node.querySelector(`select option[value="${optionValue}"]`).textContent;
172+
expect(optionText).to.equal('object');
173+
let addButton = node.querySelector('.rjsf-object-property-expand button');
174+
expect(addButton).to.exist;
175+
// click the add button
176+
act(() => addButton.click());
177+
178+
// Verify formData was casted to number
179+
sinon.assert.calledWithMatch(
180+
onChange.lastCall,
181+
{
182+
formData: {
183+
unknownProperty: { newKey: 'New Value' },
184+
},
185+
schema,
186+
},
187+
'root_unknownProperty',
188+
);
189+
190+
// Change the fallback type to 'array'
191+
fireEvent.change(node.querySelector('select'), { target: { value: 4 } });
192+
optionValue = node.querySelector('select').value;
193+
optionText = node.querySelector(`select option[value="${optionValue}"]`).textContent;
194+
expect(optionText).to.equal('array');
195+
addButton = node.querySelector('.rjsf-array-item-add button');
196+
expect(addButton).to.exist;
197+
// click the add button
198+
act(() => addButton.click());
199+
200+
// Verify formData was casted to number
201+
sinon.assert.calledWithMatch(
202+
onChange.lastCall,
203+
{
204+
formData: {
205+
unknownProperty: [undefined],
206+
},
207+
schema,
208+
},
209+
'root_unknownProperty',
210+
);
148211
});
149212
});
150213

packages/utils/src/constants.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
* utility.
55
*/
66
export const ADDITIONAL_PROPERTY_FLAG = '__additional_property';
7-
export const ADDITIONAL_PROPERTY_KEY_REMOVE = Symbol('remove-this-key');
87
export const ADDITIONAL_PROPERTIES_KEY = 'additionalProperties';
98
export const ALL_OF_KEY = 'allOf';
109
export const ANY_OF_KEY = 'anyOf';

0 commit comments

Comments
 (0)