@opensaas/stack-ui 0.1.6 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/.turbo/turbo-build.log +3 -2
  2. package/CHANGELOG.md +11 -0
  3. package/dist/components/AdminUI.d.ts +2 -1
  4. package/dist/components/AdminUI.d.ts.map +1 -1
  5. package/dist/components/AdminUI.js +2 -2
  6. package/dist/components/ItemFormClient.d.ts.map +1 -1
  7. package/dist/components/ItemFormClient.js +78 -60
  8. package/dist/components/Navigation.d.ts +2 -1
  9. package/dist/components/Navigation.d.ts.map +1 -1
  10. package/dist/components/Navigation.js +3 -2
  11. package/dist/components/UserMenu.d.ts +11 -0
  12. package/dist/components/UserMenu.d.ts.map +1 -0
  13. package/dist/components/UserMenu.js +18 -0
  14. package/dist/components/fields/TextField.d.ts +2 -1
  15. package/dist/components/fields/TextField.d.ts.map +1 -1
  16. package/dist/components/fields/TextField.js +4 -2
  17. package/dist/index.d.ts +2 -0
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +1 -0
  20. package/dist/primitives/button.d.ts +1 -1
  21. package/dist/styles/globals.css +25 -1
  22. package/package.json +14 -5
  23. package/src/components/AdminUI.tsx +3 -0
  24. package/src/components/ItemFormClient.tsx +84 -62
  25. package/src/components/Navigation.tsx +9 -20
  26. package/src/components/UserMenu.tsx +44 -0
  27. package/src/components/fields/TextField.tsx +7 -2
  28. package/src/index.ts +2 -0
  29. package/tests/browser/README.md +154 -0
  30. package/tests/browser/fields/CheckboxField.browser.test.tsx +245 -0
  31. package/tests/browser/fields/SelectField.browser.test.tsx +263 -0
  32. package/tests/browser/fields/TextField.browser.test.tsx +204 -0
  33. package/tests/browser/fields/__screenshots__/CheckboxField.browser.test.tsx/CheckboxField--Browser--edit-mode-should-not-be-clickable-when-disabled-1.png +0 -0
  34. package/tests/browser/fields/__screenshots__/CheckboxField.browser.test.tsx/CheckboxField--Browser--edit-mode-should-toggle-state-with-multiple-clicks-1.png +0 -0
  35. package/tests/browser/fields/__screenshots__/TextField.browser.test.tsx/TextField--Browser--edit-mode-should-handle-special-characters-1.png +0 -0
  36. package/tests/browser/fields/__screenshots__/TextField.browser.test.tsx/TextField--Browser--edit-mode-should-support-copy-and-paste-1.png +0 -0
  37. package/tests/browser/primitives/Button.browser.test.tsx +122 -0
  38. package/tests/browser/primitives/Dialog.browser.test.tsx +279 -0
  39. package/tests/browser/primitives/__screenshots__/Button.browser.test.tsx/Button--Browser--should-not-trigger-click-when-disabled-1.png +0 -0
  40. package/tests/components/CheckboxField.test.tsx +130 -0
  41. package/tests/components/DeleteButton.test.tsx +331 -0
  42. package/tests/components/IntegerField.test.tsx +147 -0
  43. package/tests/components/ListTable.test.tsx +457 -0
  44. package/tests/components/ListViewClient.test.tsx +415 -0
  45. package/tests/components/SearchBar.test.tsx +254 -0
  46. package/tests/components/SelectField.test.tsx +192 -0
  47. package/vitest.config.ts +20 -0
@@ -0,0 +1,415 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
2
+ import { render, screen } from '@testing-library/react'
3
+ import userEvent from '@testing-library/user-event'
4
+ import { ListViewClient } from '../../src/components/ListViewClient.js'
5
+
6
+ // Mock Next.js navigation
7
+ const mockPush = vi.fn()
8
+ vi.mock('next/navigation.js', () => ({
9
+ useRouter: () => ({
10
+ push: mockPush,
11
+ }),
12
+ }))
13
+
14
+ describe('ListViewClient', () => {
15
+ const defaultProps = {
16
+ items: [
17
+ { id: '1', title: 'First Post', status: 'published', views: 100 },
18
+ { id: '2', title: 'Second Post', status: 'draft', views: 50 },
19
+ { id: '3', title: 'Third Post', status: 'published', views: 200 },
20
+ ],
21
+ fieldTypes: {
22
+ title: 'text',
23
+ status: 'select',
24
+ views: 'integer',
25
+ },
26
+ relationshipRefs: {},
27
+ listKey: 'Post',
28
+ urlKey: 'post',
29
+ basePath: '/admin',
30
+ page: 1,
31
+ pageSize: 50,
32
+ total: 3,
33
+ }
34
+
35
+ beforeEach(() => {
36
+ mockPush.mockClear()
37
+ })
38
+
39
+ describe('rendering', () => {
40
+ it('should render all items in table', () => {
41
+ render(<ListViewClient {...defaultProps} />)
42
+
43
+ expect(screen.getByText('First Post')).toBeInTheDocument()
44
+ expect(screen.getByText('Second Post')).toBeInTheDocument()
45
+ expect(screen.getByText('Third Post')).toBeInTheDocument()
46
+ })
47
+
48
+ it('should render column headers', () => {
49
+ render(<ListViewClient {...defaultProps} />)
50
+
51
+ expect(screen.getByText('Title')).toBeInTheDocument()
52
+ expect(screen.getByText('Status')).toBeInTheDocument()
53
+ expect(screen.getByText('Views')).toBeInTheDocument()
54
+ })
55
+
56
+ it('should render actions column', () => {
57
+ render(<ListViewClient {...defaultProps} />)
58
+
59
+ const editLinks = screen.getAllByText('Edit')
60
+ expect(editLinks).toHaveLength(3)
61
+ })
62
+
63
+ it('should show empty state when no items', () => {
64
+ render(<ListViewClient {...defaultProps} items={[]} total={0} />)
65
+
66
+ expect(screen.getByText('No items found')).toBeInTheDocument()
67
+ })
68
+
69
+ it('should only render specified columns when provided', () => {
70
+ render(<ListViewClient {...defaultProps} columns={['title', 'status']} />)
71
+
72
+ expect(screen.getByText('Title')).toBeInTheDocument()
73
+ expect(screen.getByText('Status')).toBeInTheDocument()
74
+ expect(screen.queryByText('Views')).not.toBeInTheDocument()
75
+ })
76
+ })
77
+
78
+ describe('sorting', () => {
79
+ it('should sort items ascending when header clicked', async () => {
80
+ const user = userEvent.setup()
81
+ render(<ListViewClient {...defaultProps} />)
82
+
83
+ await user.click(screen.getByText('Title'))
84
+
85
+ const rows = screen.getAllByRole('row')
86
+ // First row is header, so data rows start at index 1
87
+ expect(rows[1]).toHaveTextContent('First Post')
88
+ expect(rows[2]).toHaveTextContent('Second Post')
89
+ expect(rows[3]).toHaveTextContent('Third Post')
90
+ })
91
+
92
+ it('should toggle sort order when same header clicked twice', async () => {
93
+ const user = userEvent.setup()
94
+ render(<ListViewClient {...defaultProps} />)
95
+
96
+ const titleHeader = screen.getByText('Title')
97
+ await user.click(titleHeader)
98
+ await user.click(titleHeader)
99
+
100
+ const rows = screen.getAllByRole('row')
101
+ // Should be descending now
102
+ expect(rows[1]).toHaveTextContent('Third Post')
103
+ expect(rows[2]).toHaveTextContent('Second Post')
104
+ expect(rows[3]).toHaveTextContent('First Post')
105
+ })
106
+
107
+ it('should show sort indicator on active column', async () => {
108
+ const user = userEvent.setup()
109
+ render(<ListViewClient {...defaultProps} />)
110
+
111
+ await user.click(screen.getByText('Title'))
112
+
113
+ expect(screen.getByText('↑')).toBeInTheDocument()
114
+ })
115
+
116
+ it('should sort numeric fields correctly', async () => {
117
+ const user = userEvent.setup()
118
+ render(<ListViewClient {...defaultProps} />)
119
+
120
+ await user.click(screen.getByText('Views'))
121
+
122
+ const rows = screen.getAllByRole('row')
123
+ // Should sort by views: 100, 200, 50 (string comparison)
124
+ // Note: The current implementation sorts as strings, not numbers
125
+ expect(rows[1]).toHaveTextContent('100')
126
+ expect(rows[2]).toHaveTextContent('200')
127
+ expect(rows[3]).toHaveTextContent('50')
128
+ })
129
+ })
130
+
131
+ describe('search', () => {
132
+ it('should render search input', () => {
133
+ render(<ListViewClient {...defaultProps} />)
134
+
135
+ expect(screen.getByPlaceholderText('Search...')).toBeInTheDocument()
136
+ })
137
+
138
+ it('should update search input value', async () => {
139
+ const user = userEvent.setup()
140
+ render(<ListViewClient {...defaultProps} />)
141
+
142
+ const searchInput = screen.getByPlaceholderText('Search...')
143
+ await user.type(searchInput, 'test')
144
+
145
+ expect(searchInput).toHaveValue('test')
146
+ })
147
+
148
+ it('should navigate with search query when search submitted', async () => {
149
+ const user = userEvent.setup()
150
+ render(<ListViewClient {...defaultProps} />)
151
+
152
+ const searchInput = screen.getByPlaceholderText('Search...')
153
+ await user.type(searchInput, 'test')
154
+
155
+ const searchButton = screen.getByRole('button', { name: /search/i })
156
+ await user.click(searchButton)
157
+
158
+ expect(mockPush).toHaveBeenCalledWith('/admin/post?search=test&page=1')
159
+ })
160
+
161
+ it('should show clear button when search has value', async () => {
162
+ const user = userEvent.setup()
163
+ render(<ListViewClient {...defaultProps} />)
164
+
165
+ const searchInput = screen.getByPlaceholderText('Search...')
166
+ await user.type(searchInput, 'test')
167
+
168
+ expect(screen.getByText('✕')).toBeInTheDocument()
169
+ })
170
+
171
+ it('should clear search when clear button clicked', async () => {
172
+ const user = userEvent.setup()
173
+ render(<ListViewClient {...defaultProps} />)
174
+
175
+ const searchInput = screen.getByPlaceholderText('Search...')
176
+ await user.type(searchInput, 'test')
177
+
178
+ const clearButton = screen.getByText('✕')
179
+ await user.click(clearButton)
180
+
181
+ expect(mockPush).toHaveBeenCalledWith('/admin/post')
182
+ })
183
+
184
+ it('should reset to page 1 when new search submitted', async () => {
185
+ const user = userEvent.setup()
186
+ render(<ListViewClient {...defaultProps} page={3} />)
187
+
188
+ const searchInput = screen.getByPlaceholderText('Search...')
189
+ await user.type(searchInput, 'test')
190
+
191
+ const searchButton = screen.getByRole('button', { name: /search/i })
192
+ await user.click(searchButton)
193
+
194
+ expect(mockPush).toHaveBeenCalledWith('/admin/post?search=test&page=1')
195
+ })
196
+
197
+ it('should preserve initial search value', () => {
198
+ render(<ListViewClient {...defaultProps} search="existing" />)
199
+
200
+ const searchInput = screen.getByPlaceholderText('Search...')
201
+ expect(searchInput).toHaveValue('existing')
202
+ })
203
+ })
204
+
205
+ describe('pagination', () => {
206
+ it('should show pagination when total pages > 1', () => {
207
+ render(<ListViewClient {...defaultProps} total={100} pageSize={10} />)
208
+
209
+ expect(screen.getByText(/Page 1 of 10/)).toBeInTheDocument()
210
+ })
211
+
212
+ it('should not show pagination when total pages = 1', () => {
213
+ render(<ListViewClient {...defaultProps} total={3} pageSize={50} />)
214
+
215
+ expect(screen.queryByText(/Page/)).not.toBeInTheDocument()
216
+ })
217
+
218
+ it('should show correct page info', () => {
219
+ render(<ListViewClient {...defaultProps} total={100} pageSize={10} page={2} />)
220
+
221
+ expect(screen.getByText(/Showing 11 to 20 of 100 results/)).toBeInTheDocument()
222
+ })
223
+
224
+ it('should disable Previous button on first page', () => {
225
+ render(<ListViewClient {...defaultProps} total={100} pageSize={10} page={1} />)
226
+
227
+ const prevButton = screen.getByRole('button', { name: /previous/i })
228
+ expect(prevButton).toBeDisabled()
229
+ })
230
+
231
+ it('should disable Next button on last page', () => {
232
+ render(<ListViewClient {...defaultProps} total={100} pageSize={10} page={10} />)
233
+
234
+ const nextButton = screen.getByRole('button', { name: /next/i })
235
+ expect(nextButton).toBeDisabled()
236
+ })
237
+
238
+ it('should navigate to previous page when Previous clicked', async () => {
239
+ const user = userEvent.setup()
240
+ render(<ListViewClient {...defaultProps} total={100} pageSize={10} page={2} />)
241
+
242
+ const prevButton = screen.getByRole('button', { name: /previous/i })
243
+ await user.click(prevButton)
244
+
245
+ expect(mockPush).toHaveBeenCalledWith('/admin/post?page=1')
246
+ })
247
+
248
+ it('should navigate to next page when Next clicked', async () => {
249
+ const user = userEvent.setup()
250
+ render(<ListViewClient {...defaultProps} total={100} pageSize={10} page={1} />)
251
+
252
+ const nextButton = screen.getByRole('button', { name: /next/i })
253
+ await user.click(nextButton)
254
+
255
+ expect(mockPush).toHaveBeenCalledWith('/admin/post?page=2')
256
+ })
257
+
258
+ it('should preserve search in pagination URLs', async () => {
259
+ const user = userEvent.setup()
260
+ render(<ListViewClient {...defaultProps} total={100} pageSize={10} page={1} search="test" />)
261
+
262
+ const nextButton = screen.getByRole('button', { name: /next/i })
263
+ await user.click(nextButton)
264
+
265
+ expect(mockPush).toHaveBeenCalledWith('/admin/post?search=test&page=2')
266
+ })
267
+ })
268
+
269
+ describe('relationships', () => {
270
+ it('should render relationship as link when relationshipRefs provided', () => {
271
+ const items = [
272
+ {
273
+ id: '1',
274
+ title: 'Post 1',
275
+ author: { id: 'user-1', name: 'John Doe' },
276
+ },
277
+ ]
278
+
279
+ render(
280
+ <ListViewClient
281
+ {...defaultProps}
282
+ items={items}
283
+ fieldTypes={{ title: 'text', author: 'relationship' }}
284
+ relationshipRefs={{ author: 'User.posts' }}
285
+ columns={['title', 'author']}
286
+ />,
287
+ )
288
+
289
+ const authorLink = screen.getByRole('link', { name: 'John Doe' })
290
+ expect(authorLink).toBeInTheDocument()
291
+ expect(authorLink).toHaveAttribute('href', '/admin/user/user-1')
292
+ })
293
+
294
+ it('should render multiple relationships as comma-separated links', () => {
295
+ const items = [
296
+ {
297
+ id: '1',
298
+ title: 'Post 1',
299
+ tags: [
300
+ { id: 'tag-1', name: 'JavaScript' },
301
+ { id: 'tag-2', name: 'TypeScript' },
302
+ ],
303
+ },
304
+ ]
305
+
306
+ const { container } = render(
307
+ <ListViewClient
308
+ {...defaultProps}
309
+ items={items}
310
+ fieldTypes={{ title: 'text', tags: 'relationship' }}
311
+ relationshipRefs={{ tags: 'Tag.posts' }}
312
+ columns={['title', 'tags']}
313
+ />,
314
+ )
315
+
316
+ expect(screen.getByRole('link', { name: 'JavaScript' })).toBeInTheDocument()
317
+ expect(screen.getByRole('link', { name: 'TypeScript' })).toBeInTheDocument()
318
+ // Check for comma separator in the container
319
+ expect(container.textContent).toContain(', ')
320
+ })
321
+
322
+ it('should show dash for empty relationship', () => {
323
+ const items = [{ id: '1', title: 'Post 1', author: null }]
324
+
325
+ render(
326
+ <ListViewClient
327
+ {...defaultProps}
328
+ items={items}
329
+ fieldTypes={{ title: 'text', author: 'relationship' }}
330
+ relationshipRefs={{ author: 'User.posts' }}
331
+ columns={['title', 'author']}
332
+ />,
333
+ )
334
+
335
+ const cells = screen.getAllByRole('cell')
336
+ const authorCell = cells.find((cell) => cell.textContent === '-')
337
+ expect(authorCell).toBeInTheDocument()
338
+ })
339
+
340
+ it('should show dash for empty relationship array', () => {
341
+ const items = [{ id: '1', title: 'Post 1', tags: [] }]
342
+
343
+ render(
344
+ <ListViewClient
345
+ {...defaultProps}
346
+ items={items}
347
+ fieldTypes={{ title: 'text', tags: 'relationship' }}
348
+ relationshipRefs={{ tags: 'Tag.posts' }}
349
+ columns={['title', 'tags']}
350
+ />,
351
+ )
352
+
353
+ const cells = screen.getAllByRole('cell')
354
+ const tagsCell = cells.find((cell) => cell.textContent === '-')
355
+ expect(tagsCell).toBeInTheDocument()
356
+ })
357
+ })
358
+
359
+ describe('field display values', () => {
360
+ it('should display checkbox values as Yes/No', () => {
361
+ const items = [
362
+ { id: '1', title: 'Post 1', published: true },
363
+ { id: '2', title: 'Post 2', published: false },
364
+ ]
365
+
366
+ render(
367
+ <ListViewClient
368
+ {...defaultProps}
369
+ items={items}
370
+ fieldTypes={{ title: 'text', published: 'checkbox' }}
371
+ columns={['title', 'published']}
372
+ />,
373
+ )
374
+
375
+ expect(screen.getByText('Yes')).toBeInTheDocument()
376
+ expect(screen.getByText('No')).toBeInTheDocument()
377
+ })
378
+
379
+ it('should display timestamp values as formatted dates', () => {
380
+ const items = [{ id: '1', title: 'Post 1', createdAt: '2024-01-01T12:00:00Z' }]
381
+
382
+ render(
383
+ <ListViewClient
384
+ {...defaultProps}
385
+ items={items}
386
+ fieldTypes={{ title: 'text', createdAt: 'timestamp' }}
387
+ columns={['title', 'createdAt']}
388
+ />,
389
+ )
390
+
391
+ // Just check that it's formatted (not the exact format which depends on locale)
392
+ const cells = screen.getAllByRole('cell')
393
+ const dateCell = cells.find((cell) => cell.textContent?.includes('2024'))
394
+ expect(dateCell).toBeInTheDocument()
395
+ })
396
+ })
397
+
398
+ describe('edit links', () => {
399
+ it('should link to correct edit page', () => {
400
+ render(<ListViewClient {...defaultProps} />)
401
+
402
+ const editLinks = screen.getAllByRole('link', { name: 'Edit' })
403
+ expect(editLinks[0]).toHaveAttribute('href', '/admin/post/1')
404
+ expect(editLinks[1]).toHaveAttribute('href', '/admin/post/2')
405
+ expect(editLinks[2]).toHaveAttribute('href', '/admin/post/3')
406
+ })
407
+
408
+ it('should use custom basePath in edit links', () => {
409
+ render(<ListViewClient {...defaultProps} basePath="/custom" />)
410
+
411
+ const editLinks = screen.getAllByRole('link', { name: 'Edit' })
412
+ expect(editLinks[0]).toHaveAttribute('href', '/custom/post/1')
413
+ })
414
+ })
415
+ })
@@ -0,0 +1,254 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
2
+ import { render, screen } from '@testing-library/react'
3
+ import userEvent from '@testing-library/user-event'
4
+ import { SearchBar } from '../../src/components/standalone/SearchBar.js'
5
+
6
+ // Mock Next.js navigation
7
+ const mockPush = vi.fn()
8
+ const mockPathname = '/admin/posts'
9
+
10
+ vi.mock('next/navigation.js', () => ({
11
+ useRouter: () => ({
12
+ push: mockPush,
13
+ }),
14
+ usePathname: () => mockPathname,
15
+ }))
16
+
17
+ describe('SearchBar', () => {
18
+ beforeEach(() => {
19
+ mockPush.mockClear()
20
+ })
21
+
22
+ describe('rendering', () => {
23
+ it('should render search input', () => {
24
+ render(<SearchBar />)
25
+
26
+ expect(screen.getByPlaceholderText('Search...')).toBeInTheDocument()
27
+ })
28
+
29
+ it('should render search button', () => {
30
+ render(<SearchBar />)
31
+
32
+ expect(screen.getByRole('button', { name: 'Search' })).toBeInTheDocument()
33
+ })
34
+
35
+ it('should use custom placeholder', () => {
36
+ render(<SearchBar placeholder="Search posts..." />)
37
+
38
+ expect(screen.getByPlaceholderText('Search posts...')).toBeInTheDocument()
39
+ })
40
+
41
+ it('should use custom search button label', () => {
42
+ render(<SearchBar searchLabel="Find" />)
43
+
44
+ expect(screen.getByRole('button', { name: 'Find' })).toBeInTheDocument()
45
+ })
46
+
47
+ it('should apply custom className', () => {
48
+ const { container } = render(<SearchBar className="custom-class" />)
49
+
50
+ expect(container.querySelector('.custom-class')).toBeInTheDocument()
51
+ })
52
+
53
+ it('should show default value', () => {
54
+ render(<SearchBar defaultValue="initial search" />)
55
+
56
+ const input = screen.getByPlaceholderText('Search...')
57
+ expect(input).toHaveValue('initial search')
58
+ })
59
+ })
60
+
61
+ describe('user interactions', () => {
62
+ it('should update input value when user types', async () => {
63
+ const user = userEvent.setup()
64
+ render(<SearchBar />)
65
+
66
+ const input = screen.getByPlaceholderText('Search...')
67
+ await user.type(input, 'test query')
68
+
69
+ expect(input).toHaveValue('test query')
70
+ })
71
+
72
+ it('should show clear button when input has value', async () => {
73
+ const user = userEvent.setup()
74
+ render(<SearchBar />)
75
+
76
+ const input = screen.getByPlaceholderText('Search...')
77
+ await user.type(input, 'test')
78
+
79
+ expect(screen.getByText('✕')).toBeInTheDocument()
80
+ })
81
+
82
+ it('should not show clear button when input is empty', () => {
83
+ render(<SearchBar />)
84
+
85
+ expect(screen.queryByText('✕')).not.toBeInTheDocument()
86
+ })
87
+
88
+ it('should clear input when clear button clicked', async () => {
89
+ const user = userEvent.setup()
90
+ render(<SearchBar />)
91
+
92
+ const input = screen.getByPlaceholderText('Search...')
93
+ await user.type(input, 'test')
94
+
95
+ const clearButton = screen.getByText('✕')
96
+ await user.click(clearButton)
97
+
98
+ expect(input).toHaveValue('')
99
+ })
100
+
101
+ it('should hide clear button after clearing', async () => {
102
+ const user = userEvent.setup()
103
+ render(<SearchBar />)
104
+
105
+ const input = screen.getByPlaceholderText('Search...')
106
+ await user.type(input, 'test')
107
+
108
+ const clearButton = screen.getByText('✕')
109
+ await user.click(clearButton)
110
+
111
+ expect(screen.queryByText('✕')).not.toBeInTheDocument()
112
+ })
113
+ })
114
+
115
+ describe('search functionality', () => {
116
+ it('should call onSearch with trimmed query when search button clicked', async () => {
117
+ const onSearch = vi.fn()
118
+ const user = userEvent.setup()
119
+
120
+ render(<SearchBar onSearch={onSearch} />)
121
+
122
+ const input = screen.getByPlaceholderText('Search...')
123
+ await user.type(input, ' test query ')
124
+
125
+ const searchButton = screen.getByRole('button', { name: 'Search' })
126
+ await user.click(searchButton)
127
+
128
+ expect(onSearch).toHaveBeenCalledWith('test query')
129
+ })
130
+
131
+ it('should call onSearch when form submitted via Enter key', async () => {
132
+ const onSearch = vi.fn()
133
+ const user = userEvent.setup()
134
+
135
+ render(<SearchBar onSearch={onSearch} />)
136
+
137
+ const input = screen.getByPlaceholderText('Search...')
138
+ await user.type(input, 'test query{Enter}')
139
+
140
+ expect(onSearch).toHaveBeenCalledWith('test query')
141
+ })
142
+
143
+ it('should navigate with search param when onSearch not provided', async () => {
144
+ const user = userEvent.setup()
145
+ render(<SearchBar />)
146
+
147
+ const input = screen.getByPlaceholderText('Search...')
148
+ await user.type(input, 'test query')
149
+
150
+ const searchButton = screen.getByRole('button', { name: 'Search' })
151
+ await user.click(searchButton)
152
+
153
+ expect(mockPush).toHaveBeenCalledWith('/admin/posts?search=test query')
154
+ })
155
+
156
+ it('should call onClear when clear button clicked', async () => {
157
+ const onClear = vi.fn()
158
+ const user = userEvent.setup()
159
+
160
+ render(<SearchBar onClear={onClear} />)
161
+
162
+ const input = screen.getByPlaceholderText('Search...')
163
+ await user.type(input, 'test')
164
+
165
+ const clearButton = screen.getByText('✕')
166
+ await user.click(clearButton)
167
+
168
+ expect(onClear).toHaveBeenCalled()
169
+ })
170
+
171
+ it('should not call onClear when not provided', async () => {
172
+ const user = userEvent.setup()
173
+ render(<SearchBar />)
174
+
175
+ const input = screen.getByPlaceholderText('Search...')
176
+ await user.type(input, 'test')
177
+
178
+ const clearButton = screen.getByText('✕')
179
+ await user.click(clearButton)
180
+
181
+ // Should not throw error
182
+ expect(input).toHaveValue('')
183
+ })
184
+
185
+ it('should trim whitespace from search query', async () => {
186
+ const onSearch = vi.fn()
187
+ const user = userEvent.setup()
188
+
189
+ render(<SearchBar onSearch={onSearch} />)
190
+
191
+ const input = screen.getByPlaceholderText('Search...')
192
+ await user.type(input, ' spaces ')
193
+
194
+ const searchButton = screen.getByRole('button', { name: 'Search' })
195
+ await user.click(searchButton)
196
+
197
+ expect(onSearch).toHaveBeenCalledWith('spaces')
198
+ })
199
+
200
+ it('should allow searching for empty string', async () => {
201
+ const onSearch = vi.fn()
202
+ const user = userEvent.setup()
203
+
204
+ render(<SearchBar onSearch={onSearch} />)
205
+
206
+ const searchButton = screen.getByRole('button', { name: 'Search' })
207
+ await user.click(searchButton)
208
+
209
+ expect(onSearch).toHaveBeenCalledWith('')
210
+ })
211
+ })
212
+
213
+ describe('form behavior', () => {
214
+ it('should prevent default form submission', async () => {
215
+ const onSearch = vi.fn()
216
+ const user = userEvent.setup()
217
+
218
+ render(<SearchBar onSearch={onSearch} />)
219
+
220
+ const input = screen.getByPlaceholderText('Search...')
221
+ await user.type(input, 'test{Enter}')
222
+
223
+ // Form should not reload the page
224
+ expect(onSearch).toHaveBeenCalled()
225
+ })
226
+
227
+ it('should submit via button click', async () => {
228
+ const onSearch = vi.fn()
229
+ const user = userEvent.setup()
230
+
231
+ render(<SearchBar onSearch={onSearch} />)
232
+
233
+ const input = screen.getByPlaceholderText('Search...')
234
+ await user.type(input, 'test')
235
+
236
+ const searchButton = screen.getByRole('button', { name: 'Search' })
237
+ await user.click(searchButton)
238
+
239
+ expect(onSearch).toHaveBeenCalledWith('test')
240
+ })
241
+
242
+ it('should submit via Enter key', async () => {
243
+ const onSearch = vi.fn()
244
+ const user = userEvent.setup()
245
+
246
+ render(<SearchBar onSearch={onSearch} />)
247
+
248
+ const input = screen.getByPlaceholderText('Search...')
249
+ await user.type(input, 'test{Enter}')
250
+
251
+ expect(onSearch).toHaveBeenCalledWith('test')
252
+ })
253
+ })
254
+ })