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

## What kind of change does this PR introduce?

Chore that references DEPR-394.

## What is the current behavior?

Key/value editors for headers are implemented separately in multiple
places.

## What is the new behavior?

DEPR-394 is consolidating repeated RHF field-array UIs across Studio and
the design system.

- adds a shared `KeyValueFieldArray` component in `ui-patterns`
- adds a shared `httpHeaderAddActions` helper for preset header rows
- migrates the key/value header editors in:
  - Platform Webhooks
  - Cron Jobs HTTP headers
  - Database Webhooks HTTP headers
- documents the key/value pattern in the design system with:
  - a dedicated fragment page
  - updated forms guidance
  - updated form pattern demos

| Preview |
| --- |
| <img width="1102" height="420" alt="CleanShot 2026-03-23 at 12 22
18@2x"
src="https://github.com/user-attachments/assets/f8d23ff9-7063-462f-8074-b400561f77e9"
/> |

## Additional context

This is PR 1 of a 3-PR stack for DEPR-394.
This commit is contained in:
Danny White authored and GitHub committed 2026-03-24 16:16:03 +11:00
1 parent d9077528f8
commit b23c6a7fed
17 files changed
+817 -321

No files matched your search

+33
View File
@@ -1963,6 +1963,17 @@ export const Index: Record<string, any> = {
subcategory: "undefined",
chunks: []
},
"key-value-field-array-demo": {
name: "key-value-field-array-demo",
type: "components:example",
registryDependencies: ["button","form","input"],
component: React.lazy(() => import("@/registry/default/example/key-value-field-array-demo")),
source: "",
files: ["registry/default/example/key-value-field-array-demo.tsx"],
category: "undefined",
subcategory: "undefined",
chunks: []
},
"field-choice-card": {
name: "field-choice-card",
type: "components:example",
@@ -2678,6 +2689,28 @@ export const Index: Record<string, any> = {
subcategory: "undefined",
chunks: []
},
"error-display-demo": {
name: "error-display-demo",
type: "components:example",
registryDependencies: ["error-display"],
component: React.lazy(() => import("@/registry/default/example/error-display-demo")),
source: "",
files: ["registry/default/example/error-display-demo.tsx"],
category: "undefined",
subcategory: "undefined",
chunks: []
},
"error-display-with-children": {
name: "error-display-with-children",
type: "components:example",
registryDependencies: ["error-display"],
component: React.lazy(() => import("@/registry/default/example/error-display-with-children")),
source: "",
files: ["registry/default/example/error-display-with-children.tsx"],
category: "undefined",
subcategory: "undefined",
chunks: []
},
"logs-bar-chart": {
name: "logs-bar-chart",
type: "components:example",
+5
View File
@@ -212,6 +212,11 @@ export const docsConfig: DocsConfig = {
href: '/docs/fragments/status-codes',
items: [],
},
{
title: 'Key/Value Field Array',
href: '/docs/fragments/key-value-field-array',
items: [],
},
],
},
{
@@ -0,0 +1,43 @@
---
title: Key/Value Field Array
description: A shared form fragment for repeated text key/value pairs.
component: true
fragment: true
---
<ComponentPreview
name="key-value-field-array-demo"
description="A shared form fragment for repeated text key/value pairs."
peekCode
showDottedGrid
wide
/>
## Usage
Use `KeyValueFieldArray` when each row is two text inputs backed by `react-hook-form`, such as HTTP headers, query parameters, or configuration parameters.
```tsx
import { KeyValueFieldArray } from 'ui-patterns/form/KeyValueFieldArray/KeyValueFieldArray'
```
```tsx
<KeyValueFieldArray
control={form.control}
name="headers"
keyFieldName="name"
valueFieldName="value"
createEmptyRow={() => ({ name: '', value: '' })}
keyPlaceholder="Header name"
valuePlaceholder="Header value"
addLabel="Add header"
/>
```
`KeyValueFieldArray` owns the row add/remove behavior and renders the per-input form messages for you. Compose it inside `FormItemLayout` when you want the standard label, description, and message treatment around the entire section.
## When to use it
- Use a plain field array for repeated single values such as redirect URIs.
- Use `KeyValueFieldArray` for repeated text/text pairs such as headers, parameters, and config entries.
- Build a custom row UI instead when each row mixes different controls, such as a text input paired with a `Select`.
@@ -27,6 +27,15 @@ Forms in side panels (Sheets) use `FormItemLayout` with `layout="horizontal"` on
wide
/>
## Field Arrays
The form previews above include both repeated-field patterns used across Studio:
- **Field Array** for repeated single-value rows such as redirect URIs.
- **Key/Value Field Array** for repeated text pairs such as headers, parameters, and config entries.
Use the shared [Key/Value Field Array](../fragments/key-value-field-array) fragment when each row is two text inputs managed by `react-hook-form`. Keep using a plain field array when each row is just one input, and build a custom row when the cells are mixed controls.
## Best Practices
1. **Always use FormItemLayout**: Use `FormItemLayout` instead of manually composing `FormItem`, `FormLabel`, `FormMessage`, and `FormDescription`.
@@ -1,6 +1,6 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { format } from 'date-fns'
import { CalendarIcon, ExternalLink, Plus, Trash2, Upload } from 'lucide-react'
import { CalendarIcon, ExternalLink, Plus, Trash, Upload } from 'lucide-react'
import { useRef, useState } from 'react'
import { useFieldArray, useForm } from 'react-hook-form'
import {
@@ -30,6 +30,7 @@ import {
} from 'ui'
import { Input } from 'ui-patterns/DataInputs/Input'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { KeyValueFieldArray } from 'ui-patterns/form/KeyValueFieldArray/KeyValueFieldArray'
import {
MultiSelector,
MultiSelectorContent,
@@ -61,6 +62,7 @@ const formSchema = z.object({
password: z.string().min(8, 'Password must be at least 8 characters'),
duration: z.number().min(5).max(30),
redirectUris: z.array(z.object({ value: z.string().url('Must be a valid URL') })),
httpHeaders: z.array(z.object({ key: z.string(), value: z.string() })),
apiKey: z.string().optional(),
})
@@ -91,6 +93,7 @@ export default function FormPatternsPageLayout() {
password: '',
duration: 10,
redirectUris: [{ value: '' }],
httpHeaders: [{ key: '', value: '' }],
apiKey: fakeApiKey,
},
})
@@ -285,7 +288,7 @@ export default function FormPatternsPageLayout() {
<Button
type="default"
size="tiny"
icon={<Trash2 size={12} />}
icon={<Trash size={12} />}
onClick={() => {
setLogoFile(undefined)
setLogoUrl(undefined)
@@ -389,7 +392,7 @@ export default function FormPatternsPageLayout() {
<Button
type="default"
size="tiny"
icon={<Trash2 size={12} />}
icon={<Trash size={12} />}
onClick={() => {
setUploadedFiles((prev) =>
prev.filter((_, i) => i !== idx)
@@ -669,7 +672,8 @@ export default function FormPatternsPageLayout() {
<Button
type="default"
size="tiny"
icon={<Trash2 size={12} />}
htmlType="button"
icon={<Trash size={12} />}
onClick={() => remove(index)}
/>
)}
@@ -679,6 +683,7 @@ export default function FormPatternsPageLayout() {
))}
<Button
type="default"
htmlType="button"
icon={<Plus />}
onClick={() => append({ value: '' })}
>
@@ -690,6 +695,33 @@ export default function FormPatternsPageLayout() {
/>
</CardContent>
{/* Key/Value Field Array */}
<CardContent>
<FormField_Shadcn_
control={form.control}
name="httpHeaders"
render={() => (
<FormItemLayout
layout="flex-row-reverse"
label="Key/Value Field Array"
description="Repeated text pairs for headers, parameters, and config entries"
>
<KeyValueFieldArray
control={form.control}
name="httpHeaders"
keyFieldName="key"
valueFieldName="value"
createEmptyRow={() => ({ key: '', value: '' })}
keyPlaceholder="Header name"
valuePlaceholder="Header value"
addLabel="Add header"
removeLabel="Remove header"
/>
</FormItemLayout>
)}
/>
</CardContent>
{/* Action Field */}
<CardContent>
<FormItemLayout
@@ -1,6 +1,6 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { format } from 'date-fns'
import { CalendarIcon, ExternalLink, Plus, Trash2, Upload } from 'lucide-react'
import { CalendarIcon, ExternalLink, Plus, Trash, Upload } from 'lucide-react'
import { useRef, useState } from 'react'
import { useFieldArray, useForm } from 'react-hook-form'
import {
@@ -34,6 +34,7 @@ import {
} from 'ui'
import { Input } from 'ui-patterns/DataInputs/Input'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { KeyValueFieldArray } from 'ui-patterns/form/KeyValueFieldArray/KeyValueFieldArray'
import {
MultiSelector,
MultiSelectorContent,
@@ -58,6 +59,7 @@ const formSchema = z.object({
password: z.string().min(8, 'Password must be at least 8 characters'),
duration: z.number().min(5).max(30),
redirectUris: z.array(z.object({ value: z.string().url('Must be a valid URL') })),
httpHeaders: z.array(z.object({ key: z.string(), value: z.string() })),
apiKey: z.string().optional(),
})
@@ -89,6 +91,7 @@ export default function FormPatternsSidePanel() {
password: '',
duration: 10,
redirectUris: [{ value: '' }],
httpHeaders: [{ key: '', value: '' }],
apiKey: fakeApiKey,
},
})
@@ -303,7 +306,7 @@ export default function FormPatternsSidePanel() {
<Button
type="default"
size="tiny"
icon={<Trash2 size={12} />}
icon={<Trash size={12} />}
onClick={() => {
setLogoFile(undefined)
setLogoUrl(undefined)
@@ -409,7 +412,7 @@ export default function FormPatternsSidePanel() {
<Button
type="default"
size="tiny"
icon={<Trash2 size={12} />}
icon={<Trash size={12} />}
onClick={() => {
setUploadedFiles((prev) => prev.filter((_, i) => i !== idx))
}}
@@ -702,7 +705,8 @@ export default function FormPatternsSidePanel() {
<Button
type="default"
size="tiny"
icon={<Trash2 size={12} />}
htmlType="button"
icon={<Trash size={12} />}
onClick={() => remove(index)}
/>
)}
@@ -712,6 +716,7 @@ export default function FormPatternsSidePanel() {
))}
<Button
type="default"
htmlType="button"
icon={<Plus />}
onClick={() => append({ value: '' })}
>
@@ -725,6 +730,37 @@ export default function FormPatternsSidePanel() {
<Separator className="-mx-5 w-[calc(100%+2.5rem)]" />
{/* Key/Value Field Array */}
<SheetSection>
<FormField_Shadcn_
control={form.control}
name="httpHeaders"
render={() => (
<FormItemLayout
layout="horizontal"
label="Key/Value Field Array"
description="Repeated text pairs for headers, parameters, and config entries"
>
<div className="col-span-6">
<KeyValueFieldArray
control={form.control}
name="httpHeaders"
keyFieldName="key"
valueFieldName="value"
createEmptyRow={() => ({ key: '', value: '' })}
keyPlaceholder="Header name"
valuePlaceholder="Header value"
addLabel="Add header"
removeLabel="Remove header"
/>
</div>
</FormItemLayout>
)}
/>
</SheetSection>
<Separator className="-mx-5 w-[calc(100%+2.5rem)]" />
{/* Action Field */}
<SheetSection>
<FormItemLayout
@@ -0,0 +1,57 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { useForm } from 'react-hook-form'
import { Button, Form_Shadcn_ } from 'ui'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { KeyValueFieldArray } from 'ui-patterns/form/KeyValueFieldArray/KeyValueFieldArray'
import { z } from 'zod'
const formSchema = z.object({
headers: z.array(
z.object({
name: z.string(),
value: z.string(),
})
),
})
export default function KeyValueFieldArrayDemo() {
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
headers: [{ name: 'x-client-info', value: 'studio-docs' }],
},
})
function onSubmit(values: z.infer<typeof formSchema>) {
console.log(values)
}
return (
<Form_Shadcn_ {...form}>
<form className="w-full max-w-2xl" onSubmit={form.handleSubmit(onSubmit)}>
<FormItemLayout
label="HTTP headers"
description="Use KeyValueFieldArray for repeated text key/value pairs."
>
<KeyValueFieldArray
control={form.control}
name="headers"
keyFieldName="name"
valueFieldName="value"
createEmptyRow={() => ({ name: '', value: '' })}
keyPlaceholder="Header name"
valuePlaceholder="Header value"
addLabel="Add header"
removeLabel="Remove header"
/>
</FormItemLayout>
<div className="mt-4">
<Button size="tiny" type="primary" htmlType="submit">
Submit
</Button>
</div>
</form>
</Form_Shadcn_>
)
}
+6
View File
@@ -1107,6 +1107,12 @@ export const examples: Registry = [
type: 'components:example',
files: ['example/form-item-layout-demo.tsx'],
},
{
name: 'key-value-field-array-demo',
type: 'components:example',
registryDependencies: ['button', 'form', 'input'],
files: ['example/key-value-field-array-demo.tsx'],
},
{
name: 'field-choice-card',
type: 'components:example',
@@ -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>
)
}
@@ -212,4 +212,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')
submitForm()
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 {
@@ -171,11 +172,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(
@@ -549,57 +545,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,212 @@
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,232 @@
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>
)
}