@hypersonic-js/admin 0.2.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/LICENSE +21 -0
- package/dist/constants.d.ts +6 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +6 -0
- package/dist/constants.js.map +1 -0
- package/dist/crud/pagination.d.ts +11 -0
- package/dist/crud/pagination.d.ts.map +1 -0
- package/dist/crud/pagination.js +31 -0
- package/dist/crud/pagination.js.map +1 -0
- package/dist/crud/query.d.ts +64 -0
- package/dist/crud/query.d.ts.map +1 -0
- package/dist/crud/query.js +137 -0
- package/dist/crud/query.js.map +1 -0
- package/dist/crud/router.d.ts +49 -0
- package/dist/crud/router.d.ts.map +1 -0
- package/dist/crud/router.js +335 -0
- package/dist/crud/router.js.map +1 -0
- package/dist/dmmf/fields.d.ts +37 -0
- package/dist/dmmf/fields.d.ts.map +1 -0
- package/dist/dmmf/fields.js +88 -0
- package/dist/dmmf/fields.js.map +1 -0
- package/dist/dmmf/parser.d.ts +15 -0
- package/dist/dmmf/parser.d.ts.map +1 -0
- package/dist/dmmf/parser.js +54 -0
- package/dist/dmmf/parser.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -0
- package/dist/middleware/auth.d.ts +8 -0
- package/dist/middleware/auth.d.ts.map +1 -0
- package/dist/middleware/auth.js +21 -0
- package/dist/middleware/auth.js.map +1 -0
- package/dist/mount.d.ts +4 -0
- package/dist/mount.d.ts.map +1 -0
- package/dist/mount.js +21 -0
- package/dist/mount.js.map +1 -0
- package/dist/scaffold/index.d.ts +14 -0
- package/dist/scaffold/index.d.ts.map +1 -0
- package/dist/scaffold/index.js +35 -0
- package/dist/scaffold/index.js.map +1 -0
- package/dist/scaffold/templates.d.ts +19 -0
- package/dist/scaffold/templates.d.ts.map +1 -0
- package/dist/scaffold/templates.js +371 -0
- package/dist/scaffold/templates.js.map +1 -0
- package/dist/templates/Dashboard.tsx +40 -0
- package/dist/templates/ModelForm.tsx +288 -0
- package/dist/templates/ModelIndex.tsx +142 -0
- package/dist/templates/UserCreate.tsx +189 -0
- package/dist/types.d.ts +159 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/package.json +60 -0
- package/templates/Dashboard.tsx +40 -0
- package/templates/ModelForm.tsx +288 -0
- package/templates/ModelIndex.tsx +142 -0
- package/templates/UserCreate.tsx +189 -0
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import { useState, useRef } from 'react'
|
|
2
|
+
import { useForm, Link } from '@inertiajs/react'
|
|
3
|
+
|
|
4
|
+
type FieldKind = 'scalar' | 'relation' | 'enum'
|
|
5
|
+
|
|
6
|
+
interface FieldMeta {
|
|
7
|
+
name: string
|
|
8
|
+
prismaType: string
|
|
9
|
+
kind: FieldKind
|
|
10
|
+
isRequired: boolean
|
|
11
|
+
isForeignKey: boolean
|
|
12
|
+
relatedModelName?: string
|
|
13
|
+
relatedModelSlug?: string
|
|
14
|
+
enumValues?: string[]
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface ModelMeta {
|
|
18
|
+
name: string
|
|
19
|
+
urlSlug: string
|
|
20
|
+
displayName: string
|
|
21
|
+
idField: string
|
|
22
|
+
formFields: FieldMeta[]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface FkOption {
|
|
26
|
+
id: string
|
|
27
|
+
label: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type RelatedOptionsMap = Record<string, { options: FkOption[]; hasMore: boolean }>
|
|
31
|
+
|
|
32
|
+
interface FieldOptionsState {
|
|
33
|
+
options: FkOption[]
|
|
34
|
+
hasMore: boolean
|
|
35
|
+
page: number
|
|
36
|
+
loading: boolean
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface Props {
|
|
40
|
+
model: ModelMeta
|
|
41
|
+
record: Record<string, unknown> | null
|
|
42
|
+
models: Array<{ name: string; urlSlug: string }>
|
|
43
|
+
errors: Record<string, string>
|
|
44
|
+
prefix: string
|
|
45
|
+
relatedOptions: RelatedOptionsMap
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function toLocalDateTimeString(date: Date): string {
|
|
49
|
+
const pad = (n: number): string => String(n).padStart(2, '0')
|
|
50
|
+
return (
|
|
51
|
+
`${date.getFullYear()}-` +
|
|
52
|
+
`${pad(date.getMonth() + 1)}-` +
|
|
53
|
+
`${pad(date.getDate())}T` +
|
|
54
|
+
`${pad(date.getHours())}:` +
|
|
55
|
+
`${pad(date.getMinutes())}:` +
|
|
56
|
+
`${pad(date.getSeconds())}`
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function buildInitialData(
|
|
61
|
+
formFields: FieldMeta[],
|
|
62
|
+
record: Record<string, unknown> | null,
|
|
63
|
+
relatedOptions: RelatedOptionsMap = {},
|
|
64
|
+
): Record<string, string> {
|
|
65
|
+
return Object.fromEntries(
|
|
66
|
+
formFields.map((f) => {
|
|
67
|
+
const value = record?.[f.name]
|
|
68
|
+
if (value instanceof Date) return [f.name, toLocalDateTimeString(value)]
|
|
69
|
+
if (value !== null && value !== undefined) return [f.name, String(value)]
|
|
70
|
+
|
|
71
|
+
// New record — use type-aware defaults only for REQUIRED fields so that
|
|
72
|
+
// optional columns are left unset rather than silently written with a
|
|
73
|
+
// synthetic value the user never chose.
|
|
74
|
+
if (f.prismaType === 'Boolean' && f.isRequired) return [f.name, 'false']
|
|
75
|
+
if (f.kind === 'enum' && f.isRequired && f.enumValues !== undefined && f.enumValues.length > 0) {
|
|
76
|
+
return [f.name, f.enumValues[0]!]
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// For required FK fields on create forms, default to the first available
|
|
80
|
+
// option so the controlled <select> value matches what the browser renders
|
|
81
|
+
// as visually selected. Without this the select appears to have a valid
|
|
82
|
+
// selection but the underlying form value is '', which coerceData converts
|
|
83
|
+
// to undefined for required fields, causing a Prisma validation error.
|
|
84
|
+
if (f.isForeignKey && f.isRequired) {
|
|
85
|
+
const firstOption = relatedOptions[f.name]?.options[0]
|
|
86
|
+
if (firstOption !== undefined) {
|
|
87
|
+
return [f.name, String(firstOption.id)]
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return [f.name, '']
|
|
92
|
+
}),
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export default function AdminModelForm({ model, record, errors, prefix, relatedOptions }: Props) {
|
|
97
|
+
const isEdit = record !== null
|
|
98
|
+
const { data, setData, post, patch, processing } = useForm(
|
|
99
|
+
buildInitialData(model.formFields, record, relatedOptions),
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
const [fkOptions, setFkOptions] = useState<Record<string, FieldOptionsState>>(() =>
|
|
103
|
+
Object.fromEntries(
|
|
104
|
+
Object.entries(relatedOptions).map(([key, val]) => [
|
|
105
|
+
key,
|
|
106
|
+
{ options: val.options, hasMore: val.hasMore, page: 1, loading: false },
|
|
107
|
+
]),
|
|
108
|
+
),
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
// Ref-based guard prevents duplicate in-flight requests for the same field.
|
|
112
|
+
// A ref is used (not state) so the guard is updated synchronously — two rapid
|
|
113
|
+
// clicks both read the ref before any setState is committed, ensuring only
|
|
114
|
+
// the first click proceeds.
|
|
115
|
+
const inflight = useRef(new Set<string>())
|
|
116
|
+
|
|
117
|
+
async function loadMore(fieldName: string, relatedModelSlug: string): Promise<void> {
|
|
118
|
+
if (inflight.current.has(fieldName)) return
|
|
119
|
+
|
|
120
|
+
const current = fkOptions[fieldName]
|
|
121
|
+
if (current === undefined) return
|
|
122
|
+
|
|
123
|
+
inflight.current.add(fieldName)
|
|
124
|
+
setFkOptions((prev) => ({
|
|
125
|
+
...prev,
|
|
126
|
+
[fieldName]: { ...prev[fieldName]!, loading: true },
|
|
127
|
+
}))
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
const nextPage = current.page + 1
|
|
131
|
+
const res = await fetch(`${prefix}/related-options/${relatedModelSlug}?page=${nextPage}`)
|
|
132
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
133
|
+
const payload = (await res.json()) as { options: FkOption[]; hasMore: boolean }
|
|
134
|
+
setFkOptions((prev) => ({
|
|
135
|
+
...prev,
|
|
136
|
+
[fieldName]: {
|
|
137
|
+
options: [...prev[fieldName]!.options, ...payload.options],
|
|
138
|
+
hasMore: payload.hasMore,
|
|
139
|
+
page: nextPage,
|
|
140
|
+
loading: false,
|
|
141
|
+
},
|
|
142
|
+
}))
|
|
143
|
+
} catch {
|
|
144
|
+
setFkOptions((prev) => ({
|
|
145
|
+
...prev,
|
|
146
|
+
[fieldName]: { ...prev[fieldName]!, loading: false },
|
|
147
|
+
}))
|
|
148
|
+
} finally {
|
|
149
|
+
inflight.current.delete(fieldName)
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function handleSubmit(e: React.FormEvent) {
|
|
154
|
+
e.preventDefault()
|
|
155
|
+
if (isEdit) {
|
|
156
|
+
patch(`${prefix}/${model.urlSlug}/${String(record![model.idField])}`)
|
|
157
|
+
} else {
|
|
158
|
+
post(`${prefix}/${model.urlSlug}`)
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function renderInput(field: FieldMeta) {
|
|
163
|
+
const value = data[field.name] ?? ''
|
|
164
|
+
const error = errors[field.name]
|
|
165
|
+
const baseClass = 'w-full border rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500'
|
|
166
|
+
const errorClass = error ? ' border-red-500' : ' border-gray-300'
|
|
167
|
+
|
|
168
|
+
if (field.isForeignKey) {
|
|
169
|
+
const state = fkOptions[field.name] ?? { options: [], hasMore: false, page: 1, loading: false }
|
|
170
|
+
return (
|
|
171
|
+
<div>
|
|
172
|
+
<select
|
|
173
|
+
value={value}
|
|
174
|
+
onChange={(e) => setData(field.name, e.target.value)}
|
|
175
|
+
className={baseClass + errorClass}
|
|
176
|
+
>
|
|
177
|
+
{!field.isRequired && <option value="">— select —</option>}
|
|
178
|
+
{state.options.map((opt) => (
|
|
179
|
+
<option key={String(opt.id)} value={String(opt.id)}>{opt.label}</option>
|
|
180
|
+
))}
|
|
181
|
+
</select>
|
|
182
|
+
{state.hasMore && (
|
|
183
|
+
<button
|
|
184
|
+
type="button"
|
|
185
|
+
onClick={() => void loadMore(field.name, field.relatedModelSlug!)}
|
|
186
|
+
disabled={state.loading}
|
|
187
|
+
className="mt-1 text-xs text-blue-600 hover:underline disabled:opacity-50"
|
|
188
|
+
>
|
|
189
|
+
{state.loading ? 'Loading…' : 'Load more'}
|
|
190
|
+
</button>
|
|
191
|
+
)}
|
|
192
|
+
</div>
|
|
193
|
+
)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (field.kind === 'enum' && field.enumValues) {
|
|
197
|
+
return (
|
|
198
|
+
<select
|
|
199
|
+
value={value}
|
|
200
|
+
onChange={(e) => setData(field.name, e.target.value)}
|
|
201
|
+
className={baseClass + errorClass}
|
|
202
|
+
>
|
|
203
|
+
{!field.isRequired && <option value="">— select —</option>}
|
|
204
|
+
{field.enumValues.map((v) => (
|
|
205
|
+
<option key={v} value={v}>{v}</option>
|
|
206
|
+
))}
|
|
207
|
+
</select>
|
|
208
|
+
)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (field.prismaType === 'Boolean') {
|
|
212
|
+
return (
|
|
213
|
+
<input
|
|
214
|
+
type="checkbox"
|
|
215
|
+
checked={value === 'true'}
|
|
216
|
+
onChange={(e) => setData(field.name, String(e.target.checked))}
|
|
217
|
+
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
|
|
218
|
+
/>
|
|
219
|
+
)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const inputType =
|
|
223
|
+
field.prismaType === 'Int' || field.prismaType === 'Float'
|
|
224
|
+
? 'number'
|
|
225
|
+
: field.prismaType === 'DateTime'
|
|
226
|
+
? 'datetime-local'
|
|
227
|
+
: 'text'
|
|
228
|
+
|
|
229
|
+
return (
|
|
230
|
+
<input
|
|
231
|
+
type={inputType}
|
|
232
|
+
value={value}
|
|
233
|
+
onChange={(e) => setData(field.name, e.target.value)}
|
|
234
|
+
required={field.isRequired}
|
|
235
|
+
className={baseClass + errorClass}
|
|
236
|
+
/>
|
|
237
|
+
)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return (
|
|
241
|
+
<div className="min-h-screen bg-gray-50 p-8">
|
|
242
|
+
<div className="max-w-2xl mx-auto">
|
|
243
|
+
<div className="flex items-center gap-4 mb-6">
|
|
244
|
+
<Link
|
|
245
|
+
href={`${prefix}/${model.urlSlug}`}
|
|
246
|
+
className="text-blue-600 hover:underline text-sm"
|
|
247
|
+
>
|
|
248
|
+
← {model.displayName}
|
|
249
|
+
</Link>
|
|
250
|
+
<h1 className="text-2xl font-bold text-gray-900">
|
|
251
|
+
{isEdit ? `Edit ${model.name}` : `New ${model.name}`}
|
|
252
|
+
</h1>
|
|
253
|
+
</div>
|
|
254
|
+
|
|
255
|
+
<form onSubmit={handleSubmit} className="bg-white rounded-lg border border-gray-200 p-6 space-y-5">
|
|
256
|
+
{model.formFields.map((field) => (
|
|
257
|
+
<div key={field.name}>
|
|
258
|
+
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
259
|
+
{field.name}
|
|
260
|
+
{field.isRequired && <span className="text-red-500 ml-1">*</span>}
|
|
261
|
+
</label>
|
|
262
|
+
{renderInput(field)}
|
|
263
|
+
{errors[field.name] && (
|
|
264
|
+
<p className="mt-1 text-xs text-red-600">{errors[field.name]}</p>
|
|
265
|
+
)}
|
|
266
|
+
</div>
|
|
267
|
+
))}
|
|
268
|
+
|
|
269
|
+
<div className="flex items-center gap-3 pt-2">
|
|
270
|
+
<button
|
|
271
|
+
type="submit"
|
|
272
|
+
disabled={processing}
|
|
273
|
+
className="bg-blue-600 text-white px-5 py-2 rounded-md hover:bg-blue-700 disabled:opacity-50"
|
|
274
|
+
>
|
|
275
|
+
{processing ? 'Saving…' : isEdit ? 'Update' : 'Create'}
|
|
276
|
+
</button>
|
|
277
|
+
<Link
|
|
278
|
+
href={`${prefix}/${model.urlSlug}`}
|
|
279
|
+
className="text-gray-600 hover:underline text-sm"
|
|
280
|
+
>
|
|
281
|
+
Cancel
|
|
282
|
+
</Link>
|
|
283
|
+
</div>
|
|
284
|
+
</form>
|
|
285
|
+
</div>
|
|
286
|
+
</div>
|
|
287
|
+
)
|
|
288
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { Link, router } from '@inertiajs/react'
|
|
2
|
+
|
|
3
|
+
interface FieldMeta {
|
|
4
|
+
name: string
|
|
5
|
+
isId: boolean
|
|
6
|
+
prismaType: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface ModelMeta {
|
|
10
|
+
name: string
|
|
11
|
+
urlSlug: string
|
|
12
|
+
displayName: string
|
|
13
|
+
idField: string
|
|
14
|
+
listFields: FieldMeta[]
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface PaginationMeta {
|
|
18
|
+
page: number
|
|
19
|
+
perPage: number
|
|
20
|
+
total: number
|
|
21
|
+
totalPages: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface Props {
|
|
25
|
+
model: ModelMeta
|
|
26
|
+
records: Record<string, unknown>[]
|
|
27
|
+
pagination: PaginationMeta
|
|
28
|
+
models: Array<{ name: string; urlSlug: string }>
|
|
29
|
+
prefix: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function displayValue(value: unknown, prismaType: string): string {
|
|
33
|
+
if (value === null || value === undefined) return '—'
|
|
34
|
+
if (prismaType === 'DateTime') {
|
|
35
|
+
const date = value instanceof Date ? value : new Date(String(value))
|
|
36
|
+
return date.toLocaleString()
|
|
37
|
+
}
|
|
38
|
+
if (value instanceof Date) return value.toLocaleString()
|
|
39
|
+
if (typeof value === 'object') return JSON.stringify(value)
|
|
40
|
+
return String(value)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export default function AdminModelIndex({ model, records, pagination, prefix }: Props) {
|
|
44
|
+
function handleDelete(id: unknown) {
|
|
45
|
+
if (window.confirm(`Delete this ${model.name}?`)) {
|
|
46
|
+
router.delete(`${prefix}/${model.urlSlug}/${String(id)}`)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<div className="min-h-screen bg-gray-50 p-8">
|
|
52
|
+
<div className="max-w-6xl mx-auto">
|
|
53
|
+
<div className="flex items-center justify-between mb-6">
|
|
54
|
+
<div className="flex items-center gap-4">
|
|
55
|
+
<Link href={prefix} className="text-blue-600 hover:underline text-sm">
|
|
56
|
+
← Dashboard
|
|
57
|
+
</Link>
|
|
58
|
+
<h1 className="text-2xl font-bold text-gray-900">{model.displayName}</h1>
|
|
59
|
+
</div>
|
|
60
|
+
<Link
|
|
61
|
+
href={`${prefix}/${model.urlSlug}/new`}
|
|
62
|
+
className="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700"
|
|
63
|
+
>
|
|
64
|
+
New {model.name}
|
|
65
|
+
</Link>
|
|
66
|
+
</div>
|
|
67
|
+
|
|
68
|
+
{records.length === 0 ? (
|
|
69
|
+
<div className="bg-white rounded-lg border border-gray-200 p-12 text-center">
|
|
70
|
+
<p className="text-gray-500">No {model.displayName.toLowerCase()} yet.</p>
|
|
71
|
+
</div>
|
|
72
|
+
) : (
|
|
73
|
+
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
|
74
|
+
<table className="w-full text-sm">
|
|
75
|
+
<thead className="bg-gray-50 border-b border-gray-200">
|
|
76
|
+
<tr>
|
|
77
|
+
{model.listFields.map((f) => (
|
|
78
|
+
<th key={f.name} className="px-4 py-3 text-left font-medium text-gray-600">
|
|
79
|
+
{f.name}
|
|
80
|
+
</th>
|
|
81
|
+
))}
|
|
82
|
+
<th className="px-4 py-3 text-right font-medium text-gray-600">Actions</th>
|
|
83
|
+
</tr>
|
|
84
|
+
</thead>
|
|
85
|
+
<tbody className="divide-y divide-gray-100">
|
|
86
|
+
{records.map((record) => (
|
|
87
|
+
<tr key={String(record[model.idField])} className="hover:bg-gray-50">
|
|
88
|
+
{model.listFields.map((f) => (
|
|
89
|
+
<td key={f.name} className="px-4 py-3 text-gray-800">
|
|
90
|
+
{displayValue(record[f.name], f.prismaType)}
|
|
91
|
+
</td>
|
|
92
|
+
))}
|
|
93
|
+
<td className="px-4 py-3 text-right space-x-2">
|
|
94
|
+
<Link
|
|
95
|
+
href={`${prefix}/${model.urlSlug}/${String(record[model.idField])}`}
|
|
96
|
+
className="text-blue-600 hover:underline"
|
|
97
|
+
>
|
|
98
|
+
Edit
|
|
99
|
+
</Link>
|
|
100
|
+
<button
|
|
101
|
+
onClick={() => handleDelete(record[model.idField])}
|
|
102
|
+
className="text-red-600 hover:underline"
|
|
103
|
+
>
|
|
104
|
+
Delete
|
|
105
|
+
</button>
|
|
106
|
+
</td>
|
|
107
|
+
</tr>
|
|
108
|
+
))}
|
|
109
|
+
</tbody>
|
|
110
|
+
</table>
|
|
111
|
+
</div>
|
|
112
|
+
)}
|
|
113
|
+
|
|
114
|
+
{pagination.totalPages > 1 && (
|
|
115
|
+
<div className="flex items-center justify-between mt-4 text-sm text-gray-600">
|
|
116
|
+
<span>
|
|
117
|
+
Page {pagination.page} of {pagination.totalPages} ({pagination.total} total)
|
|
118
|
+
</span>
|
|
119
|
+
<div className="flex gap-2">
|
|
120
|
+
{pagination.page > 1 && (
|
|
121
|
+
<Link
|
|
122
|
+
href={`${prefix}/${model.urlSlug}?page=${pagination.page - 1}`}
|
|
123
|
+
className="px-3 py-1 border rounded hover:bg-gray-50"
|
|
124
|
+
>
|
|
125
|
+
Previous
|
|
126
|
+
</Link>
|
|
127
|
+
)}
|
|
128
|
+
{pagination.page < pagination.totalPages && (
|
|
129
|
+
<Link
|
|
130
|
+
href={`${prefix}/${model.urlSlug}?page=${pagination.page + 1}`}
|
|
131
|
+
className="px-3 py-1 border rounded hover:bg-gray-50"
|
|
132
|
+
>
|
|
133
|
+
Next
|
|
134
|
+
</Link>
|
|
135
|
+
)}
|
|
136
|
+
</div>
|
|
137
|
+
</div>
|
|
138
|
+
)}
|
|
139
|
+
</div>
|
|
140
|
+
</div>
|
|
141
|
+
)
|
|
142
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { useForm, Link } from '@inertiajs/react'
|
|
2
|
+
|
|
3
|
+
type FieldKind = 'scalar' | 'relation' | 'enum'
|
|
4
|
+
|
|
5
|
+
interface FieldMeta {
|
|
6
|
+
name: string
|
|
7
|
+
prismaType: string
|
|
8
|
+
kind: FieldKind
|
|
9
|
+
isRequired: boolean
|
|
10
|
+
enumValues?: string[]
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface ModelMeta {
|
|
14
|
+
name: string
|
|
15
|
+
urlSlug: string
|
|
16
|
+
displayName: string
|
|
17
|
+
formFields: FieldMeta[]
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface Props {
|
|
21
|
+
model: ModelMeta
|
|
22
|
+
models: Array<{ name: string; urlSlug: string }>
|
|
23
|
+
errors: Record<string, string>
|
|
24
|
+
prefix: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Fields rendered by dedicated hardcoded inputs — excluded from the generic
|
|
29
|
+
* metadata loop. `password` is also hardcoded but is not a Prisma field and
|
|
30
|
+
* therefore never appears in model.formFields.
|
|
31
|
+
*/
|
|
32
|
+
const CORE_FIELD_NAMES = new Set(['name', 'email'])
|
|
33
|
+
|
|
34
|
+
export default function AdminUserCreate({ model, errors, prefix }: Props) {
|
|
35
|
+
// All formFields except the two that are rendered by dedicated hardcoded
|
|
36
|
+
// inputs. Includes `role` (if present) and any custom fields.
|
|
37
|
+
const extraFields = model.formFields.filter((f) => !CORE_FIELD_NAMES.has(f.name))
|
|
38
|
+
|
|
39
|
+
const extraInitialValues = Object.fromEntries(
|
|
40
|
+
extraFields.map((f) => [
|
|
41
|
+
f.name,
|
|
42
|
+
f.kind === 'enum' && f.enumValues != null && f.enumValues.length > 0
|
|
43
|
+
? (f.enumValues[0] ?? '')
|
|
44
|
+
: '',
|
|
45
|
+
]),
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
const { data, setData, post, processing } = useForm<Record<string, string>>({
|
|
49
|
+
name: '',
|
|
50
|
+
email: '',
|
|
51
|
+
password: '',
|
|
52
|
+
...extraInitialValues,
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
function handleSubmit(e: React.FormEvent) {
|
|
56
|
+
e.preventDefault()
|
|
57
|
+
post(`${prefix}/${model.urlSlug}`)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const inputClass =
|
|
61
|
+
'w-full border rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500'
|
|
62
|
+
|
|
63
|
+
function borderClass(field: string): string {
|
|
64
|
+
return errors[field] ? ' border-red-500' : ' border-gray-300'
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<div className="min-h-screen bg-gray-50 p-8">
|
|
69
|
+
<div className="max-w-2xl mx-auto">
|
|
70
|
+
<div className="flex items-center gap-4 mb-6">
|
|
71
|
+
<Link
|
|
72
|
+
href={`${prefix}/${model.urlSlug}`}
|
|
73
|
+
className="text-blue-600 hover:underline text-sm"
|
|
74
|
+
>
|
|
75
|
+
← {model.displayName}
|
|
76
|
+
</Link>
|
|
77
|
+
<h1 className="text-2xl font-bold text-gray-900">New {model.name}</h1>
|
|
78
|
+
</div>
|
|
79
|
+
|
|
80
|
+
<form
|
|
81
|
+
onSubmit={handleSubmit}
|
|
82
|
+
className="bg-white rounded-lg border border-gray-200 p-6 space-y-5"
|
|
83
|
+
>
|
|
84
|
+
{/* name — hardcoded: always required by the Better Auth createUser API */}
|
|
85
|
+
<div>
|
|
86
|
+
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
87
|
+
name <span className="text-red-500">*</span>
|
|
88
|
+
</label>
|
|
89
|
+
<input
|
|
90
|
+
type="text"
|
|
91
|
+
value={data['name']}
|
|
92
|
+
onChange={(e) => setData('name', e.target.value)}
|
|
93
|
+
required
|
|
94
|
+
className={inputClass + borderClass('name')}
|
|
95
|
+
/>
|
|
96
|
+
{errors['name'] && (
|
|
97
|
+
<p className="mt-1 text-xs text-red-600">{errors['name']}</p>
|
|
98
|
+
)}
|
|
99
|
+
</div>
|
|
100
|
+
|
|
101
|
+
{/* email — hardcoded: always required by the Better Auth createUser API */}
|
|
102
|
+
<div>
|
|
103
|
+
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
104
|
+
email <span className="text-red-500">*</span>
|
|
105
|
+
</label>
|
|
106
|
+
<input
|
|
107
|
+
type="email"
|
|
108
|
+
value={data['email']}
|
|
109
|
+
onChange={(e) => setData('email', e.target.value)}
|
|
110
|
+
required
|
|
111
|
+
className={inputClass + borderClass('email')}
|
|
112
|
+
/>
|
|
113
|
+
{errors['email'] && (
|
|
114
|
+
<p className="mt-1 text-xs text-red-600">{errors['email']}</p>
|
|
115
|
+
)}
|
|
116
|
+
</div>
|
|
117
|
+
|
|
118
|
+
{/* password — hardcoded: always required by the Better Auth createUser API.
|
|
119
|
+
Not a Prisma field so it never appears in model.formFields. */}
|
|
120
|
+
<div>
|
|
121
|
+
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
122
|
+
password <span className="text-red-500">*</span>
|
|
123
|
+
</label>
|
|
124
|
+
<input
|
|
125
|
+
type="password"
|
|
126
|
+
value={data['password']}
|
|
127
|
+
onChange={(e) => setData('password', e.target.value)}
|
|
128
|
+
required
|
|
129
|
+
className={inputClass + borderClass('password')}
|
|
130
|
+
/>
|
|
131
|
+
{errors['password'] && (
|
|
132
|
+
<p className="mt-1 text-xs text-red-600">{errors['password']}</p>
|
|
133
|
+
)}
|
|
134
|
+
</div>
|
|
135
|
+
|
|
136
|
+
{/* role and any custom fields — driven from model.formFields metadata */}
|
|
137
|
+
{extraFields.map((field) => (
|
|
138
|
+
<div key={field.name}>
|
|
139
|
+
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
140
|
+
{field.name}
|
|
141
|
+
{field.isRequired && <span className="text-red-500"> *</span>}
|
|
142
|
+
</label>
|
|
143
|
+
{field.kind === 'enum' && field.enumValues != null ? (
|
|
144
|
+
<select
|
|
145
|
+
value={data[field.name] ?? ''}
|
|
146
|
+
onChange={(e) => setData(field.name, e.target.value)}
|
|
147
|
+
className={inputClass + borderClass(field.name)}
|
|
148
|
+
>
|
|
149
|
+
{field.enumValues.map((v) => (
|
|
150
|
+
<option key={v} value={v}>
|
|
151
|
+
{v}
|
|
152
|
+
</option>
|
|
153
|
+
))}
|
|
154
|
+
</select>
|
|
155
|
+
) : (
|
|
156
|
+
<input
|
|
157
|
+
type="text"
|
|
158
|
+
value={data[field.name] ?? ''}
|
|
159
|
+
onChange={(e) => setData(field.name, e.target.value)}
|
|
160
|
+
required={field.isRequired}
|
|
161
|
+
className={inputClass + borderClass(field.name)}
|
|
162
|
+
/>
|
|
163
|
+
)}
|
|
164
|
+
{errors[field.name] && (
|
|
165
|
+
<p className="mt-1 text-xs text-red-600">{errors[field.name]}</p>
|
|
166
|
+
)}
|
|
167
|
+
</div>
|
|
168
|
+
))}
|
|
169
|
+
|
|
170
|
+
<div className="flex items-center gap-3 pt-2">
|
|
171
|
+
<button
|
|
172
|
+
type="submit"
|
|
173
|
+
disabled={processing}
|
|
174
|
+
className="bg-blue-600 text-white px-5 py-2 rounded-md hover:bg-blue-700 disabled:opacity-50"
|
|
175
|
+
>
|
|
176
|
+
{processing ? 'Creating…' : 'Create'}
|
|
177
|
+
</button>
|
|
178
|
+
<Link
|
|
179
|
+
href={`${prefix}/${model.urlSlug}`}
|
|
180
|
+
className="text-gray-600 hover:underline text-sm"
|
|
181
|
+
>
|
|
182
|
+
Cancel
|
|
183
|
+
</Link>
|
|
184
|
+
</div>
|
|
185
|
+
</form>
|
|
186
|
+
</div>
|
|
187
|
+
</div>
|
|
188
|
+
)
|
|
189
|
+
}
|