chore(studio): share key-value field array editor

This commit is contained in:
Danny White committed 2026-03-19 11:42:19 +11:00
1 parent 49ad1eb325
commit 4eb02a46b1
9 files changed
+587 -313

No files matched your search

@@ -1,28 +1,16 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useParams } from 'common'
import { ChevronDown, Plus, Trash } from 'lucide-react'
import { useFieldArray, UseFormReturn } from 'react-hook-form'
import {
Button,
cn,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
FormControl_Shadcn_,
FormField_Shadcn_,
Input_Shadcn_,
useWatch_Shadcn_,
} from 'ui'
import { UseFormReturn } from 'react-hook-form'
import { useWatch_Shadcn_ } from 'ui'
import { KeyValueFieldArray } from 'ui-patterns/form/KeyValueFieldArray/KeyValueFieldArray'
import { WebhookFormValues } from './EditHookPanel.constants'
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
import {
FormSection,
FormSectionContent,
FormSectionLabel,
} from '@/components/ui/Forms/FormSection'
import { buildEdgeFunctionHeaderAddActions } from '@/components/interfaces/Functions/httpHeaderAddActions'
import { getKeys, useAPIKeysQuery } from '@/data/api-keys/api-keys-query'
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
import { uuidv4 } from '@/lib/helpers'
@@ -44,126 +32,31 @@ export const HTTPHeaders = ({ form }: HTTPHeadersProps) => {
const apiKey = secretKey?.api_key ?? serviceKey?.api_key ?? '[YOUR API KEY]'
const functionType = useWatch_Shadcn_({ control: form.control, name: 'function_type' })
const {
fields: headerFields,
append: appendHeader,
remove: removeHeader,
} = useFieldArray({
control: form.control,
name: 'httpHeaders',
})
const onAddHeaders = (headers?: { id: string; name: string; value: string }[]) => {
if (headers) {
headers.forEach((header) => appendHeader(header))
} else {
appendHeader({ id: uuidv4(), name: '', value: '' })
}
}
const addActions =
functionType === 'supabase_function'
? buildEdgeFunctionHeaderAddActions({
apiKey,
includeApiKeyHeader: serviceKey?.type === 'secret',
createRow: (name: string, value: string) => ({ id: uuidv4(), name, value }),
})
: []
return (
<FormSection
header={<FormSectionLabel className="lg:!col-span-4">HTTP Headers</FormSectionLabel>}
>
<FormSectionContent loading={false} className="lg:!col-span-8">
<div className="space-y-2">
{headerFields.map((field, index) => (
<div key={field.id} className="flex items-center space-x-2">
<FormField_Shadcn_
control={form.control}
name={`httpHeaders.${index}.name`}
render={({ field }) => (
<FormControl_Shadcn_>
<Input_Shadcn_ {...field} className="w-full" placeholder="Header name" />
</FormControl_Shadcn_>
)}
/>
<FormField_Shadcn_
control={form.control}
name={`httpHeaders.${index}.value`}
render={({ field }) => (
<FormControl_Shadcn_>
<Input_Shadcn_ {...field} className="w-full" placeholder="Header value" />
</FormControl_Shadcn_>
)}
/>
<ButtonTooltip
type="text"
icon={<Trash />}
className="py-4"
onClick={() => removeHeader(index)}
tooltip={{ content: { side: 'bottom', text: 'Remove header' } }}
/>
</div>
))}
<div className="flex items-center">
<Button
type="default"
size="tiny"
icon={<Plus />}
className={cn(functionType === 'supabase_function' && 'rounded-r-none px-3')}
onClick={() => onAddHeaders()}
>
Add a new header
</Button>
{functionType === 'supabase_function' && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="default"
icon={<ChevronDown />}
className="rounded-l-none px-[4px] py-[5px]"
/>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" side="bottom">
<DropdownMenuItem
key="add-auth-header"
onClick={() => {
onAddHeaders([
{
id: uuidv4(),
name: 'Authorization',
value: `Bearer ${apiKey}`,
},
...(serviceKey?.type === 'secret'
? [{ id: uuidv4(), name: 'apikey', value: apiKey }]
: []),
])
}}
>
<div className="space-y-1">
<p className="block text-foreground">Add auth header with secret key</p>
<p className="text-foreground-light">
Required if your edge function enforces JWT verification
</p>
</div>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
key="add-source-header"
onClick={() =>
onAddHeaders([
{
id: uuidv4(),
name: 'x-supabase-webhook-source',
value: `[Use a secret value]`,
},
])
}
>
<div className="space-y-1">
<p className="block text-foreground">Add custom source header</p>
<p className="text-foreground-light">
Useful to verify that the edge function was triggered from this webhook
</p>
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
<KeyValueFieldArray
control={form.control}
name="httpHeaders"
keyFieldName="name"
valueFieldName="value"
createEmptyRow={() => ({ id: uuidv4(), name: '', value: '' })}
keyPlaceholder="Header name"
valuePlaceholder="Header value"
addLabel="Add a new header"
addActions={addActions}
/>
</FormSectionContent>
</FormSection>
)
@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest'
import { buildEdgeFunctionHeaderAddActions } from './httpHeaderAddActions'
describe('buildEdgeFunctionHeaderAddActions', () => {
it('includes the apikey header when requested', () => {
const [authAction] = buildEdgeFunctionHeaderAddActions({
apiKey: 'secret-key',
includeApiKeyHeader: true,
createRow: (name, value) => ({ name, value }),
})
expect(authAction.createRows()).toEqual([
{ name: 'Authorization', value: 'Bearer secret-key' },
{ name: 'apikey', value: 'secret-key' },
])
})
it('omits the apikey header when not requested', () => {
const [authAction] = buildEdgeFunctionHeaderAddActions({
apiKey: 'service-key',
includeApiKeyHeader: false,
createRow: (name, value) => ({ name, value }),
})
expect(authAction.createRows()).toEqual([
{ name: 'Authorization', value: 'Bearer service-key' },
])
})
})
@@ -0,0 +1,30 @@
import type { KeyValueFieldArrayAction } from 'ui-patterns/form/KeyValueFieldArray/KeyValueFieldArray'
interface BuildEdgeFunctionHeaderAddActionsParams<TRow> {
apiKey: string
includeApiKeyHeader?: boolean
createRow: (name: string, value: string) => TRow
}
export const buildEdgeFunctionHeaderAddActions = <TRow,>({
apiKey,
includeApiKeyHeader = false,
createRow,
}: BuildEdgeFunctionHeaderAddActionsParams<TRow>): KeyValueFieldArrayAction<TRow>[] => [
{
key: 'add-auth-header',
label: 'Add auth header with secret key',
description: 'Required if your edge function enforces JWT verification',
createRows: () => [
createRow('Authorization', `Bearer ${apiKey}`),
...(includeApiKeyHeader ? [createRow('apikey', apiKey)] : []),
],
},
{
key: 'add-source-header',
label: 'Add custom source header',
description: 'Useful to verify that the edge function was triggered from this webhook',
createRows: () => createRow('x-supabase-webhook-source', '[Use a secret value]'),
separatorAbove: true,
},
]
@@ -1,37 +1,20 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { ChevronDown, Plus, Trash } from 'lucide-react'
import { useFieldArray } from 'react-hook-form'
import { useFormContext } from 'react-hook-form'
import { useParams } from 'common'
import { getKeys, useAPIKeysQuery } from 'data/api-keys/api-keys-query'
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
import {
Button,
cn,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
FormControl_Shadcn_,
FormField_Shadcn_,
FormItem_Shadcn_,
FormLabel_Shadcn_,
FormMessage_Shadcn_,
Input_Shadcn_,
SheetSection,
} from 'ui'
import { FormLabel_Shadcn_, SheetSection } from 'ui'
import { KeyValueFieldArray } from 'ui-patterns/form/KeyValueFieldArray/KeyValueFieldArray'
import { CreateCronJobForm } from './CreateCronJobSheet/CreateCronJobSheet.constants'
import { buildEdgeFunctionHeaderAddActions } from '@/components/interfaces/Functions/httpHeaderAddActions'
interface HTTPHeaderFieldsSectionProps {
variant: 'edge_function' | 'http_request'
}
export const HTTPHeaderFieldsSection = ({ variant }: HTTPHeaderFieldsSectionProps) => {
// gets the fields through form context
const { fields, append, remove } = useFieldArray<CreateCronJobForm>({
name: 'values.httpHeaders',
})
const form = useFormContext<CreateCronJobForm>()
const { ref } = useParams()
const { can: canReadAPIKeys } = useAsyncCheckPermissions(PermissionAction.SECRETS_READ, '*')
@@ -42,114 +25,29 @@ export const HTTPHeaderFieldsSection = ({ variant }: HTTPHeaderFieldsSectionProp
const { serviceKey, secretKey } = getKeys(apiKeys)
const apiKey = secretKey?.api_key ?? serviceKey?.api_key ?? '[YOUR API KEY]'
const addActions =
variant === 'edge_function'
? buildEdgeFunctionHeaderAddActions({
apiKey,
includeApiKeyHeader: serviceKey?.type === 'secret',
createRow: (name: string, value: string) => ({ name, value }),
})
: []
return (
<SheetSection>
<FormLabel_Shadcn_>HTTP Headers</FormLabel_Shadcn_>
<div className="space-y-3 mt-1">
{fields.map((field, index) => (
<div key={field.id} className="flex items-center space-x-2">
<FormField_Shadcn_
name={`values.httpHeaders.${index}.name`}
render={({ field }) => (
<FormItem_Shadcn_ className="flex-1">
<FormControl_Shadcn_>
<Input_Shadcn_
{...field}
size="small"
className="w-full"
placeholder="Header name"
/>
</FormControl_Shadcn_>
<FormMessage_Shadcn_ />
</FormItem_Shadcn_>
)}
/>
<FormField_Shadcn_
name={`values.httpHeaders.${index}.value`}
render={({ field }) => (
<FormItem_Shadcn_ className="flex-1">
<FormControl_Shadcn_>
<Input_Shadcn_
{...field}
value={field.value}
size="small"
className="w-full"
placeholder="Header value"
/>
</FormControl_Shadcn_>
<FormMessage_Shadcn_ />
</FormItem_Shadcn_>
)}
/>
<Button
type="default"
icon={<Trash size={12} />}
onClick={() => remove(index)}
className="h-[34px] w-[34px]"
/>
</div>
))}
<div className="flex items-center">
<Button
type="default"
size="tiny"
icon={<Plus />}
className={cn(variant === 'edge_function' && 'rounded-r-none px-3 border-r-0')}
onClick={() => append({ name: '', value: '' })}
>
Add a new header
</Button>
{variant === 'edge_function' && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="default" className="rounded-l-none px-[4px] py-[5px]">
<ChevronDown size={14} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" side="bottom">
<DropdownMenuItem
key="add-auth-header"
onClick={() => {
append({
name: 'Authorization',
value: `Bearer ${apiKey}`,
})
if (serviceKey?.type === 'secret') {
append({ name: 'apikey', value: apiKey })
}
}}
>
<div className="space-y-1">
<p className="block text-foreground">Add auth header with secret key</p>
<p className="text-foreground-light">
Required if your edge function enforces JWT verification
</p>
</div>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
key="add-source-header"
onClick={() =>
append({
name: 'x-supabase-webhook-source',
value: `[Use a secret value]`,
})
}
>
<div className="space-y-1">
<p className="block text-foreground">Add custom source header</p>
<p className="text-foreground-light">
Useful to verify that the edge function was triggered from this webhook
</p>
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
<KeyValueFieldArray
control={form.control}
name="values.httpHeaders"
keyFieldName="name"
valueFieldName="value"
createEmptyRow={() => ({ name: '', value: '' })}
keyPlaceholder="Header name"
valuePlaceholder="Header value"
addLabel="Add a new header"
addActions={addActions}
/>
</SheetSection>
)
}
@@ -97,4 +97,25 @@ describe('PlatformWebhooksEndpointSheet', () => {
expect.anything()
)
})
it('submits custom headers added through the shared header editor', async () => {
const user = userEvent.setup()
const { onSubmit } = renderEndpointSheet({
mode: 'edit',
endpoint: createEndpoint(),
})
await user.click(screen.getByRole('button', { name: 'Add header' }))
await user.type(screen.getByPlaceholderText('Header name'), 'X-Webhook-Secret')
await user.type(screen.getByPlaceholderText('Header value'), 'super-secret')
fireEvent.submit(document.getElementById('platform-webhook-endpoint-form')!)
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1))
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({
customHeaders: [{ key: 'X-Webhook-Secret', value: 'super-secret' }],
}),
expect.anything()
)
})
})
@@ -4,7 +4,7 @@ import { InlineLink } from 'components/ui/InlineLink'
import { useConfirmOnClose } from 'hooks/ui/useConfirmOnClose'
import { ChevronDown, Trash2 } from 'lucide-react'
import { useEffect, useMemo, useState } from 'react'
import { useFieldArray, useForm } from 'react-hook-form'
import { useForm } from 'react-hook-form'
import {
Accordion_Shadcn_ as Accordion,
AccordionContent_Shadcn_ as AccordionContent,
@@ -30,6 +30,7 @@ import {
TextArea_Shadcn_ as Textarea,
} from 'ui'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { KeyValueFieldArray } from 'ui-patterns/form/KeyValueFieldArray/KeyValueFieldArray'
import * as z from 'zod'
import type {
@@ -166,11 +167,6 @@ export const PlatformWebhooksEndpointSheet = ({
onClose,
})
const { fields, append, remove } = useFieldArray({
control: form.control,
name: 'customHeaders',
})
const subscribeAll = form.watch('subscribeAll')
const selectedEventTypes = form.watch('eventTypes')
const groupedEventTypes = useMemo(
@@ -542,57 +538,16 @@ export const PlatformWebhooksEndpointSheet = ({
layout="vertical"
className="gap-3"
>
{fields.length > 0 && (
<div className="overflow-hidden rounded-md border divide-y mb-3 bg-surface-100">
{fields.map((customHeaderField, index) => (
<div key={customHeaderField.id} className="px-3 py-3">
<div className="grid gap-2 sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto] items-center">
<FormField_Shadcn_
control={form.control}
name={`customHeaders.${index}.key`}
render={({ field }) => (
<FormControl_Shadcn_>
<InputField
{...field}
placeholder="Header name"
className="font-mono text-xs"
/>
</FormControl_Shadcn_>
)}
/>
<FormField_Shadcn_
control={form.control}
name={`customHeaders.${index}.value`}
render={({ field }) => (
<FormControl_Shadcn_>
<InputField
{...field}
placeholder="Header value"
className="font-mono text-xs"
/>
</FormControl_Shadcn_>
)}
/>
<Button
type="text"
htmlType="button"
onClick={() => remove(index)}
icon={<Trash2 size={14} />}
className="justify-self-start sm:justify-self-auto h-full"
/>
</div>
</div>
))}
</div>
)}
<Button
type="default"
htmlType="button"
onClick={() => append({ key: '', value: '' })}
>
Add header
</Button>
<KeyValueFieldArray
control={form.control}
name="customHeaders"
keyFieldName="key"
valueFieldName="value"
createEmptyRow={() => ({ key: '', value: '' })}
keyPlaceholder="Header name"
valuePlaceholder="Header value"
addLabel="Add header"
/>
</FormItemLayout>
</div>
</form>
+4
View File
@@ -642,6 +642,10 @@
"import": "./src/form/FormItemLayout/FormItemLayout.tsx",
"types": "./src/form/FormItemLayout/FormItemLayout.tsx"
},
"./form/KeyValueFieldArray/KeyValueFieldArray": {
"import": "./src/form/KeyValueFieldArray/KeyValueFieldArray.tsx",
"types": "./src/form/KeyValueFieldArray/KeyValueFieldArray.tsx"
},
"./form/FormLayout2": {
"import": "./src/form/FormLayout2.tsx",
"types": "./src/form/FormLayout2.tsx"
@@ -0,0 +1,210 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useForm } from 'react-hook-form'
import { describe, expect, it, vi } from 'vitest'
import { z } from 'zod'
import { Button, Form_Shadcn_ } from 'ui'
import { KeyValueFieldArray, type KeyValueFieldArrayAction } from './KeyValueFieldArray'
const keyValueSchema = z.object({
headers: z.array(
z.object({
key: z.string().min(1, 'Header name is required'),
value: z.string().min(1, 'Header value is required'),
})
),
})
const nameValueSchema = z.object({
headers: z.array(
z.object({
id: z.string(),
name: z.string().min(1, 'Header name is required'),
value: z.string().min(1, 'Header value is required'),
})
),
})
type KeyValueFormValues = z.infer<typeof keyValueSchema>
type NameValueFormValues = z.infer<typeof nameValueSchema>
const presetActions: KeyValueFieldArrayAction<KeyValueFormValues['headers'][number]>[] = [
{
key: 'auth',
label: 'Add auth header with secret key',
description: 'Required if your edge function enforces JWT verification',
createRows: () => [
{ key: 'Authorization', value: 'Bearer test-secret' },
{ key: 'apikey', value: 'test-secret' },
],
},
{
key: 'source',
label: 'Add custom source header',
description: 'Useful to verify that the edge function was triggered from this webhook',
createRows: () => ({ key: 'x-supabase-webhook-source', value: '[Use a secret value]' }),
separatorAbove: true,
},
]
const KeyValueForm = ({
defaultValues = { headers: [] },
addActions,
onSubmit = vi.fn(),
}: {
defaultValues?: KeyValueFormValues
addActions?: KeyValueFieldArrayAction<KeyValueFormValues['headers'][number]>[]
onSubmit?: (values: KeyValueFormValues) => void
}) => {
const form = useForm<KeyValueFormValues>({
resolver: zodResolver(keyValueSchema),
defaultValues,
})
return (
<Form_Shadcn_ {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<KeyValueFieldArray
control={form.control}
name="headers"
keyFieldName="key"
valueFieldName="value"
createEmptyRow={() => ({ key: '', value: '' })}
keyPlaceholder="Header name"
valuePlaceholder="Header value"
addLabel="Add header"
addActions={addActions}
removeLabel="Remove header"
/>
<Button htmlType="submit">Submit</Button>
</form>
</Form_Shadcn_>
)
}
const NameValueForm = ({
onSubmit = vi.fn(),
}: {
onSubmit?: (values: NameValueFormValues) => void
}) => {
const form = useForm<NameValueFormValues>({
resolver: zodResolver(nameValueSchema),
defaultValues: {
headers: [{ id: 'row-1', name: 'Authorization', value: 'Bearer token' }],
},
})
return (
<Form_Shadcn_ {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<KeyValueFieldArray
control={form.control}
name="headers"
keyFieldName="name"
valueFieldName="value"
createEmptyRow={() => ({ id: crypto.randomUUID(), name: '', value: '' })}
keyPlaceholder="Header name"
valuePlaceholder="Header value"
addLabel="Add header"
removeLabel="Remove header"
/>
<Button htmlType="submit">Submit</Button>
</form>
</Form_Shadcn_>
)
}
describe('KeyValueFieldArray', () => {
it('appends an empty row', async () => {
const user = userEvent.setup()
render(<KeyValueForm />)
await user.click(screen.getByRole('button', { name: 'Add header' }))
expect(screen.getByPlaceholderText('Header name')).toBeInTheDocument()
expect(screen.getByPlaceholderText('Header value')).toBeInTheDocument()
})
it('removes a row', async () => {
const user = userEvent.setup()
render(<KeyValueForm defaultValues={{ headers: [{ key: 'x-test', value: '1' }] }} />)
await user.click(screen.getByRole('button', { name: 'Add header' }))
expect(screen.getAllByPlaceholderText('Header name')).toHaveLength(2)
await user.click(screen.getAllByRole('button', { name: 'Remove header' })[1])
expect(screen.getAllByPlaceholderText('Header name')).toHaveLength(1)
})
it('renders and submits key/value field names', async () => {
const user = userEvent.setup()
const onSubmit = vi.fn()
render(<KeyValueForm defaultValues={{ headers: [{ key: '', value: '' }] }} onSubmit={onSubmit} />)
await user.type(screen.getByPlaceholderText('Header name'), 'X-Test-Header')
await user.type(screen.getByPlaceholderText('Header value'), 'test-value')
await user.click(screen.getByRole('button', { name: 'Submit' }))
await waitFor(() =>
expect(onSubmit).toHaveBeenCalledWith(
{ headers: [{ key: 'X-Test-Header', value: 'test-value' }] },
expect.anything()
)
)
})
it('renders and submits name/value field names while preserving row ids', async () => {
const user = userEvent.setup()
const onSubmit = vi.fn()
render(<NameValueForm onSubmit={onSubmit} />)
await user.click(screen.getByRole('button', { name: 'Submit' }))
await waitFor(() =>
expect(onSubmit).toHaveBeenCalledWith(
{
headers: [{ id: 'row-1', name: 'Authorization', value: 'Bearer token' }],
},
expect.anything()
)
)
})
it('shows RHF field errors through FormMessage', async () => {
const user = userEvent.setup()
render(<KeyValueForm defaultValues={{ headers: [{ key: '', value: '' }] }} />)
await user.click(screen.getByRole('button', { name: 'Submit' }))
expect(await screen.findByText('Header name is required')).toBeInTheDocument()
expect(screen.getByText('Header value is required')).toBeInTheDocument()
})
it('supports preset actions that append one or multiple rows', async () => {
const user = userEvent.setup()
render(<KeyValueForm addActions={presetActions} />)
await user.click(screen.getByRole('button', { name: 'Add header options' }))
await user.click(screen.getByText('Add auth header with secret key'))
expect(screen.getAllByPlaceholderText('Header name')).toHaveLength(2)
await user.click(screen.getByRole('button', { name: 'Add header options' }))
await user.click(screen.getByText('Add custom source header'))
expect(screen.getAllByPlaceholderText('Header name')).toHaveLength(3)
expect(screen.getByDisplayValue('Authorization')).toBeInTheDocument()
expect(screen.getByDisplayValue('apikey')).toBeInTheDocument()
expect(screen.getByDisplayValue('x-supabase-webhook-source')).toBeInTheDocument()
})
})
@@ -0,0 +1,233 @@
import { ChevronDown, Plus, Trash } from 'lucide-react'
import { Fragment, ReactNode } from 'react'
import {
Control,
FieldArray,
FieldArrayPath,
FieldArrayWithId,
FieldPath,
FieldValues,
useFieldArray,
} from 'react-hook-form'
import {
Button,
cn,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
FormControl_Shadcn_,
FormField_Shadcn_,
FormItem_Shadcn_,
FormMessage_Shadcn_,
Input_Shadcn_,
} from 'ui'
export type KeyValueFieldArrayAction<TItem> = {
key: string
label: ReactNode
description?: ReactNode
createRows: () => TItem | TItem[]
separatorAbove?: boolean
}
export interface KeyValueFieldArrayProps<
TFieldValues extends FieldValues,
TFieldArrayName extends FieldArrayPath<TFieldValues>,
TItem extends FieldArray<TFieldValues, TFieldArrayName> = FieldArray<TFieldValues, TFieldArrayName>,
> {
control: Control<TFieldValues>
name: TFieldArrayName
keyFieldName: Extract<keyof TItem, string>
valueFieldName: Extract<keyof TItem, string>
createEmptyRow: () => TItem
keyPlaceholder: string
valuePlaceholder: string
addLabel: string
addActions?: KeyValueFieldArrayAction<TItem>[]
disabled?: boolean
inputSize?: React.ComponentProps<typeof Input_Shadcn_>['size']
className?: string
rowsClassName?: string
rowClassName?: string
keyInputClassName?: string
valueInputClassName?: string
addButtonClassName?: string
removeButtonClassName?: string
removeLabel?: string
}
const toFieldPath = <TFieldValues extends FieldValues>(path: string) => {
return path as FieldPath<TFieldValues>
}
const appendRows = <
TFieldValues extends FieldValues,
TFieldArrayName extends FieldArrayPath<TFieldValues>,
>(
append: (
value:
| FieldArray<TFieldValues, TFieldArrayName>
| FieldArray<TFieldValues, TFieldArrayName>[]
) => void,
rows:
| FieldArray<TFieldValues, TFieldArrayName>
| FieldArray<TFieldValues, TFieldArrayName>[]
) => {
append(Array.isArray(rows) && rows.length === 1 ? rows[0] : rows)
}
export const KeyValueFieldArray = <
TFieldValues extends FieldValues,
TFieldArrayName extends FieldArrayPath<TFieldValues>,
TItem extends FieldArray<TFieldValues, TFieldArrayName> = FieldArray<
TFieldValues,
TFieldArrayName
>,
>({
control,
name,
keyFieldName,
valueFieldName,
createEmptyRow,
keyPlaceholder,
valuePlaceholder,
addLabel,
addActions = [],
disabled = false,
inputSize = 'small',
className,
rowsClassName = 'space-y-3 mt-1',
rowClassName,
keyInputClassName,
valueInputClassName,
addButtonClassName,
removeButtonClassName,
removeLabel = 'Remove row',
}: KeyValueFieldArrayProps<TFieldValues, TFieldArrayName, TItem>) => {
const { fields, append, remove } = useFieldArray<TFieldValues, TFieldArrayName, 'fieldId'>({
control,
name,
keyName: 'fieldId',
})
const typedFields = fields as FieldArrayWithId<TFieldValues, TFieldArrayName, 'fieldId'>[]
const hasAddActions = addActions.length > 0
const addActionsLabel = `${addLabel} options`
return (
<div className={cn('space-y-3', className)}>
<div className={rowsClassName}>
{typedFields.map((field, index) => (
<div key={field.fieldId} className={cn('flex items-start space-x-2', rowClassName)}>
<FormField_Shadcn_
control={control}
name={toFieldPath<TFieldValues>(`${name}.${index}.${keyFieldName}`)}
render={({ field }) => (
<FormItem_Shadcn_ className="flex-1">
<FormControl_Shadcn_>
<Input_Shadcn_
{...field}
size={inputSize}
className={cn('w-full', keyInputClassName)}
placeholder={keyPlaceholder}
disabled={disabled}
/>
</FormControl_Shadcn_>
<FormMessage_Shadcn_ />
</FormItem_Shadcn_>
)}
/>
<FormField_Shadcn_
control={control}
name={toFieldPath<TFieldValues>(`${name}.${index}.${valueFieldName}`)}
render={({ field }) => (
<FormItem_Shadcn_ className="flex-1">
<FormControl_Shadcn_>
<Input_Shadcn_
{...field}
size={inputSize}
className={cn('w-full', valueInputClassName)}
placeholder={valuePlaceholder}
disabled={disabled}
/>
</FormControl_Shadcn_>
<FormMessage_Shadcn_ />
</FormItem_Shadcn_>
)}
/>
<Button
type="default"
size="tiny"
htmlType="button"
icon={<Trash size={12} />}
aria-label={removeLabel}
disabled={disabled}
onClick={() => remove(index)}
className={cn('h-[34px] w-[34px] shrink-0', removeButtonClassName)}
/>
</div>
))}
</div>
<div className="flex items-center">
<Button
type="default"
size="tiny"
htmlType="button"
icon={<Plus />}
disabled={disabled}
onClick={() => append(createEmptyRow())}
className={cn(hasAddActions && 'rounded-r-none border-r-0 px-3', addButtonClassName)}
>
{addLabel}
</Button>
{hasAddActions && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="default"
size="tiny"
htmlType="button"
icon={<ChevronDown size={14} />}
aria-label={addActionsLabel}
disabled={disabled}
className="rounded-l-none px-[4px] py-[5px]"
/>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" side="bottom">
{addActions.map((action) => (
<Fragment key={action.key}>
{action.separatorAbove && <DropdownMenuSeparator />}
<DropdownMenuItem
onClick={() =>
appendRows<TFieldValues, TFieldArrayName>(
append,
action.createRows() as
| FieldArray<TFieldValues, TFieldArrayName>
| FieldArray<TFieldValues, TFieldArrayName>[]
)
}
>
{action.description ? (
<div className="space-y-1">
<div className="block text-foreground">{action.label}</div>
<div className="text-foreground-light">{action.description}</div>
</div>
) : (
action.label
)}
</DropdownMenuItem>
</Fragment>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
)
}