KeyValueFieldArray.test.tsx212 lines · main
1import { zodResolver } from '@hookform/resolvers/zod'
2import { render, screen, waitFor } from '@testing-library/react'
3import userEvent from '@testing-library/user-event'
4import { useForm } from 'react-hook-form'
5import { describe, expect, it, vi } from 'vitest'
6import { z } from 'zod'
7
8import { Button, Form } from 'ui'
9
10import { KeyValueFieldArray, type KeyValueFieldArrayAction } from './KeyValueFieldArray'
11
12const 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
21const 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
31type KeyValueFormValues = z.infer<typeof keyValueSchema>
32type NameValueFormValues = z.infer<typeof nameValueSchema>
33
34const 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
53const 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
88const 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
120describe('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})