@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,237 @@
1
+ 'use client'
2
+
3
+ import * as React from 'react'
4
+ import { useState, useTransition } from 'react'
5
+ import { useRouter } from 'next/navigation'
6
+ import { FieldRenderer } from './fields/FieldRenderer.js'
7
+ import { ConfirmDialog } from './ConfirmDialog.js'
8
+ import { LoadingSpinner } from './LoadingSpinner.js'
9
+ import { Button } from '../primitives/button.js'
10
+ import type { ServerActionInput } from '../server/types.js'
11
+ import type { SerializableFieldConfig } from '../lib/serializeFieldConfig.js'
12
+
13
+ export interface ItemFormClientProps {
14
+ listKey: string
15
+ urlKey: string
16
+ mode: 'create' | 'edit'
17
+ fields: Record<string, SerializableFieldConfig>
18
+ initialData?: Record<string, unknown>
19
+ itemId?: string
20
+ basePath: string
21
+ serverAction: (input: ServerActionInput) => Promise<unknown>
22
+ relationshipData?: Record<string, Array<{ id: string; label: string }>>
23
+ }
24
+
25
+ /**
26
+ * Client component for interactive form
27
+ * Handles form state, validation, and submission
28
+ */
29
+ export function ItemFormClient({
30
+ listKey,
31
+ urlKey,
32
+ mode,
33
+ fields,
34
+ initialData = {},
35
+ itemId,
36
+ basePath,
37
+ serverAction,
38
+ relationshipData = {},
39
+ }: ItemFormClientProps) {
40
+ const router = useRouter()
41
+ const [isPending, startTransition] = useTransition()
42
+ const [formData, setFormData] = useState<Record<string, unknown>>(initialData)
43
+ const [errors, setErrors] = useState<Record<string, string>>({})
44
+ const [generalError, setGeneralError] = useState<string | null>(null)
45
+ const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
46
+
47
+ const handleFieldChange = (fieldName: string, value: unknown) => {
48
+ setFormData((prev) => ({ ...prev, [fieldName]: value }))
49
+ // Clear error for this field when user starts typing
50
+ if (errors[fieldName]) {
51
+ setErrors((prev) => {
52
+ const newErrors = { ...prev }
53
+ delete newErrors[fieldName]
54
+ return newErrors
55
+ })
56
+ }
57
+ }
58
+
59
+ const handleSubmit = async (e: React.FormEvent) => {
60
+ e.preventDefault()
61
+ setErrors({})
62
+ setGeneralError(null)
63
+
64
+ startTransition(async () => {
65
+ try {
66
+ // Transform relationship fields to Prisma format
67
+ // Filter out password fields with isSet objects (unchanged passwords)
68
+ const transformedData: Record<string, unknown> = {}
69
+ for (const [fieldName, value] of Object.entries(formData)) {
70
+ const fieldConfig = fields[fieldName]
71
+
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 })),
85
+ }
86
+ }
87
+ } else {
88
+ // Single relationship: use connect format
89
+ if (value) {
90
+ transformedData[fieldName] = {
91
+ connect: { id: value },
92
+ }
93
+ }
94
+ }
95
+ } else {
96
+ // Non-relationship field: pass through
97
+ transformedData[fieldName] = value
98
+ }
99
+ }
100
+
101
+ const result =
102
+ mode === 'create'
103
+ ? await serverAction({
104
+ listKey,
105
+ action: 'create',
106
+ data: transformedData,
107
+ })
108
+ : await serverAction({
109
+ listKey,
110
+ action: 'update',
111
+ id: itemId!,
112
+ data: transformedData,
113
+ })
114
+
115
+ if (result) {
116
+ // Navigate back to list view
117
+ router.push(`${basePath}/${urlKey}`)
118
+ router.refresh()
119
+ } else {
120
+ setGeneralError('Access denied or operation failed')
121
+ }
122
+ } catch (error) {
123
+ const errorMessage = error instanceof Error ? error.message : 'Failed to save item'
124
+ setGeneralError(errorMessage)
125
+ }
126
+ })
127
+ }
128
+
129
+ const handleDelete = async () => {
130
+ if (!itemId) return
131
+
132
+ setGeneralError(null)
133
+ setShowDeleteConfirm(false)
134
+
135
+ startTransition(async () => {
136
+ try {
137
+ const result = await serverAction({
138
+ listKey,
139
+ action: 'delete',
140
+ id: itemId,
141
+ })
142
+
143
+ if (result) {
144
+ router.push(`${basePath}/${urlKey}`)
145
+ router.refresh()
146
+ } else {
147
+ setGeneralError('Access denied or failed to delete item')
148
+ }
149
+ } catch (error) {
150
+ const errorMessage = error instanceof Error ? error.message : 'Failed to delete item'
151
+ setGeneralError(errorMessage)
152
+ }
153
+ })
154
+ }
155
+
156
+ // Filter out system fields
157
+ const editableFields = Object.entries(fields).filter(
158
+ ([key]) => !['id', 'createdAt', 'updatedAt'].includes(key),
159
+ )
160
+
161
+ return (
162
+ <form onSubmit={handleSubmit} className="space-y-6">
163
+ {/* General Error */}
164
+ {generalError && (
165
+ <div className="bg-destructive/10 border border-destructive text-destructive rounded-lg p-4">
166
+ <p className="text-sm font-medium">{generalError}</p>
167
+ </div>
168
+ )}
169
+
170
+ {/* Form Fields */}
171
+ <div className="space-y-6">
172
+ {editableFields.map(([fieldName, fieldConfig]) => (
173
+ <FieldRenderer
174
+ key={fieldName}
175
+ fieldName={fieldName}
176
+ fieldConfig={fieldConfig}
177
+ value={formData[fieldName]}
178
+ onChange={(value) => handleFieldChange(fieldName, value)}
179
+ error={errors[fieldName]}
180
+ disabled={isPending}
181
+ mode="edit"
182
+ relationshipItems={relationshipData[fieldName] || []}
183
+ relationshipLoading={false}
184
+ basePath={basePath}
185
+ />
186
+ ))}
187
+ </div>
188
+
189
+ {/* Form Actions */}
190
+ <div className="flex items-center justify-between pt-6 border-t border-border">
191
+ <div className="flex gap-3">
192
+ <Button type="submit" disabled={isPending} className="gap-2">
193
+ {isPending && (
194
+ <LoadingSpinner
195
+ size="sm"
196
+ className="border-primary-foreground border-t-transparent"
197
+ />
198
+ )}
199
+ {isPending ? 'Saving...' : mode === 'create' ? 'Create' : 'Save'}
200
+ </Button>
201
+ <Button
202
+ type="button"
203
+ variant="secondary"
204
+ onClick={() => router.push(`${basePath}/${urlKey}`)}
205
+ disabled={isPending}
206
+ >
207
+ Cancel
208
+ </Button>
209
+ </div>
210
+
211
+ {/* Delete Button (Edit Mode Only) */}
212
+ {mode === 'edit' && itemId && (
213
+ <Button
214
+ type="button"
215
+ variant="destructive"
216
+ onClick={() => setShowDeleteConfirm(true)}
217
+ disabled={isPending}
218
+ >
219
+ Delete
220
+ </Button>
221
+ )}
222
+ </div>
223
+
224
+ {/* Delete Confirmation Dialog */}
225
+ <ConfirmDialog
226
+ isOpen={showDeleteConfirm}
227
+ title="Delete Item"
228
+ message="Are you sure you want to delete this item? This action cannot be undone."
229
+ confirmLabel="Delete"
230
+ cancelLabel="Cancel"
231
+ variant="danger"
232
+ onConfirm={handleDelete}
233
+ onCancel={() => setShowDeleteConfirm(false)}
234
+ />
235
+ </form>
236
+ )
237
+ }
@@ -0,0 +1,153 @@
1
+ import Link from 'next/link'
2
+ import { ListViewClient } from './ListViewClient.js'
3
+ import { formatListName } from '../lib/utils.js'
4
+ import {
5
+ AccessContext,
6
+ getDbKey,
7
+ getUrlKey,
8
+ OpenSaasConfig,
9
+ type PrismaClientLike,
10
+ } from '@opensaas/stack-core'
11
+
12
+ export interface ListViewProps<TPrisma extends PrismaClientLike = PrismaClientLike> {
13
+ context: AccessContext<TPrisma>
14
+ config: OpenSaasConfig
15
+ listKey: string
16
+ basePath?: string
17
+ columns?: string[]
18
+ page?: number
19
+ pageSize?: number
20
+ search?: string
21
+ }
22
+
23
+ /**
24
+ * List view component - displays items in a table
25
+ * Server Component that fetches data and renders client table
26
+ */
27
+ export async function ListView<TPrisma extends PrismaClientLike = PrismaClientLike>({
28
+ context,
29
+ config,
30
+ listKey,
31
+ basePath = '/admin',
32
+ columns,
33
+ page = 1,
34
+ pageSize = 50,
35
+ search,
36
+ }: ListViewProps<TPrisma>) {
37
+ const key = getDbKey(listKey)
38
+ const urlKey = getUrlKey(listKey)
39
+ const listConfig = config.lists[listKey]
40
+
41
+ if (!listConfig) {
42
+ return (
43
+ <div className="p-8">
44
+ <div className="bg-destructive/10 border border-destructive text-destructive rounded-lg p-6">
45
+ <h2 className="text-lg font-semibold mb-2">List not found</h2>
46
+ <p>The list &quot;{listKey}&quot; does not exist in your configuration.</p>
47
+ </div>
48
+ </div>
49
+ )
50
+ }
51
+
52
+ // Fetch items using access-controlled context
53
+ const skip = (page - 1) * pageSize
54
+ let items: Array<Record<string, unknown>> = []
55
+ let total = 0
56
+
57
+ try {
58
+ const dbContext = context.db
59
+ if (!dbContext || !dbContext[key]) {
60
+ throw new Error(`Context for ${listKey} not found`)
61
+ }
62
+
63
+ // Build search filter if search term provided
64
+ let where: Record<string, unknown> | undefined = undefined
65
+ if (search && search.trim()) {
66
+ // Find all text fields to search across
67
+ const searchableFields = Object.entries(listConfig.fields)
68
+ .filter(([_, field]) => (field as { type: string }).type === 'text')
69
+ .map(([fieldName]) => fieldName)
70
+
71
+ if (searchableFields.length > 0) {
72
+ where = {
73
+ OR: searchableFields.map((fieldName) => ({
74
+ [fieldName]: {
75
+ contains: search.trim(),
76
+ },
77
+ })),
78
+ }
79
+ }
80
+ }
81
+
82
+ // Build include object for relationship fields
83
+ const include: Record<string, boolean> = {}
84
+ Object.entries(listConfig.fields).forEach(([fieldName, field]) => {
85
+ if ((field as { type: string }).type === 'relationship') {
86
+ include[fieldName] = true
87
+ }
88
+ })
89
+ ;[items, total] = await Promise.all([
90
+ dbContext[key].findMany({
91
+ where,
92
+ skip,
93
+ take: pageSize,
94
+ ...(Object.keys(include).length > 0 ? { include } : {}),
95
+ }),
96
+ dbContext[key].count({ where }),
97
+ ])
98
+ } catch (error) {
99
+ console.error(`Failed to fetch ${listKey}:`, error)
100
+ }
101
+
102
+ // Serialize items for client component (convert Dates, etc to JSON-safe format)
103
+ const serializedItems = JSON.parse(JSON.stringify(items))
104
+
105
+ // Extract only the relationship refs needed by client (don't send entire config)
106
+ const relationshipRefs: Record<string, string> = {}
107
+ Object.entries(listConfig.fields).forEach(([fieldName, field]) => {
108
+ if ('type' in field && field.type === 'relationship' && 'ref' in field && field.ref) {
109
+ relationshipRefs[fieldName] = field.ref
110
+ }
111
+ })
112
+
113
+ return (
114
+ <div className="p-8">
115
+ {/* Header */}
116
+ <div className="flex items-center justify-between mb-8">
117
+ <div>
118
+ <h1 className="text-3xl font-bold mb-2">{formatListName(listKey)}</h1>
119
+ <p className="text-muted-foreground">
120
+ {total} {total === 1 ? 'item' : 'items'}
121
+ </p>
122
+ </div>
123
+ <Link
124
+ href={`${basePath}/${urlKey}/create`}
125
+ className="inline-flex items-center px-4 py-2 bg-primary text-primary-foreground rounded-md font-medium hover:bg-primary/90 transition-colors"
126
+ >
127
+ <span className="mr-2">+</span>
128
+ Create {formatListName(listKey)}
129
+ </Link>
130
+ </div>
131
+
132
+ {/* Client Table */}
133
+ <ListViewClient
134
+ items={serializedItems || []}
135
+ fieldTypes={Object.fromEntries(
136
+ Object.entries(listConfig.fields).map(([key, field]) => [
137
+ key,
138
+ (field as { type: string }).type,
139
+ ]),
140
+ )}
141
+ relationshipRefs={relationshipRefs}
142
+ columns={columns}
143
+ listKey={listKey}
144
+ urlKey={urlKey}
145
+ basePath={basePath}
146
+ page={page}
147
+ pageSize={pageSize}
148
+ total={total || 0}
149
+ search={search}
150
+ />
151
+ </div>
152
+ )
153
+ }
@@ -0,0 +1,282 @@
1
+ 'use client'
2
+
3
+ import * as React from 'react'
4
+ import { useState } from 'react'
5
+ import Link from 'next/link'
6
+ import { useRouter } from 'next/navigation'
7
+ import { formatFieldName, getFieldDisplayValue } from '../lib/utils.js'
8
+ import {
9
+ Table,
10
+ TableBody,
11
+ TableCell,
12
+ TableHead,
13
+ TableHeader,
14
+ TableRow,
15
+ } from '../primitives/table.js'
16
+ import { Input } from '../primitives/input.js'
17
+ import { Button } from '../primitives/button.js'
18
+ import { Card } from '../primitives/card.js'
19
+ import { getUrlKey } from '@opensaas/stack-core'
20
+
21
+ export interface ListViewClientProps {
22
+ items: Array<Record<string, unknown>>
23
+ fieldTypes: Record<string, string>
24
+ relationshipRefs: Record<string, string>
25
+ columns?: string[]
26
+ listKey: string
27
+ urlKey: string
28
+ basePath: string
29
+ page: number
30
+ pageSize: number
31
+ total: number
32
+ search?: string
33
+ }
34
+
35
+ /**
36
+ * Client component for interactive list table
37
+ * Handles sorting, pagination, and row interactions
38
+ */
39
+ export function ListViewClient({
40
+ items,
41
+ fieldTypes,
42
+ relationshipRefs,
43
+ columns,
44
+ urlKey,
45
+ basePath,
46
+ page,
47
+ pageSize,
48
+ total,
49
+ search: initialSearch,
50
+ }: ListViewClientProps) {
51
+ const router = useRouter()
52
+ const [sortBy, setSortBy] = useState<string | null>(null)
53
+ const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc')
54
+ const [searchInput, setSearchInput] = useState(initialSearch || '')
55
+
56
+ // Determine which columns to show
57
+ const displayColumns =
58
+ columns ||
59
+ Object.keys(fieldTypes).filter((key) => !['password', 'createdAt', 'updatedAt'].includes(key))
60
+
61
+ // Sort items if needed
62
+ const sortedItems = [...items]
63
+ if (sortBy) {
64
+ sortedItems.sort((a, b) => {
65
+ const aVal = a[sortBy]
66
+ const bVal = b[sortBy]
67
+ if (aVal === bVal) return 0
68
+ // Handle unknown types for comparison - convert to string for safety
69
+ const aStr = String(aVal ?? '')
70
+ const bStr = String(bVal ?? '')
71
+ const comparison = aStr > bStr ? 1 : -1
72
+ return sortOrder === 'asc' ? comparison : -comparison
73
+ })
74
+ }
75
+
76
+ const totalPages = Math.ceil(total / pageSize)
77
+ const hasNextPage = page < totalPages
78
+ const hasPrevPage = page > 1
79
+
80
+ const handleSort = (column: string) => {
81
+ if (sortBy === column) {
82
+ setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc')
83
+ } else {
84
+ setSortBy(column)
85
+ setSortOrder('asc')
86
+ }
87
+ }
88
+
89
+ const handleSearch = (e: React.FormEvent) => {
90
+ e.preventDefault()
91
+ const params = new URLSearchParams()
92
+ if (searchInput.trim()) {
93
+ params.set('search', searchInput.trim())
94
+ }
95
+ params.set('page', '1') // Reset to page 1 on new search
96
+ router.push(`${basePath}/${urlKey}?${params.toString()}`)
97
+ }
98
+
99
+ const handleClearSearch = () => {
100
+ setSearchInput('')
101
+ router.push(`${basePath}/${urlKey}`)
102
+ }
103
+
104
+ const buildPaginationUrl = (newPage: number) => {
105
+ const params = new URLSearchParams()
106
+ if (initialSearch) {
107
+ params.set('search', initialSearch)
108
+ }
109
+ params.set('page', newPage.toString())
110
+ return `${basePath}/${urlKey}?${params.toString()}`
111
+ }
112
+
113
+ /**
114
+ * Render a relationship field as a clickable link or links
115
+ */
116
+ const renderRelationshipCell = (value: unknown, fieldName: string) => {
117
+ const ref = relationshipRefs[fieldName]
118
+ if (!ref) {
119
+ return getFieldDisplayValue(value, 'relationship')
120
+ }
121
+
122
+ // Parse ref to get related list name
123
+ const [relatedListKey] = ref.split('.')
124
+ const relatedUrlKey = getUrlKey(relatedListKey)
125
+
126
+ if (!value || typeof value !== 'object') {
127
+ return <span className="text-muted-foreground">-</span>
128
+ }
129
+
130
+ // Handle array of relationships (many: true)
131
+ if (Array.isArray(value)) {
132
+ if (value.length === 0) return <span className="text-muted-foreground">-</span>
133
+ return (
134
+ <span className="flex flex-wrap gap-1">
135
+ {value.map((item, idx) => {
136
+ if (!item || typeof item !== 'object') return null
137
+ const displayValue = getFieldDisplayValue(item, 'relationship')
138
+ const itemId = 'id' in item ? item.id : null
139
+ const key = itemId || idx
140
+ return (
141
+ <React.Fragment key={key}>
142
+ {idx > 0 && <span className="text-muted-foreground">, </span>}
143
+ <Link
144
+ href={`${basePath}/${relatedUrlKey}/${itemId}`}
145
+ className="text-primary hover:underline"
146
+ onClick={(e) => e.stopPropagation()}
147
+ >
148
+ {displayValue}
149
+ </Link>
150
+ </React.Fragment>
151
+ )
152
+ })}
153
+ </span>
154
+ )
155
+ }
156
+
157
+ // Handle single relationship
158
+ const itemId = 'id' in value ? value.id : null
159
+ const displayValue = getFieldDisplayValue(value, 'relationship')
160
+ return (
161
+ <Link
162
+ href={`${basePath}/${relatedUrlKey}/${itemId}`}
163
+ className="text-primary hover:underline"
164
+ onClick={(e) => e.stopPropagation()}
165
+ >
166
+ {displayValue}
167
+ </Link>
168
+ )
169
+ }
170
+
171
+ return (
172
+ <div className="space-y-4">
173
+ {/* Search Bar */}
174
+ <Card className="p-4">
175
+ <form onSubmit={handleSearch} className="flex gap-2">
176
+ <div className="flex-1 relative">
177
+ <Input
178
+ type="text"
179
+ value={searchInput}
180
+ onChange={(e) => setSearchInput(e.target.value)}
181
+ placeholder="Search..."
182
+ className="pr-10"
183
+ />
184
+ {searchInput && (
185
+ <button
186
+ type="button"
187
+ onClick={handleClearSearch}
188
+ className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
189
+ >
190
+
191
+ </button>
192
+ )}
193
+ </div>
194
+ <Button type="submit">Search</Button>
195
+ </form>
196
+ </Card>
197
+
198
+ {/* Table */}
199
+ <div className="rounded-lg border">
200
+ <Table>
201
+ <TableHeader>
202
+ <TableRow>
203
+ {displayColumns.map((column) => (
204
+ <TableHead
205
+ key={column}
206
+ className="cursor-pointer hover:bg-muted/70 transition-colors"
207
+ onClick={() => handleSort(column)}
208
+ >
209
+ <div className="flex items-center space-x-1">
210
+ <span>{formatFieldName(column)}</span>
211
+ {sortBy === column && (
212
+ <span className="text-primary">{sortOrder === 'asc' ? '↑' : '↓'}</span>
213
+ )}
214
+ </div>
215
+ </TableHead>
216
+ ))}
217
+ <TableHead className="text-right">Actions</TableHead>
218
+ </TableRow>
219
+ </TableHeader>
220
+ <TableBody>
221
+ {sortedItems.length === 0 ? (
222
+ <TableRow>
223
+ <TableCell colSpan={displayColumns.length + 1} className="h-24 text-center">
224
+ No items found
225
+ </TableCell>
226
+ </TableRow>
227
+ ) : (
228
+ sortedItems.map((item) => (
229
+ <TableRow key={String(item.id)}>
230
+ {displayColumns.map((column) => (
231
+ <TableCell key={column}>
232
+ {fieldTypes[column] === 'relationship'
233
+ ? renderRelationshipCell(item[column], column)
234
+ : getFieldDisplayValue(item[column], fieldTypes[column])}
235
+ </TableCell>
236
+ ))}
237
+ <TableCell className="text-right">
238
+ <Link
239
+ href={`${basePath}/${urlKey}/${item.id}`}
240
+ className="text-primary hover:underline"
241
+ >
242
+ Edit
243
+ </Link>
244
+ </TableCell>
245
+ </TableRow>
246
+ ))
247
+ )}
248
+ </TableBody>
249
+ </Table>
250
+ </div>
251
+
252
+ {/* Pagination */}
253
+ {totalPages > 1 && (
254
+ <div className="flex items-center justify-between">
255
+ <p className="text-sm text-muted-foreground">
256
+ Showing {(page - 1) * pageSize + 1} to {Math.min(page * pageSize, total)} of {total}{' '}
257
+ results
258
+ </p>
259
+ <div className="flex items-center space-x-2">
260
+ <Button
261
+ variant="outline"
262
+ onClick={() => router.push(buildPaginationUrl(page - 1))}
263
+ disabled={!hasPrevPage}
264
+ >
265
+ Previous
266
+ </Button>
267
+ <span className="text-sm text-muted-foreground">
268
+ Page {page} of {totalPages}
269
+ </span>
270
+ <Button
271
+ variant="outline"
272
+ onClick={() => router.push(buildPaginationUrl(page + 1))}
273
+ disabled={!hasNextPage}
274
+ >
275
+ Next
276
+ </Button>
277
+ </div>
278
+ </div>
279
+ )}
280
+ </div>
281
+ )
282
+ }
@@ -0,0 +1,32 @@
1
+ import { cn } from '../lib/utils.js'
2
+
3
+ export interface LoadingSpinnerProps {
4
+ size?: 'sm' | 'md' | 'lg'
5
+ className?: string
6
+ }
7
+
8
+ /**
9
+ * Loading spinner component
10
+ * Used to indicate loading states
11
+ */
12
+ export function LoadingSpinner({ size = 'md', className }: LoadingSpinnerProps) {
13
+ const sizeClasses = {
14
+ sm: 'h-4 w-4 border-2',
15
+ md: 'h-8 w-8 border-2',
16
+ lg: 'h-12 w-12 border-3',
17
+ }
18
+
19
+ return (
20
+ <div
21
+ className={cn(
22
+ 'animate-spin rounded-full border-primary border-t-transparent',
23
+ sizeClasses[size],
24
+ className,
25
+ )}
26
+ role="status"
27
+ aria-label="Loading"
28
+ >
29
+ <span className="sr-only">Loading...</span>
30
+ </div>
31
+ )
32
+ }