UNPKG

@rjsf/core

Version:

A simple React component capable of building HTML forms out of a JSON schema.

598 lines (597 loc) 29.5 kB
import { jsx as _jsx } from "react/jsx-runtime"; import { Component } from 'react'; import { getTemplate, getWidget, getUiOptions, isFixedItems, allowAdditionalItems, isCustomWidget, optionsList, TranslatableString, ITEMS_KEY, } from '@rjsf/utils'; import cloneDeep from 'lodash-es/cloneDeep.js'; import get from 'lodash-es/get.js'; import isObject from 'lodash-es/isObject.js'; import set from 'lodash-es/set.js'; import { nanoid } from 'nanoid'; /** Used to generate a unique ID for an element in a row */ function generateRowId() { return nanoid(); } /** Converts the `formData` into `KeyedFormDataType` data, using the `generateRowId()` function to create the key * * @param formData - The data for the form * @returns - The `formData` converted into a `KeyedFormDataType` element */ function generateKeyedFormData(formData) { return !Array.isArray(formData) ? [] : formData.map((item) => { return { key: generateRowId(), item, }; }); } /** Converts `KeyedFormDataType` data into the inner `formData` * * @param keyedFormData - The `KeyedFormDataType` to be converted * @returns - The inner `formData` item(s) in the `keyedFormData` */ function keyedToPlainFormData(keyedFormData) { if (Array.isArray(keyedFormData)) { return keyedFormData.map((keyedItem) => keyedItem.item); } return []; } /** The `ArrayField` component is used to render a field in the schema that is of type `array`. It supports both normal * and fixed array, allowing user to add and remove elements from the array data. */ class ArrayField extends Component { /** Constructs an `ArrayField` from the `props`, generating the initial keyed data from the `formData` * * @param props - The `FieldProps` for this template */ constructor(props) { super(props); const { formData = [] } = props; const keyedFormData = generateKeyedFormData(formData); this.state = { keyedFormData, updatedKeyedFormData: false, }; } /** React lifecycle method that is called when the props are about to change allowing the state to be updated. It * regenerates the keyed form data and returns it * * @param nextProps - The next set of props data * @param prevState - The previous set of state data */ static getDerivedStateFromProps(nextProps, prevState) { // Don't call getDerivedStateFromProps if keyed formdata was just updated. if (prevState.updatedKeyedFormData) { return { updatedKeyedFormData: false, }; } const nextFormData = Array.isArray(nextProps.formData) ? nextProps.formData : []; const previousKeyedFormData = prevState.keyedFormData || []; const newKeyedFormData = nextFormData.length === previousKeyedFormData.length ? previousKeyedFormData.map((previousKeyedFormDatum, index) => { return { key: previousKeyedFormDatum.key, item: nextFormData[index], }; }) : generateKeyedFormData(nextFormData); return { keyedFormData: newKeyedFormData, }; } /** Returns the appropriate title for an item by getting first the title from the schema.items, then falling back to * the description from the schema.items, and finally the string "Item" */ get itemTitle() { const { schema, registry } = this.props; const { translateString } = registry; return get(schema, [ITEMS_KEY, 'title'], get(schema, [ITEMS_KEY, 'description'], translateString(TranslatableString.ArrayItemTitle))); } /** Determines whether the item described in the schema is always required, which is determined by whether any item * may be null. * * @param itemSchema - The schema for the item * @return - True if the item schema type does not contain the "null" type */ isItemRequired(itemSchema) { if (Array.isArray(itemSchema.type)) { // While we don't yet support composite/nullable jsonschema types, it's // future-proof to check for requirement against these. return !itemSchema.type.includes('null'); } // All non-null array item types are inherently required by design return itemSchema.type !== 'null'; } /** Determines whether more items can be added to the array. If the uiSchema indicates the array doesn't allow adding * then false is returned. Otherwise, if the schema indicates that there are a maximum number of items and the * `formData` matches that value, then false is returned, otherwise true is returned. * * @param formItems - The list of items in the form * @returns - True if the item is addable otherwise false */ canAddItem(formItems) { const { schema, uiSchema, registry } = this.props; let { addable } = getUiOptions(uiSchema, registry.globalUiOptions); if (addable !== false) { // if ui:options.addable was not explicitly set to false, we can add // another item if we have not exceeded maxItems yet if (schema.maxItems !== undefined) { addable = formItems.length < schema.maxItems; } else { addable = true; } } return addable; } /** Returns the default form information for an item based on the schema for that item. Deals with the possibility * that the schema is fixed and allows additional items. */ _getNewFormDataRow = () => { const { schema, registry } = this.props; const { schemaUtils } = registry; let itemSchema = schema.items; if (isFixedItems(schema) && allowAdditionalItems(schema)) { itemSchema = schema.additionalItems; } // Cast this as a T to work around schema utils being for T[] caused by the FieldProps<T[], S, F> call on the class return schemaUtils.getDefaultFormState(itemSchema); }; /** Callback handler for when the user clicks on the add or add at index buttons. Creates a new row of keyed form data * either at the end of the list (when index is not specified) or inserted at the `index` when it is, adding it into * the state, and then returning `onChange()` with the plain form data converted from the keyed data * * @param event - The event for the click * @param [index] - The optional index at which to add the new data */ _handleAddClick(event, index) { if (event) { event.preventDefault(); } const { onChange, errorSchema } = this.props; const { keyedFormData } = this.state; // refs #195: revalidate to ensure properly reindexing errors let newErrorSchema; if (errorSchema) { newErrorSchema = {}; for (const idx in errorSchema) { const i = parseInt(idx); if (index === undefined || i < index) { set(newErrorSchema, [i], errorSchema[idx]); } else if (i >= index) { set(newErrorSchema, [i + 1], errorSchema[idx]); } } } const newKeyedFormDataRow = { key: generateRowId(), item: this._getNewFormDataRow(), }; const newKeyedFormData = [...keyedFormData]; if (index !== undefined) { newKeyedFormData.splice(index, 0, newKeyedFormDataRow); } else { newKeyedFormData.push(newKeyedFormDataRow); } this.setState({ keyedFormData: newKeyedFormData, updatedKeyedFormData: true, }, () => onChange(keyedToPlainFormData(newKeyedFormData), newErrorSchema)); } /** Callback handler for when the user clicks on the add button. Creates a new row of keyed form data at the end of * the list, adding it into the state, and then returning `onChange()` with the plain form data converted from the * keyed data * * @param event - The event for the click */ onAddClick = (event) => { this._handleAddClick(event); }; /** Callback handler for when the user clicks on the add button on an existing array element. Creates a new row of * keyed form data inserted at the `index`, adding it into the state, and then returning `onChange()` with the plain * form data converted from the keyed data * * @param index - The index at which the add button is clicked */ onAddIndexClick = (index) => { return (event) => { this._handleAddClick(event, index); }; }; /** Callback handler for when the user clicks on the copy button on an existing array element. Clones the row of * keyed form data at the `index` into the next position in the state, and then returning `onChange()` with the plain * form data converted from the keyed data * * @param index - The index at which the copy button is clicked */ onCopyIndexClick = (index) => { return (event) => { if (event) { event.preventDefault(); } const { onChange, errorSchema } = this.props; const { keyedFormData } = this.state; // refs #195: revalidate to ensure properly reindexing errors let newErrorSchema; if (errorSchema) { newErrorSchema = {}; for (const idx in errorSchema) { const i = parseInt(idx); if (i <= index) { set(newErrorSchema, [i], errorSchema[idx]); } else if (i > index) { set(newErrorSchema, [i + 1], errorSchema[idx]); } } } const newKeyedFormDataRow = { key: generateRowId(), item: cloneDeep(keyedFormData[index].item), }; const newKeyedFormData = [...keyedFormData]; if (index !== undefined) { newKeyedFormData.splice(index + 1, 0, newKeyedFormDataRow); } else { newKeyedFormData.push(newKeyedFormDataRow); } this.setState({ keyedFormData: newKeyedFormData, updatedKeyedFormData: true, }, () => onChange(keyedToPlainFormData(newKeyedFormData), newErrorSchema)); }; }; /** Callback handler for when the user clicks on the remove button on an existing array element. Removes the row of * keyed form data at the `index` in the state, and then returning `onChange()` with the plain form data converted * from the keyed data * * @param index - The index at which the remove button is clicked */ onDropIndexClick = (index) => { return (event) => { if (event) { event.preventDefault(); } const { onChange, errorSchema } = this.props; const { keyedFormData } = this.state; // refs #195: revalidate to ensure properly reindexing errors let newErrorSchema; if (errorSchema) { newErrorSchema = {}; for (const idx in errorSchema) { const i = parseInt(idx); if (i < index) { set(newErrorSchema, [i], errorSchema[idx]); } else if (i > index) { set(newErrorSchema, [i - 1], errorSchema[idx]); } } } const newKeyedFormData = keyedFormData.filter((_, i) => i !== index); this.setState({ keyedFormData: newKeyedFormData, updatedKeyedFormData: true, }, () => onChange(keyedToPlainFormData(newKeyedFormData), newErrorSchema)); }; }; /** Callback handler for when the user clicks on one of the move item buttons on an existing array element. Moves the * row of keyed form data at the `index` to the `newIndex` in the state, and then returning `onChange()` with the * plain form data converted from the keyed data * * @param index - The index of the item to move * @param newIndex - The index to where the item is to be moved */ onReorderClick = (index, newIndex) => { return (event) => { if (event) { event.preventDefault(); event.currentTarget.blur(); } const { onChange, errorSchema } = this.props; let newErrorSchema; if (errorSchema) { newErrorSchema = {}; for (const idx in errorSchema) { const i = parseInt(idx); if (i == index) { set(newErrorSchema, [newIndex], errorSchema[index]); } else if (i == newIndex) { set(newErrorSchema, [index], errorSchema[newIndex]); } else { set(newErrorSchema, [idx], errorSchema[i]); } } } const { keyedFormData } = this.state; function reOrderArray() { // Copy item const _newKeyedFormData = keyedFormData.slice(); // Moves item from index to newIndex _newKeyedFormData.splice(index, 1); _newKeyedFormData.splice(newIndex, 0, keyedFormData[index]); return _newKeyedFormData; } const newKeyedFormData = reOrderArray(); this.setState({ keyedFormData: newKeyedFormData, }, () => onChange(keyedToPlainFormData(newKeyedFormData), newErrorSchema)); }; }; /** Callback handler used to deal with changing the value of the data in the array at the `index`. Calls the * `onChange` callback with the updated form data * * @param index - The index of the item being changed */ onChangeForIndex = (index) => { return (value, newErrorSchema, id) => { const { formData, onChange, errorSchema } = this.props; const arrayData = Array.isArray(formData) ? formData : []; const newFormData = arrayData.map((item, i) => { // We need to treat undefined items as nulls to have validation. // See https://github.com/tdegrunt/jsonschema/issues/206 const jsonValue = typeof value === 'undefined' ? null : value; return index === i ? jsonValue : item; }); onChange(newFormData, errorSchema && errorSchema && { ...errorSchema, [index]: newErrorSchema, }, id); }; }; /** Callback handler used to change the value for a checkbox */ onSelectChange = (value) => { const { onChange, idSchema } = this.props; onChange(value, undefined, idSchema && idSchema.$id); }; /** Renders the `ArrayField` depending on the specific needs of the schema and uischema elements */ render() { const { schema, uiSchema, idSchema, registry } = this.props; const { schemaUtils, translateString } = registry; if (!(ITEMS_KEY in schema)) { const uiOptions = getUiOptions(uiSchema); const UnsupportedFieldTemplate = getTemplate('UnsupportedFieldTemplate', registry, uiOptions); return (_jsx(UnsupportedFieldTemplate, { schema: schema, idSchema: idSchema, reason: translateString(TranslatableString.MissingItems), registry: registry })); } if (schemaUtils.isMultiSelect(schema)) { // If array has enum or uniqueItems set to true, call renderMultiSelect() to render the default multiselect widget or a custom widget, if specified. return this.renderMultiSelect(); } if (isCustomWidget(uiSchema)) { return this.renderCustomWidget(); } if (isFixedItems(schema)) { return this.renderFixedArray(); } if (schemaUtils.isFilesArray(schema, uiSchema)) { return this.renderFiles(); } return this.renderNormalArray(); } /** Renders a normal array without any limitations of length */ renderNormalArray() { const { schema, uiSchema = {}, errorSchema, idSchema, name, title, disabled = false, readonly = false, autofocus = false, required = false, registry, onBlur, onFocus, idPrefix, idSeparator = '_', rawErrors, } = this.props; const { keyedFormData } = this.state; const fieldTitle = schema.title || title || name; const { schemaUtils, formContext } = registry; const uiOptions = getUiOptions(uiSchema); const _schemaItems = isObject(schema.items) ? schema.items : {}; const itemsSchema = schemaUtils.retrieveSchema(_schemaItems); const formData = keyedToPlainFormData(this.state.keyedFormData); const canAdd = this.canAddItem(formData); const arrayProps = { canAdd, items: keyedFormData.map((keyedItem, index) => { const { key, item } = keyedItem; // While we are actually dealing with a single item of type T, the types require a T[], so cast const itemCast = item; const itemSchema = schemaUtils.retrieveSchema(_schemaItems, itemCast); const itemErrorSchema = errorSchema ? errorSchema[index] : undefined; const itemIdPrefix = idSchema.$id + idSeparator + index; const itemIdSchema = schemaUtils.toIdSchema(itemSchema, itemIdPrefix, itemCast, idPrefix, idSeparator); return this.renderArrayFieldItem({ key, index, name: name && `${name}-${index}`, title: fieldTitle ? `${fieldTitle}-${index + 1}` : undefined, canAdd, canMoveUp: index > 0, canMoveDown: index < formData.length - 1, itemSchema, itemIdSchema, itemErrorSchema, itemData: itemCast, itemUiSchema: uiSchema.items, autofocus: autofocus && index === 0, onBlur, onFocus, rawErrors, totalItems: keyedFormData.length, }); }), className: `field field-array field-array-of-${itemsSchema.type}`, disabled, idSchema, uiSchema, onAddClick: this.onAddClick, readonly, required, schema, title: fieldTitle, formContext, formData, rawErrors, registry, }; const Template = getTemplate('ArrayFieldTemplate', registry, uiOptions); return _jsx(Template, { ...arrayProps }); } /** Renders an array using the custom widget provided by the user in the `uiSchema` */ renderCustomWidget() { const { schema, idSchema, uiSchema, disabled = false, readonly = false, autofocus = false, required = false, hideError, placeholder, onBlur, onFocus, formData: items = [], registry, rawErrors, name, } = this.props; const { widgets, formContext, globalUiOptions, schemaUtils } = registry; const { widget, title: uiTitle, ...options } = getUiOptions(uiSchema, globalUiOptions); const Widget = getWidget(schema, widget, widgets); const label = uiTitle ?? schema.title ?? name; const displayLabel = schemaUtils.getDisplayLabel(schema, uiSchema, globalUiOptions); return (_jsx(Widget, { id: idSchema.$id, name: name, multiple: true, onChange: this.onSelectChange, onBlur: onBlur, onFocus: onFocus, options: options, schema: schema, uiSchema: uiSchema, registry: registry, value: items, disabled: disabled, readonly: readonly, hideError: hideError, required: required, label: label, hideLabel: !displayLabel, placeholder: placeholder, formContext: formContext, autofocus: autofocus, rawErrors: rawErrors })); } /** Renders an array as a set of checkboxes */ renderMultiSelect() { const { schema, idSchema, uiSchema, formData: items = [], disabled = false, readonly = false, autofocus = false, required = false, placeholder, onBlur, onFocus, registry, rawErrors, name, } = this.props; const { widgets, schemaUtils, formContext, globalUiOptions } = registry; const itemsSchema = schemaUtils.retrieveSchema(schema.items, items); const enumOptions = optionsList(itemsSchema, uiSchema); const { widget = 'select', title: uiTitle, ...options } = getUiOptions(uiSchema, globalUiOptions); const Widget = getWidget(schema, widget, widgets); const label = uiTitle ?? schema.title ?? name; const displayLabel = schemaUtils.getDisplayLabel(schema, uiSchema, globalUiOptions); return (_jsx(Widget, { id: idSchema.$id, name: name, multiple: true, onChange: this.onSelectChange, onBlur: onBlur, onFocus: onFocus, options: { ...options, enumOptions }, schema: schema, uiSchema: uiSchema, registry: registry, value: items, disabled: disabled, readonly: readonly, required: required, label: label, hideLabel: !displayLabel, placeholder: placeholder, formContext: formContext, autofocus: autofocus, rawErrors: rawErrors })); } /** Renders an array of files using the `FileWidget` */ renderFiles() { const { schema, uiSchema, idSchema, name, disabled = false, readonly = false, autofocus = false, required = false, onBlur, onFocus, registry, formData: items = [], rawErrors, } = this.props; const { widgets, formContext, globalUiOptions, schemaUtils } = registry; const { widget = 'files', title: uiTitle, ...options } = getUiOptions(uiSchema, globalUiOptions); const Widget = getWidget(schema, widget, widgets); const label = uiTitle ?? schema.title ?? name; const displayLabel = schemaUtils.getDisplayLabel(schema, uiSchema, globalUiOptions); return (_jsx(Widget, { options: options, id: idSchema.$id, name: name, multiple: true, onChange: this.onSelectChange, onBlur: onBlur, onFocus: onFocus, schema: schema, uiSchema: uiSchema, value: items, disabled: disabled, readonly: readonly, required: required, registry: registry, formContext: formContext, autofocus: autofocus, rawErrors: rawErrors, label: label, hideLabel: !displayLabel })); } /** Renders an array that has a maximum limit of items */ renderFixedArray() { const { schema, uiSchema = {}, formData = [], errorSchema, idPrefix, idSeparator = '_', idSchema, name, title, disabled = false, readonly = false, autofocus = false, required = false, registry, onBlur, onFocus, rawErrors, } = this.props; const { keyedFormData } = this.state; let { formData: items = [] } = this.props; const fieldTitle = schema.title || title || name; const uiOptions = getUiOptions(uiSchema); const { schemaUtils, formContext } = registry; const _schemaItems = isObject(schema.items) ? schema.items : []; const itemSchemas = _schemaItems.map((item, index) => schemaUtils.retrieveSchema(item, formData[index])); const additionalSchema = isObject(schema.additionalItems) ? schemaUtils.retrieveSchema(schema.additionalItems, formData) : null; if (!items || items.length < itemSchemas.length) { // to make sure at least all fixed items are generated items = items || []; items = items.concat(new Array(itemSchemas.length - items.length)); } // These are the props passed into the render function const canAdd = this.canAddItem(items) && !!additionalSchema; const arrayProps = { canAdd, className: 'field field-array field-array-fixed-items', disabled, idSchema, formData, items: keyedFormData.map((keyedItem, index) => { const { key, item } = keyedItem; // While we are actually dealing with a single item of type T, the types require a T[], so cast const itemCast = item; const additional = index >= itemSchemas.length; const itemSchema = (additional && isObject(schema.additionalItems) ? schemaUtils.retrieveSchema(schema.additionalItems, itemCast) : itemSchemas[index]) || {}; const itemIdPrefix = idSchema.$id + idSeparator + index; const itemIdSchema = schemaUtils.toIdSchema(itemSchema, itemIdPrefix, itemCast, idPrefix, idSeparator); const itemUiSchema = additional ? uiSchema.additionalItems || {} : Array.isArray(uiSchema.items) ? uiSchema.items[index] : uiSchema.items || {}; const itemErrorSchema = errorSchema ? errorSchema[index] : undefined; return this.renderArrayFieldItem({ key, index, name: name && `${name}-${index}`, title: fieldTitle ? `${fieldTitle}-${index + 1}` : undefined, canAdd, canRemove: additional, canMoveUp: index >= itemSchemas.length + 1, canMoveDown: additional && index < items.length - 1, itemSchema, itemData: itemCast, itemUiSchema, itemIdSchema, itemErrorSchema, autofocus: autofocus && index === 0, onBlur, onFocus, rawErrors, totalItems: keyedFormData.length, }); }), onAddClick: this.onAddClick, readonly, required, registry, schema, uiSchema, title: fieldTitle, formContext, errorSchema, rawErrors, }; const Template = getTemplate('ArrayFieldTemplate', registry, uiOptions); return _jsx(Template, { ...arrayProps }); } /** Renders the individual array item using a `SchemaField` along with the additional properties required to be send * back to the `ArrayFieldItemTemplate`. * * @param props - The props for the individual array item to be rendered */ renderArrayFieldItem(props) { const { key, index, name, canAdd, canRemove = true, canMoveUp, canMoveDown, itemSchema, itemData, itemUiSchema, itemIdSchema, itemErrorSchema, autofocus, onBlur, onFocus, rawErrors, totalItems, title, } = props; const { disabled, hideError, idPrefix, idSeparator, readonly, uiSchema, registry, formContext } = this.props; const { fields: { ArraySchemaField, SchemaField }, globalUiOptions, } = registry; const ItemSchemaField = ArraySchemaField || SchemaField; const { orderable = true, removable = true, copyable = false } = getUiOptions(uiSchema, globalUiOptions); const has = { moveUp: orderable && canMoveUp, moveDown: orderable && canMoveDown, copy: copyable && canAdd, remove: removable && canRemove, toolbar: false, }; has.toolbar = Object.keys(has).some((key) => has[key]); return { children: (_jsx(ItemSchemaField, { name: name, title: title, index: index, schema: itemSchema, uiSchema: itemUiSchema, formData: itemData, formContext: formContext, errorSchema: itemErrorSchema, idPrefix: idPrefix, idSeparator: idSeparator, idSchema: itemIdSchema, required: this.isItemRequired(itemSchema), onChange: this.onChangeForIndex(index), onBlur: onBlur, onFocus: onFocus, registry: registry, disabled: disabled, readonly: readonly, hideError: hideError, autofocus: autofocus, rawErrors: rawErrors })), className: 'array-item', disabled, canAdd, hasCopy: has.copy, hasToolbar: has.toolbar, hasMoveUp: has.moveUp, hasMoveDown: has.moveDown, hasRemove: has.remove, index, totalItems, key, onAddIndexClick: this.onAddIndexClick, onCopyIndexClick: this.onCopyIndexClick, onDropIndexClick: this.onDropIndexClick, onReorderClick: this.onReorderClick, readonly, registry, schema: itemSchema, uiSchema: itemUiSchema, }; } } /** `ArrayField` is `React.ComponentType<FieldProps<T[], S, F>>` (necessarily) but the `registry` requires things to be a * `Field` which is defined as `React.ComponentType<FieldProps<T, S, F>>`, so cast it to make `registry` happy. */ export default ArrayField;