@opensaas/stack-ui 0.1.7 → 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 +4 -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 +24 -0
  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,331 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
2
+ import { render, screen, waitFor } from '@testing-library/react'
3
+ import userEvent from '@testing-library/user-event'
4
+ import { DeleteButton } from '../../src/components/standalone/DeleteButton.js'
5
+
6
+ describe('DeleteButton', () => {
7
+ const mockOnDelete = vi.fn()
8
+
9
+ beforeEach(() => {
10
+ mockOnDelete.mockClear()
11
+ })
12
+
13
+ describe('rendering', () => {
14
+ it('should render delete button', () => {
15
+ render(<DeleteButton onDelete={mockOnDelete} />)
16
+
17
+ expect(screen.getByRole('button', { name: /delete/i })).toBeInTheDocument()
18
+ })
19
+
20
+ it('should use custom button label', () => {
21
+ render(<DeleteButton onDelete={mockOnDelete} buttonLabel="Remove" />)
22
+
23
+ expect(screen.getByRole('button', { name: 'Remove' })).toBeInTheDocument()
24
+ })
25
+
26
+ it('should apply custom className', () => {
27
+ render(<DeleteButton onDelete={mockOnDelete} className="custom-class" />)
28
+
29
+ const button = screen.getByRole('button', { name: /delete/i })
30
+ expect(button).toHaveClass('custom-class')
31
+ })
32
+
33
+ it('should be disabled when disabled prop is true', () => {
34
+ render(<DeleteButton onDelete={mockOnDelete} disabled />)
35
+
36
+ const button = screen.getByRole('button', { name: /delete/i })
37
+ expect(button).toBeDisabled()
38
+ })
39
+
40
+ it('should not show confirmation dialog initially', () => {
41
+ render(<DeleteButton onDelete={mockOnDelete} itemName="post" />)
42
+
43
+ expect(screen.queryByText(/delete post/i)).not.toBeInTheDocument()
44
+ })
45
+ })
46
+
47
+ describe('confirmation dialog', () => {
48
+ it('should show confirmation dialog when button clicked', async () => {
49
+ const user = userEvent.setup()
50
+ render(<DeleteButton onDelete={mockOnDelete} itemName="post" />)
51
+
52
+ const deleteButton = screen.getByRole('button', { name: /delete/i })
53
+ await user.click(deleteButton)
54
+
55
+ expect(screen.getByText(/delete post/i)).toBeInTheDocument()
56
+ })
57
+
58
+ it('should show default confirmation message', async () => {
59
+ const user = userEvent.setup()
60
+ render(<DeleteButton onDelete={mockOnDelete} itemName="post" />)
61
+
62
+ const deleteButton = screen.getByRole('button', { name: /delete/i })
63
+ await user.click(deleteButton)
64
+
65
+ expect(screen.getByText(/are you sure you want to delete this post/i)).toBeInTheDocument()
66
+ })
67
+
68
+ it('should show custom confirmation title', async () => {
69
+ const user = userEvent.setup()
70
+ render(
71
+ <DeleteButton onDelete={mockOnDelete} confirmTitle="Confirm Deletion" itemName="post" />,
72
+ )
73
+
74
+ const deleteButton = screen.getByRole('button', { name: /delete/i })
75
+ await user.click(deleteButton)
76
+
77
+ expect(screen.getByText('Confirm Deletion')).toBeInTheDocument()
78
+ })
79
+
80
+ it('should show custom confirmation message', async () => {
81
+ const user = userEvent.setup()
82
+ render(
83
+ <DeleteButton
84
+ onDelete={mockOnDelete}
85
+ confirmMessage="This will permanently delete the post."
86
+ />,
87
+ )
88
+
89
+ const deleteButton = screen.getByRole('button', { name: /delete/i })
90
+ await user.click(deleteButton)
91
+
92
+ expect(screen.getByText('This will permanently delete the post.')).toBeInTheDocument()
93
+ })
94
+
95
+ it('should show custom confirm label', async () => {
96
+ const user = userEvent.setup()
97
+ render(<DeleteButton onDelete={mockOnDelete} confirmLabel="Yes, delete it" />)
98
+
99
+ const deleteButton = screen.getByRole('button', { name: /delete/i })
100
+ await user.click(deleteButton)
101
+
102
+ expect(screen.getByRole('button', { name: 'Yes, delete it' })).toBeInTheDocument()
103
+ })
104
+
105
+ it('should show custom cancel label', async () => {
106
+ const user = userEvent.setup()
107
+ render(<DeleteButton onDelete={mockOnDelete} cancelLabel="No, keep it" />)
108
+
109
+ const deleteButton = screen.getByRole('button', { name: /delete/i })
110
+ await user.click(deleteButton)
111
+
112
+ expect(screen.getByRole('button', { name: 'No, keep it' })).toBeInTheDocument()
113
+ })
114
+
115
+ it('should close dialog when cancel button clicked', async () => {
116
+ const user = userEvent.setup()
117
+ render(<DeleteButton onDelete={mockOnDelete} itemName="post" />)
118
+
119
+ const deleteButton = screen.getByRole('button', { name: /delete/i })
120
+ await user.click(deleteButton)
121
+
122
+ const cancelButton = screen.getByRole('button', { name: /cancel/i })
123
+ await user.click(cancelButton)
124
+
125
+ await waitFor(() => {
126
+ expect(screen.queryByText(/delete post/i)).not.toBeInTheDocument()
127
+ })
128
+ })
129
+
130
+ it('should not call onDelete when cancel button clicked', async () => {
131
+ const user = userEvent.setup()
132
+ render(<DeleteButton onDelete={mockOnDelete} />)
133
+
134
+ const deleteButton = screen.getByRole('button', { name: /delete/i })
135
+ await user.click(deleteButton)
136
+
137
+ const cancelButton = screen.getByRole('button', { name: /cancel/i })
138
+ await user.click(cancelButton)
139
+
140
+ expect(mockOnDelete).not.toHaveBeenCalled()
141
+ })
142
+ })
143
+
144
+ describe('delete functionality', () => {
145
+ it('should call onDelete when confirmed', async () => {
146
+ mockOnDelete.mockResolvedValue({ success: true })
147
+ const user = userEvent.setup()
148
+
149
+ render(<DeleteButton onDelete={mockOnDelete} />)
150
+
151
+ const deleteButton = screen.getByRole('button', { name: /delete/i })
152
+ await user.click(deleteButton)
153
+
154
+ const confirmButton = screen.getByRole('button', { name: /^delete$/i })
155
+ await user.click(confirmButton)
156
+
157
+ await waitFor(() => {
158
+ expect(mockOnDelete).toHaveBeenCalled()
159
+ })
160
+ })
161
+
162
+ it('should close dialog when delete confirmed', async () => {
163
+ mockOnDelete.mockResolvedValue({ success: true })
164
+ const user = userEvent.setup()
165
+
166
+ render(<DeleteButton onDelete={mockOnDelete} itemName="post" />)
167
+
168
+ const deleteButton = screen.getByRole('button', { name: /delete/i })
169
+ await user.click(deleteButton)
170
+
171
+ const confirmButton = screen.getByRole('button', { name: /^delete$/i })
172
+ await user.click(confirmButton)
173
+
174
+ await waitFor(() => {
175
+ expect(screen.queryByText(/delete post/i)).not.toBeInTheDocument()
176
+ })
177
+ })
178
+
179
+ it('should show loading state during delete', async () => {
180
+ let resolveDelete: (value: { success: boolean }) => void
181
+ const deletePromise = new Promise<{ success: boolean }>((resolve) => {
182
+ resolveDelete = resolve
183
+ })
184
+ mockOnDelete.mockReturnValue(deletePromise)
185
+
186
+ const user = userEvent.setup()
187
+ render(<DeleteButton onDelete={mockOnDelete} />)
188
+
189
+ const deleteButton = screen.getByRole('button', { name: /delete/i })
190
+ await user.click(deleteButton)
191
+
192
+ const confirmButton = screen.getByRole('button', { name: /^delete$/i })
193
+ await user.click(confirmButton)
194
+
195
+ await waitFor(() => {
196
+ const button = screen.getByRole('button', { name: /delete/i })
197
+ expect(button).toBeDisabled()
198
+ })
199
+
200
+ resolveDelete!({ success: true })
201
+ })
202
+
203
+ it('should disable button during delete', async () => {
204
+ let resolveDelete: (value: { success: boolean }) => void
205
+ const deletePromise = new Promise<{ success: boolean }>((resolve) => {
206
+ resolveDelete = resolve
207
+ })
208
+ mockOnDelete.mockReturnValue(deletePromise)
209
+
210
+ const user = userEvent.setup()
211
+ render(<DeleteButton onDelete={mockOnDelete} />)
212
+
213
+ const deleteButton = screen.getByRole('button', { name: /delete/i })
214
+ await user.click(deleteButton)
215
+
216
+ const confirmButton = screen.getByRole('button', { name: /^delete$/i })
217
+ await user.click(confirmButton)
218
+
219
+ await waitFor(() => {
220
+ const button = screen.getByRole('button', { name: /delete/i })
221
+ expect(button).toBeDisabled()
222
+ })
223
+
224
+ resolveDelete!({ success: true })
225
+
226
+ await waitFor(() => {
227
+ const button = screen.getByRole('button', { name: /delete/i })
228
+ expect(button).not.toBeDisabled()
229
+ })
230
+ })
231
+ })
232
+
233
+ describe('error handling', () => {
234
+ it('should show error when delete fails with error message', async () => {
235
+ mockOnDelete.mockResolvedValue({ success: false, error: 'Database error' })
236
+ const user = userEvent.setup()
237
+
238
+ render(<DeleteButton onDelete={mockOnDelete} />)
239
+
240
+ const deleteButton = screen.getByRole('button', { name: /delete/i })
241
+ await user.click(deleteButton)
242
+
243
+ const confirmButton = screen.getByRole('button', { name: /^delete$/i })
244
+ await user.click(confirmButton)
245
+
246
+ await waitFor(() => {
247
+ expect(screen.getByText('Database error')).toBeInTheDocument()
248
+ })
249
+ })
250
+
251
+ it('should show default error when delete fails without error message', async () => {
252
+ mockOnDelete.mockResolvedValue({ success: false })
253
+ const user = userEvent.setup()
254
+
255
+ render(<DeleteButton onDelete={mockOnDelete} itemName="post" />)
256
+
257
+ const deleteButton = screen.getByRole('button', { name: /delete/i })
258
+ await user.click(deleteButton)
259
+
260
+ const confirmButton = screen.getByRole('button', { name: /^delete$/i })
261
+ await user.click(confirmButton)
262
+
263
+ await waitFor(() => {
264
+ expect(screen.getByText('Failed to delete post')).toBeInTheDocument()
265
+ })
266
+ })
267
+
268
+ it('should show error when delete throws exception', async () => {
269
+ mockOnDelete.mockRejectedValue(new Error('Network error'))
270
+ const user = userEvent.setup()
271
+
272
+ render(<DeleteButton onDelete={mockOnDelete} />)
273
+
274
+ const deleteButton = screen.getByRole('button', { name: /delete/i })
275
+ await user.click(deleteButton)
276
+
277
+ const confirmButton = screen.getByRole('button', { name: /^delete$/i })
278
+ await user.click(confirmButton)
279
+
280
+ await waitFor(() => {
281
+ expect(screen.getByText('Network error')).toBeInTheDocument()
282
+ })
283
+ })
284
+
285
+ it('should clear previous error on new delete attempt', async () => {
286
+ mockOnDelete
287
+ .mockResolvedValueOnce({ success: false, error: 'First error' })
288
+ .mockResolvedValueOnce({ success: true })
289
+
290
+ const user = userEvent.setup()
291
+ render(<DeleteButton onDelete={mockOnDelete} />)
292
+
293
+ // First attempt - fails
294
+ const deleteButton = screen.getByRole('button', { name: /delete/i })
295
+ await user.click(deleteButton)
296
+
297
+ const confirmButton1 = screen.getByRole('button', { name: /^delete$/i })
298
+ await user.click(confirmButton1)
299
+
300
+ await waitFor(() => {
301
+ expect(screen.getByText('First error')).toBeInTheDocument()
302
+ })
303
+
304
+ // Second attempt - succeeds
305
+ await user.click(deleteButton)
306
+
307
+ const confirmButton2 = screen.getByRole('button', { name: /^delete$/i })
308
+ await user.click(confirmButton2)
309
+
310
+ await waitFor(() => {
311
+ expect(screen.queryByText('First error')).not.toBeInTheDocument()
312
+ })
313
+ })
314
+ })
315
+
316
+ describe('button variants and sizes', () => {
317
+ it('should apply custom button variant', () => {
318
+ render(<DeleteButton onDelete={mockOnDelete} buttonVariant="outline" />)
319
+
320
+ const button = screen.getByRole('button', { name: /delete/i })
321
+ expect(button).toBeInTheDocument()
322
+ })
323
+
324
+ it('should apply custom size', () => {
325
+ render(<DeleteButton onDelete={mockOnDelete} size="sm" />)
326
+
327
+ const button = screen.getByRole('button', { name: /delete/i })
328
+ expect(button).toBeInTheDocument()
329
+ })
330
+ })
331
+ })
@@ -0,0 +1,147 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import { render, screen } from '@testing-library/react'
3
+ import userEvent from '@testing-library/user-event'
4
+ import { IntegerField } from '../../src/components/fields/IntegerField.js'
5
+
6
+ describe('IntegerField', () => {
7
+ describe('edit mode', () => {
8
+ it('should render number input with label', () => {
9
+ render(<IntegerField name="age" value={null} onChange={vi.fn()} label="Age" />)
10
+
11
+ expect(screen.getByLabelText('Age')).toBeInTheDocument()
12
+ expect(screen.getByRole('spinbutton')).toBeInTheDocument()
13
+ })
14
+
15
+ it('should display current numeric value', () => {
16
+ render(<IntegerField name="age" value={25} onChange={vi.fn()} label="Age" />)
17
+
18
+ const input = screen.getByRole('spinbutton')
19
+ expect(input).toHaveValue(25)
20
+ })
21
+
22
+ it('should display empty string for null value', () => {
23
+ render(<IntegerField name="age" value={null} onChange={vi.fn()} label="Age" />)
24
+
25
+ const input = screen.getByRole('spinbutton')
26
+ expect(input).toHaveValue(null)
27
+ })
28
+
29
+ it('should call onChange with parsed integer', async () => {
30
+ const onChange = vi.fn()
31
+ const user = userEvent.setup()
32
+
33
+ render(<IntegerField name="age" value={null} onChange={onChange} label="Age" />)
34
+
35
+ const input = screen.getByRole('spinbutton')
36
+ await user.type(input, '5')
37
+
38
+ expect(onChange).toHaveBeenCalled()
39
+ // Verify it's called with a number, not a string
40
+ expect(onChange).toHaveBeenCalledWith(5)
41
+ })
42
+
43
+ it('should call onChange with null when input is cleared', async () => {
44
+ const onChange = vi.fn()
45
+ const user = userEvent.setup()
46
+
47
+ render(<IntegerField name="age" value={25} onChange={onChange} label="Age" />)
48
+
49
+ const input = screen.getByRole('spinbutton')
50
+ await user.clear(input)
51
+
52
+ expect(onChange).toHaveBeenCalledWith(null)
53
+ })
54
+
55
+ it('should show required indicator when required', () => {
56
+ render(<IntegerField name="age" value={null} onChange={vi.fn()} label="Age" required />)
57
+
58
+ expect(screen.getByText('*')).toBeInTheDocument()
59
+ })
60
+
61
+ it('should display error message', () => {
62
+ render(
63
+ <IntegerField
64
+ name="age"
65
+ value={null}
66
+ onChange={vi.fn()}
67
+ label="Age"
68
+ error="Age must be a positive number"
69
+ />,
70
+ )
71
+
72
+ expect(screen.getByText('Age must be a positive number')).toBeInTheDocument()
73
+ })
74
+
75
+ it('should be disabled when disabled prop is true', () => {
76
+ render(<IntegerField name="age" value={null} onChange={vi.fn()} label="Age" disabled />)
77
+
78
+ const input = screen.getByRole('spinbutton')
79
+ expect(input).toBeDisabled()
80
+ })
81
+
82
+ it('should show placeholder text', () => {
83
+ render(
84
+ <IntegerField
85
+ name="age"
86
+ value={null}
87
+ onChange={vi.fn()}
88
+ label="Age"
89
+ placeholder="Enter your age"
90
+ />,
91
+ )
92
+
93
+ expect(screen.getByPlaceholderText('Enter your age')).toBeInTheDocument()
94
+ })
95
+
96
+ it('should set min attribute', () => {
97
+ render(<IntegerField name="age" value={null} onChange={vi.fn()} label="Age" min={0} />)
98
+
99
+ const input = screen.getByRole('spinbutton')
100
+ expect(input).toHaveAttribute('min', '0')
101
+ })
102
+
103
+ it('should set max attribute', () => {
104
+ render(<IntegerField name="age" value={null} onChange={vi.fn()} label="Age" max={100} />)
105
+
106
+ const input = screen.getByRole('spinbutton')
107
+ expect(input).toHaveAttribute('max', '100')
108
+ })
109
+
110
+ it('should handle negative numbers', async () => {
111
+ const onChange = vi.fn()
112
+ const user = userEvent.setup()
113
+
114
+ render(
115
+ <IntegerField name="temperature" value={null} onChange={onChange} label="Temperature" />,
116
+ )
117
+
118
+ const input = screen.getByRole('spinbutton')
119
+ await user.type(input, '-5')
120
+
121
+ expect(onChange).toHaveBeenCalledWith(-5)
122
+ })
123
+ })
124
+
125
+ describe('read mode', () => {
126
+ it('should render value as text', () => {
127
+ render(<IntegerField name="age" value={25} onChange={vi.fn()} label="Age" mode="read" />)
128
+
129
+ expect(screen.getByText('Age')).toBeInTheDocument()
130
+ expect(screen.getByText('25')).toBeInTheDocument()
131
+ expect(screen.queryByRole('spinbutton')).not.toBeInTheDocument()
132
+ })
133
+
134
+ it('should show dash when value is null', () => {
135
+ render(<IntegerField name="age" value={null} onChange={vi.fn()} label="Age" mode="read" />)
136
+
137
+ expect(screen.getByText('-')).toBeInTheDocument()
138
+ })
139
+
140
+ it('should display zero correctly', () => {
141
+ render(<IntegerField name="count" value={0} onChange={vi.fn()} label="Count" mode="read" />)
142
+
143
+ expect(screen.getByText('0')).toBeInTheDocument()
144
+ expect(screen.queryByText('-')).not.toBeInTheDocument()
145
+ })
146
+ })
147
+ })