import { useAutoAnimate } from "@formkit/auto-animate/react"; import { useFieldArray, useFormContext, type FieldValues, type Path, type PathValue, type ArrayPath, } from "react-hook-form"; import { v4 as uuidv4 } from "uuid"; import classNames from "@calcom/ui/classNames"; import { Button } from "@calcom/ui/components/button"; import { TextField } from "@calcom/ui/components/form"; import { ArrowDownIcon, ArrowUpIcon, XIcon } from "@coss/ui/icons"; export interface Option { label: string; value?: string; id?: string; [key: string]: any; } interface MultiOptionInputProps { /** * The field array name in the form schema * @example "fields.0.options" or "questions" */ fieldArrayName: ArrayPath; /** * Whether to display as key-value pairs * @default false */ keyValueMode?: boolean; /** * Label for the key field in key-value mode * @default "Key" */ keyLabel?: string; /** * Label for the value field in key-value mode * @default "Value" */ valueLabel?: string; /** * Custom regex pattern to validate keys */ keyPattern?: string; addOptionLabel?: string; addOptionButtonColor?: "primary" | "secondary" | "minimal"; optionPlaceholders?: string[]; /** * Placeholders for value fields in key-value mode */ valuePlaceholders?: string[]; defaultNumberOfOptions?: number; /** * Delimiters to split pasted text on. Defaults to ["\n", ","] * For key-value pairs, will try to split on ":" or "=" between key and value */ pasteDelimiters?: string[]; /** * Delimiters to split key-value pairs on. Defaults to [":", "="] */ keyValueDelimiters?: string[]; /** * Whether to show move up/down buttons. Defaults to true */ showMoveButtons?: boolean; /** * Whether to show remove button. Defaults to true when there is more than 1 option */ showRemoveButton?: boolean; /** * Minimum number of options required. Defaults to 1 */ minOptions?: number; disabled?: boolean; } export const MultiOptionInput = ({ fieldArrayName, keyValueMode = false, keyLabel = "Key", valueLabel = "Value", keyPattern, addOptionLabel = "Add an option", optionPlaceholders = ["Option 1", "Option 2", "Option 3", "Option 4"], valuePlaceholders = ["Value 1", "Value 2", "Value 3", "Value 4"], defaultNumberOfOptions = 4, pasteDelimiters = ["\n", ","], keyValueDelimiters = [":", "="], showMoveButtons = true, showRemoveButton: _showRemoveButton, minOptions = 1, disabled = false, addOptionButtonColor = "primary", }: MultiOptionInputProps) => { const [animationRef] = useAutoAnimate(); const { control } = useFormContext(); const { fields: options, append: appendOption, remove: removeOption, move: moveOption, replace: replaceOptions, } = useFieldArray({ control, name: fieldArrayName, }); // Initialize with default options if none exist if (options.length === 0) { const defaultOptions = Array(defaultNumberOfOptions) .fill(0) .map(() => ({ label: "", value: "", id: uuidv4() })); replaceOptions(defaultOptions as PathValue>); } const showRemoveButton = _showRemoveButton ?? options.length > minOptions; const handlePasteInOptionAtIndex = (event: React.ClipboardEvent, optionIndex: number) => { const paste = event.clipboardData.getData("text"); // Split on any of the delimiters const delimiterRegex = new RegExp(`[${pasteDelimiters.join("")}]+`); const keyValueRegex = new RegExp( `([^${keyValueDelimiters.join("")}]+)([${keyValueDelimiters.join("")}])(.+)` ); const optionsBeingPasted = paste .split(delimiterRegex) .map((optionText) => optionText.trim()) .filter((optionText) => optionText) .map((optionText) => { // If in key-value mode, try to parse as key-value if (keyValueMode) { const match = optionText.match(keyValueRegex); if (match) { return { label: match[1].trim(), value: match[3].trim(), id: uuidv4(), }; } return { label: optionText, value: "", id: uuidv4() }; } return { label: optionText, id: uuidv4() }; }); if (optionsBeingPasted.length === 1) { // If there is only one option, let the default paste behavior handle it return; } // Don't allow pasting that value, as we would update the options through state update event.preventDefault(); // Replace the current option with the first pasted option const updatedOptions = [...options] as PathValue>[]; updatedOptions[optionIndex] = optionsBeingPasted[0] as PathValue>; // Insert the rest of the options after the current option updatedOptions.splice( optionIndex + 1, 0, ...(optionsBeingPasted.slice(1) as PathValue>[]) ); replaceOptions(updatedOptions as PathValue>); }; const addOption = () => { appendOption({ label: "", value: "", id: uuidv4() } as PathValue>); }; const handleRemoveOption = (index: number) => { if (options.length <= minOptions) return; removeOption(index); }; const moveUp = (index: number) => { if (index === 0) return; moveOption(index, index - 1); }; const moveDown = (index: number) => { if (index === options.length - 1) return; moveOption(index, index + 1); }; return (
{keyValueMode && (
{keyLabel}
{valueLabel}
{/* Space for buttons */}
)}
    {options.map((option, index) => (
  • handlePasteInOptionAtIndex(event, index)}> {keyValueMode ? ( // Key-value pair mode
    )} />
    )} addOnSuffix={ showRemoveButton ? ( ) : null } />
    ) : ( // Standard mode
    )} data-testid={`${fieldArrayName}.${index}-input`} addOnSuffix={ showRemoveButton ? ( ) : null } />
    )} {showMoveButtons && (
    )}
  • ))}
); };