@actuate-media/cms-admin 0.22.0 → 0.24.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 (66) 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__/components/forms-pagination.render.test.d.ts +2 -0
  5. package/dist/__tests__/components/forms-pagination.render.test.d.ts.map +1 -0
  6. package/dist/__tests__/components/forms-pagination.render.test.js +33 -0
  7. package/dist/__tests__/components/forms-pagination.render.test.js.map +1 -0
  8. package/dist/__tests__/layout/sidebar-forms-submenu.render.test.d.ts +2 -0
  9. package/dist/__tests__/layout/sidebar-forms-submenu.render.test.d.ts.map +1 -0
  10. package/dist/__tests__/layout/sidebar-forms-submenu.render.test.js +28 -0
  11. package/dist/__tests__/layout/sidebar-forms-submenu.render.test.js.map +1 -0
  12. package/dist/__tests__/views/form-entries.render.test.d.ts +2 -0
  13. package/dist/__tests__/views/form-entries.render.test.d.ts.map +1 -0
  14. package/dist/__tests__/views/form-entries.render.test.js +112 -0
  15. package/dist/__tests__/views/form-entries.render.test.js.map +1 -0
  16. package/dist/__tests__/views/form-submissions.render.test.d.ts +2 -0
  17. package/dist/__tests__/views/form-submissions.render.test.d.ts.map +1 -0
  18. package/dist/__tests__/views/form-submissions.render.test.js +110 -0
  19. package/dist/__tests__/views/form-submissions.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/components/forms/EntryDetailDrawer.d.ts +11 -0
  26. package/dist/components/forms/EntryDetailDrawer.d.ts.map +1 -0
  27. package/dist/components/forms/EntryDetailDrawer.js +145 -0
  28. package/dist/components/forms/EntryDetailDrawer.js.map +1 -0
  29. package/dist/components/forms/NotificationsPanel.js +1 -1
  30. package/dist/components/forms/NotificationsPanel.js.map +1 -1
  31. package/dist/components/forms/primitives.d.ts +15 -0
  32. package/dist/components/forms/primitives.d.ts.map +1 -1
  33. package/dist/components/forms/primitives.js +36 -1
  34. package/dist/components/forms/primitives.js.map +1 -1
  35. package/dist/layout/Sidebar.d.ts.map +1 -1
  36. package/dist/layout/Sidebar.js +44 -21
  37. package/dist/layout/Sidebar.js.map +1 -1
  38. package/dist/lib/forms-service.d.ts +36 -2
  39. package/dist/lib/forms-service.d.ts.map +1 -1
  40. package/dist/lib/forms-service.js +66 -0
  41. package/dist/lib/forms-service.js.map +1 -1
  42. package/dist/views/FormEntries.d.ts +5 -0
  43. package/dist/views/FormEntries.d.ts.map +1 -0
  44. package/dist/views/FormEntries.js +211 -0
  45. package/dist/views/FormEntries.js.map +1 -0
  46. package/dist/views/FormSubmissions.d.ts.map +1 -1
  47. package/dist/views/FormSubmissions.js +204 -77
  48. package/dist/views/FormSubmissions.js.map +1 -1
  49. package/dist/views/Forms.d.ts.map +1 -1
  50. package/dist/views/Forms.js +26 -10
  51. package/dist/views/Forms.js.map +1 -1
  52. package/package.json +1 -1
  53. package/src/AdminRoot.tsx +4 -0
  54. package/src/__tests__/components/forms-pagination.render.test.tsx +49 -0
  55. package/src/__tests__/layout/sidebar-forms-submenu.render.test.tsx +38 -0
  56. package/src/__tests__/views/form-entries.render.test.tsx +133 -0
  57. package/src/__tests__/views/form-submissions.render.test.tsx +129 -0
  58. package/src/components/forms/EntriesTable.tsx +197 -0
  59. package/src/components/forms/EntryDetailDrawer.tsx +312 -0
  60. package/src/components/forms/NotificationsPanel.tsx +1 -1
  61. package/src/components/forms/primitives.tsx +101 -1
  62. package/src/layout/Sidebar.tsx +75 -17
  63. package/src/lib/forms-service.ts +99 -0
  64. package/src/views/FormEntries.tsx +449 -0
  65. package/src/views/FormSubmissions.tsx +391 -401
  66. package/src/views/Forms.tsx +145 -107
@@ -9,7 +9,7 @@
9
9
  */
10
10
  import { ChevronRight, ClipboardList, Plus, Search, Settings2 } from 'lucide-react'
11
11
  import * as Switch from '@radix-ui/react-switch'
12
- import { useCallback, useEffect, useMemo, useState } from 'react'
12
+ import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'
13
13
  import { toast } from 'sonner'
14
14
  import { sortByRelevance } from '../lib/search.js'
15
15
  import {
@@ -28,11 +28,14 @@ import {
28
28
  FormsEmptyState,
29
29
  FormsErrorState,
30
30
  FormsLoading,
31
+ FormsPagination,
31
32
  SummaryChip,
32
33
  UnreadBadge,
33
34
  btnPrimary,
34
35
  } from '../components/forms/primitives.js'
35
36
 
37
+ const PAGE_SIZE = 10
38
+
36
39
  export interface FormsProps {
37
40
  onNavigate?: (path: string) => void
38
41
  }
@@ -72,6 +75,7 @@ export function Forms({ onNavigate }: FormsProps) {
72
75
  const [error, setError] = useState<string | null>(null)
73
76
  const [search, setSearch] = useState('')
74
77
  const [filter, setFilter] = useState<StatusFilter>('all')
78
+ const [page, setPage] = useState(1)
75
79
  const [schemaForm, setSchemaForm] = useState<FormListItem | null>(null)
76
80
  const [schemaOpen, setSchemaOpen] = useState(false)
77
81
 
@@ -121,6 +125,18 @@ export function Forms({ onNavigate }: FormsProps) {
121
125
  return results
122
126
  }, [forms, filter, search])
123
127
 
128
+ const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE))
129
+
130
+ // Keep the page in range when the filtered set shrinks (filters, refresh).
131
+ useEffect(() => {
132
+ if (page > totalPages) setPage(totalPages)
133
+ }, [page, totalPages])
134
+
135
+ const paged = useMemo(
136
+ () => filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE),
137
+ [filtered, page],
138
+ )
139
+
124
140
  const openSchema = useCallback((form: FormListItem) => {
125
141
  setSchemaForm(form)
126
142
  setSchemaOpen(true)
@@ -176,7 +192,10 @@ export function Forms({ onNavigate }: FormsProps) {
176
192
  type="search"
177
193
  placeholder="Search forms…"
178
194
  value={search}
179
- onChange={(e) => setSearch(e.target.value)}
195
+ onChange={(e) => {
196
+ setSearch(e.target.value)
197
+ setPage(1)
198
+ }}
180
199
  aria-label="Search forms"
181
200
  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
201
  />
@@ -186,7 +205,10 @@ export function Forms({ onNavigate }: FormsProps) {
186
205
  <button
187
206
  key={f.id}
188
207
  type="button"
189
- onClick={() => setFilter(f.id)}
208
+ onClick={() => {
209
+ setFilter(f.id)
210
+ setPage(1)
211
+ }}
190
212
  aria-pressed={filter === f.id}
191
213
  className={`rounded-full px-3 py-1.5 text-sm font-medium transition-colors ${
192
214
  filter === f.id
@@ -226,6 +248,7 @@ export function Forms({ onNavigate }: FormsProps) {
226
248
  onClick={() => {
227
249
  setSearch('')
228
250
  setFilter('all')
251
+ setPage(1)
229
252
  }}
230
253
  className="text-primary text-sm font-medium hover:underline"
231
254
  >
@@ -235,11 +258,21 @@ export function Forms({ onNavigate }: FormsProps) {
235
258
  />
236
259
  ) : (
237
260
  <FormsTable
238
- forms={filtered}
261
+ forms={paged}
239
262
  onOpenForm={(id) => onNavigate?.(`/forms/${id}/edit`)}
240
263
  onOpenEntries={(id) => onNavigate?.(`/forms/${id}/submissions`)}
241
264
  onOpenSchema={openSchema}
242
265
  onNotifyToggle={handleNotifyToggle}
266
+ footer={
267
+ <FormsPagination
268
+ page={page}
269
+ pageSize={PAGE_SIZE}
270
+ total={filtered.length}
271
+ onPageChange={setPage}
272
+ noun="form"
273
+ label="Forms pagination"
274
+ />
275
+ }
243
276
  />
244
277
  )}
245
278
 
@@ -259,117 +292,122 @@ function FormsTable({
259
292
  onOpenEntries,
260
293
  onOpenSchema,
261
294
  onNotifyToggle,
295
+ footer,
262
296
  }: {
263
297
  forms: FormListItem[]
264
298
  onOpenForm: (id: string) => void
265
299
  onOpenEntries: (id: string) => void
266
300
  onOpenSchema: (form: FormListItem) => void
267
301
  onNotifyToggle: (form: FormListItem, next: boolean) => void
302
+ footer?: ReactNode
268
303
  }) {
269
304
  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>
305
+ <div className="border-border bg-card rounded-lg border">
306
+ <div className="overflow-x-auto">
307
+ <table className="w-full min-w-[760px] text-sm" aria-label="Forms">
308
+ <thead>
309
+ <tr className="border-border text-muted-foreground border-b text-left text-xs">
310
+ <th scope="col" className="px-4 py-2.5 font-medium">
311
+ Form
312
+ </th>
313
+ <th scope="col" className="px-4 py-2.5 font-medium">
314
+ Fields
315
+ </th>
316
+ <th scope="col" className="px-4 py-2.5 font-medium">
317
+ Status
318
+ </th>
319
+ <th scope="col" className="px-4 py-2.5 font-medium">
320
+ Entries
321
+ </th>
322
+ <th scope="col" className="px-4 py-2.5 font-medium">
323
+ Last Entry
324
+ </th>
325
+ <th scope="col" className="px-4 py-2.5 text-center font-medium">
326
+ Notify
327
+ </th>
328
+ <th scope="col" className="px-4 py-2.5 text-right font-medium">
329
+ <span className="sr-only">Schema</span>
330
+ </th>
331
+ </tr>
332
+ </thead>
333
+ <tbody>
334
+ {forms.map((form) => {
335
+ const fieldCount = form.fieldCount ?? form.fields.length
336
+ const unread = form.unreadEntries ?? 0
337
+ const muted = form.status === 'archived'
338
+ return (
339
+ <tr
340
+ key={form.id}
341
+ className={`border-border hover:bg-accent/40 border-b transition-colors last:border-b-0 ${muted ? 'opacity-60' : ''}`}
342
+ >
343
+ <td className="px-4 py-3">
344
+ <button
345
+ type="button"
346
+ onClick={() => onOpenForm(form.id)}
347
+ className="block max-w-xs text-left"
348
+ >
349
+ <span className="text-foreground block truncate font-medium hover:underline">
350
+ {form.name}
351
+ </span>
352
+ <span className="text-muted-foreground block truncate text-xs">
353
+ /{form.slug}
354
+ {form.description ? ` · ${form.description}` : ''}
355
+ </span>
356
+ </button>
357
+ </td>
358
+ <td className="px-4 py-3">
359
+ <div className="flex flex-wrap items-center gap-1">
360
+ {form.fields.slice(0, 4).map((field) => (
361
+ <FieldTypeBadge key={field.id} type={field.type} />
362
+ ))}
363
+ <span className="text-muted-foreground text-xs">{fieldCount} fields</span>
364
+ </div>
365
+ </td>
366
+ <td className="px-4 py-3">
367
+ <FormStatusBadge status={form.status} />
368
+ </td>
369
+ <td className="px-4 py-3">
370
+ <button
371
+ type="button"
372
+ onClick={() => onOpenEntries(form.id)}
373
+ 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"
374
+ aria-label={`View ${form.totalEntries ?? 0} entries for ${form.name}`}
375
+ >
376
+ {form.totalEntries ?? 0}
377
+ <UnreadBadge count={unread} />
378
+ <ChevronRight className="text-muted-foreground h-3.5 w-3.5" aria-hidden />
379
+ </button>
380
+ </td>
381
+ <td className="text-muted-foreground px-4 py-3 whitespace-nowrap">
382
+ {formatRelative(form.lastEntryAt)}
383
+ </td>
384
+ <td className="px-4 py-3 text-center">
385
+ <Switch.Root
386
+ checked={form.notifyEnabled ?? false}
387
+ onCheckedChange={(v) => onNotifyToggle(form, v)}
388
+ aria-label={`Notifications for ${form.name}`}
389
+ className="bg-destructive data-[state=checked]:bg-success 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"
390
+ >
391
+ <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]" />
392
+ </Switch.Root>
393
+ </td>
394
+ <td className="px-4 py-3 text-right">
395
+ <button
396
+ type="button"
397
+ onClick={() => onOpenSchema(form)}
398
+ 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"
399
+ >
400
+ <Settings2 className="h-3.5 w-3.5" aria-hidden />
401
+ Schema
402
+ </button>
403
+ </td>
404
+ </tr>
405
+ )
406
+ })}
407
+ </tbody>
408
+ </table>
409
+ </div>
410
+ {footer}
373
411
  </div>
374
412
  )
375
413
  }