@actuate-media/cms-admin 0.21.1 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/dist/__tests__/layout/sidebar-forms-badge.render.test.d.ts +2 -0
  2. package/dist/__tests__/layout/sidebar-forms-badge.render.test.d.ts.map +1 -0
  3. package/dist/__tests__/layout/sidebar-forms-badge.render.test.js +30 -0
  4. package/dist/__tests__/layout/sidebar-forms-badge.render.test.js.map +1 -0
  5. package/dist/__tests__/layout/sidebar-submenu.render.test.js +8 -2
  6. package/dist/__tests__/layout/sidebar-submenu.render.test.js.map +1 -1
  7. package/dist/__tests__/views/forms-list.render.test.d.ts +2 -0
  8. package/dist/__tests__/views/forms-list.render.test.d.ts.map +1 -0
  9. package/dist/__tests__/views/forms-list.render.test.js +122 -0
  10. package/dist/__tests__/views/forms-list.render.test.js.map +1 -0
  11. package/dist/actuate-admin.css +1 -1
  12. package/dist/components/forms/Drawer.d.ts +13 -0
  13. package/dist/components/forms/Drawer.d.ts.map +1 -0
  14. package/dist/components/forms/Drawer.js +13 -0
  15. package/dist/components/forms/Drawer.js.map +1 -0
  16. package/dist/components/forms/EmbedPanel.d.ts +7 -0
  17. package/dist/components/forms/EmbedPanel.d.ts.map +1 -0
  18. package/dist/components/forms/EmbedPanel.js +91 -0
  19. package/dist/components/forms/EmbedPanel.js.map +1 -0
  20. package/dist/components/forms/FieldsPanel.d.ts +8 -0
  21. package/dist/components/forms/FieldsPanel.d.ts.map +1 -0
  22. package/dist/components/forms/FieldsPanel.js +123 -0
  23. package/dist/components/forms/FieldsPanel.js.map +1 -0
  24. package/dist/components/forms/FormSchemaDrawer.d.ts +9 -0
  25. package/dist/components/forms/FormSchemaDrawer.d.ts.map +1 -0
  26. package/dist/components/forms/FormSchemaDrawer.js +96 -0
  27. package/dist/components/forms/FormSchemaDrawer.js.map +1 -0
  28. package/dist/components/forms/NotificationsPanel.d.ts +6 -0
  29. package/dist/components/forms/NotificationsPanel.d.ts.map +1 -0
  30. package/dist/components/forms/NotificationsPanel.js +80 -0
  31. package/dist/components/forms/NotificationsPanel.js.map +1 -0
  32. package/dist/components/forms/primitives.d.ts +42 -0
  33. package/dist/components/forms/primitives.d.ts.map +1 -0
  34. package/dist/components/forms/primitives.js +96 -0
  35. package/dist/components/forms/primitives.js.map +1 -0
  36. package/dist/layout/Sidebar.d.ts +5 -0
  37. package/dist/layout/Sidebar.d.ts.map +1 -1
  38. package/dist/layout/Sidebar.js +43 -3
  39. package/dist/layout/Sidebar.js.map +1 -1
  40. package/dist/lib/forms-events.d.ts +17 -0
  41. package/dist/lib/forms-events.d.ts.map +1 -0
  42. package/dist/lib/forms-events.js +20 -0
  43. package/dist/lib/forms-events.js.map +1 -0
  44. package/dist/lib/forms-service.d.ts +80 -0
  45. package/dist/lib/forms-service.d.ts.map +1 -0
  46. package/dist/lib/forms-service.js +144 -0
  47. package/dist/lib/forms-service.js.map +1 -0
  48. package/dist/views/Forms.d.ts.map +1 -1
  49. package/dist/views/Forms.js +119 -17
  50. package/dist/views/Forms.js.map +1 -1
  51. package/package.json +1 -1
  52. package/src/__tests__/layout/sidebar-forms-badge.render.test.tsx +50 -0
  53. package/src/__tests__/layout/sidebar-submenu.render.test.tsx +9 -2
  54. package/src/__tests__/views/forms-list.render.test.tsx +141 -0
  55. package/src/components/forms/Drawer.tsx +70 -0
  56. package/src/components/forms/EmbedPanel.tsx +173 -0
  57. package/src/components/forms/FieldsPanel.tsx +385 -0
  58. package/src/components/forms/FormSchemaDrawer.tsx +185 -0
  59. package/src/components/forms/NotificationsPanel.tsx +240 -0
  60. package/src/components/forms/primitives.tsx +200 -0
  61. package/src/layout/Sidebar.tsx +72 -6
  62. package/src/lib/forms-events.ts +32 -0
  63. package/src/lib/forms-service.ts +244 -0
  64. package/src/views/Forms.tsx +343 -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
+ }