@actuate-media/cms-admin 0.21.0 → 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 (71) hide show
  1. package/dist/AdminRoot.d.ts.map +1 -1
  2. package/dist/AdminRoot.js +24 -0
  3. package/dist/AdminRoot.js.map +1 -1
  4. package/dist/__tests__/layout/sidebar-forms-badge.render.test.d.ts +2 -0
  5. package/dist/__tests__/layout/sidebar-forms-badge.render.test.d.ts.map +1 -0
  6. package/dist/__tests__/layout/sidebar-forms-badge.render.test.js +30 -0
  7. package/dist/__tests__/layout/sidebar-forms-badge.render.test.js.map +1 -0
  8. package/dist/__tests__/layout/sidebar-submenu.render.test.d.ts +2 -0
  9. package/dist/__tests__/layout/sidebar-submenu.render.test.d.ts.map +1 -0
  10. package/dist/__tests__/layout/sidebar-submenu.render.test.js +66 -0
  11. package/dist/__tests__/layout/sidebar-submenu.render.test.js.map +1 -0
  12. package/dist/__tests__/views/forms-list.render.test.d.ts +2 -0
  13. package/dist/__tests__/views/forms-list.render.test.d.ts.map +1 -0
  14. package/dist/__tests__/views/forms-list.render.test.js +122 -0
  15. package/dist/__tests__/views/forms-list.render.test.js.map +1 -0
  16. package/dist/actuate-admin.css +1 -1
  17. package/dist/components/ErrorBoundary.d.ts +1 -1
  18. package/dist/components/forms/Drawer.d.ts +13 -0
  19. package/dist/components/forms/Drawer.d.ts.map +1 -0
  20. package/dist/components/forms/Drawer.js +13 -0
  21. package/dist/components/forms/Drawer.js.map +1 -0
  22. package/dist/components/forms/EmbedPanel.d.ts +7 -0
  23. package/dist/components/forms/EmbedPanel.d.ts.map +1 -0
  24. package/dist/components/forms/EmbedPanel.js +91 -0
  25. package/dist/components/forms/EmbedPanel.js.map +1 -0
  26. package/dist/components/forms/FieldsPanel.d.ts +8 -0
  27. package/dist/components/forms/FieldsPanel.d.ts.map +1 -0
  28. package/dist/components/forms/FieldsPanel.js +123 -0
  29. package/dist/components/forms/FieldsPanel.js.map +1 -0
  30. package/dist/components/forms/FormSchemaDrawer.d.ts +9 -0
  31. package/dist/components/forms/FormSchemaDrawer.d.ts.map +1 -0
  32. package/dist/components/forms/FormSchemaDrawer.js +96 -0
  33. package/dist/components/forms/FormSchemaDrawer.js.map +1 -0
  34. package/dist/components/forms/NotificationsPanel.d.ts +6 -0
  35. package/dist/components/forms/NotificationsPanel.d.ts.map +1 -0
  36. package/dist/components/forms/NotificationsPanel.js +80 -0
  37. package/dist/components/forms/NotificationsPanel.js.map +1 -0
  38. package/dist/components/forms/primitives.d.ts +42 -0
  39. package/dist/components/forms/primitives.d.ts.map +1 -0
  40. package/dist/components/forms/primitives.js +96 -0
  41. package/dist/components/forms/primitives.js.map +1 -0
  42. package/dist/layout/Sidebar.d.ts +5 -0
  43. package/dist/layout/Sidebar.d.ts.map +1 -1
  44. package/dist/layout/Sidebar.js +143 -10
  45. package/dist/layout/Sidebar.js.map +1 -1
  46. package/dist/lib/forms-events.d.ts +17 -0
  47. package/dist/lib/forms-events.d.ts.map +1 -0
  48. package/dist/lib/forms-events.js +20 -0
  49. package/dist/lib/forms-events.js.map +1 -0
  50. package/dist/lib/forms-service.d.ts +80 -0
  51. package/dist/lib/forms-service.d.ts.map +1 -0
  52. package/dist/lib/forms-service.js +144 -0
  53. package/dist/lib/forms-service.js.map +1 -0
  54. package/dist/views/Forms.d.ts.map +1 -1
  55. package/dist/views/Forms.js +119 -16
  56. package/dist/views/Forms.js.map +1 -1
  57. package/package.json +2 -2
  58. package/src/AdminRoot.tsx +22 -0
  59. package/src/__tests__/layout/sidebar-forms-badge.render.test.tsx +50 -0
  60. package/src/__tests__/layout/sidebar-submenu.render.test.tsx +93 -0
  61. package/src/__tests__/views/forms-list.render.test.tsx +141 -0
  62. package/src/components/forms/Drawer.tsx +70 -0
  63. package/src/components/forms/EmbedPanel.tsx +173 -0
  64. package/src/components/forms/FieldsPanel.tsx +385 -0
  65. package/src/components/forms/FormSchemaDrawer.tsx +185 -0
  66. package/src/components/forms/NotificationsPanel.tsx +240 -0
  67. package/src/components/forms/primitives.tsx +200 -0
  68. package/src/layout/Sidebar.tsx +295 -14
  69. package/src/lib/forms-events.ts +32 -0
  70. package/src/lib/forms-service.ts +244 -0
  71. package/src/views/Forms.tsx +343 -104
@@ -1,136 +1,375 @@
1
1
  'use client'
2
2
 
3
- import { Plus, MoreVertical, FileText, Send, Search, Loader2, AlertTriangle } from 'lucide-react'
4
- import { useState, useMemo } from 'react'
3
+ /**
4
+ * Forms list manage forms and view high-level submission status. Matches the
5
+ * Forms list screenshot: summary chips, search + status filter pills, and a
6
+ * compact table (Form / Fields / Status / Entries / Last Entry / Notify /
7
+ * Schema). All data flows through `lib/forms-service.ts`; the Schema action
8
+ * opens the right-side `FormSchemaDrawer`.
9
+ */
10
+ import { ChevronRight, ClipboardList, Plus, Search, Settings2 } from 'lucide-react'
11
+ import * as Switch from '@radix-ui/react-switch'
12
+ import { useCallback, useEffect, useMemo, useState } from 'react'
13
+ import { toast } from 'sonner'
5
14
  import { sortByRelevance } from '../lib/search.js'
6
- import { useApiData } from '../lib/useApiData.js'
15
+ import {
16
+ fetchForms,
17
+ fetchFormStats,
18
+ setNotifyEnabled,
19
+ type FormListItem,
20
+ type FormStats,
21
+ type FormStatus,
22
+ } from '../lib/forms-service.js'
23
+ import { emitFormsChanged, onFormsChanged } from '../lib/forms-events.js'
24
+ import { FormSchemaDrawer } from '../components/forms/FormSchemaDrawer.js'
25
+ import {
26
+ FieldTypeBadge,
27
+ FormStatusBadge,
28
+ FormsEmptyState,
29
+ FormsErrorState,
30
+ FormsLoading,
31
+ SummaryChip,
32
+ UnreadBadge,
33
+ btnPrimary,
34
+ } from '../components/forms/primitives.js'
7
35
 
8
36
  export interface FormsProps {
9
37
  onNavigate?: (path: string) => void
10
38
  }
11
39
 
40
+ type StatusFilter = 'all' | FormStatus
41
+
42
+ const FILTERS: { id: StatusFilter; label: string }[] = [
43
+ { id: 'all', label: 'All' },
44
+ { id: 'active', label: 'Active' },
45
+ { id: 'draft', label: 'Draft' },
46
+ { id: 'archived', label: 'Archived' },
47
+ ]
48
+
49
+ function formatRelative(iso: string | null | undefined): string {
50
+ if (!iso) return '—'
51
+ const then = new Date(iso).getTime()
52
+ if (Number.isNaN(then)) return '—'
53
+ const diff = Date.now() - then
54
+ const mins = Math.round(diff / 60000)
55
+ if (mins < 1) return 'just now'
56
+ if (mins < 60) return `${mins} min ago`
57
+ const hrs = Math.round(mins / 60)
58
+ if (hrs < 24) return `${hrs} hour${hrs === 1 ? '' : 's'} ago`
59
+ const days = Math.round(hrs / 24)
60
+ if (days < 7) return `${days} day${days === 1 ? '' : 's'} ago`
61
+ return new Date(iso).toLocaleDateString(undefined, {
62
+ month: 'short',
63
+ day: 'numeric',
64
+ year: 'numeric',
65
+ })
66
+ }
67
+
12
68
  export function Forms({ onNavigate }: FormsProps) {
13
- const { data, loading, error, refetch } = useApiData<any[]>('/forms')
14
- const [searchQuery, setSearchQuery] = useState('')
69
+ const [forms, setForms] = useState<FormListItem[]>([])
70
+ const [stats, setStats] = useState<FormStats | null>(null)
71
+ const [loading, setLoading] = useState(true)
72
+ const [error, setError] = useState<string | null>(null)
73
+ const [search, setSearch] = useState('')
74
+ const [filter, setFilter] = useState<StatusFilter>('all')
75
+ const [schemaForm, setSchemaForm] = useState<FormListItem | null>(null)
76
+ const [schemaOpen, setSchemaOpen] = useState(false)
15
77
 
16
- const forms = data ?? []
78
+ const load = useCallback(async () => {
79
+ setLoading(true)
80
+ setError(null)
81
+ try {
82
+ const [formsData, statsData] = await Promise.all([fetchForms(), fetchFormStats()])
83
+ setForms(formsData)
84
+ setStats(statsData)
85
+ } catch (e) {
86
+ setError(e instanceof Error ? e.message : 'Failed to load forms')
87
+ } finally {
88
+ setLoading(false)
89
+ }
90
+ }, [])
91
+
92
+ useEffect(() => {
93
+ void load()
94
+ }, [load])
17
95
 
18
- const filteredAndSorted = useMemo(() => {
19
- let results = forms.filter(
20
- (form: any) =>
21
- (form.name ?? '').toLowerCase().includes(searchQuery.toLowerCase()) ||
22
- (form.description ?? '').toLowerCase().includes(searchQuery.toLowerCase()),
23
- )
96
+ // Refresh when another surface signals a forms/submission change.
97
+ useEffect(() => onFormsChanged(() => void load()), [load])
24
98
 
25
- if (searchQuery.trim()) {
26
- results = sortByRelevance(results, searchQuery, (f: any) => [f.name, f.description ?? ''])
99
+ const filtered = useMemo(() => {
100
+ let results = forms
101
+ if (filter !== 'all') results = results.filter((f) => f.status === filter)
102
+ const q = search.trim().toLowerCase()
103
+ if (q) {
104
+ results = results.filter(
105
+ (f) =>
106
+ f.name.toLowerCase().includes(q) ||
107
+ f.slug.toLowerCase().includes(q) ||
108
+ (f.description ?? '').toLowerCase().includes(q) ||
109
+ f.fields.some(
110
+ (field) =>
111
+ field.label.toLowerCase().includes(q) || field.type.toLowerCase().includes(q),
112
+ ),
113
+ )
114
+ results = sortByRelevance(results, search, (f) => [
115
+ f.name,
116
+ f.slug,
117
+ f.description ?? '',
118
+ ...f.fields.map((field) => field.label),
119
+ ])
27
120
  }
28
121
  return results
29
- }, [forms, searchQuery])
122
+ }, [forms, filter, search])
30
123
 
31
- if (loading) {
32
- return (
33
- <div className="flex h-64 items-center justify-center p-3 pr-6 sm:p-4 sm:pr-8">
34
- <Loader2 className="h-6 w-6 animate-spin text-blue-600" />
35
- </div>
36
- )
37
- }
124
+ const openSchema = useCallback((form: FormListItem) => {
125
+ setSchemaForm(form)
126
+ setSchemaOpen(true)
127
+ }, [])
38
128
 
39
- return (
40
- <div className="p-3 pr-6 sm:p-4 sm:pr-8">
41
- {error && (
42
- <div className="mb-4 flex items-center gap-3 rounded-lg border border-red-200 bg-red-50 p-3">
43
- <AlertTriangle className="h-5 w-5 shrink-0 text-red-600" />
44
- <span className="flex-1 text-sm text-red-800">{error}</span>
45
- <button
46
- onClick={refetch}
47
- className="rounded-lg border border-red-300 px-3 py-1 text-sm text-red-700 transition-colors hover:bg-red-100"
48
- >
49
- Retry
50
- </button>
51
- </div>
52
- )}
129
+ const handleNotifyToggle = useCallback(async (form: FormListItem, next: boolean) => {
130
+ // Optimistic update; roll back on error.
131
+ setForms((cur) => cur.map((f) => (f.id === form.id ? { ...f, notifyEnabled: next } : f)))
132
+ const res = await setNotifyEnabled(form.id, next)
133
+ if (res.error) {
134
+ setForms((cur) => cur.map((f) => (f.id === form.id ? { ...f, notifyEnabled: !next } : f)))
135
+ toast.error(res.error)
136
+ return
137
+ }
138
+ emitFormsChanged()
139
+ }, [])
53
140
 
54
- <div className="mb-4 flex items-center justify-between">
141
+ return (
142
+ <div className="space-y-5 p-6">
143
+ <header className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
55
144
  <div>
56
- <h1 className="mb-1 text-xl font-semibold text-gray-900 sm:text-2xl">Forms</h1>
57
- <p className="text-sm text-gray-600">{filteredAndSorted.length} total forms</p>
145
+ <h1 className="text-foreground text-2xl font-medium">Forms</h1>
146
+ <p className="text-muted-foreground mt-1 text-sm">
147
+ Manage forms and view incoming submissions
148
+ </p>
58
149
  </div>
59
- <button
60
- onClick={() => onNavigate?.('/forms/new')}
61
- className="flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm text-white transition-colors hover:bg-blue-700"
62
- >
63
- <Plus className="h-4 w-4" />
150
+ <button className={btnPrimary} onClick={() => onNavigate?.('/forms/new')}>
151
+ <Plus className="h-4 w-4" aria-hidden />
64
152
  New Form
65
153
  </button>
66
- </div>
154
+ </header>
67
155
 
68
- <div className="mb-4 rounded-lg border border-gray-200 bg-white">
69
- <div className="p-3">
70
- <div className="relative">
71
- <Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-gray-400" />
72
- <input
73
- type="text"
74
- placeholder="Search forms..."
75
- value={searchQuery}
76
- onChange={(e) => setSearchQuery(e.target.value)}
77
- className="w-full rounded-lg border border-gray-300 py-2 pr-3 pl-9 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
78
- />
79
- </div>
156
+ {stats && (
157
+ <div className="flex flex-wrap gap-2">
158
+ <SummaryChip value={stats.totalForms} label={stats.totalForms === 1 ? 'form' : 'forms'} />
159
+ <SummaryChip value={stats.activeForms} label="active" tone="success" />
160
+ <SummaryChip value={stats.totalEntries} label="total entries" />
161
+ <SummaryChip
162
+ value={stats.unreadEntries}
163
+ label="unread"
164
+ tone={stats.unreadEntries > 0 ? 'destructive' : 'neutral'}
165
+ />
80
166
  </div>
81
- </div>
167
+ )}
82
168
 
83
- {filteredAndSorted.length === 0 ? (
84
- <div className="rounded-lg border border-gray-200 bg-white p-8 text-center">
85
- <p className="mb-2 text-sm text-gray-500">No forms yet</p>
86
- <button className="rounded-lg bg-blue-600 px-4 py-2 text-sm text-white transition-colors hover:bg-blue-700">
87
- Create your first form
88
- </button>
169
+ <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
170
+ <div className="relative w-full sm:max-w-xs">
171
+ <Search
172
+ className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2"
173
+ aria-hidden
174
+ />
175
+ <input
176
+ type="search"
177
+ placeholder="Search forms…"
178
+ value={search}
179
+ onChange={(e) => setSearch(e.target.value)}
180
+ aria-label="Search forms"
181
+ className="border-border bg-input-background text-foreground focus-visible:ring-ring w-full rounded-lg border py-2 pr-3 pl-9 text-sm focus-visible:ring-2 focus-visible:outline-none"
182
+ />
89
183
  </div>
90
- ) : (
91
- <div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
92
- {filteredAndSorted.map((form: any) => (
93
- <div
94
- key={form.id}
95
- className="rounded-lg border border-gray-200 bg-white p-4 transition-shadow hover:shadow-md"
184
+ <div className="flex gap-1" role="group" aria-label="Filter by status">
185
+ {FILTERS.map((f) => (
186
+ <button
187
+ key={f.id}
188
+ type="button"
189
+ onClick={() => setFilter(f.id)}
190
+ aria-pressed={filter === f.id}
191
+ className={`rounded-full px-3 py-1.5 text-sm font-medium transition-colors ${
192
+ filter === f.id
193
+ ? 'bg-primary text-primary-foreground'
194
+ : 'text-muted-foreground hover:bg-accent hover:text-foreground'
195
+ }`}
96
196
  >
97
- <div className="mb-3 flex items-start justify-between">
98
- <div className="flex-1">
99
- <h3 className="mb-1 font-semibold text-gray-900">{form.name}</h3>
100
- <p className="mb-2 text-sm text-gray-600">{form.description}</p>
101
- </div>
102
- <button className="rounded p-1.5 transition-colors hover:bg-gray-100">
103
- <MoreVertical className="h-4 w-4 text-gray-600" />
104
- </button>
105
- </div>
106
- <div className="mb-3 flex items-center gap-4 text-sm text-gray-600">
107
- <span className="flex items-center gap-1">
108
- <FileText className="h-4 w-4" />
109
- {form.fields} fields
110
- </span>
111
- <span className="flex items-center gap-1">
112
- <Send className="h-4 w-4" />
113
- {form.submissions} submissions
114
- </span>
115
- </div>
116
- <div className="flex items-center gap-2">
117
- <button
118
- onClick={() => onNavigate?.(`/forms/${form.id}/submissions`)}
119
- className="flex-1 rounded-lg bg-blue-600 px-3 py-1.5 text-center text-sm text-white transition-colors hover:bg-blue-700"
120
- >
121
- View Submissions
122
- </button>
123
- <button
124
- onClick={() => onNavigate?.(`/forms/${form.id}/edit`)}
125
- className="rounded-lg border border-gray-300 px-3 py-1.5 text-sm transition-colors hover:bg-gray-50"
126
- >
127
- Edit
128
- </button>
129
- </div>
130
- </div>
197
+ {f.label}
198
+ </button>
131
199
  ))}
132
200
  </div>
201
+ </div>
202
+
203
+ {error ? (
204
+ <FormsErrorState message={error} onRetry={load} />
205
+ ) : loading ? (
206
+ <FormsLoading label="Loading forms…" />
207
+ ) : forms.length === 0 ? (
208
+ <FormsEmptyState
209
+ icon={<ClipboardList className="h-6 w-6" aria-hidden />}
210
+ title="No forms yet"
211
+ description="Create your first form to start collecting submissions."
212
+ action={
213
+ <button className={btnPrimary} onClick={() => onNavigate?.('/forms/new')}>
214
+ <Plus className="h-4 w-4" aria-hidden />
215
+ Create New Form
216
+ </button>
217
+ }
218
+ />
219
+ ) : filtered.length === 0 ? (
220
+ <FormsEmptyState
221
+ title="No forms match your filters"
222
+ description="Try a different search term or status filter."
223
+ action={
224
+ <button
225
+ type="button"
226
+ onClick={() => {
227
+ setSearch('')
228
+ setFilter('all')
229
+ }}
230
+ className="text-primary text-sm font-medium hover:underline"
231
+ >
232
+ Clear filters
233
+ </button>
234
+ }
235
+ />
236
+ ) : (
237
+ <FormsTable
238
+ forms={filtered}
239
+ onOpenForm={(id) => onNavigate?.(`/forms/${id}/edit`)}
240
+ onOpenEntries={(id) => onNavigate?.(`/forms/${id}/submissions`)}
241
+ onOpenSchema={openSchema}
242
+ onNotifyToggle={handleNotifyToggle}
243
+ />
133
244
  )}
245
+
246
+ <FormSchemaDrawer
247
+ form={schemaForm}
248
+ open={schemaOpen}
249
+ onOpenChange={setSchemaOpen}
250
+ onSaved={load}
251
+ />
252
+ </div>
253
+ )
254
+ }
255
+
256
+ function FormsTable({
257
+ forms,
258
+ onOpenForm,
259
+ onOpenEntries,
260
+ onOpenSchema,
261
+ onNotifyToggle,
262
+ }: {
263
+ forms: FormListItem[]
264
+ onOpenForm: (id: string) => void
265
+ onOpenEntries: (id: string) => void
266
+ onOpenSchema: (form: FormListItem) => void
267
+ onNotifyToggle: (form: FormListItem, next: boolean) => void
268
+ }) {
269
+ return (
270
+ <div className="border-border bg-card overflow-x-auto rounded-lg border">
271
+ <table className="w-full min-w-[760px] text-sm" aria-label="Forms">
272
+ <thead>
273
+ <tr className="border-border text-muted-foreground border-b text-left text-xs">
274
+ <th scope="col" className="px-4 py-2.5 font-medium">
275
+ Form
276
+ </th>
277
+ <th scope="col" className="px-4 py-2.5 font-medium">
278
+ Fields
279
+ </th>
280
+ <th scope="col" className="px-4 py-2.5 font-medium">
281
+ Status
282
+ </th>
283
+ <th scope="col" className="px-4 py-2.5 font-medium">
284
+ Entries
285
+ </th>
286
+ <th scope="col" className="px-4 py-2.5 font-medium">
287
+ Last Entry
288
+ </th>
289
+ <th scope="col" className="px-4 py-2.5 text-center font-medium">
290
+ Notify
291
+ </th>
292
+ <th scope="col" className="px-4 py-2.5 text-right font-medium">
293
+ <span className="sr-only">Schema</span>
294
+ </th>
295
+ </tr>
296
+ </thead>
297
+ <tbody>
298
+ {forms.map((form) => {
299
+ const fieldCount = form.fieldCount ?? form.fields.length
300
+ const unread = form.unreadEntries ?? 0
301
+ const muted = form.status === 'archived'
302
+ return (
303
+ <tr
304
+ key={form.id}
305
+ className={`border-border hover:bg-accent/40 border-b transition-colors last:border-b-0 ${muted ? 'opacity-60' : ''}`}
306
+ >
307
+ <td className="px-4 py-3">
308
+ <button
309
+ type="button"
310
+ onClick={() => onOpenForm(form.id)}
311
+ className="block max-w-xs text-left"
312
+ >
313
+ <span className="text-foreground block truncate font-medium hover:underline">
314
+ {form.name}
315
+ </span>
316
+ <span className="text-muted-foreground block truncate text-xs">
317
+ /{form.slug}
318
+ {form.description ? ` · ${form.description}` : ''}
319
+ </span>
320
+ </button>
321
+ </td>
322
+ <td className="px-4 py-3">
323
+ <div className="flex flex-wrap items-center gap-1">
324
+ {form.fields.slice(0, 4).map((field) => (
325
+ <FieldTypeBadge key={field.id} type={field.type} />
326
+ ))}
327
+ <span className="text-muted-foreground text-xs">{fieldCount} fields</span>
328
+ </div>
329
+ </td>
330
+ <td className="px-4 py-3">
331
+ <FormStatusBadge status={form.status} />
332
+ </td>
333
+ <td className="px-4 py-3">
334
+ <button
335
+ type="button"
336
+ onClick={() => onOpenEntries(form.id)}
337
+ className="text-foreground hover:text-primary focus-visible:ring-ring inline-flex items-center gap-1.5 rounded font-medium tabular-nums focus-visible:ring-2 focus-visible:outline-none"
338
+ aria-label={`View ${form.totalEntries ?? 0} entries for ${form.name}`}
339
+ >
340
+ {form.totalEntries ?? 0}
341
+ <UnreadBadge count={unread} />
342
+ <ChevronRight className="text-muted-foreground h-3.5 w-3.5" aria-hidden />
343
+ </button>
344
+ </td>
345
+ <td className="text-muted-foreground px-4 py-3 whitespace-nowrap">
346
+ {formatRelative(form.lastEntryAt)}
347
+ </td>
348
+ <td className="px-4 py-3 text-center">
349
+ <Switch.Root
350
+ checked={form.notifyEnabled ?? false}
351
+ onCheckedChange={(v) => onNotifyToggle(form, v)}
352
+ aria-label={`Notifications for ${form.name}`}
353
+ className="bg-input-background data-[state=checked]:bg-primary focus-visible:ring-ring relative inline-flex h-[18px] w-8 shrink-0 rounded-full transition-colors focus-visible:ring-2 focus-visible:outline-none"
354
+ >
355
+ <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]" />
356
+ </Switch.Root>
357
+ </td>
358
+ <td className="px-4 py-3 text-right">
359
+ <button
360
+ type="button"
361
+ onClick={() => onOpenSchema(form)}
362
+ className="border-border text-foreground hover:bg-accent focus-visible:ring-ring inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none"
363
+ >
364
+ <Settings2 className="h-3.5 w-3.5" aria-hidden />
365
+ Schema
366
+ </button>
367
+ </td>
368
+ </tr>
369
+ )
370
+ })}
371
+ </tbody>
372
+ </table>
134
373
  </div>
135
374
  )
136
375
  }