@opensaas/stack-ui 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +8 -0
- package/README.md +286 -0
- package/dist/components/AdminUI.d.ts +24 -0
- package/dist/components/AdminUI.d.ts.map +1 -0
- package/dist/components/AdminUI.js +48 -0
- package/dist/components/ConfirmDialog.d.ts +16 -0
- package/dist/components/ConfirmDialog.d.ts.map +1 -0
- package/dist/components/ConfirmDialog.js +11 -0
- package/dist/components/Dashboard.d.ts +12 -0
- package/dist/components/Dashboard.d.ts.map +1 -0
- package/dist/components/Dashboard.js +30 -0
- package/dist/components/ItemForm.d.ts +17 -0
- package/dist/components/ItemForm.d.ts.map +1 -0
- package/dist/components/ItemForm.js +97 -0
- package/dist/components/ItemFormClient.d.ts +22 -0
- package/dist/components/ItemFormClient.d.ts.map +1 -0
- package/dist/components/ItemFormClient.js +127 -0
- package/dist/components/ListView.d.ts +17 -0
- package/dist/components/ListView.d.ts.map +1 -0
- package/dist/components/ListView.js +76 -0
- package/dist/components/ListViewClient.d.ts +19 -0
- package/dist/components/ListViewClient.d.ts.map +1 -0
- package/dist/components/ListViewClient.js +108 -0
- package/dist/components/LoadingSpinner.d.ts +10 -0
- package/dist/components/LoadingSpinner.d.ts.map +1 -0
- package/dist/components/LoadingSpinner.js +14 -0
- package/dist/components/Navigation.d.ts +13 -0
- package/dist/components/Navigation.d.ts.map +1 -0
- package/dist/components/Navigation.js +20 -0
- package/dist/components/SkeletonLoader.d.ts +22 -0
- package/dist/components/SkeletonLoader.d.ts.map +1 -0
- package/dist/components/SkeletonLoader.js +25 -0
- package/dist/components/fields/CheckboxField.d.ts +11 -0
- package/dist/components/fields/CheckboxField.d.ts.map +1 -0
- package/dist/components/fields/CheckboxField.js +10 -0
- package/dist/components/fields/ComboboxField.d.ts +18 -0
- package/dist/components/fields/ComboboxField.d.ts.map +1 -0
- package/dist/components/fields/ComboboxField.js +32 -0
- package/dist/components/fields/FieldRenderer.d.ts +22 -0
- package/dist/components/fields/FieldRenderer.d.ts.map +1 -0
- package/dist/components/fields/FieldRenderer.js +81 -0
- package/dist/components/fields/IntegerField.d.ts +15 -0
- package/dist/components/fields/IntegerField.d.ts.map +1 -0
- package/dist/components/fields/IntegerField.js +14 -0
- package/dist/components/fields/PasswordField.d.ts +18 -0
- package/dist/components/fields/PasswordField.d.ts.map +1 -0
- package/dist/components/fields/PasswordField.js +42 -0
- package/dist/components/fields/RelationshipField.d.ts +20 -0
- package/dist/components/fields/RelationshipField.d.ts.map +1 -0
- package/dist/components/fields/RelationshipField.js +11 -0
- package/dist/components/fields/RelationshipManager.d.ts +19 -0
- package/dist/components/fields/RelationshipManager.d.ts.map +1 -0
- package/dist/components/fields/RelationshipManager.js +37 -0
- package/dist/components/fields/SelectField.d.ts +16 -0
- package/dist/components/fields/SelectField.d.ts.map +1 -0
- package/dist/components/fields/SelectField.js +11 -0
- package/dist/components/fields/TextField.d.ts +13 -0
- package/dist/components/fields/TextField.d.ts.map +1 -0
- package/dist/components/fields/TextField.js +11 -0
- package/dist/components/fields/TimestampField.d.ts +12 -0
- package/dist/components/fields/TimestampField.d.ts.map +1 -0
- package/dist/components/fields/TimestampField.js +12 -0
- package/dist/components/fields/index.d.ts +23 -0
- package/dist/components/fields/index.d.ts.map +1 -0
- package/dist/components/fields/index.js +13 -0
- package/dist/components/fields/registry.d.ts +43 -0
- package/dist/components/fields/registry.d.ts.map +1 -0
- package/dist/components/fields/registry.js +42 -0
- package/dist/components/standalone/DeleteButton.d.ts +35 -0
- package/dist/components/standalone/DeleteButton.d.ts.map +1 -0
- package/dist/components/standalone/DeleteButton.js +46 -0
- package/dist/components/standalone/ItemCreateForm.d.ts +34 -0
- package/dist/components/standalone/ItemCreateForm.d.ts.map +1 -0
- package/dist/components/standalone/ItemCreateForm.js +91 -0
- package/dist/components/standalone/ItemEditForm.d.ts +37 -0
- package/dist/components/standalone/ItemEditForm.d.ts.map +1 -0
- package/dist/components/standalone/ItemEditForm.js +112 -0
- package/dist/components/standalone/ListTable.d.ts +33 -0
- package/dist/components/standalone/ListTable.d.ts.map +1 -0
- package/dist/components/standalone/ListTable.js +94 -0
- package/dist/components/standalone/SearchBar.d.ts +29 -0
- package/dist/components/standalone/SearchBar.d.ts.map +1 -0
- package/dist/components/standalone/SearchBar.js +43 -0
- package/dist/components/standalone/index.d.ts +11 -0
- package/dist/components/standalone/index.d.ts.map +1 -0
- package/dist/components/standalone/index.js +6 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +19 -0
- package/dist/lib/serializeFieldConfig.d.ts +43 -0
- package/dist/lib/serializeFieldConfig.d.ts.map +1 -0
- package/dist/lib/serializeFieldConfig.js +48 -0
- package/dist/lib/theme.d.ts +17 -0
- package/dist/lib/theme.d.ts.map +1 -0
- package/dist/lib/theme.js +192 -0
- package/dist/lib/utils.d.ts +18 -0
- package/dist/lib/utils.d.ts.map +1 -0
- package/dist/lib/utils.js +76 -0
- package/dist/primitives/button.d.ts +12 -0
- package/dist/primitives/button.d.ts.map +1 -0
- package/dist/primitives/button.js +33 -0
- package/dist/primitives/calendar.d.ts +9 -0
- package/dist/primitives/calendar.d.ts.map +1 -0
- package/dist/primitives/calendar.js +48 -0
- package/dist/primitives/card.d.ts +9 -0
- package/dist/primitives/card.d.ts.map +1 -0
- package/dist/primitives/card.js +16 -0
- package/dist/primitives/checkbox.d.ts +5 -0
- package/dist/primitives/checkbox.d.ts.map +1 -0
- package/dist/primitives/checkbox.js +7 -0
- package/dist/primitives/combobox.d.ts +14 -0
- package/dist/primitives/combobox.d.ts.map +1 -0
- package/dist/primitives/combobox.js +20 -0
- package/dist/primitives/datetime-picker.d.ts +9 -0
- package/dist/primitives/datetime-picker.d.ts.map +1 -0
- package/dist/primitives/datetime-picker.js +42 -0
- package/dist/primitives/dialog.d.ts +20 -0
- package/dist/primitives/dialog.d.ts.map +1 -0
- package/dist/primitives/dialog.js +21 -0
- package/dist/primitives/index.d.ts +14 -0
- package/dist/primitives/index.d.ts.map +1 -0
- package/dist/primitives/index.js +14 -0
- package/dist/primitives/input.d.ts +5 -0
- package/dist/primitives/input.d.ts.map +1 -0
- package/dist/primitives/input.js +8 -0
- package/dist/primitives/label.d.ts +6 -0
- package/dist/primitives/label.d.ts.map +1 -0
- package/dist/primitives/label.js +9 -0
- package/dist/primitives/popover.d.ts +7 -0
- package/dist/primitives/popover.d.ts.map +1 -0
- package/dist/primitives/popover.js +10 -0
- package/dist/primitives/select.d.ts +14 -0
- package/dist/primitives/select.d.ts.map +1 -0
- package/dist/primitives/select.js +24 -0
- package/dist/primitives/table.d.ts +11 -0
- package/dist/primitives/table.d.ts.map +1 -0
- package/dist/primitives/table.js +20 -0
- package/dist/primitives/time-picker.d.ts +8 -0
- package/dist/primitives/time-picker.d.ts.map +1 -0
- package/dist/primitives/time-picker.js +27 -0
- package/dist/server/index.d.ts +2 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +2 -0
- package/dist/server/types.d.ts +15 -0
- package/dist/server/types.d.ts.map +1 -0
- package/dist/server/types.js +1 -0
- package/dist/styles/globals.css +1896 -0
- package/package.json +91 -0
- package/postcss.config.cjs +5 -0
- package/src/components/AdminUI.tsx +112 -0
- package/src/components/ConfirmDialog.tsx +56 -0
- package/src/components/Dashboard.tsx +134 -0
- package/src/components/ItemForm.tsx +195 -0
- package/src/components/ItemFormClient.tsx +237 -0
- package/src/components/ListView.tsx +153 -0
- package/src/components/ListViewClient.tsx +282 -0
- package/src/components/LoadingSpinner.tsx +32 -0
- package/src/components/Navigation.tsx +117 -0
- package/src/components/SkeletonLoader.tsx +82 -0
- package/src/components/fields/CheckboxField.tsx +54 -0
- package/src/components/fields/ComboboxField.tsx +127 -0
- package/src/components/fields/FieldRenderer.tsx +132 -0
- package/src/components/fields/IntegerField.tsx +68 -0
- package/src/components/fields/PasswordField.tsx +159 -0
- package/src/components/fields/RelationshipField.tsx +71 -0
- package/src/components/fields/RelationshipManager.tsx +189 -0
- package/src/components/fields/SelectField.tsx +71 -0
- package/src/components/fields/TextField.tsx +59 -0
- package/src/components/fields/TimestampField.tsx +49 -0
- package/src/components/fields/index.ts +27 -0
- package/src/components/fields/registry.ts +72 -0
- package/src/components/standalone/DeleteButton.tsx +114 -0
- package/src/components/standalone/ItemCreateForm.tsx +161 -0
- package/src/components/standalone/ItemEditForm.tsx +193 -0
- package/src/components/standalone/ListTable.tsx +211 -0
- package/src/components/standalone/SearchBar.tsx +86 -0
- package/src/components/standalone/index.ts +13 -0
- package/src/index.ts +74 -0
- package/src/lib/serializeFieldConfig.ts +88 -0
- package/src/lib/theme.ts +202 -0
- package/src/lib/utils.ts +81 -0
- package/src/primitives/button.tsx +49 -0
- package/src/primitives/calendar.tsx +160 -0
- package/src/primitives/card.tsx +58 -0
- package/src/primitives/checkbox.tsx +27 -0
- package/src/primitives/combobox.tsx +159 -0
- package/src/primitives/datetime-picker.tsx +130 -0
- package/src/primitives/dialog.tsx +108 -0
- package/src/primitives/index.ts +54 -0
- package/src/primitives/input.tsx +24 -0
- package/src/primitives/label.tsx +19 -0
- package/src/primitives/popover.tsx +31 -0
- package/src/primitives/select.tsx +158 -0
- package/src/primitives/table.tsx +91 -0
- package/src/primitives/time-picker.tsx +65 -0
- package/src/server/index.ts +3 -0
- package/src/server/types.ts +15 -0
- package/src/styles/globals.css +123 -0
- package/tailwind.config.ts +3 -0
- package/tests/components/TextField.test.tsx +94 -0
- package/tests/setup.ts +11 -0
- package/tsconfig.json +26 -0
- package/vitest.config.ts +22 -0
|
@@ -0,0 +1,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 "{listKey}" 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
|
+
}
|