mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
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:
1 parent
d9077528f8
commit
b23c6a7fed
17 files changed
+817
-321
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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_>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
]
|
||||
+24
-126
@@ -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>
|
||||
)
|
||||
}
|
||||
+21
@@ -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()
|
||||
)
|
||||
})
|
||||
})
|
||||
+12
-57
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user