@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
@@ -62,68 +62,81 @@ export function ItemFormClient({
62
62
  setGeneralError(null)
63
63
 
64
64
  startTransition(async () => {
65
- try {
66
- // Transform relationship fields to Prisma format
67
- // Filter out password fields with isSet objects (unchanged passwords)
68
- // File/Image fields: pass File objects through (Next.js will serialize them)
69
- const transformedData: Record<string, unknown> = {}
70
- for (const [fieldName, value] of Object.entries(formData)) {
71
- const fieldConfig = fields[fieldName]
72
-
73
- // Skip password fields that have { isSet: boolean } value (not being changed)
74
- if (typeof value === 'object' && value !== null && 'isSet' in value) {
75
- continue
76
- }
65
+ // Transform relationship fields to Prisma format
66
+ // Filter out password fields with isSet objects (unchanged passwords)
67
+ // File/Image fields: pass File objects through (Next.js will serialize them)
68
+ const transformedData: Record<string, unknown> = {}
69
+ for (const [fieldName, value] of Object.entries(formData)) {
70
+ const fieldConfig = fields[fieldName]
77
71
 
78
- // Transform relationship fields - check discriminated union type
79
- const fieldAny = fieldConfig as { type: string; many?: boolean }
80
- if (fieldAny?.type === 'relationship') {
81
- if (fieldAny.many) {
82
- // Many relationship: use connect format
83
- if (Array.isArray(value) && value.length > 0) {
84
- transformedData[fieldName] = {
85
- connect: value.map((id: string) => ({ id })),
86
- }
87
- }
88
- } else {
89
- // Single relationship: use connect format
90
- if (value) {
91
- transformedData[fieldName] = {
92
- connect: { id: value },
93
- }
72
+ // Skip password fields that have { isSet: boolean } value (not being changed)
73
+ if (typeof value === 'object' && value !== null && 'isSet' in value) {
74
+ continue
75
+ }
76
+
77
+ // Transform relationship fields - check discriminated union type
78
+ const fieldAny = fieldConfig as { type: string; many?: boolean }
79
+ if (fieldAny?.type === 'relationship') {
80
+ if (fieldAny.many) {
81
+ // Many relationship: use connect format
82
+ if (Array.isArray(value) && value.length > 0) {
83
+ transformedData[fieldName] = {
84
+ connect: value.map((id: string) => ({ id })),
94
85
  }
95
86
  }
96
87
  } else {
97
- // Non-relationship field: pass through (including File objects for file/image fields)
98
- // File objects will be serialized by Next.js server action
99
- transformedData[fieldName] = value
88
+ // Single relationship: use connect format
89
+ if (value) {
90
+ transformedData[fieldName] = {
91
+ connect: { id: value },
92
+ }
93
+ }
100
94
  }
95
+ } else {
96
+ // Non-relationship field: pass through (including File objects for file/image fields)
97
+ // File objects will be serialized by Next.js server action
98
+ transformedData[fieldName] = value
101
99
  }
100
+ }
101
+
102
+ const result =
103
+ mode === 'create'
104
+ ? await serverAction({
105
+ listKey,
106
+ action: 'create',
107
+ data: transformedData,
108
+ })
109
+ : await serverAction({
110
+ listKey,
111
+ action: 'update',
112
+ id: itemId!,
113
+ data: transformedData,
114
+ })
102
115
 
103
- const result =
104
- mode === 'create'
105
- ? await serverAction({
106
- listKey,
107
- action: 'create',
108
- data: transformedData,
109
- })
110
- : await serverAction({
111
- listKey,
112
- action: 'update',
113
- id: itemId!,
114
- data: transformedData,
115
- })
116
-
117
- if (result) {
116
+ // Check if result has the new format with success/error fields
117
+ if (result && typeof result === 'object' && 'success' in result) {
118
+ const actionResult = result as
119
+ | { success: true; data: unknown }
120
+ | { success: false; error: string; fieldErrors?: Record<string, string> }
121
+
122
+ if (actionResult.success) {
118
123
  // Navigate back to list view
119
124
  router.push(`${basePath}/${urlKey}`)
120
125
  router.refresh()
121
126
  } else {
122
- setGeneralError('Access denied or operation failed')
127
+ // Handle error response
128
+ if (actionResult.fieldErrors) {
129
+ setErrors(actionResult.fieldErrors)
130
+ }
131
+ setGeneralError(actionResult.error)
123
132
  }
124
- } catch (error) {
125
- const errorMessage = error instanceof Error ? error.message : 'Failed to save item'
126
- setGeneralError(errorMessage)
133
+ } else if (result) {
134
+ // Legacy format: result is the data itself
135
+ router.push(`${basePath}/${urlKey}`)
136
+ router.refresh()
137
+ } else {
138
+ // null result means access denied
139
+ setGeneralError('Access denied or operation failed')
127
140
  }
128
141
  })
129
142
  }
@@ -135,22 +148,31 @@ export function ItemFormClient({
135
148
  setShowDeleteConfirm(false)
136
149
 
137
150
  startTransition(async () => {
138
- try {
139
- const result = await serverAction({
140
- listKey,
141
- action: 'delete',
142
- id: itemId,
143
- })
144
-
145
- if (result) {
151
+ const result = await serverAction({
152
+ listKey,
153
+ action: 'delete',
154
+ id: itemId,
155
+ })
156
+
157
+ // Check if result has the new format with success/error fields
158
+ if (result && typeof result === 'object' && 'success' in result) {
159
+ const actionResult = result as
160
+ | { success: true; data: unknown }
161
+ | { success: false; error: string; fieldErrors?: Record<string, string> }
162
+
163
+ if (actionResult.success) {
146
164
  router.push(`${basePath}/${urlKey}`)
147
165
  router.refresh()
148
166
  } else {
149
- setGeneralError('Access denied or failed to delete item')
167
+ setGeneralError(actionResult.error)
150
168
  }
151
- } catch (error) {
152
- const errorMessage = error instanceof Error ? error.message : 'Failed to delete item'
153
- setGeneralError(errorMessage)
169
+ } else if (result) {
170
+ // Legacy format: result is the data itself
171
+ router.push(`${basePath}/${urlKey}`)
172
+ router.refresh()
173
+ } else {
174
+ // null result means access denied
175
+ setGeneralError('Access denied or failed to delete item')
154
176
  }
155
177
  })
156
178
  }
@@ -1,12 +1,14 @@
1
1
  import Link from 'next/link.js'
2
2
  import { formatListName } from '../lib/utils.js'
3
3
  import { AccessContext, getUrlKey, OpenSaasConfig } from '@opensaas/stack-core'
4
+ import { UserMenu } from './UserMenu.js'
4
5
 
5
6
  export interface NavigationProps<TPrisma> {
6
7
  context: AccessContext<TPrisma>
7
8
  config: OpenSaasConfig
8
9
  basePath?: string
9
10
  currentPath?: string
11
+ onSignOut?: () => Promise<void>
10
12
  }
11
13
 
12
14
  /**
@@ -18,6 +20,7 @@ export function Navigation<TPrisma>({
18
20
  config,
19
21
  basePath = '/admin',
20
22
  currentPath = '',
23
+ onSignOut,
21
24
  }: NavigationProps<TPrisma>) {
22
25
  const lists = Object.keys(config.lists || {})
23
26
 
@@ -90,27 +93,13 @@ export function Navigation<TPrisma>({
90
93
  </div>
91
94
  </div>
92
95
 
93
- {/* Footer */}
96
+ {/* Footer - User Menu */}
94
97
  {context.session && (
95
- <div className="p-4 border-t border-border bg-gradient-to-br from-primary/5 to-accent/5">
96
- <div className="flex items-center space-x-3">
97
- <div className="h-9 w-9 rounded-full bg-gradient-to-br from-primary to-accent flex items-center justify-center shadow-lg shadow-primary/25">
98
- <span className="text-sm font-bold text-primary-foreground">
99
- {String(
100
- (context.session.data as Record<string, unknown>)?.name,
101
- )?.[0]?.toUpperCase() || '?'}
102
- </span>
103
- </div>
104
- <div className="flex-1 min-w-0">
105
- <p className="text-sm font-medium truncate">
106
- {String((context.session.data as Record<string, unknown>)?.name) || 'User'}
107
- </p>
108
- <p className="text-xs text-muted-foreground truncate">
109
- {String((context.session.data as Record<string, unknown>)?.email) || ''}
110
- </p>
111
- </div>
112
- </div>
113
- </div>
98
+ <UserMenu
99
+ userName={String((context.session.data as Record<string, unknown>)?.name) || 'User'}
100
+ userEmail={String((context.session.data as Record<string, unknown>)?.email) || ''}
101
+ onSignOut={onSignOut}
102
+ />
114
103
  )}
115
104
  </nav>
116
105
  )
@@ -0,0 +1,44 @@
1
+ 'use client'
2
+
3
+ import { useRouter } from 'next/navigation.js'
4
+ import { Button } from '../primitives/button.js'
5
+
6
+ export interface UserMenuProps {
7
+ userName?: string
8
+ userEmail?: string
9
+ onSignOut?: () => Promise<void>
10
+ }
11
+
12
+ /**
13
+ * User menu component with sign-out button
14
+ * Client Component
15
+ */
16
+ export function UserMenu({ userName, userEmail, onSignOut }: UserMenuProps) {
17
+ const router = useRouter()
18
+
19
+ const handleSignOut = async () => {
20
+ if (onSignOut) {
21
+ await onSignOut()
22
+ }
23
+ router.push('/sign-in')
24
+ }
25
+
26
+ return (
27
+ <div className="p-4 border-t border-border bg-gradient-to-br from-primary/5 to-accent/5">
28
+ <div className="flex items-center space-x-3 mb-3">
29
+ <div className="h-9 w-9 rounded-full bg-gradient-to-br from-primary to-accent flex items-center justify-center shadow-lg shadow-primary/25">
30
+ <span className="text-sm font-bold text-primary-foreground">
31
+ {userName?.[0]?.toUpperCase() || '?'}
32
+ </span>
33
+ </div>
34
+ <div className="flex-1 min-w-0">
35
+ <p className="text-sm font-medium truncate">{userName || 'User'}</p>
36
+ <p className="text-xs text-muted-foreground truncate">{userEmail || ''}</p>
37
+ </div>
38
+ </div>
39
+ <Button onClick={handleSignOut} variant="outline" size="sm" className="w-full text-sm">
40
+ Sign Out
41
+ </Button>
42
+ </div>
43
+ )
44
+ }
@@ -1,6 +1,7 @@
1
1
  'use client'
2
2
 
3
3
  import { Input } from '../../primitives/input.js'
4
+ import { Textarea } from '../../primitives/textarea.js'
4
5
  import { Label } from '../../primitives/label.js'
5
6
  import { cn } from '../../lib/utils.js'
6
7
 
@@ -14,6 +15,7 @@ export interface TextFieldProps {
14
15
  disabled?: boolean
15
16
  required?: boolean
16
17
  mode?: 'read' | 'edit'
18
+ displayMode?: 'input' | 'textarea'
17
19
  }
18
20
 
19
21
  export function TextField({
@@ -26,6 +28,7 @@ export function TextField({
26
28
  disabled,
27
29
  required,
28
30
  mode = 'edit',
31
+ displayMode = 'input',
29
32
  }: TextFieldProps) {
30
33
  if (mode === 'read') {
31
34
  return (
@@ -36,16 +39,18 @@ export function TextField({
36
39
  )
37
40
  }
38
41
 
42
+ const InputComponent = displayMode === 'textarea' ? Textarea : Input
43
+
39
44
  return (
40
45
  <div className="space-y-2">
41
46
  <Label htmlFor={name}>
42
47
  {label}
43
48
  {required && <span className="text-destructive ml-1">*</span>}
44
49
  </Label>
45
- <Input
50
+ <InputComponent
46
51
  id={name}
47
52
  name={name}
48
- type="text"
53
+ type={displayMode === 'input' ? 'text' : undefined}
49
54
  value={value || ''}
50
55
  onChange={(e) => onChange(e.target.value)}
51
56
  placeholder={placeholder}
package/src/index.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  export { AdminUI } from './components/AdminUI.js'
3
3
  export { Dashboard } from './components/Dashboard.js'
4
4
  export { Navigation } from './components/Navigation.js'
5
+ export { UserMenu } from './components/UserMenu.js'
5
6
  export { ListView } from './components/ListView.js'
6
7
  export { ListViewClient } from './components/ListViewClient.js'
7
8
  export { ItemForm } from './components/ItemForm.js'
@@ -29,6 +30,7 @@ export {
29
30
  export type { AdminUIProps } from './components/AdminUI.js'
30
31
  export type { DashboardProps } from './components/Dashboard.js'
31
32
  export type { NavigationProps } from './components/Navigation.js'
33
+ export type { UserMenuProps } from './components/UserMenu.js'
32
34
  export type { ListViewProps } from './components/ListView.js'
33
35
  export type { ListViewClientProps } from './components/ListViewClient.js'
34
36
  export type { ItemFormProps } from './components/ItemForm.js'
@@ -0,0 +1,154 @@
1
+ # Browser Tests
2
+
3
+ This directory contains UI tests that run in a real browser environment using Vitest Browser Mode with Playwright.
4
+
5
+ ## Overview
6
+
7
+ Browser tests provide more realistic testing by running tests in actual browser environments (Chromium, Firefox, or WebKit). These tests can verify:
8
+
9
+ - Real browser interactions (clicks, keyboard input, focus management)
10
+ - Visual rendering and CSS behavior
11
+ - Accessibility features
12
+ - Browser-specific bugs
13
+
14
+ ## Running Browser Tests
15
+
16
+ ### Prerequisites
17
+
18
+ 1. Install Playwright browsers:
19
+
20
+ ```bash
21
+ npx playwright install chromium
22
+ ```
23
+
24
+ 2. Ensure you have a display server available (for headless: false mode)
25
+
26
+ ### Commands
27
+
28
+ ```bash
29
+ # Run browser tests in headless mode
30
+ pnpm test:browser
31
+
32
+ # Run browser tests with UI
33
+ pnpm test:browser:ui
34
+ ```
35
+
36
+ ### Configuration
37
+
38
+ Browser tests are configured in `vitest.config.ts`:
39
+
40
+ - **Enabled**: Only when `BROWSER_TEST=true` environment variable is set
41
+ - **Browser**: Chromium (via Playwright)
42
+ - **Headless**: true (can be set to false for debugging)
43
+ - **Screenshot on failure**: Enabled
44
+
45
+ ## Test Structure
46
+
47
+ Browser tests follow this structure:
48
+
49
+ ```typescript
50
+ import { describe, it, expect } from 'vitest'
51
+ import { render, screen } from '@testing-library/react'
52
+ import { userEvent } from 'vitest/browser'
53
+ import { MyComponent } from '../../../src/components/MyComponent.js'
54
+
55
+ describe('MyComponent (Browser)', () => {
56
+ it('should handle real browser interactions', async () => {
57
+ render(<MyComponent />)
58
+
59
+ const button = screen.getByRole('button')
60
+ await userEvent.click(button)
61
+
62
+ expect(button).toHaveAttribute('aria-pressed', 'true')
63
+ })
64
+ })
65
+ ```
66
+
67
+ ## Test Categories
68
+
69
+ ### Primitives
70
+
71
+ - **Button.browser.test.tsx**: Tests button variants, keyboard navigation, focus states
72
+ - **Dialog.browser.test.tsx**: Tests modal dialogs, focus trapping, keyboard shortcuts (Escape)
73
+
74
+ ### Fields
75
+
76
+ - **TextField.browser.test.tsx**: Tests text input, paste, special characters, focus/blur
77
+ - **CheckboxField.browser.test.tsx**: Tests checkbox toggling, keyboard (Space), label clicks
78
+ - **SelectField.browser.test.tsx**: Tests dropdown behavior, keyboard navigation (arrows)
79
+
80
+ ## Browser vs Regular Tests
81
+
82
+ | Aspect | Regular Tests (Happy DOM) | Browser Tests |
83
+ | --------------------- | ------------------------- | -------------- |
84
+ | **Speed** | Fast (~500ms) | Slower (~5s) |
85
+ | **Environment** | Simulated DOM | Real browser |
86
+ | **User Interactions** | Simulated | Real events |
87
+ | **Visual Testing** | No | Yes |
88
+ | **CI/CD** | Easy | Requires setup |
89
+
90
+ ## When to Use Browser Tests
91
+
92
+ Use browser tests for:
93
+
94
+ - Complex user interactions (drag-drop, keyboard navigation)
95
+ - Focus management and accessibility
96
+ - Browser-specific features
97
+ - Visual regression testing
98
+ - Issues that only reproduce in real browsers
99
+
100
+ Use regular tests (Happy DOM) for:
101
+
102
+ - Unit testing components
103
+ - Testing props and state
104
+ - Fast feedback during development
105
+ - Most UI logic
106
+
107
+ ## CI/CD Setup
108
+
109
+ For CI/CD environments, ensure:
110
+
111
+ 1. **Display Server**: Use `xvfb` or run in headless mode
112
+ 2. **Playwright Installation**: Include `npx playwright install --with-deps` in CI setup
113
+ 3. **Browser Binaries**: Ensure chromium/firefox/webkit are available
114
+
115
+ Example GitHub Actions:
116
+
117
+ ```yaml
118
+ - name: Install Playwright
119
+ run: npx playwright install --with-deps chromium
120
+
121
+ - name: Run browser tests
122
+ run: pnpm test:browser
123
+ ```
124
+
125
+ ## Troubleshooting
126
+
127
+ ### "Browser connection was closed"
128
+
129
+ - **Cause**: No display server available
130
+ - **Solution**: Run in headless mode or use xvfb
131
+
132
+ ### "vitest/browser can be imported only inside Browser Mode"
133
+
134
+ - **Cause**: Browser tests running in regular test mode
135
+ - **Solution**: Ensure tests are in `tests/browser/` directory and use `pnpm test:browser`
136
+
137
+ ### Playwright installation errors
138
+
139
+ - **Cause**: Missing system dependencies
140
+ - **Solution**: Run `npx playwright install --with-deps chromium`
141
+
142
+ ## Development Tips
143
+
144
+ 1. **Debugging**: Set `headless: false` in vitest.config.ts to see the browser
145
+ 2. **Screenshots**: Failed tests automatically capture screenshots
146
+ 3. **Slow Tests**: Use `{ timeout: 10000 }` for tests that need more time
147
+ 4. **Browser DevTools**: Set `slowMo: 100` to slow down interactions for debugging
148
+
149
+ ## Future Enhancements
150
+
151
+ - [ ] Add visual regression testing with screenshots
152
+ - [ ] Test in multiple browsers (Firefox, WebKit)
153
+ - [ ] Add E2E tests for complete user flows
154
+ - [ ] Integrate with Percy or similar visual testing service