@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,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
|
+
}
|