@actuate-media/cms-admin 0.23.0 → 0.25.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 (62) hide show
  1. package/dist/AdminRoot.d.ts.map +1 -1
  2. package/dist/AdminRoot.js +4 -0
  3. package/dist/AdminRoot.js.map +1 -1
  4. package/dist/__tests__/layout/sidebar-forms-submenu.render.test.d.ts +2 -0
  5. package/dist/__tests__/layout/sidebar-forms-submenu.render.test.d.ts.map +1 -0
  6. package/dist/__tests__/layout/sidebar-forms-submenu.render.test.js +28 -0
  7. package/dist/__tests__/layout/sidebar-forms-submenu.render.test.js.map +1 -0
  8. package/dist/__tests__/views/form-entries.render.test.d.ts +2 -0
  9. package/dist/__tests__/views/form-entries.render.test.d.ts.map +1 -0
  10. package/dist/__tests__/views/form-entries.render.test.js +112 -0
  11. package/dist/__tests__/views/form-entries.render.test.js.map +1 -0
  12. package/dist/__tests__/views/page-builder-block-renderer.render.test.d.ts +2 -0
  13. package/dist/__tests__/views/page-builder-block-renderer.render.test.d.ts.map +1 -0
  14. package/dist/__tests__/views/page-builder-block-renderer.render.test.js +48 -0
  15. package/dist/__tests__/views/page-builder-block-renderer.render.test.js.map +1 -0
  16. package/dist/__tests__/views/page-builder-form-picker.render.test.d.ts +2 -0
  17. package/dist/__tests__/views/page-builder-form-picker.render.test.d.ts.map +1 -0
  18. package/dist/__tests__/views/page-builder-form-picker.render.test.js +44 -0
  19. package/dist/__tests__/views/page-builder-form-picker.render.test.js.map +1 -0
  20. package/dist/actuate-admin.css +1 -1
  21. package/dist/components/forms/EntriesTable.d.ts +28 -0
  22. package/dist/components/forms/EntriesTable.d.ts.map +1 -0
  23. package/dist/components/forms/EntriesTable.js +26 -0
  24. package/dist/components/forms/EntriesTable.js.map +1 -0
  25. package/dist/layout/Sidebar.d.ts.map +1 -1
  26. package/dist/layout/Sidebar.js +44 -21
  27. package/dist/layout/Sidebar.js.map +1 -1
  28. package/dist/views/FormEntries.d.ts +5 -0
  29. package/dist/views/FormEntries.d.ts.map +1 -0
  30. package/dist/views/FormEntries.js +211 -0
  31. package/dist/views/FormEntries.js.map +1 -0
  32. package/dist/views/FormSubmissions.d.ts.map +1 -1
  33. package/dist/views/FormSubmissions.js +4 -24
  34. package/dist/views/FormSubmissions.js.map +1 -1
  35. package/dist/views/page-builder/BlockEditor.d.ts.map +1 -1
  36. package/dist/views/page-builder/BlockEditor.js +2 -1
  37. package/dist/views/page-builder/BlockEditor.js.map +1 -1
  38. package/dist/views/page-builder/RelationshipField.d.ts +15 -0
  39. package/dist/views/page-builder/RelationshipField.d.ts.map +1 -0
  40. package/dist/views/page-builder/RelationshipField.js +65 -0
  41. package/dist/views/page-builder/RelationshipField.js.map +1 -0
  42. package/dist/views/page-builder/block-renderers/FormPreview.d.ts +6 -0
  43. package/dist/views/page-builder/block-renderers/FormPreview.d.ts.map +1 -1
  44. package/dist/views/page-builder/block-renderers/FormPreview.js +44 -6
  45. package/dist/views/page-builder/block-renderers/FormPreview.js.map +1 -1
  46. package/dist/views/page-builder/canvas/BlockRenderer.d.ts.map +1 -1
  47. package/dist/views/page-builder/canvas/BlockRenderer.js +3 -9
  48. package/dist/views/page-builder/canvas/BlockRenderer.js.map +1 -1
  49. package/package.json +2 -2
  50. package/src/AdminRoot.tsx +4 -0
  51. package/src/__tests__/layout/sidebar-forms-submenu.render.test.tsx +38 -0
  52. package/src/__tests__/views/form-entries.render.test.tsx +133 -0
  53. package/src/__tests__/views/page-builder-block-renderer.render.test.tsx +66 -0
  54. package/src/__tests__/views/page-builder-form-picker.render.test.tsx +54 -0
  55. package/src/components/forms/EntriesTable.tsx +197 -0
  56. package/src/layout/Sidebar.tsx +75 -17
  57. package/src/views/FormEntries.tsx +449 -0
  58. package/src/views/FormSubmissions.tsx +4 -149
  59. package/src/views/page-builder/BlockEditor.tsx +7 -10
  60. package/src/views/page-builder/RelationshipField.tsx +113 -0
  61. package/src/views/page-builder/block-renderers/FormPreview.tsx +85 -33
  62. package/src/views/page-builder/canvas/BlockRenderer.tsx +10 -12
@@ -0,0 +1,449 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * All Entries — a cross-form submissions inbox. Same token-only table, search,
5
+ * status pills (All / Unread / Starred / Archived), bulk actions, CSV export,
6
+ * pagination, and detail drawer as the per-form Submissions view, plus a Form
7
+ * filter and a Form column so editors can triage every form from one place.
8
+ */
9
+ import { useCallback, useEffect, useState } from 'react'
10
+ import { Download, Inbox, Search } from 'lucide-react'
11
+ import { toast } from 'sonner'
12
+ import {
13
+ bulkUpdateEntries,
14
+ deleteEntry,
15
+ fetchEntries,
16
+ fetchEntryCounts,
17
+ fetchForms,
18
+ updateEntry,
19
+ type EntryCounts,
20
+ type FetchFormEntriesParams,
21
+ type FormListItem,
22
+ type FormSubmission,
23
+ } from '../lib/forms-service.js'
24
+ import { emitFormsChanged, onFormsChanged } from '../lib/forms-events.js'
25
+ import { EntryDetailDrawer } from '../components/forms/EntryDetailDrawer.js'
26
+ import { EntriesTable } from '../components/forms/EntriesTable.js'
27
+ import { ConfirmDialog } from '../components/ui/ConfirmDialog.js'
28
+ import {
29
+ FormsEmptyState,
30
+ FormsErrorState,
31
+ FormsLoading,
32
+ FormsPagination,
33
+ SummaryChip,
34
+ btnSecondary,
35
+ } from '../components/forms/primitives.js'
36
+
37
+ export interface FormEntriesProps {
38
+ onNavigate?: (path: string) => void
39
+ }
40
+
41
+ type StatusFilter = 'all' | 'unread' | 'starred' | 'archived'
42
+
43
+ const FILTERS: { id: StatusFilter; label: string }[] = [
44
+ { id: 'all', label: 'All' },
45
+ { id: 'unread', label: 'Unread' },
46
+ { id: 'starred', label: 'Starred' },
47
+ { id: 'archived', label: 'Archived' },
48
+ ]
49
+
50
+ const PAGE_SIZE = 25
51
+
52
+ function filterToParams(filter: StatusFilter): Partial<FetchFormEntriesParams> {
53
+ switch (filter) {
54
+ case 'unread':
55
+ return { unread: true }
56
+ case 'starred':
57
+ return { starred: true }
58
+ case 'archived':
59
+ return { archived: true }
60
+ default:
61
+ return {}
62
+ }
63
+ }
64
+
65
+ function csvEscape(value: unknown): string {
66
+ const s = value == null ? '' : typeof value === 'object' ? JSON.stringify(value) : String(value)
67
+ return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s
68
+ }
69
+
70
+ function exportCsv(entries: FormSubmission[]) {
71
+ const dataKeys = [...new Set(entries.flatMap((e) => Object.keys(e.data ?? {})))]
72
+ const header = ['Entry', 'Form', 'Submitted', 'Name', 'Email', 'Phone', 'Status', ...dataKeys]
73
+ const lines = [header.map(csvEscape).join(',')]
74
+ for (const e of entries) {
75
+ const row = [
76
+ e.entryNumber ?? '',
77
+ e.formName ?? '',
78
+ e.submittedAt,
79
+ e.senderName ?? '',
80
+ e.senderEmail ?? '',
81
+ e.senderPhone ?? '',
82
+ e.unread ? 'unread' : 'read',
83
+ ...dataKeys.map((k) => e.data?.[k]),
84
+ ]
85
+ lines.push(row.map(csvEscape).join(','))
86
+ }
87
+ const blob = new Blob([lines.join('\n')], { type: 'text/csv;charset=utf-8' })
88
+ const url = URL.createObjectURL(blob)
89
+ const a = document.createElement('a')
90
+ a.href = url
91
+ a.download = 'all-entries.csv'
92
+ a.click()
93
+ URL.revokeObjectURL(url)
94
+ }
95
+
96
+ export function FormEntries({ onNavigate }: FormEntriesProps) {
97
+ const [entries, setEntries] = useState<FormSubmission[]>([])
98
+ const [total, setTotal] = useState(0)
99
+ const [counts, setCounts] = useState<EntryCounts | null>(null)
100
+ const [forms, setForms] = useState<FormListItem[]>([])
101
+ const [loading, setLoading] = useState(true)
102
+ const [error, setError] = useState<string | null>(null)
103
+ const [search, setSearch] = useState('')
104
+ const [filter, setFilter] = useState<StatusFilter>('all')
105
+ const [formFilter, setFormFilter] = useState<string>('all')
106
+ const [page, setPage] = useState(1)
107
+ const [selected, setSelected] = useState<Set<string>>(new Set())
108
+ const [activeEntry, setActiveEntry] = useState<string | null>(null)
109
+ const [drawerOpen, setDrawerOpen] = useState(false)
110
+ const [confirmBulkDelete, setConfirmBulkDelete] = useState(false)
111
+
112
+ const scopedFormId = formFilter === 'all' ? undefined : formFilter
113
+
114
+ // Forms for the filter dropdown (loads once).
115
+ useEffect(() => {
116
+ let cancelled = false
117
+ void fetchForms()
118
+ .then((list) => {
119
+ if (!cancelled) setForms(list)
120
+ })
121
+ .catch(() => {})
122
+ return () => {
123
+ cancelled = true
124
+ }
125
+ }, [])
126
+
127
+ const loadCounts = useCallback(() => {
128
+ void fetchEntryCounts({ formId: scopedFormId })
129
+ .then(setCounts)
130
+ .catch(() => {})
131
+ }, [scopedFormId])
132
+
133
+ const load = useCallback(async () => {
134
+ setLoading(true)
135
+ setError(null)
136
+ try {
137
+ const result = await fetchEntries({
138
+ formId: scopedFormId,
139
+ search: search.trim() || undefined,
140
+ page,
141
+ pageSize: PAGE_SIZE,
142
+ ...filterToParams(filter),
143
+ })
144
+ setEntries(result.entries)
145
+ setTotal(result.total)
146
+ setSelected(new Set())
147
+ } catch (e) {
148
+ setError(e instanceof Error ? e.message : 'Failed to load entries')
149
+ } finally {
150
+ setLoading(false)
151
+ }
152
+ }, [scopedFormId, search, filter, page])
153
+
154
+ useEffect(() => {
155
+ void load()
156
+ }, [load])
157
+
158
+ useEffect(() => {
159
+ loadCounts()
160
+ }, [loadCounts])
161
+
162
+ useEffect(() => onFormsChanged(() => loadCounts()), [loadCounts])
163
+
164
+ const refresh = useCallback(() => {
165
+ void load()
166
+ loadCounts()
167
+ }, [load, loadCounts])
168
+
169
+ const openEntry = useCallback((id: string) => {
170
+ setActiveEntry(id)
171
+ setDrawerOpen(true)
172
+ setEntries((cur) => cur.map((e) => (e.id === id ? { ...e, unread: false } : e)))
173
+ }, [])
174
+
175
+ const toggleStar = useCallback(async (entry: FormSubmission) => {
176
+ const next = !entry.starred
177
+ setEntries((cur) => cur.map((e) => (e.id === entry.id ? { ...e, starred: next } : e)))
178
+ const res = await updateEntry(entry.id, { starred: next })
179
+ if (res.error) {
180
+ setEntries((cur) => cur.map((e) => (e.id === entry.id ? { ...e, starred: !next } : e)))
181
+ toast.error(res.error)
182
+ return
183
+ }
184
+ emitFormsChanged()
185
+ }, [])
186
+
187
+ const allSelected = entries.length > 0 && selected.size === entries.length
188
+ const toggleSelectAll = useCallback(() => {
189
+ setSelected((cur) =>
190
+ cur.size === entries.length ? new Set() : new Set(entries.map((e) => e.id)),
191
+ )
192
+ }, [entries])
193
+
194
+ const toggleSelect = useCallback((id: string) => {
195
+ setSelected((cur) => {
196
+ const next = new Set(cur)
197
+ if (next.has(id)) next.delete(id)
198
+ else next.add(id)
199
+ return next
200
+ })
201
+ }, [])
202
+
203
+ const bulkPatch = useCallback(
204
+ async (patch: { unread?: boolean; starred?: boolean; archived?: boolean }, label: string) => {
205
+ const ids = [...selected]
206
+ if (ids.length === 0) return
207
+ const res = await bulkUpdateEntries(ids, patch)
208
+ if (res.error) {
209
+ toast.error(res.error)
210
+ return
211
+ }
212
+ toast.success(`${ids.length} ${ids.length === 1 ? 'entry' : 'entries'} ${label}`)
213
+ emitFormsChanged()
214
+ refresh()
215
+ },
216
+ [selected, refresh],
217
+ )
218
+
219
+ const handleBulkDelete = useCallback(async () => {
220
+ const ids = [...selected]
221
+ if (ids.length === 0) return
222
+ const results = await Promise.allSettled(ids.map((id) => deleteEntry(id)))
223
+ const failed = results.filter((r) => r.status === 'rejected' || r.value.error).length
224
+ if (failed > 0) toast.error(`${failed} of ${ids.length} could not be deleted`)
225
+ else toast.success(`${ids.length} ${ids.length === 1 ? 'entry' : 'entries'} deleted`)
226
+ emitFormsChanged()
227
+ refresh()
228
+ }, [selected, refresh])
229
+
230
+ const showEmpty = !loading && !error && entries.length === 0
231
+ const isFiltered = filter !== 'all' || formFilter !== 'all' || search.trim().length > 0
232
+
233
+ return (
234
+ <div className="space-y-5 p-6">
235
+ <div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
236
+ <div>
237
+ <h1 className="text-foreground text-2xl font-medium">All Entries</h1>
238
+ <p className="text-muted-foreground mt-1 text-sm">
239
+ {total} {total === 1 ? 'submission' : 'submissions'} across your forms
240
+ </p>
241
+ </div>
242
+ <button
243
+ type="button"
244
+ className={btnSecondary}
245
+ onClick={() => exportCsv(entries)}
246
+ disabled={entries.length === 0}
247
+ >
248
+ <Download className="h-4 w-4" aria-hidden />
249
+ Export CSV
250
+ </button>
251
+ </div>
252
+
253
+ {counts && (
254
+ <div className="flex flex-wrap gap-2">
255
+ <SummaryChip value={counts.total} label="total" />
256
+ <SummaryChip
257
+ value={counts.unread}
258
+ label="unread"
259
+ tone={counts.unread > 0 ? 'destructive' : 'neutral'}
260
+ />
261
+ <SummaryChip value={counts.starred} label="starred" tone="warning" />
262
+ <SummaryChip value={counts.thisWeek} label="this week" />
263
+ </div>
264
+ )}
265
+
266
+ <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
267
+ <div className="flex flex-1 flex-col gap-3 sm:flex-row sm:items-center">
268
+ <div className="relative w-full sm:max-w-xs">
269
+ <Search
270
+ className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2"
271
+ aria-hidden
272
+ />
273
+ <input
274
+ type="search"
275
+ placeholder="Search entries…"
276
+ value={search}
277
+ onChange={(e) => {
278
+ setSearch(e.target.value)
279
+ setPage(1)
280
+ }}
281
+ aria-label="Search entries"
282
+ 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"
283
+ />
284
+ </div>
285
+ <select
286
+ value={formFilter}
287
+ onChange={(e) => {
288
+ setFormFilter(e.target.value)
289
+ setPage(1)
290
+ }}
291
+ aria-label="Filter by form"
292
+ className="border-border bg-input-background text-foreground focus-visible:ring-ring rounded-lg border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:outline-none"
293
+ >
294
+ <option value="all">All forms</option>
295
+ {forms.map((f) => (
296
+ <option key={f.id} value={f.id}>
297
+ {f.name}
298
+ </option>
299
+ ))}
300
+ </select>
301
+ </div>
302
+ <div className="flex gap-1" role="group" aria-label="Filter entries">
303
+ {FILTERS.map((f) => (
304
+ <button
305
+ key={f.id}
306
+ type="button"
307
+ onClick={() => {
308
+ setFilter(f.id)
309
+ setPage(1)
310
+ }}
311
+ aria-pressed={filter === f.id}
312
+ className={`rounded-full px-3 py-1.5 text-sm font-medium transition-colors ${
313
+ filter === f.id
314
+ ? 'bg-primary text-primary-foreground'
315
+ : 'text-muted-foreground hover:bg-accent hover:text-foreground'
316
+ }`}
317
+ >
318
+ {f.label}
319
+ </button>
320
+ ))}
321
+ </div>
322
+ </div>
323
+
324
+ {selected.size > 0 && (
325
+ <div
326
+ className="border-border bg-accent/40 flex flex-wrap items-center gap-2 rounded-lg border px-3 py-2 text-sm"
327
+ role="region"
328
+ aria-label="Bulk actions"
329
+ >
330
+ <span className="text-foreground font-medium">{selected.size} selected</span>
331
+ <span className="bg-border mx-1 h-4 w-px" aria-hidden />
332
+ <button
333
+ type="button"
334
+ className="text-foreground hover:bg-accent rounded-md px-2 py-1 font-medium transition-colors"
335
+ onClick={() => void bulkPatch({ unread: false }, 'marked read')}
336
+ >
337
+ Mark read
338
+ </button>
339
+ <button
340
+ type="button"
341
+ className="text-foreground hover:bg-accent rounded-md px-2 py-1 font-medium transition-colors"
342
+ onClick={() => void bulkPatch({ starred: true }, 'starred')}
343
+ >
344
+ Star
345
+ </button>
346
+ <button
347
+ type="button"
348
+ className="text-foreground hover:bg-accent rounded-md px-2 py-1 font-medium transition-colors"
349
+ onClick={() =>
350
+ void bulkPatch(
351
+ { archived: filter !== 'archived' },
352
+ filter !== 'archived' ? 'archived' : 'unarchived',
353
+ )
354
+ }
355
+ >
356
+ {filter === 'archived' ? 'Unarchive' : 'Archive'}
357
+ </button>
358
+ <button
359
+ type="button"
360
+ className="text-destructive hover:bg-destructive/10 rounded-md px-2 py-1 font-medium transition-colors"
361
+ onClick={() => setConfirmBulkDelete(true)}
362
+ >
363
+ Delete
364
+ </button>
365
+ <button
366
+ type="button"
367
+ className="text-muted-foreground hover:text-foreground ml-auto rounded-md px-2 py-1 font-medium transition-colors"
368
+ onClick={() => setSelected(new Set())}
369
+ >
370
+ Clear
371
+ </button>
372
+ </div>
373
+ )}
374
+
375
+ {error ? (
376
+ <FormsErrorState message={error} onRetry={load} />
377
+ ) : loading ? (
378
+ <FormsLoading label="Loading entries…" />
379
+ ) : showEmpty ? (
380
+ <FormsEmptyState
381
+ icon={<Inbox className="h-6 w-6" aria-hidden />}
382
+ title={isFiltered ? 'No entries match your filters' : 'No entries yet'}
383
+ description={
384
+ isFiltered
385
+ ? 'Try a different search term, form, or status filter.'
386
+ : 'Submissions across all your forms will appear here.'
387
+ }
388
+ action={
389
+ isFiltered ? (
390
+ <button
391
+ type="button"
392
+ onClick={() => {
393
+ setSearch('')
394
+ setFilter('all')
395
+ setFormFilter('all')
396
+ setPage(1)
397
+ }}
398
+ className="text-primary text-sm font-medium hover:underline"
399
+ >
400
+ Clear filters
401
+ </button>
402
+ ) : undefined
403
+ }
404
+ />
405
+ ) : (
406
+ <EntriesTable
407
+ entries={entries}
408
+ selected={selected}
409
+ allSelected={allSelected}
410
+ onToggleSelectAll={toggleSelectAll}
411
+ onToggleSelect={toggleSelect}
412
+ onOpen={openEntry}
413
+ onToggleStar={toggleStar}
414
+ showForm
415
+ onOpenForm={(id) => onNavigate?.(`/forms/${id}/submissions`)}
416
+ ariaLabel="All entries"
417
+ footer={
418
+ <FormsPagination
419
+ page={page}
420
+ pageSize={PAGE_SIZE}
421
+ total={total}
422
+ onPageChange={setPage}
423
+ noun="submission"
424
+ label="Entries pagination"
425
+ />
426
+ }
427
+ />
428
+ )}
429
+
430
+ <EntryDetailDrawer
431
+ entryId={activeEntry}
432
+ open={drawerOpen}
433
+ onOpenChange={setDrawerOpen}
434
+ onChanged={refresh}
435
+ onDeleted={() => refresh()}
436
+ />
437
+
438
+ <ConfirmDialog
439
+ open={confirmBulkDelete}
440
+ onClose={() => setConfirmBulkDelete(false)}
441
+ onConfirm={() => void handleBulkDelete()}
442
+ title={`Delete ${selected.size} ${selected.size === 1 ? 'submission' : 'submissions'}?`}
443
+ description="This permanently removes the selected submissions and any attached files. This cannot be undone."
444
+ confirmLabel="Delete"
445
+ destructive
446
+ />
447
+ </div>
448
+ )
449
+ }
@@ -10,8 +10,8 @@
10
10
  * row click (marking the entry read). Loading / empty / filtered-empty / error
11
11
  * states are all handled.
12
12
  */
13
- import { useCallback, useEffect, useState, type ReactNode } from 'react'
14
- import { ArrowLeft, Download, Inbox, Search, Star, Trash2 } from 'lucide-react'
13
+ import { useCallback, useEffect, useState } from 'react'
14
+ import { ArrowLeft, Download, Inbox, Search } from 'lucide-react'
15
15
  import { toast } from 'sonner'
16
16
  import {
17
17
  bulkUpdateEntries,
@@ -26,6 +26,7 @@ import {
26
26
  } from '../lib/forms-service.js'
27
27
  import { emitFormsChanged, onFormsChanged } from '../lib/forms-events.js'
28
28
  import { EntryDetailDrawer } from '../components/forms/EntryDetailDrawer.js'
29
+ import { EntriesTable } from '../components/forms/EntriesTable.js'
29
30
  import { ConfirmDialog } from '../components/ui/ConfirmDialog.js'
30
31
  import {
31
32
  FormsEmptyState,
@@ -33,7 +34,6 @@ import {
33
34
  FormsLoading,
34
35
  FormsPagination,
35
36
  SummaryChip,
36
- btnGhostIcon,
37
37
  btnSecondary,
38
38
  } from '../components/forms/primitives.js'
39
39
 
@@ -66,19 +66,6 @@ function filterToParams(filter: StatusFilter): Partial<FetchFormEntriesParams> {
66
66
  }
67
67
  }
68
68
 
69
- function formatDateTime(iso: string | null | undefined): string {
70
- if (!iso) return '—'
71
- const d = new Date(iso)
72
- if (Number.isNaN(d.getTime())) return '—'
73
- return d.toLocaleString(undefined, {
74
- month: 'short',
75
- day: 'numeric',
76
- year: 'numeric',
77
- hour: 'numeric',
78
- minute: '2-digit',
79
- })
80
- }
81
-
82
69
  function csvEscape(value: unknown): string {
83
70
  const s = value == null ? '' : typeof value === 'object' ? JSON.stringify(value) : String(value)
84
71
  return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s
@@ -420,7 +407,7 @@ export function FormSubmissions({ formId, onNavigate }: FormSubmissionsProps) {
420
407
  }
421
408
  />
422
409
  ) : (
423
- <SubmissionsTable
410
+ <EntriesTable
424
411
  entries={entries}
425
412
  selected={selected}
426
413
  allSelected={allSelected}
@@ -461,135 +448,3 @@ export function FormSubmissions({ formId, onNavigate }: FormSubmissionsProps) {
461
448
  </div>
462
449
  )
463
450
  }
464
-
465
- function SubmissionsTable({
466
- entries,
467
- selected,
468
- allSelected,
469
- onToggleSelectAll,
470
- onToggleSelect,
471
- onOpen,
472
- onToggleStar,
473
- footer,
474
- }: {
475
- entries: FormSubmission[]
476
- selected: Set<string>
477
- allSelected: boolean
478
- onToggleSelectAll: () => void
479
- onToggleSelect: (id: string) => void
480
- onOpen: (id: string) => void
481
- onToggleStar: (entry: FormSubmission) => void
482
- footer?: ReactNode
483
- }) {
484
- return (
485
- <div className="border-border bg-card rounded-lg border">
486
- <div className="overflow-x-auto">
487
- <table className="w-full min-w-[720px] text-sm" aria-label="Submissions">
488
- <thead>
489
- <tr className="border-border text-muted-foreground border-b text-left text-xs">
490
- <th scope="col" className="w-10 px-4 py-2.5">
491
- <input
492
- type="checkbox"
493
- checked={allSelected}
494
- onChange={onToggleSelectAll}
495
- aria-label="Select all submissions"
496
- className="border-border text-primary focus-visible:ring-ring h-4 w-4 rounded"
497
- />
498
- </th>
499
- <th scope="col" className="px-4 py-2.5 font-medium">
500
- Sender
501
- </th>
502
- <th scope="col" className="px-4 py-2.5 font-medium">
503
- Submission
504
- </th>
505
- <th scope="col" className="px-4 py-2.5 font-medium whitespace-nowrap">
506
- Submitted
507
- </th>
508
- <th scope="col" className="w-10 px-4 py-2.5 text-center font-medium">
509
- <span className="sr-only">Starred</span>
510
- </th>
511
- </tr>
512
- </thead>
513
- <tbody>
514
- {entries.map((entry) => {
515
- const isSelected = selected.has(entry.id)
516
- const name = entry.senderName || 'Anonymous'
517
- return (
518
- <tr
519
- key={entry.id}
520
- className={`border-border hover:bg-accent/40 group cursor-pointer border-b transition-colors last:border-b-0 ${
521
- isSelected ? 'bg-accent/30' : ''
522
- }`}
523
- onClick={() => onOpen(entry.id)}
524
- >
525
- <td className="px-4 py-3" onClick={(e) => e.stopPropagation()}>
526
- <input
527
- type="checkbox"
528
- checked={isSelected}
529
- onChange={() => onToggleSelect(entry.id)}
530
- aria-label={`Select submission from ${name}`}
531
- className="border-border text-primary focus-visible:ring-ring h-4 w-4 rounded"
532
- />
533
- </td>
534
- <td className="px-4 py-3">
535
- <div className="flex items-center gap-3">
536
- <span
537
- className="bg-muted text-muted-foreground flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-xs font-medium"
538
- aria-hidden
539
- >
540
- {name.charAt(0).toUpperCase()}
541
- </span>
542
- <div className="min-w-0">
543
- <div className="flex items-center gap-2">
544
- {entry.unread && (
545
- <span
546
- className="bg-primary h-1.5 w-1.5 shrink-0 rounded-full"
547
- aria-label="Unread"
548
- />
549
- )}
550
- <span
551
- className={`text-foreground truncate ${entry.unread ? 'font-semibold' : 'font-medium'}`}
552
- >
553
- {name}
554
- </span>
555
- </div>
556
- {entry.senderEmail && (
557
- <span className="text-muted-foreground block truncate text-xs">
558
- {entry.senderEmail}
559
- </span>
560
- )}
561
- </div>
562
- </div>
563
- </td>
564
- <td className="px-4 py-3">
565
- <span className="text-muted-foreground line-clamp-1 max-w-md">
566
- {entry.preview || '—'}
567
- </span>
568
- </td>
569
- <td className="text-muted-foreground px-4 py-3 whitespace-nowrap">
570
- {formatDateTime(entry.submittedAt)}
571
- </td>
572
- <td className="px-4 py-3 text-center" onClick={(e) => e.stopPropagation()}>
573
- <button
574
- type="button"
575
- className={btnGhostIcon}
576
- aria-pressed={entry.starred}
577
- aria-label={entry.starred ? 'Unstar submission' : 'Star submission'}
578
- onClick={() => onToggleStar(entry)}
579
- >
580
- <Star
581
- className={`h-4 w-4 ${entry.starred ? 'fill-warning text-warning' : ''}`}
582
- aria-hidden
583
- />
584
- </button>
585
- </td>
586
- </tr>
587
- )
588
- })}
589
- </tbody>
590
- </table>
591
- </div>
592
- {footer}
593
- </div>
594
- )
595
- }
@@ -6,6 +6,7 @@ import * as SwitchPrimitive from '@radix-ui/react-switch'
6
6
  import type { BlockNode, BlockTypeDefinition, FieldDefinition } from '@actuate-media/cms-core'
7
7
  import { BlockCatalog } from '@actuate-media/cms-core'
8
8
  import { AIBlockAssist } from './AIBlockAssist.js'
9
+ import { RelationshipField } from './RelationshipField.js'
9
10
  import { TipTapEditor } from '../../components/TipTapEditor.js'
10
11
  import { MediaPickerModal } from '../../components/MediaPickerModal.js'
11
12
 
@@ -297,16 +298,12 @@ function FieldRenderer({ name, definition, value, onChange }: FieldRendererProps
297
298
 
298
299
  case 'relationship':
299
300
  return (
300
- <div>
301
- <label className={LABEL_CLASS}>{label}</label>
302
- <input
303
- type="text"
304
- value={(value as string) ?? ''}
305
- onChange={(e) => onChange(e.target.value)}
306
- placeholder="Select relationship..."
307
- className={INPUT_CLASS}
308
- />
309
- </div>
301
+ <RelationshipField
302
+ definition={definition}
303
+ value={value}
304
+ onChange={onChange}
305
+ label={label}
306
+ />
310
307
  )
311
308
 
312
309
  case 'email':