KeyValueFieldArray.test.tsx212 lines · main
| 1 | import { zodResolver } from '@hookform/resolvers/zod' |
| 2 | import { render, screen, waitFor } from '@testing-library/react' |
| 3 | import userEvent from '@testing-library/user-event' |
| 4 | import { useForm } from 'react-hook-form' |
| 5 | import { describe, expect, it, vi } from 'vitest' |
| 6 | import { z } from 'zod' |
| 7 | |
| 8 | import { Button, Form } from 'ui' |
| 9 | |
| 10 | import { KeyValueFieldArray, type KeyValueFieldArrayAction } from './KeyValueFieldArray' |
| 11 | |
| 12 | const keyValueSchema = z.object({ |
| 13 | headers: z.array( |
| 14 | z.object({ |
| 15 | key: z.string().min(1, 'Header name is required'), |
| 16 | value: z.string().min(1, 'Header value is required'), |
| 17 | }) |
| 18 | ), |
| 19 | }) |
| 20 | |
| 21 | const nameValueSchema = z.object({ |
| 22 | headers: z.array( |
| 23 | z.object({ |
| 24 | id: z.string(), |
| 25 | name: z.string().min(1, 'Header name is required'), |
| 26 | value: z.string().min(1, 'Header value is required'), |
| 27 | }) |
| 28 | ), |
| 29 | }) |
| 30 | |
| 31 | type KeyValueFormValues = z.infer<typeof keyValueSchema> |
| 32 | type NameValueFormValues = z.infer<typeof nameValueSchema> |
| 33 | |
| 34 | const presetActions: KeyValueFieldArrayAction<KeyValueFormValues['headers'][number]>[] = [ |
| 35 | { |
| 36 | key: 'auth', |
| 37 | label: 'Add auth header with secret key', |
| 38 | description: 'Required if your edge function enforces JWT verification', |
| 39 | createRows: () => [ |
| 40 | { key: 'Authorization', value: 'Bearer test-secret' }, |
| 41 | { key: 'apikey', value: 'test-secret' }, |
| 42 | ], |
| 43 | }, |
| 44 | { |
| 45 | key: 'source', |
| 46 | label: 'Add custom source header', |
| 47 | description: 'Useful to verify that the edge function was triggered from this webhook', |
| 48 | createRows: () => ({ key: 'x-briven-webhook-source', value: '[Use a secret value]' }), |
| 49 | separatorAbove: true, |
| 50 | }, |
| 51 | ] |
| 52 | |
| 53 | const KeyValueForm = ({ |
| 54 | defaultValues = { headers: [] }, |
| 55 | addActions, |
| 56 | onSubmit = vi.fn(), |
| 57 | }: { |
| 58 | defaultValues?: KeyValueFormValues |
| 59 | addActions?: KeyValueFieldArrayAction<KeyValueFormValues['headers'][number]>[] |
| 60 | onSubmit?: (values: KeyValueFormValues) => void |
| 61 | }) => { |
| 62 | const form = useForm<KeyValueFormValues>({ |
| 63 | resolver: zodResolver(keyValueSchema), |
| 64 | defaultValues, |
| 65 | }) |
| 66 | |
| 67 | return ( |
| 68 | <Form {...form}> |
| 69 | <form onSubmit={form.handleSubmit(onSubmit)}> |
| 70 | <KeyValueFieldArray |
| 71 | control={form.control} |
| 72 | name="headers" |
| 73 | keyFieldName="key" |
| 74 | valueFieldName="value" |
| 75 | createEmptyRow={() => ({ key: '', value: '' })} |
| 76 | keyPlaceholder="Header name" |
| 77 | valuePlaceholder="Header value" |
| 78 | addLabel="Add header" |
| 79 | addActions={addActions} |
| 80 | removeLabel="Remove header" |
| 81 | /> |
| 82 | <Button htmlType="submit">Submit</Button> |
| 83 | </form> |
| 84 | </Form> |
| 85 | ) |
| 86 | } |
| 87 | |
| 88 | const NameValueForm = ({ |
| 89 | onSubmit = vi.fn(), |
| 90 | }: { |
| 91 | onSubmit?: (values: NameValueFormValues) => void |
| 92 | }) => { |
| 93 | const form = useForm<NameValueFormValues>({ |
| 94 | resolver: zodResolver(nameValueSchema), |
| 95 | defaultValues: { |
| 96 | headers: [{ id: 'row-1', name: 'Authorization', value: 'Bearer token' }], |
| 97 | }, |
| 98 | }) |
| 99 | |
| 100 | return ( |
| 101 | <Form {...form}> |
| 102 | <form onSubmit={form.handleSubmit(onSubmit)}> |
| 103 | <KeyValueFieldArray |
| 104 | control={form.control} |
| 105 | name="headers" |
| 106 | keyFieldName="name" |
| 107 | valueFieldName="value" |
| 108 | createEmptyRow={() => ({ id: crypto.randomUUID(), name: '', value: '' })} |
| 109 | keyPlaceholder="Header name" |
| 110 | valuePlaceholder="Header value" |
| 111 | addLabel="Add header" |
| 112 | removeLabel="Remove header" |
| 113 | /> |
| 114 | <Button htmlType="submit">Submit</Button> |
| 115 | </form> |
| 116 | </Form> |
| 117 | ) |
| 118 | } |
| 119 | |
| 120 | describe('KeyValueFieldArray', () => { |
| 121 | it('appends an empty row', async () => { |
| 122 | const user = userEvent.setup() |
| 123 | |
| 124 | render(<KeyValueForm />) |
| 125 | |
| 126 | await user.click(screen.getByRole('button', { name: 'Add header' })) |
| 127 | |
| 128 | expect(screen.getByPlaceholderText('Header name')).toBeInTheDocument() |
| 129 | expect(screen.getByPlaceholderText('Header value')).toBeInTheDocument() |
| 130 | }) |
| 131 | |
| 132 | it('removes a row', async () => { |
| 133 | const user = userEvent.setup() |
| 134 | |
| 135 | render(<KeyValueForm defaultValues={{ headers: [{ key: 'x-test', value: '1' }] }} />) |
| 136 | |
| 137 | await user.click(screen.getByRole('button', { name: 'Add header' })) |
| 138 | expect(screen.getAllByPlaceholderText('Header name')).toHaveLength(2) |
| 139 | |
| 140 | await user.click(screen.getAllByRole('button', { name: 'Remove header' })[1]) |
| 141 | |
| 142 | expect(screen.getAllByPlaceholderText('Header name')).toHaveLength(1) |
| 143 | }) |
| 144 | |
| 145 | it('renders and submits key/value field names', async () => { |
| 146 | const user = userEvent.setup() |
| 147 | const onSubmit = vi.fn() |
| 148 | |
| 149 | render( |
| 150 | <KeyValueForm defaultValues={{ headers: [{ key: '', value: '' }] }} onSubmit={onSubmit} /> |
| 151 | ) |
| 152 | |
| 153 | await user.type(screen.getByPlaceholderText('Header name'), 'X-Test-Header') |
| 154 | await user.type(screen.getByPlaceholderText('Header value'), 'test-value') |
| 155 | await user.click(screen.getByRole('button', { name: 'Submit' })) |
| 156 | |
| 157 | await waitFor(() => |
| 158 | expect(onSubmit).toHaveBeenCalledWith( |
| 159 | { headers: [{ key: 'X-Test-Header', value: 'test-value' }] }, |
| 160 | expect.anything() |
| 161 | ) |
| 162 | ) |
| 163 | }) |
| 164 | |
| 165 | it('renders and submits name/value field names while preserving row ids', async () => { |
| 166 | const user = userEvent.setup() |
| 167 | const onSubmit = vi.fn() |
| 168 | |
| 169 | render(<NameValueForm onSubmit={onSubmit} />) |
| 170 | |
| 171 | await user.click(screen.getByRole('button', { name: 'Submit' })) |
| 172 | |
| 173 | await waitFor(() => |
| 174 | expect(onSubmit).toHaveBeenCalledWith( |
| 175 | { |
| 176 | headers: [{ id: 'row-1', name: 'Authorization', value: 'Bearer token' }], |
| 177 | }, |
| 178 | expect.anything() |
| 179 | ) |
| 180 | ) |
| 181 | }) |
| 182 | |
| 183 | it('shows RHF field errors through FormMessage', async () => { |
| 184 | const user = userEvent.setup() |
| 185 | |
| 186 | render(<KeyValueForm defaultValues={{ headers: [{ key: '', value: '' }] }} />) |
| 187 | |
| 188 | await user.click(screen.getByRole('button', { name: 'Submit' })) |
| 189 | |
| 190 | expect(await screen.findByText('Header name is required')).toBeInTheDocument() |
| 191 | expect(screen.getByText('Header value is required')).toBeInTheDocument() |
| 192 | }) |
| 193 | |
| 194 | it('supports preset actions that append one or multiple rows', async () => { |
| 195 | const user = userEvent.setup() |
| 196 | |
| 197 | render(<KeyValueForm addActions={presetActions} />) |
| 198 | |
| 199 | await user.click(screen.getByRole('button', { name: 'Add header options' })) |
| 200 | await user.click(screen.getByText('Add auth header with secret key')) |
| 201 | |
| 202 | expect(screen.getAllByPlaceholderText('Header name')).toHaveLength(2) |
| 203 | |
| 204 | await user.click(screen.getByRole('button', { name: 'Add header options' })) |
| 205 | await user.click(screen.getByText('Add custom source header')) |
| 206 | |
| 207 | expect(screen.getAllByPlaceholderText('Header name')).toHaveLength(3) |
| 208 | expect(screen.getByDisplayValue('Authorization')).toBeInTheDocument() |
| 209 | expect(screen.getByDisplayValue('apikey')).toBeInTheDocument() |
| 210 | expect(screen.getByDisplayValue('x-briven-webhook-source')).toBeInTheDocument() |
| 211 | }) |
| 212 | }) |