diff --git a/apps/design-system/__registry__/index.tsx b/apps/design-system/__registry__/index.tsx index b6e9f301197..8f423322ea9 100644 --- a/apps/design-system/__registry__/index.tsx +++ b/apps/design-system/__registry__/index.tsx @@ -1963,6 +1963,17 @@ export const Index: Record = { 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 = { 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", diff --git a/apps/design-system/config/docs.ts b/apps/design-system/config/docs.ts index cc1b8f213f7..d15ae959d9a 100644 --- a/apps/design-system/config/docs.ts +++ b/apps/design-system/config/docs.ts @@ -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: [], + }, ], }, { diff --git a/apps/design-system/content/docs/fragments/key-value-field-array.mdx b/apps/design-system/content/docs/fragments/key-value-field-array.mdx new file mode 100644 index 00000000000..2ae4b87649e --- /dev/null +++ b/apps/design-system/content/docs/fragments/key-value-field-array.mdx @@ -0,0 +1,43 @@ +--- +title: Key/Value Field Array +description: A shared form fragment for repeated text key/value pairs. +component: true +fragment: true +--- + + + +## 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 + ({ 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`. diff --git a/apps/design-system/content/docs/ui-patterns/forms.mdx b/apps/design-system/content/docs/ui-patterns/forms.mdx index 8c2794495f5..8e02ea0f90f 100644 --- a/apps/design-system/content/docs/ui-patterns/forms.mdx +++ b/apps/design-system/content/docs/ui-patterns/forms.mdx @@ -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`. diff --git a/apps/design-system/registry/default/example/form-patterns-pagelayout.tsx b/apps/design-system/registry/default/example/form-patterns-pagelayout.tsx index 078a36d49af..49f00b4bee9 100644 --- a/apps/design-system/registry/default/example/form-patterns-pagelayout.tsx +++ b/apps/design-system/registry/default/example/form-patterns-pagelayout.tsx @@ -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() { + + + + ) +} diff --git a/apps/design-system/registry/examples.ts b/apps/design-system/registry/examples.ts index 34a31c697b7..cc3215c8990 100644 --- a/apps/design-system/registry/examples.ts +++ b/apps/design-system/registry/examples.ts @@ -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', diff --git a/apps/studio/components/interfaces/Database/Hooks/HTTPHeaders.tsx b/apps/studio/components/interfaces/Database/Hooks/HTTPHeaders.tsx index de803045bbd..84e3da18fd5 100644 --- a/apps/studio/components/interfaces/Database/Hooks/HTTPHeaders.tsx +++ b/apps/studio/components/interfaces/Database/Hooks/HTTPHeaders.tsx @@ -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 ( HTTP Headers} > -
- {headerFields.map((field, index) => ( -
- ( - - - - )} - /> - ( - - - - )} - /> - } - className="py-4" - onClick={() => removeHeader(index)} - tooltip={{ content: { side: 'bottom', text: 'Remove header' } }} - /> -
- ))} -
- - {functionType === 'supabase_function' && ( - - -
-
+ ({ id: uuidv4(), name: '', value: '' })} + keyPlaceholder="Header name" + valuePlaceholder="Header value" + addLabel="Add a new header" + addActions={addActions} + />
) diff --git a/apps/studio/components/interfaces/Functions/httpHeaderAddActions.test.ts b/apps/studio/components/interfaces/Functions/httpHeaderAddActions.test.ts new file mode 100644 index 00000000000..ceb81d01648 --- /dev/null +++ b/apps/studio/components/interfaces/Functions/httpHeaderAddActions.test.ts @@ -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' }, + ]) + }) +}) diff --git a/apps/studio/components/interfaces/Functions/httpHeaderAddActions.ts b/apps/studio/components/interfaces/Functions/httpHeaderAddActions.ts new file mode 100644 index 00000000000..62a6cf3fcff --- /dev/null +++ b/apps/studio/components/interfaces/Functions/httpHeaderAddActions.ts @@ -0,0 +1,30 @@ +import type { KeyValueFieldArrayAction } from 'ui-patterns/form/KeyValueFieldArray/KeyValueFieldArray' + +interface BuildEdgeFunctionHeaderAddActionsParams { + apiKey: string + includeApiKeyHeader?: boolean + createRow: (name: string, value: string) => TRow +} + +export const buildEdgeFunctionHeaderAddActions = ({ + apiKey, + includeApiKeyHeader = false, + createRow, +}: BuildEdgeFunctionHeaderAddActionsParams): KeyValueFieldArrayAction[] => [ + { + 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, + }, +] diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/HttpHeaderFieldsSection.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/HttpHeaderFieldsSection.tsx index 56c6a1edf95..c98248de0eb 100644 --- a/apps/studio/components/interfaces/Integrations/CronJobs/HttpHeaderFieldsSection.tsx +++ b/apps/studio/components/interfaces/Integrations/CronJobs/HttpHeaderFieldsSection.tsx @@ -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({ - name: 'values.httpHeaders', - }) + const form = useFormContext() 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 ( HTTP Headers -
- {fields.map((field, index) => ( -
- ( - - - - - - - )} - /> - ( - - - - - - - )} - /> - -
- ))} -
- - {variant === 'edge_function' && ( - - - - - - { - append({ - name: 'Authorization', - value: `Bearer ${apiKey}`, - }) - if (serviceKey?.type === 'secret') { - append({ name: 'apikey', value: apiKey }) - } - }} - > -
-

Add auth header with secret key

-

- Required if your edge function enforces JWT verification -

-
-
- - - append({ - name: 'x-supabase-webhook-source', - value: `[Use a secret value]`, - }) - } - > -
-

Add custom source header

-

- Useful to verify that the edge function was triggered from this webhook -

-
-
-
-
- )} -
-
+ ({ name: '', value: '' })} + keyPlaceholder="Header name" + valuePlaceholder="Header value" + addLabel="Add a new header" + addActions={addActions} + />
) } diff --git a/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointSheet.test.tsx b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointSheet.test.tsx index f82967ca320..9095fb882a6 100644 --- a/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointSheet.test.tsx +++ b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointSheet.test.tsx @@ -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() + ) + }) }) diff --git a/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointSheet.tsx b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointSheet.tsx index e51d59c0bf0..c2460b8b066 100644 --- a/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointSheet.tsx +++ b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointSheet.tsx @@ -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 && ( -
- {fields.map((customHeaderField, index) => ( -
-
- ( - - - - )} - /> - ( - - - - )} - /> -
-
- ))} -
- )} - - + ({ key: '', value: '' })} + keyPlaceholder="Header name" + valuePlaceholder="Header value" + addLabel="Add header" + /> diff --git a/packages/ui-patterns/package.json b/packages/ui-patterns/package.json index 7090d080373..4259722a192 100644 --- a/packages/ui-patterns/package.json +++ b/packages/ui-patterns/package.json @@ -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" diff --git a/packages/ui-patterns/src/form/KeyValueFieldArray/KeyValueFieldArray.test.tsx b/packages/ui-patterns/src/form/KeyValueFieldArray/KeyValueFieldArray.test.tsx new file mode 100644 index 00000000000..b95f8059163 --- /dev/null +++ b/packages/ui-patterns/src/form/KeyValueFieldArray/KeyValueFieldArray.test.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 +type NameValueFormValues = z.infer + +const presetActions: KeyValueFieldArrayAction[] = [ + { + 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[] + onSubmit?: (values: KeyValueFormValues) => void +}) => { + const form = useForm({ + resolver: zodResolver(keyValueSchema), + defaultValues, + }) + + return ( + +
+ ({ key: '', value: '' })} + keyPlaceholder="Header name" + valuePlaceholder="Header value" + addLabel="Add header" + addActions={addActions} + removeLabel="Remove header" + /> + + +
+ ) +} + +const NameValueForm = ({ + onSubmit = vi.fn(), +}: { + onSubmit?: (values: NameValueFormValues) => void +}) => { + const form = useForm({ + resolver: zodResolver(nameValueSchema), + defaultValues: { + headers: [{ id: 'row-1', name: 'Authorization', value: 'Bearer token' }], + }, + }) + + return ( + +
+ ({ id: crypto.randomUUID(), name: '', value: '' })} + keyPlaceholder="Header name" + valuePlaceholder="Header value" + addLabel="Add header" + removeLabel="Remove header" + /> + + +
+ ) +} + +describe('KeyValueFieldArray', () => { + it('appends an empty row', async () => { + const user = userEvent.setup() + + render() + + 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() + + 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( + + ) + + 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() + + 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() + + 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() + + 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() + }) +}) diff --git a/packages/ui-patterns/src/form/KeyValueFieldArray/KeyValueFieldArray.tsx b/packages/ui-patterns/src/form/KeyValueFieldArray/KeyValueFieldArray.tsx new file mode 100644 index 00000000000..194d83488ca --- /dev/null +++ b/packages/ui-patterns/src/form/KeyValueFieldArray/KeyValueFieldArray.tsx @@ -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 = { + key: string + label: ReactNode + description?: ReactNode + createRows: () => TItem | TItem[] + separatorAbove?: boolean +} + +export interface KeyValueFieldArrayProps< + TFieldValues extends FieldValues, + TFieldArrayName extends FieldArrayPath, + TItem extends FieldArray = FieldArray< + TFieldValues, + TFieldArrayName + >, +> { + control: Control + name: TFieldArrayName + keyFieldName: Extract + valueFieldName: Extract + createEmptyRow: () => TItem + keyPlaceholder: string + valuePlaceholder: string + addLabel: string + addActions?: KeyValueFieldArrayAction[] + disabled?: boolean + inputSize?: React.ComponentProps['size'] + className?: string + rowsClassName?: string + rowClassName?: string + keyInputClassName?: string + valueInputClassName?: string + addButtonClassName?: string + removeButtonClassName?: string + removeLabel?: string +} + +const toFieldPath = (path: string) => { + return path as FieldPath +} + +const appendRows = < + TFieldValues extends FieldValues, + TFieldArrayName extends FieldArrayPath, +>( + append: ( + value: FieldArray | FieldArray[] + ) => void, + rows: FieldArray | FieldArray[] +) => { + append(Array.isArray(rows) && rows.length === 1 ? rows[0] : rows) +} + +export const KeyValueFieldArray = < + TFieldValues extends FieldValues, + TFieldArrayName extends FieldArrayPath, + TItem extends FieldArray = 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) => { + const { fields, append, remove } = useFieldArray({ + control, + name, + keyName: 'fieldId', + }) + + const typedFields = fields as FieldArrayWithId[] + const hasAddActions = addActions.length > 0 + const addActionsLabel = `${addLabel} options` + + return ( +
+
+ {typedFields.map((field, index) => ( +
+ (`${name}.${index}.${keyFieldName}`)} + render={({ field }) => ( + + + + + + + )} + /> + + (`${name}.${index}.${valueFieldName}`)} + render={({ field }) => ( + + + + + + + )} + /> + +
+ ))} +
+ +
+ + + {hasAddActions && ( + + +
+
+ ) +}