@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.
Files changed (203) hide show
  1. package/.turbo/turbo-build.log +8 -0
  2. package/README.md +286 -0
  3. package/dist/components/AdminUI.d.ts +24 -0
  4. package/dist/components/AdminUI.d.ts.map +1 -0
  5. package/dist/components/AdminUI.js +48 -0
  6. package/dist/components/ConfirmDialog.d.ts +16 -0
  7. package/dist/components/ConfirmDialog.d.ts.map +1 -0
  8. package/dist/components/ConfirmDialog.js +11 -0
  9. package/dist/components/Dashboard.d.ts +12 -0
  10. package/dist/components/Dashboard.d.ts.map +1 -0
  11. package/dist/components/Dashboard.js +30 -0
  12. package/dist/components/ItemForm.d.ts +17 -0
  13. package/dist/components/ItemForm.d.ts.map +1 -0
  14. package/dist/components/ItemForm.js +97 -0
  15. package/dist/components/ItemFormClient.d.ts +22 -0
  16. package/dist/components/ItemFormClient.d.ts.map +1 -0
  17. package/dist/components/ItemFormClient.js +127 -0
  18. package/dist/components/ListView.d.ts +17 -0
  19. package/dist/components/ListView.d.ts.map +1 -0
  20. package/dist/components/ListView.js +76 -0
  21. package/dist/components/ListViewClient.d.ts +19 -0
  22. package/dist/components/ListViewClient.d.ts.map +1 -0
  23. package/dist/components/ListViewClient.js +108 -0
  24. package/dist/components/LoadingSpinner.d.ts +10 -0
  25. package/dist/components/LoadingSpinner.d.ts.map +1 -0
  26. package/dist/components/LoadingSpinner.js +14 -0
  27. package/dist/components/Navigation.d.ts +13 -0
  28. package/dist/components/Navigation.d.ts.map +1 -0
  29. package/dist/components/Navigation.js +20 -0
  30. package/dist/components/SkeletonLoader.d.ts +22 -0
  31. package/dist/components/SkeletonLoader.d.ts.map +1 -0
  32. package/dist/components/SkeletonLoader.js +25 -0
  33. package/dist/components/fields/CheckboxField.d.ts +11 -0
  34. package/dist/components/fields/CheckboxField.d.ts.map +1 -0
  35. package/dist/components/fields/CheckboxField.js +10 -0
  36. package/dist/components/fields/ComboboxField.d.ts +18 -0
  37. package/dist/components/fields/ComboboxField.d.ts.map +1 -0
  38. package/dist/components/fields/ComboboxField.js +32 -0
  39. package/dist/components/fields/FieldRenderer.d.ts +22 -0
  40. package/dist/components/fields/FieldRenderer.d.ts.map +1 -0
  41. package/dist/components/fields/FieldRenderer.js +81 -0
  42. package/dist/components/fields/IntegerField.d.ts +15 -0
  43. package/dist/components/fields/IntegerField.d.ts.map +1 -0
  44. package/dist/components/fields/IntegerField.js +14 -0
  45. package/dist/components/fields/PasswordField.d.ts +18 -0
  46. package/dist/components/fields/PasswordField.d.ts.map +1 -0
  47. package/dist/components/fields/PasswordField.js +42 -0
  48. package/dist/components/fields/RelationshipField.d.ts +20 -0
  49. package/dist/components/fields/RelationshipField.d.ts.map +1 -0
  50. package/dist/components/fields/RelationshipField.js +11 -0
  51. package/dist/components/fields/RelationshipManager.d.ts +19 -0
  52. package/dist/components/fields/RelationshipManager.d.ts.map +1 -0
  53. package/dist/components/fields/RelationshipManager.js +37 -0
  54. package/dist/components/fields/SelectField.d.ts +16 -0
  55. package/dist/components/fields/SelectField.d.ts.map +1 -0
  56. package/dist/components/fields/SelectField.js +11 -0
  57. package/dist/components/fields/TextField.d.ts +13 -0
  58. package/dist/components/fields/TextField.d.ts.map +1 -0
  59. package/dist/components/fields/TextField.js +11 -0
  60. package/dist/components/fields/TimestampField.d.ts +12 -0
  61. package/dist/components/fields/TimestampField.d.ts.map +1 -0
  62. package/dist/components/fields/TimestampField.js +12 -0
  63. package/dist/components/fields/index.d.ts +23 -0
  64. package/dist/components/fields/index.d.ts.map +1 -0
  65. package/dist/components/fields/index.js +13 -0
  66. package/dist/components/fields/registry.d.ts +43 -0
  67. package/dist/components/fields/registry.d.ts.map +1 -0
  68. package/dist/components/fields/registry.js +42 -0
  69. package/dist/components/standalone/DeleteButton.d.ts +35 -0
  70. package/dist/components/standalone/DeleteButton.d.ts.map +1 -0
  71. package/dist/components/standalone/DeleteButton.js +46 -0
  72. package/dist/components/standalone/ItemCreateForm.d.ts +34 -0
  73. package/dist/components/standalone/ItemCreateForm.d.ts.map +1 -0
  74. package/dist/components/standalone/ItemCreateForm.js +91 -0
  75. package/dist/components/standalone/ItemEditForm.d.ts +37 -0
  76. package/dist/components/standalone/ItemEditForm.d.ts.map +1 -0
  77. package/dist/components/standalone/ItemEditForm.js +112 -0
  78. package/dist/components/standalone/ListTable.d.ts +33 -0
  79. package/dist/components/standalone/ListTable.d.ts.map +1 -0
  80. package/dist/components/standalone/ListTable.js +94 -0
  81. package/dist/components/standalone/SearchBar.d.ts +29 -0
  82. package/dist/components/standalone/SearchBar.d.ts.map +1 -0
  83. package/dist/components/standalone/SearchBar.js +43 -0
  84. package/dist/components/standalone/index.d.ts +11 -0
  85. package/dist/components/standalone/index.d.ts.map +1 -0
  86. package/dist/components/standalone/index.js +6 -0
  87. package/dist/index.d.ts +27 -0
  88. package/dist/index.d.ts.map +1 -0
  89. package/dist/index.js +19 -0
  90. package/dist/lib/serializeFieldConfig.d.ts +43 -0
  91. package/dist/lib/serializeFieldConfig.d.ts.map +1 -0
  92. package/dist/lib/serializeFieldConfig.js +48 -0
  93. package/dist/lib/theme.d.ts +17 -0
  94. package/dist/lib/theme.d.ts.map +1 -0
  95. package/dist/lib/theme.js +192 -0
  96. package/dist/lib/utils.d.ts +18 -0
  97. package/dist/lib/utils.d.ts.map +1 -0
  98. package/dist/lib/utils.js +76 -0
  99. package/dist/primitives/button.d.ts +12 -0
  100. package/dist/primitives/button.d.ts.map +1 -0
  101. package/dist/primitives/button.js +33 -0
  102. package/dist/primitives/calendar.d.ts +9 -0
  103. package/dist/primitives/calendar.d.ts.map +1 -0
  104. package/dist/primitives/calendar.js +48 -0
  105. package/dist/primitives/card.d.ts +9 -0
  106. package/dist/primitives/card.d.ts.map +1 -0
  107. package/dist/primitives/card.js +16 -0
  108. package/dist/primitives/checkbox.d.ts +5 -0
  109. package/dist/primitives/checkbox.d.ts.map +1 -0
  110. package/dist/primitives/checkbox.js +7 -0
  111. package/dist/primitives/combobox.d.ts +14 -0
  112. package/dist/primitives/combobox.d.ts.map +1 -0
  113. package/dist/primitives/combobox.js +20 -0
  114. package/dist/primitives/datetime-picker.d.ts +9 -0
  115. package/dist/primitives/datetime-picker.d.ts.map +1 -0
  116. package/dist/primitives/datetime-picker.js +42 -0
  117. package/dist/primitives/dialog.d.ts +20 -0
  118. package/dist/primitives/dialog.d.ts.map +1 -0
  119. package/dist/primitives/dialog.js +21 -0
  120. package/dist/primitives/index.d.ts +14 -0
  121. package/dist/primitives/index.d.ts.map +1 -0
  122. package/dist/primitives/index.js +14 -0
  123. package/dist/primitives/input.d.ts +5 -0
  124. package/dist/primitives/input.d.ts.map +1 -0
  125. package/dist/primitives/input.js +8 -0
  126. package/dist/primitives/label.d.ts +6 -0
  127. package/dist/primitives/label.d.ts.map +1 -0
  128. package/dist/primitives/label.js +9 -0
  129. package/dist/primitives/popover.d.ts +7 -0
  130. package/dist/primitives/popover.d.ts.map +1 -0
  131. package/dist/primitives/popover.js +10 -0
  132. package/dist/primitives/select.d.ts +14 -0
  133. package/dist/primitives/select.d.ts.map +1 -0
  134. package/dist/primitives/select.js +24 -0
  135. package/dist/primitives/table.d.ts +11 -0
  136. package/dist/primitives/table.d.ts.map +1 -0
  137. package/dist/primitives/table.js +20 -0
  138. package/dist/primitives/time-picker.d.ts +8 -0
  139. package/dist/primitives/time-picker.d.ts.map +1 -0
  140. package/dist/primitives/time-picker.js +27 -0
  141. package/dist/server/index.d.ts +2 -0
  142. package/dist/server/index.d.ts.map +1 -0
  143. package/dist/server/index.js +2 -0
  144. package/dist/server/types.d.ts +15 -0
  145. package/dist/server/types.d.ts.map +1 -0
  146. package/dist/server/types.js +1 -0
  147. package/dist/styles/globals.css +1896 -0
  148. package/package.json +91 -0
  149. package/postcss.config.cjs +5 -0
  150. package/src/components/AdminUI.tsx +112 -0
  151. package/src/components/ConfirmDialog.tsx +56 -0
  152. package/src/components/Dashboard.tsx +134 -0
  153. package/src/components/ItemForm.tsx +195 -0
  154. package/src/components/ItemFormClient.tsx +237 -0
  155. package/src/components/ListView.tsx +153 -0
  156. package/src/components/ListViewClient.tsx +282 -0
  157. package/src/components/LoadingSpinner.tsx +32 -0
  158. package/src/components/Navigation.tsx +117 -0
  159. package/src/components/SkeletonLoader.tsx +82 -0
  160. package/src/components/fields/CheckboxField.tsx +54 -0
  161. package/src/components/fields/ComboboxField.tsx +127 -0
  162. package/src/components/fields/FieldRenderer.tsx +132 -0
  163. package/src/components/fields/IntegerField.tsx +68 -0
  164. package/src/components/fields/PasswordField.tsx +159 -0
  165. package/src/components/fields/RelationshipField.tsx +71 -0
  166. package/src/components/fields/RelationshipManager.tsx +189 -0
  167. package/src/components/fields/SelectField.tsx +71 -0
  168. package/src/components/fields/TextField.tsx +59 -0
  169. package/src/components/fields/TimestampField.tsx +49 -0
  170. package/src/components/fields/index.ts +27 -0
  171. package/src/components/fields/registry.ts +72 -0
  172. package/src/components/standalone/DeleteButton.tsx +114 -0
  173. package/src/components/standalone/ItemCreateForm.tsx +161 -0
  174. package/src/components/standalone/ItemEditForm.tsx +193 -0
  175. package/src/components/standalone/ListTable.tsx +211 -0
  176. package/src/components/standalone/SearchBar.tsx +86 -0
  177. package/src/components/standalone/index.ts +13 -0
  178. package/src/index.ts +74 -0
  179. package/src/lib/serializeFieldConfig.ts +88 -0
  180. package/src/lib/theme.ts +202 -0
  181. package/src/lib/utils.ts +81 -0
  182. package/src/primitives/button.tsx +49 -0
  183. package/src/primitives/calendar.tsx +160 -0
  184. package/src/primitives/card.tsx +58 -0
  185. package/src/primitives/checkbox.tsx +27 -0
  186. package/src/primitives/combobox.tsx +159 -0
  187. package/src/primitives/datetime-picker.tsx +130 -0
  188. package/src/primitives/dialog.tsx +108 -0
  189. package/src/primitives/index.ts +54 -0
  190. package/src/primitives/input.tsx +24 -0
  191. package/src/primitives/label.tsx +19 -0
  192. package/src/primitives/popover.tsx +31 -0
  193. package/src/primitives/select.tsx +158 -0
  194. package/src/primitives/table.tsx +91 -0
  195. package/src/primitives/time-picker.tsx +65 -0
  196. package/src/server/index.ts +3 -0
  197. package/src/server/types.ts +15 -0
  198. package/src/styles/globals.css +123 -0
  199. package/tailwind.config.ts +3 -0
  200. package/tests/components/TextField.test.tsx +94 -0
  201. package/tests/setup.ts +11 -0
  202. package/tsconfig.json +26 -0
  203. 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
+ }