import get from "lodash/get"; import * as React from "react"; import type { ReactElement, ReactNode } from "react"; import { Children, useCallback, useMemo, useRef, useState } from "react"; import type { ArrayInputContextValue, RaRecord, SimpleFormIteratorItemContextValue, } from "ra-core"; import { FormDataConsumer, RecordContextProvider, SimpleFormIteratorContext, SimpleFormIteratorItemContext, SourceContextProvider, useArrayInput, useRecordContext, useResourceContext, useSimpleFormIterator, useSimpleFormIteratorItem, useSourceContext, useTranslate, useWrappedSource, } from "ra-core"; import type { UseFieldArrayReturn } from "react-hook-form"; import { useFormContext } from "react-hook-form"; import { ArrowDownCircle, ArrowUpCircle, PlusCircle, Trash, XCircle, } from "lucide-react"; import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; import { Confirm } from "@/components/admin/confirm"; import { IconButtonWithTooltip } from "@/components/admin/icon-button-with-tooltip"; type GetItemLabelFunc = (index: number) => string | ReactElement; /** * An array input iterator for managing dynamic lists of items in forms. * * Renders a list of form items with add, remove, and reorder controls. Use inside ArrayInput * for arrays of objects or scalar values. Supports inline layouts and custom buttons. * * @see {@link https://marmelab.com/shadcn-admin-kit/docs/arrayinput/ ArrayInput documentation} * * @example * import { ArrayInput, SimpleFormIterator, TextInput } from '@/components/admin'; * * const PostEdit = () => ( * * * * * * * ); */ export const SimpleFormIterator = (props: SimpleFormIteratorProps) => { const { addButton = defaultAddItemButton, removeButton, reOrderButtons, children, className, resource, disabled, disableAdd = false, disableClear, disableRemove = false, disableReordering, inline, getItemLabel = false, } = props; const finalSource = useWrappedSource(""); if (!finalSource) { throw new Error( "SimpleFormIterator can only be called within an iterator input like ArrayInput", ); } const [confirmIsOpen, setConfirmIsOpen] = useState(false); const { append, fields, move, remove, replace } = useArrayInput(props); const { trigger, getValues } = useFormContext(); const translate = useTranslate(); const record = useRecordContext(props); const initialDefaultValue = useRef({}); const removeField = useCallback( (index: number) => { remove(index); const isScalarArray = getValues(finalSource).every( (value: any) => typeof value !== "object", ); if (isScalarArray) { // Trigger validation on the Array to avoid ghost errors. // Otherwise, validation errors on removed fields might still be displayed trigger(finalSource); } }, [remove, trigger, finalSource, getValues], ); if (fields.length > 0) { const { id: _id, ...rest } = fields[0]; initialDefaultValue.current = rest; for (const k in initialDefaultValue.current) { // @ts-expect-error: reset fields initialDefaultValue.current[k] = null; } } const addField = useCallback( (item: any = undefined) => { let defaultValue = item; if (item == null) { defaultValue = initialDefaultValue.current; if ( Children.count(children) === 1 && React.isValidElement(Children.only(children)) && // @ts-expect-error: Check if the child has a source prop !Children.only(children).props.source && // Make sure it's not a FormDataConsumer // @ts-expect-error: Check if the child is a FormDataConsumer Children.only(children).type !== FormDataConsumer ) { // ArrayInput used for an array of scalar values // (e.g. tags: ['foo', 'bar']) defaultValue = ""; } else { // ArrayInput used for an array of objects // (e.g. authors: [{ firstName: 'John', lastName: 'Doe' }, { firstName: 'Jane', lastName: 'Doe' }]) defaultValue = defaultValue || ({} as Record); Children.forEach(children, (input) => { if ( React.isValidElement(input) && input.type !== FormDataConsumer && // @ts-expect-error: Check if the child has a source prop input.props.source ) { // @ts-expect-error: Check if the child has a source prop defaultValue[input.props.source] = // @ts-expect-error: Check if the child has a source prop input.props.defaultValue ?? null; } }); } } append(defaultValue); }, [append, children], ); const handleReorder = useCallback( (origin: number, destination: number) => { move(origin, destination); }, [move], ); const handleArrayClear = useCallback(() => { replace([]); setConfirmIsOpen(false); }, [replace]); const records = get(record, finalSource); const context = useMemo( () => ({ total: fields.length, add: addField, remove: removeField, clear: handleArrayClear, reOrder: handleReorder, source: finalSource, }), [ fields.length, addField, removeField, handleArrayClear, handleReorder, finalSource, ], ); return fields ? (
    {fields.map((member, index) => ( {children} ))}
{!disabled && !(disableAdd && (disableClear || disableRemove)) && (
{!disableAdd && addButton} {fields.length > 0 && !disableClear && !disableRemove && ( <> setConfirmIsOpen(false)} /> setConfirmIsOpen(true)} /> )}
)}
) : null; }; export interface SimpleFormIteratorProps extends Partial { addButton?: ReactElement; children?: ReactElement | ReactElement[]; className?: string; readOnly?: boolean; disabled?: boolean; disableAdd?: boolean; disableClear?: boolean; disableRemove?: boolean | DisableRemoveFunction; disableReordering?: boolean; fullWidth?: boolean; getItemLabel?: boolean | GetItemLabelFunc; inline?: boolean; meta?: { // the type defined in FieldArrayRenderProps says error is boolean, which is wrong. error?: any; submitFailed?: boolean; }; record?: RaRecord; removeButton?: ReactElement; reOrderButtons?: ReactElement; resource?: string; source?: string; } /** * A single item in a SimpleFormIterator list with controls. * * Renders one item from an array with its input fields and action buttons (remove, reorder). * Usually used internally by SimpleFormIterator but can be customized. * * @example * import { SimpleFormIteratorItem } from '@/components/admin'; * * // Typically used internally by SimpleFormIterator */ export const SimpleFormIteratorItem = React.forwardRef( ( props: SimpleFormIteratorItemProps, ref: React.ForwardedRef, ) => { const { children, disabled, disableReordering, disableRemove, getItemLabel, index, inline, record, removeButton = defaultRemoveItemButton, reOrderButtons = defaultReOrderButtons, } = props; const resource = useResourceContext(props); if (!resource) { throw new Error( "SimpleFormIteratorItem must be used in a ResourceContextProvider or be passed a resource prop.", ); } const { total, reOrder, remove } = useSimpleFormIterator(); // Returns a boolean to indicate whether to disable the remove button for certain fields. // If disableRemove is a function, then call the function with the current record to // determining if the button should be disabled. Otherwise, use a boolean property that // enables or disables the button for all of the fields. const disableRemoveField = (record: RaRecord) => { if (typeof disableRemove === "boolean") { return disableRemove; } return disableRemove && disableRemove(record); }; const context = useMemo( () => ({ index, total, reOrder: (newIndex) => reOrder(index, newIndex), remove: () => remove(index), }), [index, total, reOrder, remove], ); const label = typeof getItemLabel === "function" ? getItemLabel(index) : getItemLabel; const parentSourceContext = useSourceContext(); const sourceContext = useMemo( () => ({ getSource: (source: string) => { if (!source) { // source can be empty for scalar values, e.g. // => SourceContext is "tags" // => SourceContext is "tags.0" // => use its parent's getSource so finalSource = "tags.0" // // return parentSourceContext.getSource(`${index}`); } else { // Normal input with source, e.g. // => SourceContext is "orders" // => SourceContext is "orders.0" // => use its parent's getSource so finalSource = "orders.0.date" // // return parentSourceContext.getSource(`${index}.${source}`); } }, getLabel: (source: string) => { // => LabelContext is "orders" // => LabelContext is ALSO "orders" // => use its parent's getLabel so finalLabel = "orders.date" // // // // we don't prefix with the index to avoid that translation keys contain it return parentSourceContext.getLabel(source); }, }), [index, parentSourceContext], ); return (
  • .simple-form-iterator-item-actions]:pt-10", )} > {label != null && label !== false && (

    {label}

    )}
    {children}
    {!disabled && (
    {!disableReordering && reOrderButtons} {!disableRemoveField(record) && removeButton}
    )}
  • ); }, ); export type DisableRemoveFunction = (record: RaRecord) => boolean; export type SimpleFormIteratorItemProps = Partial & { children?: ReactNode; disabled?: boolean; disableRemove?: boolean | DisableRemoveFunction; disableReordering?: boolean; getItemLabel?: boolean | GetItemLabelFunc; index: number; inline?: boolean; onRemoveField: (index: number) => void; onReorder: (origin: number, destination: number) => void; record: RaRecord; removeButton?: ReactElement; reOrderButtons?: ReactElement; resource?: string; source?: string; }; /** * A button to add new items to a SimpleFormIterator. * * Renders a plus icon button that appends a new item to the array. Works with the * SimpleFormIterator context to add items with default values. * * @example * import { ArrayInput, SimpleFormIterator, AddItemButton } from '@/components/admin'; * * const PostEdit = () => ( * * }> * ... * * * ); */ export const AddItemButton = (props: React.ComponentProps<"button">) => { const { add, source } = useSimpleFormIterator(); const { className, ...rest } = props; const translate = useTranslate(); return ( {translate("ra.action.add")} ); }; /** * Up and down buttons for reordering items in a SimpleFormIterator. * * Renders arrow buttons that move an item up or down in the list. Used internally * by SimpleFormIterator but can be customized. * * @example * import { SimpleFormIterator, ReOrderButtons } from '@/components/admin'; * * const PostEdit = () => ( * }> * ... * * ); */ export const ReOrderButtons = ({ className }: { className?: string }) => { const { index, total, reOrder } = useSimpleFormIteratorItem(); const { source } = useSimpleFormIterator(); return ( reOrder(index - 1)} disabled={index <= 0} > reOrder(index + 1)} disabled={total == null || index >= total - 1} > ); }; /** * A button to clear all items from a SimpleFormIterator. * * Renders a trash icon button that removes all items from the array after confirmation. * Used internally by SimpleFormIterator when disableClear is false. * * @example * import { ClearArrayButton } from '@/components/admin'; * * // Typically used internally by SimpleFormIterator */ export const ClearArrayButton = (props: React.ComponentProps<"button">) => { const translate = useTranslate(); return ( {translate("ra.action.clear_array_input")} ); }; /** * A button to remove a single item from a SimpleFormIterator. * * Renders a close icon button that removes the current item from the array. Used internally * by SimpleFormIterator for each item when disableRemove is false. * * @example * import { SimpleFormIterator, RemoveItemButton } from '@/components/admin'; * * const PostEdit = () => ( * }> * ... * * ); */ export const RemoveItemButton = (props: React.ComponentProps<"button">) => { const { remove, index } = useSimpleFormIteratorItem(); const { source } = useSimpleFormIterator(); const { className, ...rest } = props; const translate = useTranslate(); return ( {translate("ra.action.remove")} ); }; const defaultAddItemButton = ; const defaultRemoveItemButton = ; const defaultReOrderButtons = ;