@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,114 @@
1
+ 'use client'
2
+
3
+ import { useState } from 'react'
4
+ import { Button } from '../../primitives/button.js'
5
+ import { ConfirmDialog } from '../ConfirmDialog.js'
6
+ import { LoadingSpinner } from '../LoadingSpinner.js'
7
+
8
+ export interface DeleteButtonProps {
9
+ onDelete: () => Promise<{ success: boolean; error?: string }>
10
+ itemName?: string
11
+ confirmTitle?: string
12
+ confirmMessage?: string
13
+ confirmLabel?: string
14
+ cancelLabel?: string
15
+ buttonLabel?: string
16
+ variant?: 'danger' | 'warning'
17
+ buttonVariant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link'
18
+ size?: 'default' | 'sm' | 'lg' | 'icon'
19
+ className?: string
20
+ disabled?: boolean
21
+ }
22
+
23
+ /**
24
+ * Standalone delete button with confirmation dialog
25
+ * Can be embedded in any custom page
26
+ *
27
+ * @example
28
+ * ```tsx
29
+ * <DeleteButton
30
+ * onDelete={async () => {
31
+ * await deletePost(postId);
32
+ * return { success: true };
33
+ * }}
34
+ * itemName="post"
35
+ * confirmMessage="This will permanently delete the post and all its comments."
36
+ * />
37
+ * ```
38
+ */
39
+ export function DeleteButton({
40
+ onDelete,
41
+ itemName = 'item',
42
+ confirmTitle,
43
+ confirmMessage,
44
+ confirmLabel = 'Delete',
45
+ cancelLabel = 'Cancel',
46
+ buttonLabel = 'Delete',
47
+ variant = 'danger',
48
+ buttonVariant = 'destructive',
49
+ size = 'default',
50
+ className,
51
+ disabled = false,
52
+ }: DeleteButtonProps) {
53
+ const [showConfirm, setShowConfirm] = useState(false)
54
+ const [isPending, setIsPending] = useState(false)
55
+ const [error, setError] = useState<string | null>(null)
56
+
57
+ const handleDelete = async () => {
58
+ setShowConfirm(false)
59
+ setIsPending(true)
60
+ setError(null)
61
+
62
+ try {
63
+ const result = await onDelete()
64
+ if (!result.success) {
65
+ setError(result.error || `Failed to delete ${itemName}`)
66
+ }
67
+ } catch (err: unknown) {
68
+ setError((err as Error).message || `Failed to delete ${itemName}`)
69
+ } finally {
70
+ setIsPending(false)
71
+ }
72
+ }
73
+
74
+ return (
75
+ <>
76
+ <Button
77
+ type="button"
78
+ variant={buttonVariant}
79
+ size={size}
80
+ onClick={() => setShowConfirm(true)}
81
+ disabled={disabled || isPending}
82
+ className={className}
83
+ >
84
+ {isPending && (
85
+ <LoadingSpinner
86
+ size="sm"
87
+ className="border-primary-foreground border-t-transparent mr-2"
88
+ />
89
+ )}
90
+ {buttonLabel}
91
+ </Button>
92
+
93
+ {error && (
94
+ <div className="mt-2 bg-destructive/10 border border-destructive text-destructive rounded-lg p-3">
95
+ <p className="text-sm font-medium">{error}</p>
96
+ </div>
97
+ )}
98
+
99
+ <ConfirmDialog
100
+ isOpen={showConfirm}
101
+ title={confirmTitle || `Delete ${itemName}`}
102
+ message={
103
+ confirmMessage ||
104
+ `Are you sure you want to delete this ${itemName}? This action cannot be undone.`
105
+ }
106
+ confirmLabel={confirmLabel}
107
+ cancelLabel={cancelLabel}
108
+ variant={variant}
109
+ onConfirm={handleDelete}
110
+ onCancel={() => setShowConfirm(false)}
111
+ />
112
+ </>
113
+ )
114
+ }
@@ -0,0 +1,161 @@
1
+ 'use client'
2
+
3
+ import * as React from 'react'
4
+ import { useState, useMemo } from 'react'
5
+ import { FieldRenderer } from '../fields/FieldRenderer.js'
6
+ import { LoadingSpinner } from '../LoadingSpinner.js'
7
+ import { Button } from '../../primitives/button.js'
8
+ import type { FieldConfig } from '@opensaas/stack-core'
9
+ import { serializeFieldConfigs } from '../../lib/serializeFieldConfig.js'
10
+
11
+ export interface ItemCreateFormProps<TData = Record<string, unknown>> {
12
+ fields: Record<string, FieldConfig>
13
+ onSubmit: (data: TData) => Promise<{ success: boolean; error?: string }>
14
+ onCancel?: () => void
15
+ relationshipData?: Record<string, Array<{ id: string; label: string }>>
16
+ submitLabel?: string
17
+ cancelLabel?: string
18
+ className?: string
19
+ }
20
+
21
+ /**
22
+ * Standalone form component for creating items
23
+ * Can be embedded in any custom page
24
+ *
25
+ * @example
26
+ * ```tsx
27
+ * <ItemCreateForm
28
+ * fields={config.lists.Post.fields}
29
+ * onSubmit={async (data) => {
30
+ * const result = await createPost(data);
31
+ * return { success: !!result };
32
+ * }}
33
+ * onCancel={() => router.back()}
34
+ * />
35
+ * ```
36
+ */
37
+ export function ItemCreateForm<TData = Record<string, unknown>>({
38
+ fields,
39
+ onSubmit,
40
+ onCancel,
41
+ relationshipData = {},
42
+ submitLabel = 'Create',
43
+ cancelLabel = 'Cancel',
44
+ className,
45
+ }: ItemCreateFormProps<TData>) {
46
+ // Serialize field configs to remove non-serializable properties
47
+ const serializedFields = useMemo(() => serializeFieldConfigs(fields), [fields])
48
+
49
+ const [isPending, setIsPending] = useState(false)
50
+ const [formData, setFormData] = useState<Partial<TData>>({} as Partial<TData>)
51
+ const [errors, setErrors] = useState<Record<string, string>>({})
52
+ const [generalError, setGeneralError] = useState<string | null>(null)
53
+
54
+ const handleFieldChange = (fieldName: string, value: unknown) => {
55
+ setFormData((prev) => ({ ...prev, [fieldName]: value }) as Partial<TData>)
56
+ // Clear error for this field when user starts typing
57
+ if (errors[fieldName]) {
58
+ setErrors((prev) => {
59
+ const newErrors = { ...prev }
60
+ delete newErrors[fieldName]
61
+ return newErrors
62
+ })
63
+ }
64
+ }
65
+
66
+ const handleSubmit = async (e: React.FormEvent) => {
67
+ e.preventDefault()
68
+ setErrors({})
69
+ setGeneralError(null)
70
+ setIsPending(true)
71
+
72
+ try {
73
+ // Transform relationship fields to Prisma format
74
+ const transformedData: Record<string, unknown> = {}
75
+ for (const [fieldName, value] of Object.entries(formData as Record<string, unknown>)) {
76
+ const fieldConfig = serializedFields[fieldName]
77
+
78
+ // Transform relationship fields
79
+ if (fieldConfig?.type === 'relationship') {
80
+ if (fieldConfig.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 = await onSubmit(transformedData as TData)
102
+
103
+ if (!result.success) {
104
+ setGeneralError(result.error || 'Failed to create item')
105
+ }
106
+ } catch (error: unknown) {
107
+ setGeneralError((error as Error).message || 'Failed to create item')
108
+ } finally {
109
+ setIsPending(false)
110
+ }
111
+ }
112
+
113
+ // Filter out system fields
114
+ const editableFields = Object.entries(serializedFields).filter(
115
+ ([key]) => !['id', 'createdAt', 'updatedAt'].includes(key),
116
+ )
117
+
118
+ return (
119
+ <form onSubmit={handleSubmit} className={className}>
120
+ {/* General Error */}
121
+ {generalError && (
122
+ <div className="bg-destructive/10 border border-destructive text-destructive rounded-lg p-4 mb-6">
123
+ <p className="text-sm font-medium">{generalError}</p>
124
+ </div>
125
+ )}
126
+
127
+ {/* Form Fields */}
128
+ <div className="space-y-6">
129
+ {editableFields.map(([fieldName, fieldConfig]) => (
130
+ <FieldRenderer
131
+ key={fieldName}
132
+ fieldName={fieldName}
133
+ fieldConfig={fieldConfig}
134
+ value={(formData as Record<string, unknown>)[fieldName]}
135
+ onChange={(value) => handleFieldChange(fieldName, value)}
136
+ error={errors[fieldName]}
137
+ disabled={isPending}
138
+ mode="edit"
139
+ relationshipItems={relationshipData[fieldName] || []}
140
+ relationshipLoading={false}
141
+ />
142
+ ))}
143
+ </div>
144
+
145
+ {/* Form Actions */}
146
+ <div className="flex gap-3 pt-6 mt-6 border-t border-border">
147
+ <Button type="submit" disabled={isPending} className="gap-2">
148
+ {isPending && (
149
+ <LoadingSpinner size="sm" className="border-primary-foreground border-t-transparent" />
150
+ )}
151
+ {isPending ? 'Creating...' : submitLabel}
152
+ </Button>
153
+ {onCancel && (
154
+ <Button type="button" variant="secondary" onClick={onCancel} disabled={isPending}>
155
+ {cancelLabel}
156
+ </Button>
157
+ )}
158
+ </div>
159
+ </form>
160
+ )
161
+ }
@@ -0,0 +1,193 @@
1
+ 'use client'
2
+
3
+ import * as React from 'react'
4
+ import { useState, useMemo } from 'react'
5
+ import { FieldRenderer } from '../fields/FieldRenderer.js'
6
+ import { LoadingSpinner } from '../LoadingSpinner.js'
7
+ import { Button } from '../../primitives/button.js'
8
+ import type { FieldConfig } from '@opensaas/stack-core'
9
+ import { serializeFieldConfigs } from '../../lib/serializeFieldConfig.js'
10
+
11
+ export interface ItemEditFormProps<TData = Record<string, unknown>> {
12
+ fields: Record<string, FieldConfig>
13
+ initialData: TData
14
+ onSubmit: (data: TData) => Promise<{ success: boolean; error?: string }>
15
+ onCancel?: () => void
16
+ relationshipData?: Record<string, Array<{ id: string; label: string }>>
17
+ submitLabel?: string
18
+ cancelLabel?: string
19
+ className?: string
20
+ basePath?: string
21
+ }
22
+
23
+ /**
24
+ * Standalone form component for editing items
25
+ * Can be embedded in any custom page
26
+ *
27
+ * @example
28
+ * ```tsx
29
+ * <ItemEditForm
30
+ * fields={config.lists.Post.fields}
31
+ * initialData={post}
32
+ * onSubmit={async (data) => {
33
+ * const result = await updatePost(postId, data);
34
+ * return { success: !!result };
35
+ * }}
36
+ * onCancel={() => router.back()}
37
+ * />
38
+ * ```
39
+ */
40
+ export function ItemEditForm<TData = Record<string, unknown>>({
41
+ fields,
42
+ initialData,
43
+ onSubmit,
44
+ onCancel,
45
+ relationshipData = {},
46
+ submitLabel = 'Save',
47
+ cancelLabel = 'Cancel',
48
+ className,
49
+ basePath = '/admin',
50
+ }: ItemEditFormProps<TData>) {
51
+ // Serialize field configs to remove non-serializable properties
52
+ const serializedFields = useMemo(() => serializeFieldConfigs(fields), [fields])
53
+
54
+ // Apply valueForClientSerialization transformations to initial data
55
+ const transformedInitialData = useMemo(() => {
56
+ const transformed = { ...initialData }
57
+ for (const [fieldName, fieldConfig] of Object.entries(fields)) {
58
+ const fieldConfigAny = fieldConfig as { ui?: Record<string, unknown> }
59
+ if (
60
+ fieldConfigAny.ui?.valueForClientSerialization &&
61
+ typeof fieldConfigAny.ui.valueForClientSerialization === 'function'
62
+ ) {
63
+ const transformer = fieldConfigAny.ui.valueForClientSerialization as (args: {
64
+ value: unknown
65
+ }) => unknown
66
+ transformed[fieldName as keyof TData] = transformer({
67
+ value: transformed[fieldName as keyof TData],
68
+ }) as TData[keyof TData]
69
+ }
70
+ }
71
+ return transformed
72
+ }, [initialData, fields])
73
+
74
+ const [isPending, setIsPending] = useState(false)
75
+ const [formData, setFormData] = useState<TData>(transformedInitialData)
76
+ const [errors, setErrors] = useState<Record<string, string>>({})
77
+ const [generalError, setGeneralError] = useState<string | null>(null)
78
+
79
+ const handleFieldChange = (fieldName: string, value: unknown) => {
80
+ setFormData((prev) => ({ ...prev, [fieldName]: value }) as TData)
81
+ // Clear error for this field when user starts typing
82
+ if (errors[fieldName]) {
83
+ setErrors((prev) => {
84
+ const newErrors = { ...prev }
85
+ delete newErrors[fieldName]
86
+ return newErrors
87
+ })
88
+ }
89
+ }
90
+
91
+ const handleSubmit = async (e: React.FormEvent) => {
92
+ e.preventDefault()
93
+ setErrors({})
94
+ setGeneralError(null)
95
+ setIsPending(true)
96
+
97
+ try {
98
+ // Transform relationship fields to Prisma format
99
+ // Filter out password fields with isSet objects (unchanged passwords)
100
+ const transformedData: Record<string, unknown> = {}
101
+ for (const [fieldName, value] of Object.entries(formData as Record<string, unknown>)) {
102
+ const fieldConfig = serializedFields[fieldName]
103
+
104
+ // Skip password fields that have { isSet: boolean } value (not being changed)
105
+ if (typeof value === 'object' && value !== null && 'isSet' in value) {
106
+ continue
107
+ }
108
+
109
+ // Transform relationship fields
110
+ if (fieldConfig?.type === 'relationship') {
111
+ if (fieldConfig.many) {
112
+ // Many relationship: use connect format
113
+ if (Array.isArray(value) && value.length > 0) {
114
+ transformedData[fieldName] = {
115
+ connect: value.map((id: string) => ({ id })),
116
+ }
117
+ }
118
+ } else {
119
+ // Single relationship: use connect format
120
+ if (value) {
121
+ transformedData[fieldName] = {
122
+ connect: { id: value },
123
+ }
124
+ }
125
+ }
126
+ } else {
127
+ // Non-relationship field: pass through
128
+ transformedData[fieldName] = value
129
+ }
130
+ }
131
+
132
+ const result = await onSubmit(transformedData as TData)
133
+
134
+ if (!result.success) {
135
+ setGeneralError(result.error || 'Failed to update item')
136
+ }
137
+ } catch (error: unknown) {
138
+ setGeneralError((error as Error).message || 'Failed to update item')
139
+ } finally {
140
+ setIsPending(false)
141
+ }
142
+ }
143
+
144
+ // Filter out system fields
145
+ const editableFields = Object.entries(serializedFields).filter(
146
+ ([key]) => !['id', 'createdAt', 'updatedAt'].includes(key),
147
+ )
148
+
149
+ return (
150
+ <form onSubmit={handleSubmit} className={className}>
151
+ {/* General Error */}
152
+ {generalError && (
153
+ <div className="bg-destructive/10 border border-destructive text-destructive rounded-lg p-4 mb-6">
154
+ <p className="text-sm font-medium">{generalError}</p>
155
+ </div>
156
+ )}
157
+
158
+ {/* Form Fields */}
159
+ <div className="space-y-6">
160
+ {editableFields.map(([fieldName, fieldConfig]) => (
161
+ <FieldRenderer
162
+ key={fieldName}
163
+ fieldName={fieldName}
164
+ fieldConfig={fieldConfig}
165
+ value={(formData as Record<string, unknown>)[fieldName]}
166
+ onChange={(value) => handleFieldChange(fieldName, value)}
167
+ error={errors[fieldName]}
168
+ disabled={isPending}
169
+ mode="edit"
170
+ relationshipItems={relationshipData[fieldName] || []}
171
+ relationshipLoading={false}
172
+ basePath={basePath}
173
+ />
174
+ ))}
175
+ </div>
176
+
177
+ {/* Form Actions */}
178
+ <div className="flex gap-3 pt-6 mt-6 border-t border-border">
179
+ <Button type="submit" disabled={isPending} className="gap-2">
180
+ {isPending && (
181
+ <LoadingSpinner size="sm" className="border-primary-foreground border-t-transparent" />
182
+ )}
183
+ {isPending ? 'Saving...' : submitLabel}
184
+ </Button>
185
+ {onCancel && (
186
+ <Button type="button" variant="secondary" onClick={onCancel} disabled={isPending}>
187
+ {cancelLabel}
188
+ </Button>
189
+ )}
190
+ </div>
191
+ </form>
192
+ )
193
+ }
@@ -0,0 +1,211 @@
1
+ 'use client'
2
+ import * as React from 'react'
3
+ import { useState } from 'react'
4
+ import Link from 'next/link'
5
+ import { formatFieldName, getFieldDisplayValue } from '../../lib/utils.js'
6
+ import { getUrlKey } from '@opensaas/stack-core'
7
+ import {
8
+ Table,
9
+ TableBody,
10
+ TableCell,
11
+ TableHead,
12
+ TableHeader,
13
+ TableRow,
14
+ } from '../../primitives/table.js'
15
+
16
+ export interface ListTableProps {
17
+ items: Array<Record<string, unknown>>
18
+ fieldTypes: Record<string, string>
19
+ relationshipRefs?: Record<string, string>
20
+ basePath?: string
21
+ columns?: string[]
22
+ onRowClick?: (item: Record<string, unknown>) => void
23
+ sortable?: boolean
24
+ emptyMessage?: string
25
+ className?: string
26
+ renderActions?: (item: Record<string, unknown>) => React.ReactNode
27
+ }
28
+
29
+ /**
30
+ * Standalone table component for displaying list data
31
+ * Can be embedded in any custom page
32
+ *
33
+ * @example
34
+ * ```tsx
35
+ * <ListTable
36
+ * items={posts}
37
+ * fieldTypes={{ title: 'text', status: 'select', publishedAt: 'timestamp', author: 'relationship' }}
38
+ * relationshipRefs={{ author: 'User.posts' }}
39
+ * columns={['title', 'status', 'publishedAt', 'author']}
40
+ * onRowClick={(post) => router.push(`/posts/${post.id}`)}
41
+ * renderActions={(post) => (
42
+ * <Button onClick={() => deletePost(post.id)}>Delete</Button>
43
+ * )}
44
+ * />
45
+ * ```
46
+ */
47
+ export function ListTable({
48
+ items,
49
+ fieldTypes,
50
+ relationshipRefs,
51
+ basePath = '/admin',
52
+ columns,
53
+ onRowClick,
54
+ sortable = true,
55
+ emptyMessage = 'No items found',
56
+ className,
57
+ renderActions,
58
+ }: ListTableProps) {
59
+ const [sortBy, setSortBy] = useState<string | null>(null)
60
+ const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc')
61
+
62
+ /**
63
+ * Render a relationship field as a clickable link or links
64
+ */
65
+ const renderRelationshipCell = (value: unknown, fieldName: string) => {
66
+ if (!relationshipRefs) {
67
+ return getFieldDisplayValue(value, 'relationship')
68
+ }
69
+
70
+ const ref = relationshipRefs[fieldName]
71
+ if (!ref) {
72
+ return getFieldDisplayValue(value, 'relationship')
73
+ }
74
+
75
+ // Parse ref to get related list name
76
+ const [relatedListKey] = ref.split('.')
77
+ const relatedUrlKey = getUrlKey(relatedListKey)
78
+
79
+ if (!value || typeof value !== 'object') {
80
+ return <span className="text-muted-foreground">-</span>
81
+ }
82
+
83
+ // Handle array of relationships (many: true)
84
+ if (Array.isArray(value)) {
85
+ if (value.length === 0) return <span className="text-muted-foreground">-</span>
86
+ return (
87
+ <span className="flex flex-wrap gap-1">
88
+ {value.map((item, idx) => {
89
+ if (!item || typeof item !== 'object') return null
90
+ const displayValue = getFieldDisplayValue(item, 'relationship')
91
+ const itemId = 'id' in item ? item.id : null
92
+ const key = itemId || idx
93
+ return (
94
+ <React.Fragment key={key}>
95
+ {idx > 0 && <span className="text-muted-foreground">, </span>}
96
+ <Link
97
+ href={`${basePath}/${relatedUrlKey}/${itemId}`}
98
+ className="text-primary hover:underline"
99
+ onClick={(e) => e.stopPropagation()}
100
+ >
101
+ {displayValue}
102
+ </Link>
103
+ </React.Fragment>
104
+ )
105
+ })}
106
+ </span>
107
+ )
108
+ }
109
+
110
+ // Handle single relationship
111
+ const itemId = 'id' in value ? value.id : null
112
+ const displayValue = getFieldDisplayValue(value, 'relationship')
113
+ return (
114
+ <Link
115
+ href={`${basePath}/${relatedUrlKey}/${itemId}`}
116
+ className="text-primary hover:underline"
117
+ onClick={(e) => e.stopPropagation()}
118
+ >
119
+ {displayValue}
120
+ </Link>
121
+ )
122
+ }
123
+
124
+ // Determine which columns to show
125
+ const displayColumns =
126
+ columns ||
127
+ Object.keys(fieldTypes).filter((key) => !['password', 'createdAt', 'updatedAt'].includes(key))
128
+
129
+ // Sort items if needed
130
+ const sortedItems = [...items]
131
+ if (sortBy && sortable) {
132
+ sortedItems.sort((a, b) => {
133
+ const aVal = a[sortBy]
134
+ const bVal = b[sortBy]
135
+ if (aVal === bVal) return 0
136
+ const comparison = String(aVal) > String(bVal) ? 1 : -1
137
+ return sortOrder === 'asc' ? comparison : -comparison
138
+ })
139
+ }
140
+
141
+ const handleSort = (column: string) => {
142
+ if (!sortable) return
143
+ if (sortBy === column) {
144
+ setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc')
145
+ } else {
146
+ setSortBy(column)
147
+ setSortOrder('asc')
148
+ }
149
+ }
150
+
151
+ return (
152
+ <div className={className}>
153
+ <div className="rounded-lg border">
154
+ <Table>
155
+ <TableHeader>
156
+ <TableRow>
157
+ {displayColumns.map((column) => (
158
+ <TableHead
159
+ key={column}
160
+ className={sortable ? 'cursor-pointer hover:bg-muted/70 transition-colors' : ''}
161
+ onClick={() => handleSort(column)}
162
+ >
163
+ <div className="flex items-center space-x-1">
164
+ <span>{formatFieldName(column)}</span>
165
+ {sortable && sortBy === column && (
166
+ <span className="text-primary">{sortOrder === 'asc' ? '↑' : '↓'}</span>
167
+ )}
168
+ </div>
169
+ </TableHead>
170
+ ))}
171
+ {renderActions && <TableHead className="text-right">Actions</TableHead>}
172
+ </TableRow>
173
+ </TableHeader>
174
+ <TableBody>
175
+ {sortedItems.length === 0 ? (
176
+ <TableRow>
177
+ <TableCell
178
+ colSpan={displayColumns.length + (renderActions ? 1 : 0)}
179
+ className="h-24 text-center"
180
+ >
181
+ {emptyMessage}
182
+ </TableCell>
183
+ </TableRow>
184
+ ) : (
185
+ sortedItems.map((item) => (
186
+ <TableRow
187
+ key={String(item.id)}
188
+ className={onRowClick ? 'cursor-pointer' : ''}
189
+ onClick={() => onRowClick?.(item)}
190
+ >
191
+ {displayColumns.map((column) => (
192
+ <TableCell key={column}>
193
+ {fieldTypes[column] === 'relationship'
194
+ ? renderRelationshipCell(item[column], column)
195
+ : getFieldDisplayValue(item[column], fieldTypes[column])}
196
+ </TableCell>
197
+ ))}
198
+ {renderActions && (
199
+ <TableCell className="text-right" onClick={(e) => e.stopPropagation()}>
200
+ {renderActions(item)}
201
+ </TableCell>
202
+ )}
203
+ </TableRow>
204
+ ))
205
+ )}
206
+ </TableBody>
207
+ </Table>
208
+ </div>
209
+ </div>
210
+ )
211
+ }