@opensaas/stack-ui 0.1.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.
- package/.turbo/turbo-build.log +8 -0
- package/README.md +286 -0
- package/dist/components/AdminUI.d.ts +24 -0
- package/dist/components/AdminUI.d.ts.map +1 -0
- package/dist/components/AdminUI.js +48 -0
- package/dist/components/ConfirmDialog.d.ts +16 -0
- package/dist/components/ConfirmDialog.d.ts.map +1 -0
- package/dist/components/ConfirmDialog.js +11 -0
- package/dist/components/Dashboard.d.ts +12 -0
- package/dist/components/Dashboard.d.ts.map +1 -0
- package/dist/components/Dashboard.js +30 -0
- package/dist/components/ItemForm.d.ts +17 -0
- package/dist/components/ItemForm.d.ts.map +1 -0
- package/dist/components/ItemForm.js +97 -0
- package/dist/components/ItemFormClient.d.ts +22 -0
- package/dist/components/ItemFormClient.d.ts.map +1 -0
- package/dist/components/ItemFormClient.js +127 -0
- package/dist/components/ListView.d.ts +17 -0
- package/dist/components/ListView.d.ts.map +1 -0
- package/dist/components/ListView.js +76 -0
- package/dist/components/ListViewClient.d.ts +19 -0
- package/dist/components/ListViewClient.d.ts.map +1 -0
- package/dist/components/ListViewClient.js +108 -0
- package/dist/components/LoadingSpinner.d.ts +10 -0
- package/dist/components/LoadingSpinner.d.ts.map +1 -0
- package/dist/components/LoadingSpinner.js +14 -0
- package/dist/components/Navigation.d.ts +13 -0
- package/dist/components/Navigation.d.ts.map +1 -0
- package/dist/components/Navigation.js +20 -0
- package/dist/components/SkeletonLoader.d.ts +22 -0
- package/dist/components/SkeletonLoader.d.ts.map +1 -0
- package/dist/components/SkeletonLoader.js +25 -0
- package/dist/components/fields/CheckboxField.d.ts +11 -0
- package/dist/components/fields/CheckboxField.d.ts.map +1 -0
- package/dist/components/fields/CheckboxField.js +10 -0
- package/dist/components/fields/ComboboxField.d.ts +18 -0
- package/dist/components/fields/ComboboxField.d.ts.map +1 -0
- package/dist/components/fields/ComboboxField.js +32 -0
- package/dist/components/fields/FieldRenderer.d.ts +22 -0
- package/dist/components/fields/FieldRenderer.d.ts.map +1 -0
- package/dist/components/fields/FieldRenderer.js +81 -0
- package/dist/components/fields/IntegerField.d.ts +15 -0
- package/dist/components/fields/IntegerField.d.ts.map +1 -0
- package/dist/components/fields/IntegerField.js +14 -0
- package/dist/components/fields/PasswordField.d.ts +18 -0
- package/dist/components/fields/PasswordField.d.ts.map +1 -0
- package/dist/components/fields/PasswordField.js +42 -0
- package/dist/components/fields/RelationshipField.d.ts +20 -0
- package/dist/components/fields/RelationshipField.d.ts.map +1 -0
- package/dist/components/fields/RelationshipField.js +11 -0
- package/dist/components/fields/RelationshipManager.d.ts +19 -0
- package/dist/components/fields/RelationshipManager.d.ts.map +1 -0
- package/dist/components/fields/RelationshipManager.js +37 -0
- package/dist/components/fields/SelectField.d.ts +16 -0
- package/dist/components/fields/SelectField.d.ts.map +1 -0
- package/dist/components/fields/SelectField.js +11 -0
- package/dist/components/fields/TextField.d.ts +13 -0
- package/dist/components/fields/TextField.d.ts.map +1 -0
- package/dist/components/fields/TextField.js +11 -0
- package/dist/components/fields/TimestampField.d.ts +12 -0
- package/dist/components/fields/TimestampField.d.ts.map +1 -0
- package/dist/components/fields/TimestampField.js +12 -0
- package/dist/components/fields/index.d.ts +23 -0
- package/dist/components/fields/index.d.ts.map +1 -0
- package/dist/components/fields/index.js +13 -0
- package/dist/components/fields/registry.d.ts +43 -0
- package/dist/components/fields/registry.d.ts.map +1 -0
- package/dist/components/fields/registry.js +42 -0
- package/dist/components/standalone/DeleteButton.d.ts +35 -0
- package/dist/components/standalone/DeleteButton.d.ts.map +1 -0
- package/dist/components/standalone/DeleteButton.js +46 -0
- package/dist/components/standalone/ItemCreateForm.d.ts +34 -0
- package/dist/components/standalone/ItemCreateForm.d.ts.map +1 -0
- package/dist/components/standalone/ItemCreateForm.js +91 -0
- package/dist/components/standalone/ItemEditForm.d.ts +37 -0
- package/dist/components/standalone/ItemEditForm.d.ts.map +1 -0
- package/dist/components/standalone/ItemEditForm.js +112 -0
- package/dist/components/standalone/ListTable.d.ts +33 -0
- package/dist/components/standalone/ListTable.d.ts.map +1 -0
- package/dist/components/standalone/ListTable.js +94 -0
- package/dist/components/standalone/SearchBar.d.ts +29 -0
- package/dist/components/standalone/SearchBar.d.ts.map +1 -0
- package/dist/components/standalone/SearchBar.js +43 -0
- package/dist/components/standalone/index.d.ts +11 -0
- package/dist/components/standalone/index.d.ts.map +1 -0
- package/dist/components/standalone/index.js +6 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +19 -0
- package/dist/lib/serializeFieldConfig.d.ts +43 -0
- package/dist/lib/serializeFieldConfig.d.ts.map +1 -0
- package/dist/lib/serializeFieldConfig.js +48 -0
- package/dist/lib/theme.d.ts +17 -0
- package/dist/lib/theme.d.ts.map +1 -0
- package/dist/lib/theme.js +192 -0
- package/dist/lib/utils.d.ts +18 -0
- package/dist/lib/utils.d.ts.map +1 -0
- package/dist/lib/utils.js +76 -0
- package/dist/primitives/button.d.ts +12 -0
- package/dist/primitives/button.d.ts.map +1 -0
- package/dist/primitives/button.js +33 -0
- package/dist/primitives/calendar.d.ts +9 -0
- package/dist/primitives/calendar.d.ts.map +1 -0
- package/dist/primitives/calendar.js +48 -0
- package/dist/primitives/card.d.ts +9 -0
- package/dist/primitives/card.d.ts.map +1 -0
- package/dist/primitives/card.js +16 -0
- package/dist/primitives/checkbox.d.ts +5 -0
- package/dist/primitives/checkbox.d.ts.map +1 -0
- package/dist/primitives/checkbox.js +7 -0
- package/dist/primitives/combobox.d.ts +14 -0
- package/dist/primitives/combobox.d.ts.map +1 -0
- package/dist/primitives/combobox.js +20 -0
- package/dist/primitives/datetime-picker.d.ts +9 -0
- package/dist/primitives/datetime-picker.d.ts.map +1 -0
- package/dist/primitives/datetime-picker.js +42 -0
- package/dist/primitives/dialog.d.ts +20 -0
- package/dist/primitives/dialog.d.ts.map +1 -0
- package/dist/primitives/dialog.js +21 -0
- package/dist/primitives/index.d.ts +14 -0
- package/dist/primitives/index.d.ts.map +1 -0
- package/dist/primitives/index.js +14 -0
- package/dist/primitives/input.d.ts +5 -0
- package/dist/primitives/input.d.ts.map +1 -0
- package/dist/primitives/input.js +8 -0
- package/dist/primitives/label.d.ts +6 -0
- package/dist/primitives/label.d.ts.map +1 -0
- package/dist/primitives/label.js +9 -0
- package/dist/primitives/popover.d.ts +7 -0
- package/dist/primitives/popover.d.ts.map +1 -0
- package/dist/primitives/popover.js +10 -0
- package/dist/primitives/select.d.ts +14 -0
- package/dist/primitives/select.d.ts.map +1 -0
- package/dist/primitives/select.js +24 -0
- package/dist/primitives/table.d.ts +11 -0
- package/dist/primitives/table.d.ts.map +1 -0
- package/dist/primitives/table.js +20 -0
- package/dist/primitives/time-picker.d.ts +8 -0
- package/dist/primitives/time-picker.d.ts.map +1 -0
- package/dist/primitives/time-picker.js +27 -0
- package/dist/server/index.d.ts +2 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +2 -0
- package/dist/server/types.d.ts +15 -0
- package/dist/server/types.d.ts.map +1 -0
- package/dist/server/types.js +1 -0
- package/dist/styles/globals.css +1896 -0
- package/package.json +91 -0
- package/postcss.config.cjs +5 -0
- package/src/components/AdminUI.tsx +112 -0
- package/src/components/ConfirmDialog.tsx +56 -0
- package/src/components/Dashboard.tsx +134 -0
- package/src/components/ItemForm.tsx +195 -0
- package/src/components/ItemFormClient.tsx +237 -0
- package/src/components/ListView.tsx +153 -0
- package/src/components/ListViewClient.tsx +282 -0
- package/src/components/LoadingSpinner.tsx +32 -0
- package/src/components/Navigation.tsx +117 -0
- package/src/components/SkeletonLoader.tsx +82 -0
- package/src/components/fields/CheckboxField.tsx +54 -0
- package/src/components/fields/ComboboxField.tsx +127 -0
- package/src/components/fields/FieldRenderer.tsx +132 -0
- package/src/components/fields/IntegerField.tsx +68 -0
- package/src/components/fields/PasswordField.tsx +159 -0
- package/src/components/fields/RelationshipField.tsx +71 -0
- package/src/components/fields/RelationshipManager.tsx +189 -0
- package/src/components/fields/SelectField.tsx +71 -0
- package/src/components/fields/TextField.tsx +59 -0
- package/src/components/fields/TimestampField.tsx +49 -0
- package/src/components/fields/index.ts +27 -0
- package/src/components/fields/registry.ts +72 -0
- package/src/components/standalone/DeleteButton.tsx +114 -0
- package/src/components/standalone/ItemCreateForm.tsx +161 -0
- package/src/components/standalone/ItemEditForm.tsx +193 -0
- package/src/components/standalone/ListTable.tsx +211 -0
- package/src/components/standalone/SearchBar.tsx +86 -0
- package/src/components/standalone/index.ts +13 -0
- package/src/index.ts +74 -0
- package/src/lib/serializeFieldConfig.ts +88 -0
- package/src/lib/theme.ts +202 -0
- package/src/lib/utils.ts +81 -0
- package/src/primitives/button.tsx +49 -0
- package/src/primitives/calendar.tsx +160 -0
- package/src/primitives/card.tsx +58 -0
- package/src/primitives/checkbox.tsx +27 -0
- package/src/primitives/combobox.tsx +159 -0
- package/src/primitives/datetime-picker.tsx +130 -0
- package/src/primitives/dialog.tsx +108 -0
- package/src/primitives/index.ts +54 -0
- package/src/primitives/input.tsx +24 -0
- package/src/primitives/label.tsx +19 -0
- package/src/primitives/popover.tsx +31 -0
- package/src/primitives/select.tsx +158 -0
- package/src/primitives/table.tsx +91 -0
- package/src/primitives/time-picker.tsx +65 -0
- package/src/server/index.ts +3 -0
- package/src/server/types.ts +15 -0
- package/src/styles/globals.css +123 -0
- package/tailwind.config.ts +3 -0
- package/tests/components/TextField.test.tsx +94 -0
- package/tests/setup.ts +11 -0
- package/tsconfig.json +26 -0
- package/vitest.config.ts +22 -0
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import Link from 'next/link'
|
|
2
|
+
import { formatListName } from '../lib/utils.js'
|
|
3
|
+
import { AccessContext, getUrlKey, OpenSaasConfig } from '@opensaas/stack-core'
|
|
4
|
+
|
|
5
|
+
export interface NavigationProps<TPrisma> {
|
|
6
|
+
context: AccessContext<TPrisma>
|
|
7
|
+
config: OpenSaasConfig
|
|
8
|
+
basePath?: string
|
|
9
|
+
currentPath?: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Navigation sidebar showing all lists
|
|
14
|
+
* Server Component
|
|
15
|
+
*/
|
|
16
|
+
export function Navigation<TPrisma>({
|
|
17
|
+
context,
|
|
18
|
+
config,
|
|
19
|
+
basePath = '/admin',
|
|
20
|
+
currentPath = '',
|
|
21
|
+
}: NavigationProps<TPrisma>) {
|
|
22
|
+
const lists = Object.keys(config.lists || {})
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<nav className="w-64 border-r border-border bg-card h-screen sticky top-0 flex flex-col">
|
|
26
|
+
{/* Header with gradient */}
|
|
27
|
+
<div className="p-6 border-b border-border relative overflow-hidden">
|
|
28
|
+
<div className="absolute inset-0 bg-gradient-to-br from-primary/10 to-accent/10 opacity-50" />
|
|
29
|
+
<Link href={basePath} className="block relative">
|
|
30
|
+
<h1 className="text-xl font-bold bg-gradient-to-r from-primary to-accent bg-clip-text text-transparent">
|
|
31
|
+
OpenSaas Admin
|
|
32
|
+
</h1>
|
|
33
|
+
</Link>
|
|
34
|
+
</div>
|
|
35
|
+
|
|
36
|
+
{/* Navigation Links */}
|
|
37
|
+
<div className="flex-1 overflow-y-auto p-4">
|
|
38
|
+
<div className="space-y-1">
|
|
39
|
+
<Link
|
|
40
|
+
href={basePath}
|
|
41
|
+
className={`block px-3 py-2.5 rounded-lg text-sm font-medium transition-all relative overflow-hidden group ${
|
|
42
|
+
currentPath === ''
|
|
43
|
+
? 'bg-gradient-to-r from-primary to-accent text-primary-foreground shadow-lg shadow-primary/25'
|
|
44
|
+
: 'text-foreground hover:bg-accent/50 hover:text-accent-foreground'
|
|
45
|
+
}`}
|
|
46
|
+
>
|
|
47
|
+
{currentPath === '' && (
|
|
48
|
+
<div className="absolute inset-0 bg-gradient-to-r from-primary/20 to-accent/20 animate-pulse" />
|
|
49
|
+
)}
|
|
50
|
+
<span className="relative flex items-center gap-2">
|
|
51
|
+
<span className={currentPath === '' ? 'text-lg' : 'text-base'}>📊</span>
|
|
52
|
+
Dashboard
|
|
53
|
+
</span>
|
|
54
|
+
</Link>
|
|
55
|
+
|
|
56
|
+
{lists.length > 0 && (
|
|
57
|
+
<>
|
|
58
|
+
<div className="pt-4 pb-2 px-3">
|
|
59
|
+
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
|
60
|
+
Lists
|
|
61
|
+
</p>
|
|
62
|
+
</div>
|
|
63
|
+
{lists.map((listKey) => {
|
|
64
|
+
const urlKey = getUrlKey(listKey)
|
|
65
|
+
const isActive = currentPath.startsWith(`/${urlKey}`)
|
|
66
|
+
return (
|
|
67
|
+
<Link
|
|
68
|
+
key={listKey}
|
|
69
|
+
href={`${basePath}/${urlKey}`}
|
|
70
|
+
className={`block px-3 py-2.5 rounded-lg text-sm font-medium transition-all relative overflow-hidden group ${
|
|
71
|
+
isActive
|
|
72
|
+
? 'bg-gradient-to-r from-primary to-accent text-primary-foreground shadow-lg shadow-primary/25'
|
|
73
|
+
: 'text-foreground hover:bg-accent/50 hover:text-accent-foreground'
|
|
74
|
+
}`}
|
|
75
|
+
>
|
|
76
|
+
{isActive && (
|
|
77
|
+
<div className="absolute inset-0 bg-gradient-to-r from-primary/20 to-accent/20 animate-pulse" />
|
|
78
|
+
)}
|
|
79
|
+
<span className="relative flex items-center gap-2">
|
|
80
|
+
<span className="opacity-60 group-hover:opacity-100 transition-opacity">
|
|
81
|
+
📁
|
|
82
|
+
</span>
|
|
83
|
+
{formatListName(listKey)}
|
|
84
|
+
</span>
|
|
85
|
+
</Link>
|
|
86
|
+
)
|
|
87
|
+
})}
|
|
88
|
+
</>
|
|
89
|
+
)}
|
|
90
|
+
</div>
|
|
91
|
+
</div>
|
|
92
|
+
|
|
93
|
+
{/* Footer */}
|
|
94
|
+
{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>
|
|
114
|
+
)}
|
|
115
|
+
</nav>
|
|
116
|
+
)
|
|
117
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { cn } from '../lib/utils.js'
|
|
2
|
+
|
|
3
|
+
export interface SkeletonLoaderProps {
|
|
4
|
+
className?: string
|
|
5
|
+
variant?: 'text' | 'circular' | 'rectangular'
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Skeleton loader component for content placeholders
|
|
10
|
+
*/
|
|
11
|
+
export function SkeletonLoader({ className, variant = 'rectangular' }: SkeletonLoaderProps) {
|
|
12
|
+
const variantClasses = {
|
|
13
|
+
text: 'h-4 rounded',
|
|
14
|
+
circular: 'rounded-full',
|
|
15
|
+
rectangular: 'rounded-md',
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
<div
|
|
20
|
+
className={cn('animate-pulse bg-muted', variantClasses[variant], className)}
|
|
21
|
+
aria-hidden="true"
|
|
22
|
+
/>
|
|
23
|
+
)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Table skeleton loader
|
|
28
|
+
*/
|
|
29
|
+
export function TableSkeleton({ rows = 5, columns = 4 }: { rows?: number; columns?: number }) {
|
|
30
|
+
return (
|
|
31
|
+
<div className="bg-card border border-border rounded-lg overflow-hidden">
|
|
32
|
+
<div className="overflow-x-auto">
|
|
33
|
+
<table className="w-full">
|
|
34
|
+
<thead className="bg-muted/50 border-b border-border">
|
|
35
|
+
<tr>
|
|
36
|
+
{Array.from({ length: columns }).map((_, i) => (
|
|
37
|
+
<th key={i} className="px-6 py-3">
|
|
38
|
+
<SkeletonLoader variant="text" className="h-4 w-24" />
|
|
39
|
+
</th>
|
|
40
|
+
))}
|
|
41
|
+
</tr>
|
|
42
|
+
</thead>
|
|
43
|
+
<tbody className="divide-y divide-border">
|
|
44
|
+
{Array.from({ length: rows }).map((_, rowIndex) => (
|
|
45
|
+
<tr key={rowIndex}>
|
|
46
|
+
{Array.from({ length: columns }).map((_, colIndex) => (
|
|
47
|
+
<td key={colIndex} className="px-6 py-4">
|
|
48
|
+
<SkeletonLoader variant="text" className="h-4 w-32" />
|
|
49
|
+
</td>
|
|
50
|
+
))}
|
|
51
|
+
</tr>
|
|
52
|
+
))}
|
|
53
|
+
</tbody>
|
|
54
|
+
</table>
|
|
55
|
+
</div>
|
|
56
|
+
</div>
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Form skeleton loader
|
|
62
|
+
*/
|
|
63
|
+
export function FormSkeleton({ fields = 4 }: { fields?: number }) {
|
|
64
|
+
return (
|
|
65
|
+
<div className="bg-card border border-border rounded-lg p-6">
|
|
66
|
+
<div className="space-y-6">
|
|
67
|
+
{Array.from({ length: fields }).map((_, i) => (
|
|
68
|
+
<div key={i} className="space-y-2">
|
|
69
|
+
<SkeletonLoader variant="text" className="h-4 w-24" />
|
|
70
|
+
<SkeletonLoader variant="rectangular" className="h-10 w-full" />
|
|
71
|
+
</div>
|
|
72
|
+
))}
|
|
73
|
+
<div className="flex items-center justify-between pt-6 border-t border-border">
|
|
74
|
+
<div className="flex gap-3">
|
|
75
|
+
<SkeletonLoader variant="rectangular" className="h-10 w-20" />
|
|
76
|
+
<SkeletonLoader variant="rectangular" className="h-10 w-20" />
|
|
77
|
+
</div>
|
|
78
|
+
</div>
|
|
79
|
+
</div>
|
|
80
|
+
</div>
|
|
81
|
+
)
|
|
82
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { Checkbox } from '../../primitives/checkbox.js'
|
|
4
|
+
import { Label } from '../../primitives/label.js'
|
|
5
|
+
|
|
6
|
+
export interface CheckboxFieldProps {
|
|
7
|
+
name: string
|
|
8
|
+
value: boolean
|
|
9
|
+
onChange: (value: boolean) => void
|
|
10
|
+
label: string
|
|
11
|
+
error?: string
|
|
12
|
+
disabled?: boolean
|
|
13
|
+
mode?: 'read' | 'edit'
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function CheckboxField({
|
|
17
|
+
name,
|
|
18
|
+
value,
|
|
19
|
+
onChange,
|
|
20
|
+
label,
|
|
21
|
+
error,
|
|
22
|
+
disabled,
|
|
23
|
+
mode = 'edit',
|
|
24
|
+
}: CheckboxFieldProps) {
|
|
25
|
+
if (mode === 'read') {
|
|
26
|
+
return (
|
|
27
|
+
<div className="space-y-1">
|
|
28
|
+
<Label className="text-muted-foreground">{label}</Label>
|
|
29
|
+
<p className="text-sm">{value ? 'Yes' : 'No'}</p>
|
|
30
|
+
</div>
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<div className="space-y-2">
|
|
36
|
+
<div className="flex items-center space-x-2">
|
|
37
|
+
<Checkbox
|
|
38
|
+
id={name}
|
|
39
|
+
name={name}
|
|
40
|
+
checked={value || false}
|
|
41
|
+
onCheckedChange={(checked) => onChange(checked === true)}
|
|
42
|
+
disabled={disabled}
|
|
43
|
+
/>
|
|
44
|
+
<Label
|
|
45
|
+
htmlFor={name}
|
|
46
|
+
className="leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
|
47
|
+
>
|
|
48
|
+
{label}
|
|
49
|
+
</Label>
|
|
50
|
+
</div>
|
|
51
|
+
{error && <p className="text-sm text-destructive">{error}</p>}
|
|
52
|
+
</div>
|
|
53
|
+
)
|
|
54
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { useState } from 'react'
|
|
5
|
+
import {
|
|
6
|
+
Combobox,
|
|
7
|
+
ComboboxTrigger,
|
|
8
|
+
ComboboxContent,
|
|
9
|
+
ComboboxSearch,
|
|
10
|
+
ComboboxList,
|
|
11
|
+
ComboboxEmpty,
|
|
12
|
+
ComboboxItem,
|
|
13
|
+
} from '../../primitives/combobox.js'
|
|
14
|
+
|
|
15
|
+
export interface ComboboxFieldProps {
|
|
16
|
+
name: string
|
|
17
|
+
value: string | null
|
|
18
|
+
onChange: (value: string | null) => void
|
|
19
|
+
label: string
|
|
20
|
+
items: Array<{ id: string; label: string }>
|
|
21
|
+
error?: string
|
|
22
|
+
disabled?: boolean
|
|
23
|
+
required?: boolean
|
|
24
|
+
mode?: 'read' | 'edit'
|
|
25
|
+
isLoading?: boolean
|
|
26
|
+
placeholder?: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function ComboboxField({
|
|
30
|
+
name,
|
|
31
|
+
value,
|
|
32
|
+
onChange,
|
|
33
|
+
label,
|
|
34
|
+
items,
|
|
35
|
+
error,
|
|
36
|
+
disabled,
|
|
37
|
+
required,
|
|
38
|
+
mode = 'edit',
|
|
39
|
+
isLoading = false,
|
|
40
|
+
placeholder = 'Select...',
|
|
41
|
+
}: ComboboxFieldProps) {
|
|
42
|
+
const [open, setOpen] = useState(false)
|
|
43
|
+
const [searchQuery, setSearchQuery] = useState('')
|
|
44
|
+
|
|
45
|
+
// Read mode
|
|
46
|
+
if (mode === 'read') {
|
|
47
|
+
const selectedItem = items.find((item) => item.id === value)
|
|
48
|
+
return (
|
|
49
|
+
<div className="space-y-1">
|
|
50
|
+
<label className="text-sm font-medium text-muted-foreground">{label}</label>
|
|
51
|
+
<p className="text-sm">{selectedItem?.label || '-'}</p>
|
|
52
|
+
</div>
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Filter items based on search query
|
|
57
|
+
const filteredItems = searchQuery
|
|
58
|
+
? items.filter((item) => item.label.toLowerCase().includes(searchQuery.toLowerCase()))
|
|
59
|
+
: items
|
|
60
|
+
|
|
61
|
+
const selectedItem = items.find((item) => item.id === value)
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<div className="space-y-2">
|
|
65
|
+
<label htmlFor={name} className="text-sm font-medium">
|
|
66
|
+
{label}
|
|
67
|
+
{required && <span className="text-destructive ml-1">*</span>}
|
|
68
|
+
</label>
|
|
69
|
+
<Combobox open={open} onOpenChange={setOpen}>
|
|
70
|
+
<ComboboxTrigger disabled={disabled || isLoading}>
|
|
71
|
+
<span className={!selectedItem ? 'text-muted-foreground' : ''}>
|
|
72
|
+
{isLoading ? 'Loading...' : selectedItem ? selectedItem.label : placeholder}
|
|
73
|
+
</span>
|
|
74
|
+
</ComboboxTrigger>
|
|
75
|
+
<ComboboxContent>
|
|
76
|
+
<ComboboxSearch
|
|
77
|
+
placeholder="Search..."
|
|
78
|
+
value={searchQuery}
|
|
79
|
+
onChange={(e) => setSearchQuery(e.target.value)}
|
|
80
|
+
onKeyDown={(e) => {
|
|
81
|
+
// Prevent form submission on Enter
|
|
82
|
+
if (e.key === 'Enter') {
|
|
83
|
+
e.preventDefault()
|
|
84
|
+
}
|
|
85
|
+
}}
|
|
86
|
+
/>
|
|
87
|
+
<ComboboxList>
|
|
88
|
+
{filteredItems.length === 0 ? (
|
|
89
|
+
<ComboboxEmpty />
|
|
90
|
+
) : (
|
|
91
|
+
<>
|
|
92
|
+
{!required && value && (
|
|
93
|
+
<>
|
|
94
|
+
<ComboboxItem
|
|
95
|
+
onClick={() => {
|
|
96
|
+
onChange(null)
|
|
97
|
+
setOpen(false)
|
|
98
|
+
setSearchQuery('')
|
|
99
|
+
}}
|
|
100
|
+
>
|
|
101
|
+
<span className="text-muted-foreground italic">Clear selection</span>
|
|
102
|
+
</ComboboxItem>
|
|
103
|
+
<div className="-mx-1 my-1 h-px bg-border" />
|
|
104
|
+
</>
|
|
105
|
+
)}
|
|
106
|
+
{filteredItems.map((item) => (
|
|
107
|
+
<ComboboxItem
|
|
108
|
+
key={item.id}
|
|
109
|
+
selected={item.id === value}
|
|
110
|
+
onClick={() => {
|
|
111
|
+
onChange(item.id)
|
|
112
|
+
setOpen(false)
|
|
113
|
+
setSearchQuery('')
|
|
114
|
+
}}
|
|
115
|
+
>
|
|
116
|
+
{item.label}
|
|
117
|
+
</ComboboxItem>
|
|
118
|
+
))}
|
|
119
|
+
</>
|
|
120
|
+
)}
|
|
121
|
+
</ComboboxList>
|
|
122
|
+
</ComboboxContent>
|
|
123
|
+
</Combobox>
|
|
124
|
+
{error && <p className="text-sm text-destructive">{error}</p>}
|
|
125
|
+
</div>
|
|
126
|
+
)
|
|
127
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { getFieldComponent } from './registry.js'
|
|
5
|
+
import { formatFieldName } from '../../lib/utils.js'
|
|
6
|
+
import { getUrlKey } from '@opensaas/stack-core'
|
|
7
|
+
import type { SerializableFieldConfig } from '../../lib/serializeFieldConfig.js'
|
|
8
|
+
|
|
9
|
+
export interface FieldRendererProps {
|
|
10
|
+
fieldName: string
|
|
11
|
+
fieldConfig: SerializableFieldConfig
|
|
12
|
+
value: unknown
|
|
13
|
+
onChange: (value: unknown) => void
|
|
14
|
+
error?: string
|
|
15
|
+
disabled?: boolean
|
|
16
|
+
mode?: 'read' | 'edit'
|
|
17
|
+
relationshipItems?: Array<{ id: string; label: string }>
|
|
18
|
+
relationshipLoading?: boolean
|
|
19
|
+
basePath?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Internal component that receives the resolved Component
|
|
24
|
+
*/
|
|
25
|
+
function FieldRendererInner({
|
|
26
|
+
Component,
|
|
27
|
+
fieldName,
|
|
28
|
+
fieldConfig,
|
|
29
|
+
value,
|
|
30
|
+
onChange,
|
|
31
|
+
error,
|
|
32
|
+
disabled,
|
|
33
|
+
mode,
|
|
34
|
+
relationshipItems,
|
|
35
|
+
relationshipLoading,
|
|
36
|
+
basePath,
|
|
37
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
38
|
+
}: FieldRendererProps & { Component: React.ComponentType<any> }) {
|
|
39
|
+
const label = (fieldConfig as Record<string, unknown>).label || formatFieldName(fieldName)
|
|
40
|
+
const isRequired =
|
|
41
|
+
(fieldConfig as Record<string, unknown>).validation &&
|
|
42
|
+
typeof (fieldConfig as Record<string, unknown>).validation === 'object' &&
|
|
43
|
+
(fieldConfig as Record<string, unknown>).validation !== null &&
|
|
44
|
+
'isRequired' in ((fieldConfig as Record<string, unknown>).validation as Record<string, unknown>)
|
|
45
|
+
? ((fieldConfig as Record<string, unknown>).validation as Record<string, unknown>).isRequired
|
|
46
|
+
: false
|
|
47
|
+
|
|
48
|
+
// Build props based on field type
|
|
49
|
+
const baseProps = {
|
|
50
|
+
name: fieldName,
|
|
51
|
+
value,
|
|
52
|
+
onChange,
|
|
53
|
+
label,
|
|
54
|
+
error,
|
|
55
|
+
disabled,
|
|
56
|
+
required: isRequired,
|
|
57
|
+
mode,
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Add field-type-specific props
|
|
61
|
+
const specificProps: Record<string, unknown> = {}
|
|
62
|
+
|
|
63
|
+
if (fieldConfig.type === 'select' && 'options' in fieldConfig && fieldConfig.options) {
|
|
64
|
+
specificProps.options = fieldConfig.options.map(
|
|
65
|
+
(opt: string | { label: string; value: string }) =>
|
|
66
|
+
typeof opt === 'string' ? { label: opt, value: opt } : opt,
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (fieldConfig.type === 'password') {
|
|
71
|
+
specificProps.showConfirm = mode === 'edit'
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (fieldConfig.type === 'relationship') {
|
|
75
|
+
specificProps.items = relationshipItems
|
|
76
|
+
specificProps.isLoading = relationshipLoading
|
|
77
|
+
specificProps.many = fieldConfig.many || false
|
|
78
|
+
|
|
79
|
+
// Extract related list key from ref (format: 'ListName.fieldName')
|
|
80
|
+
if (fieldConfig.ref) {
|
|
81
|
+
const [relatedListName] = fieldConfig.ref.split('.')
|
|
82
|
+
specificProps.relatedListKey = getUrlKey(relatedListName)
|
|
83
|
+
specificProps.basePath = basePath
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Pass through any UI options from fieldConfig.ui (excluding component and fieldType)
|
|
88
|
+
if (fieldConfig.ui) {
|
|
89
|
+
const { _component, _fieldType, ...uiOptions } = fieldConfig.ui
|
|
90
|
+
Object.assign(specificProps, uiOptions)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const allProps = { ...baseProps, ...specificProps }
|
|
94
|
+
return <Component {...allProps} />
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Factory component that renders the appropriate field type
|
|
99
|
+
* based on the field configuration and component registry
|
|
100
|
+
*/
|
|
101
|
+
export function FieldRenderer(props: FieldRendererProps) {
|
|
102
|
+
const { fieldName, fieldConfig, mode = 'edit' } = props
|
|
103
|
+
|
|
104
|
+
const label = (fieldConfig as Record<string, unknown>).label || formatFieldName(fieldName)
|
|
105
|
+
|
|
106
|
+
// Skip rendering ID and timestamp fields in forms
|
|
107
|
+
if (mode === 'edit' && ['id', 'createdAt', 'updatedAt'].includes(fieldName)) {
|
|
108
|
+
return null
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Get component from:
|
|
112
|
+
// 1. Per-field component override (ui.component)
|
|
113
|
+
// 2. Custom field type override (ui.fieldType) - uses global registry
|
|
114
|
+
// 3. Default field type (fieldConfig.type) - uses global registry
|
|
115
|
+
const Component =
|
|
116
|
+
fieldConfig.ui?.component ||
|
|
117
|
+
(fieldConfig.ui?.fieldType
|
|
118
|
+
? getFieldComponent(fieldConfig.ui.fieldType)
|
|
119
|
+
: getFieldComponent(fieldConfig.type))
|
|
120
|
+
|
|
121
|
+
if (!Component) {
|
|
122
|
+
console.warn(`No component registered for field type: ${fieldConfig.type}`)
|
|
123
|
+
return (
|
|
124
|
+
<div className="space-y-2">
|
|
125
|
+
<label className="text-sm font-medium text-muted-foreground">{String(label)}</label>
|
|
126
|
+
<p className="text-sm text-muted-foreground">Unsupported field type: {fieldConfig.type}</p>
|
|
127
|
+
</div>
|
|
128
|
+
)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return <FieldRendererInner {...props} Component={Component} />
|
|
132
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { Input } from '../../primitives/input.js'
|
|
4
|
+
import { Label } from '../../primitives/label.js'
|
|
5
|
+
import { cn } from '../../lib/utils.js'
|
|
6
|
+
|
|
7
|
+
export interface IntegerFieldProps {
|
|
8
|
+
name: string
|
|
9
|
+
value: number | null
|
|
10
|
+
onChange: (value: number | null) => void
|
|
11
|
+
label: string
|
|
12
|
+
placeholder?: string
|
|
13
|
+
error?: string
|
|
14
|
+
disabled?: boolean
|
|
15
|
+
required?: boolean
|
|
16
|
+
mode?: 'read' | 'edit'
|
|
17
|
+
min?: number
|
|
18
|
+
max?: number
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function IntegerField({
|
|
22
|
+
name,
|
|
23
|
+
value,
|
|
24
|
+
onChange,
|
|
25
|
+
label,
|
|
26
|
+
placeholder,
|
|
27
|
+
error,
|
|
28
|
+
disabled,
|
|
29
|
+
required,
|
|
30
|
+
mode = 'edit',
|
|
31
|
+
min,
|
|
32
|
+
max,
|
|
33
|
+
}: IntegerFieldProps) {
|
|
34
|
+
if (mode === 'read') {
|
|
35
|
+
return (
|
|
36
|
+
<div className="space-y-1">
|
|
37
|
+
<Label className="text-muted-foreground">{label}</Label>
|
|
38
|
+
<p className="text-sm">{value !== null ? value : '-'}</p>
|
|
39
|
+
</div>
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<div className="space-y-2">
|
|
45
|
+
<Label htmlFor={name}>
|
|
46
|
+
{label}
|
|
47
|
+
{required && <span className="text-destructive ml-1">*</span>}
|
|
48
|
+
</Label>
|
|
49
|
+
<Input
|
|
50
|
+
id={name}
|
|
51
|
+
name={name}
|
|
52
|
+
type="number"
|
|
53
|
+
value={value ?? ''}
|
|
54
|
+
onChange={(e) => {
|
|
55
|
+
const val = e.target.value
|
|
56
|
+
onChange(val === '' ? null : parseInt(val, 10))
|
|
57
|
+
}}
|
|
58
|
+
placeholder={placeholder}
|
|
59
|
+
disabled={disabled}
|
|
60
|
+
required={required}
|
|
61
|
+
min={min}
|
|
62
|
+
max={max}
|
|
63
|
+
className={cn(error && 'border-destructive')}
|
|
64
|
+
/>
|
|
65
|
+
{error && <p className="text-sm text-destructive">{error}</p>}
|
|
66
|
+
</div>
|
|
67
|
+
)
|
|
68
|
+
}
|