@actuate-media/cms-admin 0.21.1 → 0.23.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/dist/__tests__/components/forms-pagination.render.test.d.ts +2 -0
- package/dist/__tests__/components/forms-pagination.render.test.d.ts.map +1 -0
- package/dist/__tests__/components/forms-pagination.render.test.js +33 -0
- package/dist/__tests__/components/forms-pagination.render.test.js.map +1 -0
- package/dist/__tests__/layout/sidebar-forms-badge.render.test.d.ts +2 -0
- package/dist/__tests__/layout/sidebar-forms-badge.render.test.d.ts.map +1 -0
- package/dist/__tests__/layout/sidebar-forms-badge.render.test.js +30 -0
- package/dist/__tests__/layout/sidebar-forms-badge.render.test.js.map +1 -0
- package/dist/__tests__/layout/sidebar-submenu.render.test.js +8 -2
- package/dist/__tests__/layout/sidebar-submenu.render.test.js.map +1 -1
- package/dist/__tests__/views/form-submissions.render.test.d.ts +2 -0
- package/dist/__tests__/views/form-submissions.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/form-submissions.render.test.js +110 -0
- package/dist/__tests__/views/form-submissions.render.test.js.map +1 -0
- package/dist/__tests__/views/forms-list.render.test.d.ts +2 -0
- package/dist/__tests__/views/forms-list.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/forms-list.render.test.js +122 -0
- package/dist/__tests__/views/forms-list.render.test.js.map +1 -0
- package/dist/actuate-admin.css +1 -1
- package/dist/components/forms/Drawer.d.ts +13 -0
- package/dist/components/forms/Drawer.d.ts.map +1 -0
- package/dist/components/forms/Drawer.js +13 -0
- package/dist/components/forms/Drawer.js.map +1 -0
- package/dist/components/forms/EmbedPanel.d.ts +7 -0
- package/dist/components/forms/EmbedPanel.d.ts.map +1 -0
- package/dist/components/forms/EmbedPanel.js +91 -0
- package/dist/components/forms/EmbedPanel.js.map +1 -0
- package/dist/components/forms/EntryDetailDrawer.d.ts +11 -0
- package/dist/components/forms/EntryDetailDrawer.d.ts.map +1 -0
- package/dist/components/forms/EntryDetailDrawer.js +145 -0
- package/dist/components/forms/EntryDetailDrawer.js.map +1 -0
- package/dist/components/forms/FieldsPanel.d.ts +8 -0
- package/dist/components/forms/FieldsPanel.d.ts.map +1 -0
- package/dist/components/forms/FieldsPanel.js +123 -0
- package/dist/components/forms/FieldsPanel.js.map +1 -0
- package/dist/components/forms/FormSchemaDrawer.d.ts +9 -0
- package/dist/components/forms/FormSchemaDrawer.d.ts.map +1 -0
- package/dist/components/forms/FormSchemaDrawer.js +96 -0
- package/dist/components/forms/FormSchemaDrawer.js.map +1 -0
- package/dist/components/forms/NotificationsPanel.d.ts +6 -0
- package/dist/components/forms/NotificationsPanel.d.ts.map +1 -0
- package/dist/components/forms/NotificationsPanel.js +80 -0
- package/dist/components/forms/NotificationsPanel.js.map +1 -0
- package/dist/components/forms/primitives.d.ts +57 -0
- package/dist/components/forms/primitives.d.ts.map +1 -0
- package/dist/components/forms/primitives.js +131 -0
- package/dist/components/forms/primitives.js.map +1 -0
- package/dist/layout/Sidebar.d.ts +5 -0
- package/dist/layout/Sidebar.d.ts.map +1 -1
- package/dist/layout/Sidebar.js +43 -3
- package/dist/layout/Sidebar.js.map +1 -1
- package/dist/lib/forms-events.d.ts +17 -0
- package/dist/lib/forms-events.d.ts.map +1 -0
- package/dist/lib/forms-events.js +20 -0
- package/dist/lib/forms-events.js.map +1 -0
- package/dist/lib/forms-service.d.ts +114 -0
- package/dist/lib/forms-service.d.ts.map +1 -0
- package/dist/lib/forms-service.js +210 -0
- package/dist/lib/forms-service.js.map +1 -0
- package/dist/views/FormSubmissions.d.ts.map +1 -1
- package/dist/views/FormSubmissions.js +224 -77
- package/dist/views/FormSubmissions.js.map +1 -1
- package/dist/views/Forms.d.ts.map +1 -1
- package/dist/views/Forms.js +135 -17
- package/dist/views/Forms.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/components/forms-pagination.render.test.tsx +49 -0
- package/src/__tests__/layout/sidebar-forms-badge.render.test.tsx +50 -0
- package/src/__tests__/layout/sidebar-submenu.render.test.tsx +9 -2
- package/src/__tests__/views/form-submissions.render.test.tsx +129 -0
- package/src/__tests__/views/forms-list.render.test.tsx +141 -0
- package/src/components/forms/Drawer.tsx +70 -0
- package/src/components/forms/EmbedPanel.tsx +173 -0
- package/src/components/forms/EntryDetailDrawer.tsx +312 -0
- package/src/components/forms/FieldsPanel.tsx +385 -0
- package/src/components/forms/FormSchemaDrawer.tsx +185 -0
- package/src/components/forms/NotificationsPanel.tsx +240 -0
- package/src/components/forms/primitives.tsx +300 -0
- package/src/layout/Sidebar.tsx +72 -6
- package/src/lib/forms-events.ts +32 -0
- package/src/lib/forms-service.ts +343 -0
- package/src/views/FormSubmissions.tsx +529 -394
- package/src/views/Forms.tsx +381 -106
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Fields tab of the Form Schema drawer. Renders the form's field list with
|
|
5
|
+
* drag-to-reorder (dnd-kit, keyboard-accessible) plus an inline editor for the
|
|
6
|
+
* selected field. The parent owns the `fields` array and persistence; this
|
|
7
|
+
* panel is a controlled editor that calls `onChange` with the next array.
|
|
8
|
+
*/
|
|
9
|
+
import {
|
|
10
|
+
DndContext,
|
|
11
|
+
KeyboardSensor,
|
|
12
|
+
PointerSensor,
|
|
13
|
+
closestCenter,
|
|
14
|
+
useSensor,
|
|
15
|
+
useSensors,
|
|
16
|
+
type DragEndEvent,
|
|
17
|
+
} from '@dnd-kit/core'
|
|
18
|
+
import {
|
|
19
|
+
SortableContext,
|
|
20
|
+
sortableKeyboardCoordinates,
|
|
21
|
+
useSortable,
|
|
22
|
+
verticalListSortingStrategy,
|
|
23
|
+
} from '@dnd-kit/sortable'
|
|
24
|
+
import { CSS } from '@dnd-kit/utilities'
|
|
25
|
+
import { ChevronDown, GripVertical, Plus, Trash2 } from 'lucide-react'
|
|
26
|
+
import * as Switch from '@radix-ui/react-switch'
|
|
27
|
+
import { useId, useState } from 'react'
|
|
28
|
+
import type { FormField, FormFieldType } from '../../lib/forms-service.js'
|
|
29
|
+
import { FieldTypeBadge, FormsEmptyState } from './primitives.js'
|
|
30
|
+
|
|
31
|
+
const FIELD_TYPES: { value: FormFieldType; label: string }[] = [
|
|
32
|
+
{ value: 'text', label: 'Text' },
|
|
33
|
+
{ value: 'email', label: 'Email' },
|
|
34
|
+
{ value: 'phone', label: 'Phone' },
|
|
35
|
+
{ value: 'textarea', label: 'Textarea' },
|
|
36
|
+
{ value: 'select', label: 'Select' },
|
|
37
|
+
{ value: 'multiselect', label: 'Multi-select' },
|
|
38
|
+
{ value: 'radio', label: 'Radio' },
|
|
39
|
+
{ value: 'checkbox', label: 'Checkbox' },
|
|
40
|
+
{ value: 'date', label: 'Date' },
|
|
41
|
+
{ value: 'number', label: 'Number' },
|
|
42
|
+
{ value: 'url', label: 'URL' },
|
|
43
|
+
{ value: 'consent', label: 'Consent' },
|
|
44
|
+
{ value: 'file', label: 'File upload' },
|
|
45
|
+
{ value: 'hidden', label: 'Hidden' },
|
|
46
|
+
{ value: 'richtext', label: 'Content block' },
|
|
47
|
+
{ value: 'divider', label: 'Divider / heading' },
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
const OPTION_TYPES: FormFieldType[] = ['select', 'multiselect', 'radio']
|
|
51
|
+
|
|
52
|
+
function slugifyKey(label: string): string {
|
|
53
|
+
return label
|
|
54
|
+
.toLowerCase()
|
|
55
|
+
.trim()
|
|
56
|
+
.replace(/[^a-z0-9]+/g, '_')
|
|
57
|
+
.replace(/^_+|_+$/g, '')
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function makeId(): string {
|
|
61
|
+
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) return crypto.randomUUID()
|
|
62
|
+
return `f_${Math.random().toString(36).slice(2)}`
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function FieldsPanel({
|
|
66
|
+
fields,
|
|
67
|
+
onChange,
|
|
68
|
+
hasSubmissions,
|
|
69
|
+
}: {
|
|
70
|
+
fields: FormField[]
|
|
71
|
+
onChange: (next: FormField[]) => void
|
|
72
|
+
/** When the form already has submissions, key renames/deletes are riskier. */
|
|
73
|
+
hasSubmissions: boolean
|
|
74
|
+
}) {
|
|
75
|
+
const [editingId, setEditingId] = useState<string | null>(null)
|
|
76
|
+
|
|
77
|
+
const sensors = useSensors(
|
|
78
|
+
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
|
|
79
|
+
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
function handleDragEnd(event: DragEndEvent) {
|
|
83
|
+
const { active, over } = event
|
|
84
|
+
if (!over || active.id === over.id) return
|
|
85
|
+
const ids = fields.map((f) => f.id)
|
|
86
|
+
const from = ids.indexOf(String(active.id))
|
|
87
|
+
const to = ids.indexOf(String(over.id))
|
|
88
|
+
if (from === -1 || to === -1) return
|
|
89
|
+
const next = fields.slice()
|
|
90
|
+
const [moved] = next.splice(from, 1)
|
|
91
|
+
next.splice(to, 0, moved!)
|
|
92
|
+
onChange(next.map((f, i) => ({ ...f, sortOrder: i })))
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function updateField(id: string, patch: Partial<FormField>) {
|
|
96
|
+
onChange(fields.map((f) => (f.id === id ? { ...f, ...patch } : f)))
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function deleteField(id: string) {
|
|
100
|
+
onChange(fields.filter((f) => f.id !== id))
|
|
101
|
+
if (editingId === id) setEditingId(null)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function addField() {
|
|
105
|
+
const id = makeId()
|
|
106
|
+
const next: FormField = {
|
|
107
|
+
id,
|
|
108
|
+
label: 'New Field',
|
|
109
|
+
key: `field_${fields.length + 1}`,
|
|
110
|
+
type: 'text',
|
|
111
|
+
required: false,
|
|
112
|
+
sortOrder: fields.length,
|
|
113
|
+
}
|
|
114
|
+
onChange([...fields, next])
|
|
115
|
+
setEditingId(id)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return (
|
|
119
|
+
<div className="space-y-3">
|
|
120
|
+
{fields.length === 0 ? (
|
|
121
|
+
<FormsEmptyState
|
|
122
|
+
title="No fields yet"
|
|
123
|
+
description="Add your first field to start building this form."
|
|
124
|
+
/>
|
|
125
|
+
) : (
|
|
126
|
+
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
|
127
|
+
<SortableContext items={fields.map((f) => f.id)} strategy={verticalListSortingStrategy}>
|
|
128
|
+
<ul className="space-y-2">
|
|
129
|
+
{fields.map((field) => (
|
|
130
|
+
<SortableFieldRow
|
|
131
|
+
key={field.id}
|
|
132
|
+
field={field}
|
|
133
|
+
editing={editingId === field.id}
|
|
134
|
+
hasSubmissions={hasSubmissions}
|
|
135
|
+
onToggleEdit={() => setEditingId((cur) => (cur === field.id ? null : field.id))}
|
|
136
|
+
onUpdate={(patch) => updateField(field.id, patch)}
|
|
137
|
+
onDelete={() => deleteField(field.id)}
|
|
138
|
+
/>
|
|
139
|
+
))}
|
|
140
|
+
</ul>
|
|
141
|
+
</SortableContext>
|
|
142
|
+
</DndContext>
|
|
143
|
+
)}
|
|
144
|
+
|
|
145
|
+
<button
|
|
146
|
+
type="button"
|
|
147
|
+
onClick={addField}
|
|
148
|
+
className="border-border text-foreground hover:bg-accent flex w-full items-center justify-center gap-2 rounded-lg border border-dashed px-3 py-2.5 text-sm font-medium transition-colors"
|
|
149
|
+
>
|
|
150
|
+
<Plus className="h-4 w-4" aria-hidden />
|
|
151
|
+
Add Field
|
|
152
|
+
</button>
|
|
153
|
+
</div>
|
|
154
|
+
)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function SortableFieldRow({
|
|
158
|
+
field,
|
|
159
|
+
editing,
|
|
160
|
+
hasSubmissions,
|
|
161
|
+
onToggleEdit,
|
|
162
|
+
onUpdate,
|
|
163
|
+
onDelete,
|
|
164
|
+
}: {
|
|
165
|
+
field: FormField
|
|
166
|
+
editing: boolean
|
|
167
|
+
hasSubmissions: boolean
|
|
168
|
+
onToggleEdit: () => void
|
|
169
|
+
onUpdate: (patch: Partial<FormField>) => void
|
|
170
|
+
onDelete: () => void
|
|
171
|
+
}) {
|
|
172
|
+
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
|
173
|
+
id: field.id,
|
|
174
|
+
})
|
|
175
|
+
const style = {
|
|
176
|
+
transform: CSS.Transform.toString(transform),
|
|
177
|
+
transition,
|
|
178
|
+
zIndex: isDragging ? 10 : undefined,
|
|
179
|
+
}
|
|
180
|
+
const needsOptions = OPTION_TYPES.includes(field.type)
|
|
181
|
+
|
|
182
|
+
return (
|
|
183
|
+
<li ref={setNodeRef} style={style}>
|
|
184
|
+
<div
|
|
185
|
+
className={`border-border bg-card rounded-lg border ${isDragging ? 'shadow-md' : ''} ${editing ? 'ring-primary/30 ring-1' : ''}`}
|
|
186
|
+
>
|
|
187
|
+
<div className="flex items-center gap-2 px-2.5 py-2">
|
|
188
|
+
<button
|
|
189
|
+
type="button"
|
|
190
|
+
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring shrink-0 cursor-grab touch-none rounded focus-visible:ring-2 focus-visible:outline-none"
|
|
191
|
+
aria-label={`Drag to reorder ${field.label}`}
|
|
192
|
+
{...attributes}
|
|
193
|
+
{...listeners}
|
|
194
|
+
>
|
|
195
|
+
<GripVertical className="h-4 w-4" aria-hidden />
|
|
196
|
+
</button>
|
|
197
|
+
|
|
198
|
+
<button
|
|
199
|
+
type="button"
|
|
200
|
+
onClick={onToggleEdit}
|
|
201
|
+
className="flex min-w-0 flex-1 items-center gap-2 text-left"
|
|
202
|
+
aria-expanded={editing}
|
|
203
|
+
>
|
|
204
|
+
<span className="min-w-0">
|
|
205
|
+
<span className="text-foreground block truncate text-sm font-medium">
|
|
206
|
+
{field.label || '(untitled)'}
|
|
207
|
+
</span>
|
|
208
|
+
<span className="text-muted-foreground block truncate font-mono text-xs">
|
|
209
|
+
{field.key}
|
|
210
|
+
</span>
|
|
211
|
+
</span>
|
|
212
|
+
</button>
|
|
213
|
+
|
|
214
|
+
<FieldTypeBadge type={field.type} />
|
|
215
|
+
{field.required && (
|
|
216
|
+
<span className="bg-muted text-muted-foreground rounded px-1.5 py-0.5 text-[10px] font-medium uppercase">
|
|
217
|
+
Required
|
|
218
|
+
</span>
|
|
219
|
+
)}
|
|
220
|
+
|
|
221
|
+
<button
|
|
222
|
+
type="button"
|
|
223
|
+
onClick={onToggleEdit}
|
|
224
|
+
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring shrink-0 rounded p-1 focus-visible:ring-2 focus-visible:outline-none"
|
|
225
|
+
aria-label={`${editing ? 'Collapse' : 'Edit'} ${field.label}`}
|
|
226
|
+
>
|
|
227
|
+
<ChevronDown
|
|
228
|
+
className={`h-4 w-4 transition-transform ${editing ? '' : '-rotate-90'}`}
|
|
229
|
+
aria-hidden
|
|
230
|
+
/>
|
|
231
|
+
</button>
|
|
232
|
+
<button
|
|
233
|
+
type="button"
|
|
234
|
+
onClick={onDelete}
|
|
235
|
+
className="text-muted-foreground hover:text-destructive focus-visible:ring-ring shrink-0 rounded p-1 focus-visible:ring-2 focus-visible:outline-none"
|
|
236
|
+
aria-label={`Delete ${field.label}`}
|
|
237
|
+
>
|
|
238
|
+
<Trash2 className="h-4 w-4" aria-hidden />
|
|
239
|
+
</button>
|
|
240
|
+
</div>
|
|
241
|
+
|
|
242
|
+
{editing && (
|
|
243
|
+
<FieldEditor
|
|
244
|
+
field={field}
|
|
245
|
+
hasSubmissions={hasSubmissions}
|
|
246
|
+
needsOptions={needsOptions}
|
|
247
|
+
onUpdate={onUpdate}
|
|
248
|
+
/>
|
|
249
|
+
)}
|
|
250
|
+
</div>
|
|
251
|
+
</li>
|
|
252
|
+
)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function FieldEditor({
|
|
256
|
+
field,
|
|
257
|
+
hasSubmissions,
|
|
258
|
+
needsOptions,
|
|
259
|
+
onUpdate,
|
|
260
|
+
}: {
|
|
261
|
+
field: FormField
|
|
262
|
+
hasSubmissions: boolean
|
|
263
|
+
needsOptions: boolean
|
|
264
|
+
onUpdate: (patch: Partial<FormField>) => void
|
|
265
|
+
}) {
|
|
266
|
+
const reqId = useId()
|
|
267
|
+
const optionsText = (field.options ?? []).map((o) => o.label).join('\n')
|
|
268
|
+
|
|
269
|
+
return (
|
|
270
|
+
<div className="border-border space-y-3 border-t px-3 py-3">
|
|
271
|
+
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
|
272
|
+
<Labeled label="Label">
|
|
273
|
+
<input
|
|
274
|
+
type="text"
|
|
275
|
+
value={field.label}
|
|
276
|
+
onChange={(e) => {
|
|
277
|
+
const label = e.target.value
|
|
278
|
+
// Auto-derive the key from the label until the form has data and
|
|
279
|
+
// until the user has clearly customised the key.
|
|
280
|
+
const autoKey = slugifyKey(field.label)
|
|
281
|
+
const patch: Partial<FormField> = { label }
|
|
282
|
+
if (!hasSubmissions && (field.key === autoKey || !field.key)) {
|
|
283
|
+
patch.key = slugifyKey(label)
|
|
284
|
+
}
|
|
285
|
+
onUpdate(patch)
|
|
286
|
+
}}
|
|
287
|
+
className="border-border bg-input-background text-foreground focus-visible:ring-ring w-full rounded-md border px-2.5 py-1.5 text-sm focus-visible:ring-2 focus-visible:outline-none"
|
|
288
|
+
/>
|
|
289
|
+
</Labeled>
|
|
290
|
+
<Labeled
|
|
291
|
+
label="Key"
|
|
292
|
+
hint={hasSubmissions ? 'Renaming affects existing entries & exports' : undefined}
|
|
293
|
+
>
|
|
294
|
+
<input
|
|
295
|
+
type="text"
|
|
296
|
+
value={field.key}
|
|
297
|
+
onChange={(e) => onUpdate({ key: slugifyKey(e.target.value) })}
|
|
298
|
+
className="border-border bg-input-background text-foreground focus-visible:ring-ring w-full rounded-md border px-2.5 py-1.5 font-mono text-sm focus-visible:ring-2 focus-visible:outline-none"
|
|
299
|
+
/>
|
|
300
|
+
</Labeled>
|
|
301
|
+
<Labeled label="Type">
|
|
302
|
+
<select
|
|
303
|
+
value={field.type}
|
|
304
|
+
onChange={(e) => onUpdate({ type: e.target.value as FormFieldType })}
|
|
305
|
+
className="border-border bg-input-background text-foreground focus-visible:ring-ring w-full rounded-md border px-2.5 py-1.5 text-sm focus-visible:ring-2 focus-visible:outline-none"
|
|
306
|
+
>
|
|
307
|
+
{FIELD_TYPES.map((t) => (
|
|
308
|
+
<option key={t.value} value={t.value}>
|
|
309
|
+
{t.label}
|
|
310
|
+
</option>
|
|
311
|
+
))}
|
|
312
|
+
</select>
|
|
313
|
+
</Labeled>
|
|
314
|
+
<Labeled label="Placeholder">
|
|
315
|
+
<input
|
|
316
|
+
type="text"
|
|
317
|
+
value={field.placeholder ?? ''}
|
|
318
|
+
onChange={(e) => onUpdate({ placeholder: e.target.value })}
|
|
319
|
+
className="border-border bg-input-background text-foreground focus-visible:ring-ring w-full rounded-md border px-2.5 py-1.5 text-sm focus-visible:ring-2 focus-visible:outline-none"
|
|
320
|
+
/>
|
|
321
|
+
</Labeled>
|
|
322
|
+
</div>
|
|
323
|
+
|
|
324
|
+
<Labeled label="Help text">
|
|
325
|
+
<input
|
|
326
|
+
type="text"
|
|
327
|
+
value={field.helpText ?? ''}
|
|
328
|
+
onChange={(e) => onUpdate({ helpText: e.target.value })}
|
|
329
|
+
className="border-border bg-input-background text-foreground focus-visible:ring-ring w-full rounded-md border px-2.5 py-1.5 text-sm focus-visible:ring-2 focus-visible:outline-none"
|
|
330
|
+
/>
|
|
331
|
+
</Labeled>
|
|
332
|
+
|
|
333
|
+
{needsOptions && (
|
|
334
|
+
<Labeled label="Options" hint="One per line">
|
|
335
|
+
<textarea
|
|
336
|
+
rows={3}
|
|
337
|
+
value={optionsText}
|
|
338
|
+
onChange={(e) =>
|
|
339
|
+
onUpdate({
|
|
340
|
+
options: e.target.value
|
|
341
|
+
.split('\n')
|
|
342
|
+
.map((line) => line.trim())
|
|
343
|
+
.filter(Boolean)
|
|
344
|
+
.map((label) => ({ label, value: slugifyKey(label) })),
|
|
345
|
+
})
|
|
346
|
+
}
|
|
347
|
+
className="border-border bg-input-background text-foreground focus-visible:ring-ring w-full rounded-md border px-2.5 py-1.5 text-sm focus-visible:ring-2 focus-visible:outline-none"
|
|
348
|
+
/>
|
|
349
|
+
</Labeled>
|
|
350
|
+
)}
|
|
351
|
+
|
|
352
|
+
<div className="flex items-center justify-between">
|
|
353
|
+
<label htmlFor={reqId} className="text-foreground text-sm font-medium">
|
|
354
|
+
Required
|
|
355
|
+
</label>
|
|
356
|
+
<Switch.Root
|
|
357
|
+
id={reqId}
|
|
358
|
+
checked={field.required}
|
|
359
|
+
onCheckedChange={(v) => onUpdate({ required: v })}
|
|
360
|
+
className="bg-input-background data-[state=checked]:bg-primary focus-visible:ring-ring relative h-[18px] w-8 shrink-0 rounded-full transition-colors focus-visible:ring-2 focus-visible:outline-none"
|
|
361
|
+
>
|
|
362
|
+
<Switch.Thumb className="bg-background block h-3.5 w-3.5 translate-x-0.5 rounded-full shadow-sm transition-transform data-[state=checked]:translate-x-[14px]" />
|
|
363
|
+
</Switch.Root>
|
|
364
|
+
</div>
|
|
365
|
+
</div>
|
|
366
|
+
)
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function Labeled({
|
|
370
|
+
label,
|
|
371
|
+
hint,
|
|
372
|
+
children,
|
|
373
|
+
}: {
|
|
374
|
+
label: string
|
|
375
|
+
hint?: string
|
|
376
|
+
children: React.ReactNode
|
|
377
|
+
}) {
|
|
378
|
+
return (
|
|
379
|
+
<label className="block">
|
|
380
|
+
<span className="text-muted-foreground mb-1 block text-xs font-medium">{label}</span>
|
|
381
|
+
{children}
|
|
382
|
+
{hint && <span className="text-warning mt-1 block text-xs">{hint}</span>}
|
|
383
|
+
</label>
|
|
384
|
+
)
|
|
385
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Form Schema drawer — the right-side panel opened from the Forms list "Schema"
|
|
5
|
+
* action. Three tabs: Fields (versioned schema editor), Embed (public snippets
|
|
6
|
+
* + domain restrictions), Notifications (per-form notification settings).
|
|
7
|
+
*
|
|
8
|
+
* Saving the Fields tab creates a new schema version server-side; existing
|
|
9
|
+
* submissions keep referencing the version they were captured against, so
|
|
10
|
+
* historical entries never break when the schema changes.
|
|
11
|
+
*/
|
|
12
|
+
import * as Tabs from '@radix-ui/react-tabs'
|
|
13
|
+
import { useEffect, useState } from 'react'
|
|
14
|
+
import { toast } from 'sonner'
|
|
15
|
+
import { fetchFormSchema, saveFormSchema, type FormField } from '../../lib/forms-service.js'
|
|
16
|
+
import type { FormListItem } from '../../lib/forms-service.js'
|
|
17
|
+
import { emitFormsChanged } from '../../lib/forms-events.js'
|
|
18
|
+
import { ConfirmDialog } from '../ui/ConfirmDialog.js'
|
|
19
|
+
import { Drawer } from './Drawer.js'
|
|
20
|
+
import { FieldsPanel } from './FieldsPanel.js'
|
|
21
|
+
import { EmbedPanel } from './EmbedPanel.js'
|
|
22
|
+
import { NotificationsPanel } from './NotificationsPanel.js'
|
|
23
|
+
import { FormsErrorState, FormsLoading, btnPrimary, btnSecondary } from './primitives.js'
|
|
24
|
+
|
|
25
|
+
const TABS = [
|
|
26
|
+
{ id: 'fields', label: 'Fields' },
|
|
27
|
+
{ id: 'embed', label: 'Embed' },
|
|
28
|
+
{ id: 'notifications', label: 'Notifications' },
|
|
29
|
+
] as const
|
|
30
|
+
|
|
31
|
+
export function FormSchemaDrawer({
|
|
32
|
+
form,
|
|
33
|
+
open,
|
|
34
|
+
onOpenChange,
|
|
35
|
+
onSaved,
|
|
36
|
+
}: {
|
|
37
|
+
form: FormListItem | null
|
|
38
|
+
open: boolean
|
|
39
|
+
onOpenChange: (open: boolean) => void
|
|
40
|
+
/** Called after a schema/settings change so the list can refetch. */
|
|
41
|
+
onSaved?: () => void
|
|
42
|
+
}) {
|
|
43
|
+
const [tab, setTab] = useState<string>('fields')
|
|
44
|
+
const [fields, setFields] = useState<FormField[]>([])
|
|
45
|
+
const [loading, setLoading] = useState(false)
|
|
46
|
+
const [error, setError] = useState<string | null>(null)
|
|
47
|
+
const [dirty, setDirty] = useState(false)
|
|
48
|
+
const [saving, setSaving] = useState(false)
|
|
49
|
+
const [confirmDiscard, setConfirmDiscard] = useState(false)
|
|
50
|
+
|
|
51
|
+
const formId = form?.id ?? null
|
|
52
|
+
const hasSubmissions = (form?.totalEntries ?? 0) > 0
|
|
53
|
+
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
if (!open || !formId) return
|
|
56
|
+
setTab('fields')
|
|
57
|
+
setDirty(false)
|
|
58
|
+
setLoading(true)
|
|
59
|
+
setError(null)
|
|
60
|
+
let active = true
|
|
61
|
+
fetchFormSchema(formId)
|
|
62
|
+
.then((schema) => {
|
|
63
|
+
if (active) setFields(schema.fields)
|
|
64
|
+
})
|
|
65
|
+
.catch((e: unknown) => {
|
|
66
|
+
if (active) setError(e instanceof Error ? e.message : 'Failed to load schema')
|
|
67
|
+
})
|
|
68
|
+
.finally(() => {
|
|
69
|
+
if (active) setLoading(false)
|
|
70
|
+
})
|
|
71
|
+
return () => {
|
|
72
|
+
active = false
|
|
73
|
+
}
|
|
74
|
+
}, [open, formId])
|
|
75
|
+
|
|
76
|
+
function handleFieldsChange(next: FormField[]) {
|
|
77
|
+
setFields(next)
|
|
78
|
+
setDirty(true)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function handleSave() {
|
|
82
|
+
if (!formId) return
|
|
83
|
+
setSaving(true)
|
|
84
|
+
const res = await saveFormSchema(
|
|
85
|
+
formId,
|
|
86
|
+
fields.map((f, i) => ({ ...f, sortOrder: i })),
|
|
87
|
+
)
|
|
88
|
+
setSaving(false)
|
|
89
|
+
if (res.error) {
|
|
90
|
+
toast.error(res.error)
|
|
91
|
+
return
|
|
92
|
+
}
|
|
93
|
+
setDirty(false)
|
|
94
|
+
toast.success('Schema saved.')
|
|
95
|
+
emitFormsChanged()
|
|
96
|
+
onSaved?.()
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function requestClose(next: boolean) {
|
|
100
|
+
if (!next && dirty) {
|
|
101
|
+
setConfirmDiscard(true)
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
onOpenChange(next)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (!form) return null
|
|
108
|
+
|
|
109
|
+
return (
|
|
110
|
+
<>
|
|
111
|
+
<Drawer
|
|
112
|
+
open={open}
|
|
113
|
+
onOpenChange={requestClose}
|
|
114
|
+
title={form.name}
|
|
115
|
+
description={`/${form.slug}`}
|
|
116
|
+
width="max-w-2xl"
|
|
117
|
+
footer={
|
|
118
|
+
tab === 'fields' ? (
|
|
119
|
+
<>
|
|
120
|
+
<button className={btnSecondary} onClick={() => requestClose(false)}>
|
|
121
|
+
Close
|
|
122
|
+
</button>
|
|
123
|
+
<button className={btnPrimary} onClick={handleSave} disabled={saving || loading}>
|
|
124
|
+
{saving ? 'Saving…' : 'Save Schema'}
|
|
125
|
+
</button>
|
|
126
|
+
</>
|
|
127
|
+
) : undefined
|
|
128
|
+
}
|
|
129
|
+
>
|
|
130
|
+
<Tabs.Root value={tab} onValueChange={setTab}>
|
|
131
|
+
<Tabs.List
|
|
132
|
+
className="border-border mb-4 flex gap-1 border-b"
|
|
133
|
+
aria-label="Form schema sections"
|
|
134
|
+
>
|
|
135
|
+
{TABS.map((t) => (
|
|
136
|
+
<Tabs.Trigger
|
|
137
|
+
key={t.id}
|
|
138
|
+
value={t.id}
|
|
139
|
+
className="text-muted-foreground hover:text-foreground data-[state=active]:text-foreground data-[state=active]:border-primary focus-visible:ring-ring -mb-px border-b-2 border-transparent px-3 py-2 text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none"
|
|
140
|
+
>
|
|
141
|
+
{t.label}
|
|
142
|
+
</Tabs.Trigger>
|
|
143
|
+
))}
|
|
144
|
+
</Tabs.List>
|
|
145
|
+
|
|
146
|
+
<Tabs.Content value="fields" tabIndex={-1}>
|
|
147
|
+
{loading ? (
|
|
148
|
+
<FormsLoading label="Loading schema…" />
|
|
149
|
+
) : error ? (
|
|
150
|
+
<FormsErrorState message={error} onRetry={() => formId && setTab('fields')} />
|
|
151
|
+
) : (
|
|
152
|
+
<FieldsPanel
|
|
153
|
+
fields={fields}
|
|
154
|
+
onChange={handleFieldsChange}
|
|
155
|
+
hasSubmissions={hasSubmissions}
|
|
156
|
+
/>
|
|
157
|
+
)}
|
|
158
|
+
</Tabs.Content>
|
|
159
|
+
|
|
160
|
+
<Tabs.Content value="embed" tabIndex={-1}>
|
|
161
|
+
{formId && <EmbedPanel formId={formId} slug={form.slug} status={form.status} />}
|
|
162
|
+
</Tabs.Content>
|
|
163
|
+
|
|
164
|
+
<Tabs.Content value="notifications" tabIndex={-1}>
|
|
165
|
+
{formId && <NotificationsPanel formId={formId} onChanged={onSaved} />}
|
|
166
|
+
</Tabs.Content>
|
|
167
|
+
</Tabs.Root>
|
|
168
|
+
</Drawer>
|
|
169
|
+
|
|
170
|
+
<ConfirmDialog
|
|
171
|
+
open={confirmDiscard}
|
|
172
|
+
onClose={() => setConfirmDiscard(false)}
|
|
173
|
+
onConfirm={() => {
|
|
174
|
+
setDirty(false)
|
|
175
|
+
onOpenChange(false)
|
|
176
|
+
}}
|
|
177
|
+
title="Discard unsaved changes?"
|
|
178
|
+
description="You have unsaved schema changes. Closing now will discard them."
|
|
179
|
+
confirmLabel="Discard"
|
|
180
|
+
cancelLabel="Keep editing"
|
|
181
|
+
destructive
|
|
182
|
+
/>
|
|
183
|
+
</>
|
|
184
|
+
)
|
|
185
|
+
}
|