Skip to content

Commit da381db

Browse files
[feat] Add fallback UI for unsupported types (#4817)
* Add fallback UI for unsupported types - Add new opt-in feature to use a new feature to display a type selector for fields with unknown types - Feature is implemented by new FallbackField and rendered by new FallbackFieldTemplate. - Update SchemaField to use FallbackField - When not opted in, FallbackField uses existing UnsupportedField component * Update packages/docs/docs/migration-guides/v6.x upgrade guide.md Co-authored-by: Heath C <51679588+heath-freenome@users.noreply.github.com> * changes per reviewer feedback --------- Co-authored-by: Heath C <51679588+heath-freenome@users.noreply.github.com>
1 parent 144f764 commit da381db

File tree

25 files changed

+388
-42
lines changed

25 files changed

+388
-42
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ should change the heading of the (upcoming) version to include a major version b
2525
- Updated `Form` so that is behaves as a "controlled" form when `formData` is passed and uncontrolled when `initialFormData` is passed, fixing [#391](https://github.com/rjsf-team/react-jsonschema-form/issues/391)
2626
- Also fixed an issue where live validation was called on the initial form render, causing errors to show immediately, partially fixing [#512](https://github.com/rjsf-team/react-jsonschema-form/issues/512)
2727
- Updated `Form` to add a new programmatic function, `setFieldValue(fieldPath: string | FieldPathList, newValue?: T): void`, fixing [#2099](https://github.com/rjsf-team/react-jsonschema-form/issues/2099)
28+
- Added new `FallbackField` to add opt-in functionality to control form data that is of an unsupported or unknown type ([#4736](https://github.com/rjsf-team/react-jsonschema-form/issues/4736)).
2829

2930
## @rjsf/mantine
3031

packages/core/package.json

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,5 +112,21 @@
112112
"homepage": "https://github.com/rjsf-team/react-jsonschema-form",
113113
"publishConfig": {
114114
"access": "public"
115+
},
116+
"nx": {
117+
"targets": {
118+
"build": {
119+
"dependsOn": [
120+
{
121+
"//": "Break the dependency cycle between @rjsf/core and @rjsf/snapshot-tests by explicitly listing the dependencies needed for build",
122+
"projects": [
123+
"@rjsf/utils",
124+
"@rjsf/validator-ajv8"
125+
],
126+
"target": "build"
127+
}
128+
]
129+
}
130+
}
115131
}
116132
}

packages/core/src/components/Form.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,10 @@ export interface FormProps<T = any, S extends StrictRJSFSchema = RJSFSchema, F e
223223
/** Optional function to generate custom HTML `name` attributes for form fields.
224224
*/
225225
nameGenerator?: NameGeneratorFunction;
226+
/** Optional flag that, when set to true, will cause the `FallbackField` to render a type selector for unsupported
227+
* fields instead of the default UnsupportedField error UI.
228+
*/
229+
useFallbackUiForUnsupportedType?: boolean;
226230
/** Optional configuration object with flags, if provided, allows users to override default form state behavior
227231
* Currently only affecting minItems on array fields and handling of setting defaults based on the value of
228232
* `emptyObjectFields`
@@ -1118,12 +1122,14 @@ export default class Form<
11181122
idSeparator = DEFAULT_ID_SEPARATOR,
11191123
idPrefix = DEFAULT_ID_PREFIX,
11201124
nameGenerator,
1125+
useFallbackUiForUnsupportedType = false,
11211126
} = props;
11221127
const rootFieldId = uiSchema['ui:rootFieldId'];
11231128
// Omit any options that are undefined or null
11241129
return {
11251130
idPrefix: rootFieldId || idPrefix,
11261131
idSeparator,
1132+
useFallbackUiForUnsupportedType,
11271133
...(experimental_componentUpdateStrategy !== undefined && { experimental_componentUpdateStrategy }),
11281134
...(nameGenerator !== undefined && { nameGenerator }),
11291135
};
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import {
2+
FallbackFieldProps,
3+
FieldPathId,
4+
FormContextType,
5+
getTemplate,
6+
getUiOptions,
7+
hashObject,
8+
RJSFSchema,
9+
StrictRJSFSchema,
10+
toFieldPathId,
11+
TranslatableString,
12+
useDeepCompareMemo,
13+
} from '@rjsf/utils';
14+
import { useMemo, useState } from 'react';
15+
import { JSONSchema7TypeName } from 'json-schema';
16+
17+
/**
18+
* Get the schema for the type selection component.
19+
* @param title - The translated title for the type selection schema.
20+
*/
21+
function getFallbackTypeSelectionSchema(title: string): RJSFSchema {
22+
return {
23+
type: 'string',
24+
enum: ['string', 'number', 'boolean'],
25+
default: 'string',
26+
title: title,
27+
};
28+
}
29+
30+
/**
31+
* Determines the JSON Schema type of the given formData.
32+
* @param formData - The form data whose type is to be determined.
33+
*/
34+
function getTypeOfFormData(formData: any): JSONSchema7TypeName {
35+
const dataType = typeof formData;
36+
if (dataType === 'string' || dataType === 'number' || dataType === 'boolean') {
37+
return dataType;
38+
}
39+
// Treat everything else as a string
40+
return 'string';
41+
}
42+
43+
/**
44+
* Casts the given formData to the specified type.
45+
* @param formData - The form data to be casted.
46+
* @param newType - The target type to which the form data should be casted.
47+
*/
48+
function castToNewType<T = any>(formData: T, newType: JSONSchema7TypeName): T {
49+
switch (newType) {
50+
case 'string':
51+
return String(formData) as T;
52+
case 'number': {
53+
const castedNumber = Number(formData);
54+
return (isNaN(castedNumber) ? 0 : castedNumber) as T;
55+
}
56+
case 'boolean':
57+
return Boolean(formData) as T;
58+
default:
59+
return formData;
60+
}
61+
}
62+
63+
/**
64+
* The `FallbackField` component is used to render a field for unsupported or unknown schema types. If
65+
* `useFallbackUiForUnsupportedType` is enabled in the `globalUiOptions`, it provides a type selector
66+
*/
67+
export default function FallbackField<
68+
T = any,
69+
S extends StrictRJSFSchema = RJSFSchema,
70+
F extends FormContextType = any,
71+
>(props: FallbackFieldProps<T, S, F>) {
72+
const {
73+
id,
74+
formData,
75+
displayLabel = true,
76+
schema,
77+
name,
78+
uiSchema,
79+
required,
80+
disabled = false,
81+
readonly = false,
82+
onBlur,
83+
onFocus,
84+
registry,
85+
fieldPathId,
86+
onChange,
87+
errorSchema,
88+
} = props;
89+
const { translateString, fields, globalFormOptions } = registry;
90+
const [type, setType] = useState<JSONSchema7TypeName>(getTypeOfFormData(formData));
91+
92+
const uiOptions = getUiOptions<T, S, F>(uiSchema);
93+
94+
const typeSelectorInnerFieldPathId = useDeepCompareMemo<FieldPathId>(
95+
toFieldPathId('__internal_type_selector', globalFormOptions, fieldPathId),
96+
);
97+
98+
const schemaTitle = translateString(TranslatableString.Type);
99+
const typesOptionSchema = useMemo(() => getFallbackTypeSelectionSchema(schemaTitle), [schemaTitle]);
100+
101+
const onTypeChange = (newType: T | undefined) => {
102+
if (newType != null) {
103+
setType(newType as JSONSchema7TypeName);
104+
onChange(castToNewType<T>(formData as T, newType as JSONSchema7TypeName), fieldPathId.path, errorSchema, id);
105+
}
106+
};
107+
108+
if (!globalFormOptions.useFallbackUiForUnsupportedType) {
109+
const UnsupportedFieldTemplate = getTemplate<'UnsupportedFieldTemplate', T, S, F>(
110+
'UnsupportedFieldTemplate',
111+
registry,
112+
uiOptions,
113+
);
114+
115+
return (
116+
<UnsupportedFieldTemplate
117+
schema={schema}
118+
fieldPathId={fieldPathId}
119+
reason={translateString(TranslatableString.UnknownFieldType, [String(schema.type)])}
120+
registry={registry}
121+
/>
122+
);
123+
}
124+
125+
const FallbackFieldTemplate = getTemplate<'FallbackFieldTemplate', T, S, F>(
126+
'FallbackFieldTemplate',
127+
registry,
128+
uiOptions,
129+
);
130+
131+
const { SchemaField } = fields;
132+
133+
return (
134+
<FallbackFieldTemplate
135+
schema={schema}
136+
registry={registry}
137+
typeSelector={
138+
<SchemaField
139+
key={formData ? hashObject(formData) : '__empty__'}
140+
fieldPathId={typeSelectorInnerFieldPathId}
141+
name={`${name}__fallback_type`}
142+
schema={typesOptionSchema as S}
143+
formData={type as T}
144+
onChange={onTypeChange}
145+
onBlur={onBlur}
146+
onFocus={onFocus}
147+
registry={registry}
148+
hideLabel={!displayLabel}
149+
disabled={disabled}
150+
readonly={readonly}
151+
required={required}
152+
/>
153+
}
154+
schemaField={<SchemaField {...props} schema={{ type, title: translateString(TranslatableString.Value) } as S} />}
155+
/>
156+
);
157+
}

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

Lines changed: 3 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import {
55
descriptionId,
66
ErrorSchema,
77
Field,
8-
FieldPathId,
98
FieldPathList,
109
FieldProps,
1110
FieldTemplateProps,
@@ -21,7 +20,6 @@ import {
2120
shouldRender,
2221
shouldRenderOptionalField,
2322
StrictRJSFSchema,
24-
TranslatableString,
2523
UI_OPTIONS_KEY,
2624
UIOptionsType,
2725
} from '@rjsf/utils';
@@ -45,18 +43,16 @@ const COMPONENT_TYPES: { [key: string]: string } = {
4543
*
4644
* @param schema - The schema from which to obtain the type
4745
* @param uiOptions - The UI Options that may affect the component decision
48-
* @param fieldPathId - The id that is passed to the `UnsupportedFieldTemplate`
4946
* @param registry - The registry from which fields and templates are obtained
5047
* @returns - The `Field` component that is used to render the actual field data
5148
*/
5249
function getFieldComponent<T = any, S extends StrictRJSFSchema = RJSFSchema, F extends FormContextType = any>(
5350
schema: S,
5451
uiOptions: UIOptionsType<T, S, F>,
55-
fieldPathId: FieldPathId,
5652
registry: Registry<T, S, F>,
5753
): ComponentType<FieldProps<T, S, F>> {
5854
const field = uiOptions.field;
59-
const { fields, translateString } = registry;
55+
const { fields } = registry;
6056
if (typeof field === 'function') {
6157
return field;
6258
}
@@ -80,24 +76,7 @@ function getFieldComponent<T = any, S extends StrictRJSFSchema = RJSFSchema, F e
8076
return () => null;
8177
}
8278

83-
return componentName in fields
84-
? fields[componentName]
85-
: () => {
86-
const UnsupportedFieldTemplate = getTemplate<'UnsupportedFieldTemplate', T, S, F>(
87-
'UnsupportedFieldTemplate',
88-
registry,
89-
uiOptions,
90-
);
91-
92-
return (
93-
<UnsupportedFieldTemplate
94-
schema={schema}
95-
fieldPathId={fieldPathId}
96-
reason={translateString(TranslatableString.UnknownFieldType, [String(schema.type)])}
97-
registry={registry}
98-
/>
99-
);
100-
};
79+
return componentName in fields ? fields[componentName] : fields['FallbackField'];
10180
}
10281

10382
/** The `SchemaFieldRender` component is the work-horse of react-jsonschema-form, determining what kind of real field to
@@ -149,7 +128,7 @@ function SchemaFieldRender<T = any, S extends StrictRJSFSchema = RJSFSchema, F e
149128
[fieldId, onChange],
150129
);
151130

152-
const FieldComponent = getFieldComponent<T, S, F>(schema, uiOptions, fieldPathId, registry);
131+
const FieldComponent = getFieldComponent<T, S, F>(schema, uiOptions, registry);
153132
const disabled = Boolean(uiOptions.disabled ?? props.disabled);
154133
const readonly = Boolean(uiOptions.readonly ?? (props.readonly || props.schema.readOnly || schema.readOnly));
155134
const uiSchemaHideError = uiOptions.hideError;

packages/core/src/components/fields/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Field, FormContextType, RegistryFieldsType, RJSFSchema, StrictRJSFSchem
22

33
import ArrayField from './ArrayField';
44
import BooleanField from './BooleanField';
5+
import FallbackField from './FallbackField';
56
import LayoutGridField from './LayoutGridField';
67
import LayoutHeaderField from './LayoutHeaderField';
78
import LayoutMultiSchemaField from './LayoutMultiSchemaField';
@@ -23,6 +24,7 @@ function fields<
2324
ArrayField: ArrayField as unknown as Field<T, S, F>,
2425
// ArrayField falls back to SchemaField if ArraySchemaField is not defined, which it isn't by default
2526
BooleanField,
27+
FallbackField,
2628
LayoutGridField,
2729
LayoutHeaderField,
2830
LayoutMultiSchemaField,
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { FallbackFieldTemplateProps, FormContextType, getTemplate, RJSFSchema, StrictRJSFSchema } from '@rjsf/utils';
2+
3+
/**
4+
* The `FallbackFieldTemplate` is used to render a field when no field matches. The field renders a type selector and
5+
* the schema field for the form data.
6+
*/
7+
export default function FallbackFieldTemplate<
8+
T = any,
9+
S extends StrictRJSFSchema = RJSFSchema,
10+
F extends FormContextType = any,
11+
>(props: FallbackFieldTemplateProps<T, S, F>) {
12+
const { schema, registry, typeSelector, schemaField } = props;
13+
14+
// By default, use the MultiSchemaFieldTemplate, which handles the same basic requirements.
15+
const MultiSchemaFieldTemplate = getTemplate<'MultiSchemaFieldTemplate', T, S, F>(
16+
'MultiSchemaFieldTemplate',
17+
registry,
18+
);
19+
20+
return (
21+
<MultiSchemaFieldTemplate
22+
selector={typeSelector}
23+
optionSchemaField={schemaField}
24+
schema={schema}
25+
registry={registry}
26+
/>
27+
);
28+
}

packages/core/src/components/templates/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import BaseInputTemplate from './BaseInputTemplate';
99
import ButtonTemplates from './ButtonTemplates';
1010
import DescriptionField from './DescriptionField';
1111
import ErrorList from './ErrorList';
12+
import FallbackFieldTemplate from './FallbackFieldTemplate';
1213
import FieldTemplate from './FieldTemplate';
1314
import FieldErrorTemplate from './FieldErrorTemplate';
1415
import FieldHelpTemplate from './FieldHelpTemplate';
@@ -35,6 +36,7 @@ function templates<T = any, S extends StrictRJSFSchema = RJSFSchema, F extends F
3536
BaseInputTemplate,
3637
DescriptionFieldTemplate: DescriptionField,
3738
ErrorListTemplate: ErrorList,
39+
FallbackFieldTemplate,
3840
FieldTemplate,
3941
FieldErrorTemplate,
4042
FieldHelpTemplate,

packages/core/src/getDefaultRegistry.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ export default function getDefaultRegistry<
2828
rootSchema: {} as S,
2929
formContext: {} as F,
3030
translateString: englishStringTranslator,
31-
globalFormOptions: { idPrefix: DEFAULT_ID_PREFIX, idSeparator: DEFAULT_ID_SEPARATOR },
31+
globalFormOptions: {
32+
idPrefix: DEFAULT_ID_PREFIX,
33+
idSeparator: DEFAULT_ID_SEPARATOR,
34+
useFallbackUiForUnsupportedType: false,
35+
},
3236
};
3337
}

packages/core/src/getTestRegistry.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,11 @@ export default function getTestRegistry(
1717
templates: Partial<Registry['templates']> = {},
1818
widgets: Registry['widgets'] = {},
1919
formContext: Registry['formContext'] = {},
20-
globalFormOptions: Registry['globalFormOptions'] = { idPrefix: DEFAULT_ID_PREFIX, idSeparator: DEFAULT_ID_SEPARATOR },
20+
globalFormOptions: Registry['globalFormOptions'] = {
21+
idPrefix: DEFAULT_ID_PREFIX,
22+
idSeparator: DEFAULT_ID_SEPARATOR,
23+
useFallbackUiForUnsupportedType: false,
24+
},
2125
): Registry {
2226
const defaults = getDefaultRegistry();
2327
const schemaUtils = createSchemaUtils(validator, rootSchema);

0 commit comments

Comments
 (0)