import { MouseEvent, useCallback, useMemo, useState } from 'react'; import { allowAdditionalItems, getTemplate, getUiOptions, getWidget, hashObject, isCustomWidget, isFixedItems, isFormDataAvailable, optionsList, shouldRenderOptionalField, toFieldPathId, useDeepCompareMemo, ITEMS_KEY, ID_KEY, ArrayFieldTemplateProps, ErrorSchema, FieldPathId, FieldPathList, FieldProps, FormContextType, Registry, RJSFSchema, StrictRJSFSchema, TranslatableString, UiSchema, UIOptionsType, } from '@rjsf/utils'; import cloneDeep from 'lodash/cloneDeep'; import isObject from 'lodash/isObject'; import set from 'lodash/set'; import uniqueId from 'lodash/uniqueId'; /** Type used to represent the keyed form data used in the state */ type KeyedFormDataType = { key: string; item: T }; /** Used to generate a unique ID for an element in a row */ function generateRowId() { return uniqueId('rjsf-array-item-'); } /** 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?: T[]): KeyedFormDataType[] { 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: KeyedFormDataType | KeyedFormDataType[]): T[] { if (Array.isArray(keyedFormData)) { return keyedFormData.map((keyedItem) => keyedItem.item); } return []; } /** 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 */ function isItemRequired(itemSchema: S) { 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 registry - The registry * @param schema - The schema for the field * @param formItems - The list of items in the form * @param [uiSchema] - The UiSchema for the field * @returns - True if the item is addable otherwise false */ function canAddItem( registry: Registry, schema: S, formItems: T[], uiSchema?: UiSchema, ) { 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; } /** Helper method to compute item UI schema for both normal and fixed arrays * Handles both static object and dynamic function cases * * @param uiSchema - The parent UI schema containing items definition * @param item - The item data * @param index - The index of the item * @param formContext - The form context * @returns The computed UI schema for the item */ function computeItemUiSchema( uiSchema: UiSchema, item: T, index: number, formContext: F, ): UiSchema | undefined { if (typeof uiSchema.items === 'function') { try { // Call the function with item data, index, and form context // TypeScript now correctly infers the types thanks to the ArrayElement type in UiSchema const result = uiSchema.items(item, index, formContext); // Only use the result if it's truthy return result as UiSchema; } catch (e) { console.error(`Error executing dynamic uiSchema.items function for item at index ${index}:`, e); // Fall back to undefined to allow the field to still render return undefined; } } else { // Static object case - preserve undefined to maintain backward compatibility return uiSchema.items as UiSchema | undefined; } } /** 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. */ function getNewFormDataRow( registry: Registry, schema: S, ): T { const { schemaUtils, globalFormOptions } = registry; let itemSchema = schema.items as S; if (globalFormOptions.useFallbackUiForUnsupportedType && !itemSchema) { // If we don't have itemSchema and useFallbackUiForUnsupportedType is on, use an empty schema itemSchema = {} as S; } else if (isFixedItems(schema) && allowAdditionalItems(schema)) { itemSchema = schema.additionalItems as S; } // Cast this as a T to work around schema utils being for T[] caused by the FieldProps call on the class return schemaUtils.getDefaultFormState(itemSchema) as unknown as T; } /** Props used for ArrayAsXxxx type components*/ interface ArrayAsFieldProps< T = any, S extends StrictRJSFSchema = RJSFSchema, F extends FormContextType = any, > extends FieldProps { /** The callback used to update the array when the selector changes */ onSelectChange: (value: T) => void; } /** Renders an array as a set of checkboxes using the 'select' widget */ function ArrayAsMultiSelect( props: ArrayAsFieldProps, ) { const { schema, fieldPathId, uiSchema, formData: items = [], disabled = false, readonly = false, autofocus = false, required = false, placeholder, onBlur, onFocus, registry, rawErrors, name, onSelectChange, } = props; const { widgets, schemaUtils, globalFormOptions, globalUiOptions } = registry; const itemsSchema = schemaUtils.retrieveSchema(schema.items as S, items); // For computing `enumOptions`, fallback to the array property's uiSchema if there is no `items` schema // Avoids a breaking change reported in https://github.com/rjsf-team/react-jsonschema-form/issues/4985 const itemsUiSchema = (uiSchema?.items ?? uiSchema) as UiSchema; const enumOptions = optionsList(itemsSchema, itemsUiSchema); 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); // For custom widgets with multiple=true, generate a fieldPathId with isMultiValue flag const multiValueFieldPathId = useDeepCompareMemo(toFieldPathId('', globalFormOptions, fieldPathId, true)); return ( ); } /** Renders an array using the custom widget provided by the user in the `uiSchema` */ function ArrayAsCustomWidget( props: ArrayAsFieldProps, ) { const { schema, fieldPathId, uiSchema, disabled = false, readonly = false, autofocus = false, required = false, hideError, placeholder, onBlur, onFocus, formData: items = [], registry, rawErrors, name, onSelectChange, } = props; const { widgets, schemaUtils, globalFormOptions, globalUiOptions } = 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); // For custom widgets with multiple=true, generate a fieldPathId with isMultiValue flag const multiValueFieldPathId = useDeepCompareMemo(toFieldPathId('', globalFormOptions, fieldPathId, true)); return ( ); } /** Renders an array of files using the `FileWidget` */ function ArrayAsFiles( props: ArrayAsFieldProps, ) { const { schema, uiSchema, fieldPathId, name, disabled = false, readonly = false, autofocus = false, required = false, onBlur, onFocus, registry, formData: items = [], rawErrors, onSelectChange, } = props; const { widgets, schemaUtils, globalFormOptions, globalUiOptions } = 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); // For custom widgets with multiple=true, generate a fieldPathId with isMultiValue flag const multiValueFieldPathId = useDeepCompareMemo(toFieldPathId('', globalFormOptions, fieldPathId, true)); return ( ); } /** Renders the individual array item using a `SchemaField` along with the additional properties that are needed to * render the whole of the `ArrayFieldItemTemplate`. */ function ArrayFieldItem(props: { itemKey: string; index: number; name: string; disabled: boolean; readonly: boolean; required: boolean; hideError: boolean; registry: Registry; uiOptions: UIOptionsType; parentUiSchema: UiSchema; title: string | undefined; canAdd: boolean; canRemove?: boolean; canMoveUp: boolean; canMoveDown: boolean; itemSchema: S; itemData: T[]; itemUiSchema: UiSchema | undefined; itemFieldPathId: FieldPathId; itemErrorSchema?: ErrorSchema; autofocus?: boolean; onBlur: FieldProps['onBlur']; onFocus: FieldProps['onFocus']; onChange: FieldProps['onChange']; rawErrors?: string[]; totalItems: number; handleAddItem: (event: MouseEvent, index?: number) => void; handleCopyItem: (event: MouseEvent, index: number) => void; handleRemoveItem: (event: MouseEvent, index: number) => void; handleReorderItems: (event: MouseEvent, index: number, newIndex: number) => void; }) { const { itemKey, index, name, disabled, hideError, readonly, registry, uiOptions, parentUiSchema, canAdd, canRemove = true, canMoveUp, canMoveDown, itemSchema, itemData, itemUiSchema, itemFieldPathId, itemErrorSchema, autofocus, onBlur, onFocus, onChange, rawErrors, totalItems, title, handleAddItem, handleCopyItem, handleRemoveItem, handleReorderItems, } = props; const { schemaUtils, fields: { ArraySchemaField, SchemaField }, globalUiOptions, } = registry; const fieldPathId = useDeepCompareMemo(itemFieldPathId); const ItemSchemaField = ArraySchemaField || SchemaField; const ArrayFieldItemTemplate = getTemplate<'ArrayFieldItemTemplate', T[], S, F>( 'ArrayFieldItemTemplate', registry, uiOptions, ); const displayLabel = schemaUtils.getDisplayLabel(itemSchema, itemUiSchema, globalUiOptions); const { description } = getUiOptions(itemUiSchema); const hasDescription = !!description || !!itemSchema.description; const { orderable = true, removable = true, copyable = false } = uiOptions; const has: { [key: string]: boolean } = { moveUp: orderable && canMoveUp, moveDown: orderable && canMoveDown, copy: copyable && canAdd, remove: removable && canRemove, toolbar: false, }; has.toolbar = Object.keys(has).some((key: keyof typeof has) => has[key]); const onAddItem = useCallback( (event: MouseEvent) => { handleAddItem(event, index + 1); }, [handleAddItem, index], ); const onCopyItem = useCallback( (event: MouseEvent) => { handleCopyItem(event, index); }, [handleCopyItem, index], ); const onRemoveItem = useCallback( (event: MouseEvent) => { handleRemoveItem(event, index); }, [handleRemoveItem, index], ); const onMoveUpItem = useCallback( (event: MouseEvent) => { handleReorderItems(event, index, index - 1); }, [handleReorderItems, index], ); const onMoveDownItem = useCallback( (event: MouseEvent) => { handleReorderItems(event, index, index + 1); }, [handleReorderItems, index], ); const templateProps = { children: ( (itemSchema)} onChange={onChange} onBlur={onBlur} onFocus={onFocus} registry={registry} disabled={disabled} readonly={readonly} hideError={hideError} autofocus={autofocus} rawErrors={rawErrors} /> ), buttonsProps: { fieldPathId, disabled, readonly, canAdd, hasCopy: has.copy, hasMoveUp: has.moveUp, hasMoveDown: has.moveDown, hasRemove: has.remove, index: index, totalItems, onAddItem, onCopyItem, onRemoveItem, onMoveUpItem, onMoveDownItem, registry, schema: itemSchema, uiSchema: itemUiSchema, }, itemKey, className: 'rjsf-array-item', disabled, hasToolbar: has.toolbar, index, totalItems, readonly, registry, schema: itemSchema, uiSchema: itemUiSchema, parentUiSchema, displayLabel, hasDescription, }; return ; } /** The properties required by the stateless components that render the items using the `ArrayFieldItem` */ interface InternalArrayFieldProps< T = any, S extends StrictRJSFSchema = RJSFSchema, F extends FormContextType = any, > extends FieldProps { /** The keyedFormData from the `ArrayField` state */ keyedFormData: KeyedFormDataType[]; /** The callback used to handle the adding of an item at the given index (or the end, if missing) */ handleAddItem: (event: MouseEvent, index?: number) => void; /** The callback used to handle the copying of the item at the given index, below itself */ handleCopyItem: (event: MouseEvent, index: number) => void; /** The callback used to handle removing an item at the given index */ handleRemoveItem: (event: MouseEvent, index: number) => void; /** The callback used to handle reordering an item at the given index to its newIndex */ handleReorderItems: (event: MouseEvent, index: number, newIndex: number) => void; } /** Renders a normal array without any limitations of length */ function NormalArray( props: InternalArrayFieldProps, ) { const { schema, uiSchema = {}, errorSchema, fieldPathId, formData: formDataFromProps, name, title, disabled = false, readonly = false, autofocus = false, required = false, hideError = false, registry, onBlur, onFocus, rawErrors, onChange, keyedFormData, handleAddItem, handleCopyItem, handleRemoveItem, handleReorderItems, } = props; const fieldTitle = schema.title || title || name; const { schemaUtils, fields, formContext, globalFormOptions, globalUiOptions } = registry; const { OptionalDataControlsField } = fields; const uiOptions = getUiOptions(uiSchema, globalUiOptions); const _schemaItems: S = isObject(schema.items) ? (schema.items as S) : ({} as S); const itemsSchema: S = schemaUtils.retrieveSchema(_schemaItems); const formData = keyedToPlainFormData(keyedFormData); const renderOptionalField = shouldRenderOptionalField(registry, schema, required, uiSchema); const hasFormData = isFormDataAvailable(formDataFromProps); const canAdd = canAddItem(registry, schema, formData, uiSchema) && (!renderOptionalField || hasFormData); const actualFormData = hasFormData ? keyedFormData : []; const extraClass = renderOptionalField ? ' rjsf-optional-array-field' : ''; // All the children will use childFieldPathId if present in the props, falling back to the fieldPathId const childFieldPathId = props.childFieldPathId ?? fieldPathId; const optionalDataControl = renderOptionalField ? ( ) : undefined; const arrayProps: ArrayFieldTemplateProps = { canAdd, items: actualFormData.map((keyedItem, index: number) => { 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 as unknown as T[]; const itemSchema = schemaUtils.retrieveSchema(_schemaItems, itemCast); const itemErrorSchema = errorSchema ? (errorSchema[index] as ErrorSchema) : undefined; const itemFieldPathId = toFieldPathId(index, globalFormOptions, childFieldPathId); // Compute the item UI schema using the helper method const itemUiSchema = computeItemUiSchema(uiSchema, item, index, formContext); const itemProps = { itemKey: key, index, name: name && `${name}-${index}`, registry, uiOptions, parentUiSchema: uiSchema, hideError, readonly, disabled, required, title: fieldTitle ? `${fieldTitle}-${index + 1}` : undefined, canAdd, canMoveUp: index > 0, canMoveDown: index < formData.length - 1, itemSchema, itemFieldPathId, itemErrorSchema, itemData: itemCast, itemUiSchema, autofocus: autofocus && index === 0, onBlur, onFocus, rawErrors, totalItems: keyedFormData.length, handleAddItem, handleCopyItem, handleRemoveItem, handleReorderItems, onChange, }; return ; }), className: `rjsf-field rjsf-field-array rjsf-field-array-of-${itemsSchema.type}${extraClass}`, disabled, fieldPathId, uiSchema, onAddClick: handleAddItem, readonly, required, schema, title: fieldTitle, formData, rawErrors, registry, optionalDataControl, }; const Template = getTemplate<'ArrayFieldTemplate', T[], S, F>('ArrayFieldTemplate', registry, uiOptions); return