KeyValueFieldArray.tsx238 lines · main
1import { ChevronDown, Plus, Trash } from 'lucide-react'
2import { Fragment, ReactNode } from 'react'
3import {
4 Control,
5 FieldArray,
6 FieldArrayPath,
7 FieldArrayWithId,
8 FieldPath,
9 FieldValues,
10 useFieldArray,
11} from 'react-hook-form'
12import {
13 Button,
14 cn,
15 DropdownMenu,
16 DropdownMenuContent,
17 DropdownMenuItem,
18 DropdownMenuSeparator,
19 DropdownMenuTrigger,
20 FormControl,
21 FormField,
22 FormItem,
23 FormMessage,
24 Input,
25} from 'ui'
26
27export type KeyValueFieldArrayAction<TItem> = {
28 key: string
29 label: ReactNode
30 description?: ReactNode
31 createRows: () => TItem | TItem[]
32 separatorAbove?: boolean
33}
34
35export interface KeyValueFieldArrayProps<
36 TFieldValues extends FieldValues,
37 TFieldArrayName extends FieldArrayPath<TFieldValues>,
38 TItem extends FieldArray<TFieldValues, TFieldArrayName> = FieldArray<
39 TFieldValues,
40 TFieldArrayName
41 >,
42> {
43 control: Control<TFieldValues>
44 name: TFieldArrayName
45 keyFieldName: Extract<keyof TItem, string>
46 valueFieldName: Extract<keyof TItem, string>
47 createEmptyRow: () => TItem
48 keyPlaceholder: string
49 valuePlaceholder: string
50 addLabel: string
51 addActions?: KeyValueFieldArrayAction<TItem>[]
52 disabled?: boolean
53 inputSize?: React.ComponentProps<typeof Input>['size']
54 className?: string
55 rowsClassName?: string
56 rowClassName?: string
57 keyInputClassName?: string
58 valueInputClassName?: string
59 addButtonClassName?: string
60 removeButtonClassName?: string
61 removeLabel?: string
62}
63
64const toFieldPath = <TFieldValues extends FieldValues>(path: string) => {
65 return path as FieldPath<TFieldValues>
66}
67
68const appendRows = <
69 TFieldValues extends FieldValues,
70 TFieldArrayName extends FieldArrayPath<TFieldValues>,
71>(
72 append: (
73 value: FieldArray<TFieldValues, TFieldArrayName> | FieldArray<TFieldValues, TFieldArrayName>[]
74 ) => void,
75 rows: FieldArray<TFieldValues, TFieldArrayName> | FieldArray<TFieldValues, TFieldArrayName>[]
76) => {
77 append(Array.isArray(rows) && rows.length === 1 ? rows[0] : rows)
78}
79
80/**
81 * Rendering-only field array for text/text pairs.
82 *
83 * Consumers own validation in their resolver schema and can rely on the nested
84 * `FormMessage` instances here to display per-cell errors.
85 */
86export const KeyValueFieldArray = <
87 TFieldValues extends FieldValues,
88 TFieldArrayName extends FieldArrayPath<TFieldValues>,
89 TItem extends FieldArray<TFieldValues, TFieldArrayName> = FieldArray<
90 TFieldValues,
91 TFieldArrayName
92 >,
93>({
94 control,
95 name,
96 keyFieldName,
97 valueFieldName,
98 createEmptyRow,
99 keyPlaceholder,
100 valuePlaceholder,
101 addLabel,
102 addActions = [],
103 disabled = false,
104 inputSize = 'small',
105 className,
106 rowsClassName = 'space-y-3 mt-1',
107 rowClassName,
108 keyInputClassName,
109 valueInputClassName,
110 addButtonClassName,
111 removeButtonClassName,
112 removeLabel = 'Remove row',
113}: KeyValueFieldArrayProps<TFieldValues, TFieldArrayName, TItem>) => {
114 const { fields, append, remove } = useFieldArray<TFieldValues, TFieldArrayName, 'fieldId'>({
115 control,
116 name,
117 keyName: 'fieldId',
118 })
119
120 const typedFields = fields as FieldArrayWithId<TFieldValues, TFieldArrayName, 'fieldId'>[]
121 const hasAddActions = addActions.length > 0
122 const addActionsLabel = `${addLabel} options`
123
124 return (
125 <div className={cn('space-y-3', className)}>
126 <div className={rowsClassName}>
127 {typedFields.map((field, index) => (
128 <div key={field.fieldId} className={cn('flex items-start space-x-2', rowClassName)}>
129 <FormField
130 control={control}
131 name={toFieldPath<TFieldValues>(`${name}.${index}.${keyFieldName}`)}
132 render={({ field }) => (
133 <FormItem className="flex-1">
134 <FormControl>
135 <Input
136 {...field}
137 size={inputSize}
138 className={cn('w-full', keyInputClassName)}
139 placeholder={keyPlaceholder}
140 disabled={disabled}
141 />
142 </FormControl>
143 <FormMessage />
144 </FormItem>
145 )}
146 />
147
148 <FormField
149 control={control}
150 name={toFieldPath<TFieldValues>(`${name}.${index}.${valueFieldName}`)}
151 render={({ field }) => (
152 <FormItem className="flex-1">
153 <FormControl>
154 <Input
155 {...field}
156 size={inputSize}
157 className={cn('w-full', valueInputClassName)}
158 placeholder={valuePlaceholder}
159 disabled={disabled}
160 />
161 </FormControl>
162 <FormMessage />
163 </FormItem>
164 )}
165 />
166
167 <Button
168 type="default"
169 size="tiny"
170 htmlType="button"
171 icon={<Trash size={12} />}
172 aria-label={removeLabel}
173 disabled={disabled}
174 onClick={() => remove(index)}
175 className={cn('h-[34px] w-[34px] shrink-0', removeButtonClassName)}
176 />
177 </div>
178 ))}
179 </div>
180
181 <div className="flex items-center">
182 <Button
183 type="default"
184 size="tiny"
185 htmlType="button"
186 icon={<Plus />}
187 disabled={disabled}
188 onClick={() => append(createEmptyRow())}
189 className={cn(hasAddActions && 'rounded-r-none border-r-0 px-3', addButtonClassName)}
190 >
191 {addLabel}
192 </Button>
193
194 {hasAddActions && (
195 <DropdownMenu>
196 <DropdownMenuTrigger asChild>
197 <Button
198 type="default"
199 size="tiny"
200 htmlType="button"
201 icon={<ChevronDown size={14} />}
202 aria-label={addActionsLabel}
203 disabled={disabled}
204 className="rounded-l-none px-[4px] py-[5px]"
205 />
206 </DropdownMenuTrigger>
207 <DropdownMenuContent align="end" side="bottom">
208 {addActions.map((action) => (
209 <Fragment key={action.key}>
210 {action.separatorAbove && <DropdownMenuSeparator />}
211 <DropdownMenuItem
212 onClick={() =>
213 appendRows<TFieldValues, TFieldArrayName>(
214 append,
215 action.createRows() as
216 | FieldArray<TFieldValues, TFieldArrayName>
217 | FieldArray<TFieldValues, TFieldArrayName>[]
218 )
219 }
220 >
221 {action.description ? (
222 <div className="space-y-1">
223 <div className="block text-foreground">{action.label}</div>
224 <div className="text-foreground-light">{action.description}</div>
225 </div>
226 ) : (
227 action.label
228 )}
229 </DropdownMenuItem>
230 </Fragment>
231 ))}
232 </DropdownMenuContent>
233 </DropdownMenu>
234 )}
235 </div>
236 </div>
237 )
238}