@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,6 +1,10 @@
1
1
  'use client'
2
2
 
3
- import type { ReactNode } from 'react'
3
+ import { useEffect, useRef, useState, type ReactNode } from 'react'
4
+ import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
5
+ import { useTheme } from '../components/ThemeProvider.js'
6
+ import { fetchFormsSidebarCounts } from '../lib/forms-service.js'
7
+ import { onFormsChanged } from '../lib/forms-events.js'
4
8
  import {
5
9
  LayoutDashboard,
6
10
  FileText,
@@ -8,6 +12,7 @@ import {
8
12
  Image,
9
13
  Settings,
10
14
  ChevronLeft,
15
+ ChevronDown,
11
16
  ClipboardList,
12
17
  Search as SearchIcon,
13
18
  Briefcase,
@@ -172,7 +177,8 @@ export function Sidebar({
172
177
  onNavigate,
173
178
  config,
174
179
  }: SidebarProps) {
175
- const navItems = buildNavItems(config)
180
+ const formsUnread = useFormsUnread()
181
+ const navItems = applyFormsBadge(buildNavItems(config), formsUnread)
176
182
 
177
183
  return (
178
184
  <aside
@@ -220,6 +226,45 @@ export function Sidebar({
220
226
  )
221
227
  }
222
228
 
229
+ // ─── Dynamic Forms unread badge ───────────────────────────────────────────
230
+
231
+ /**
232
+ * Live total of unread submissions across active forms, surfaced on the Forms
233
+ * nav item. Refreshes on mount and whenever a forms/entries mutation fires the
234
+ * shared event (mark read, archive, notify toggle, …). Best-effort: a failed
235
+ * fetch simply leaves the badge unset rather than surfacing an error in nav.
236
+ */
237
+ function useFormsUnread(): number {
238
+ const [unread, setUnread] = useState(0)
239
+
240
+ useEffect(() => {
241
+ let active = true
242
+ const load = () => {
243
+ fetchFormsSidebarCounts()
244
+ .then((counts) => {
245
+ if (active) setUnread(counts.reduce((sum, c) => sum + (c.unread ?? 0), 0))
246
+ })
247
+ .catch(() => {
248
+ /* keep the last known value; nav must never break on a fetch error */
249
+ })
250
+ }
251
+ load()
252
+ const off = onFormsChanged(load)
253
+ return () => {
254
+ active = false
255
+ off()
256
+ }
257
+ }, [])
258
+
259
+ return unread
260
+ }
261
+
262
+ /** Return a copy of the nav tree with the live unread count on the Forms item. */
263
+ function applyFormsBadge(items: NavItem[], unread: number): NavItem[] {
264
+ if (unread <= 0) return items
265
+ return items.map((item) => (item.path === '/forms' ? { ...item, badge: unread } : item))
266
+ }
267
+
223
268
  // ─── Rendering ──────────────────────────────────────────────────────────────
224
269
 
225
270
  interface NavRenderContext {
@@ -271,36 +316,119 @@ function renderNavTree(items: NavItem[], ctx: NavRenderContext): ReactNode {
271
316
  lastGroup = undefined
272
317
  }
273
318
 
319
+ const hasChildren = !!item.children && item.children.length > 0
320
+
321
+ // Collapsed parents reveal their children in a hover/focus flyout instead
322
+ // of inline (there's no room for indented rows in the 80px rail).
323
+ if (hasChildren && ctx.collapsed) {
324
+ nodes.push(<CollapsedFlyout key={`flyout:${item.path}`} item={item} ctx={ctx} />)
325
+ continue
326
+ }
327
+
328
+ // Expanded parents with children render as a collapsible section
329
+ // (chevron toggle to contract/expand the submenu).
330
+ if (hasChildren) {
331
+ nodes.push(<NavSection key={`section:${item.path}`} item={item} ctx={ctx} />)
332
+ continue
333
+ }
334
+
274
335
  // Keys are namespaced because a parent and its first child can share
275
336
  // the same path (e.g. the Posts parent and its "All Posts" child are
276
337
  // both `/posts`). Using the raw path as the key collided and made
277
338
  // React warn about — and risk dropping — duplicate siblings.
278
339
  nodes.push(<NavLink key={`item:${item.path}`} item={item} ctx={ctx} />)
279
- if (item.children && item.children.length > 0 && !ctx.collapsed) {
280
- for (const child of item.children) {
281
- nodes.push(
282
- <NavLink key={`child:${item.path}:${child.path}`} item={child} ctx={ctx} nested />,
283
- )
284
- }
285
- }
286
340
  }
287
341
 
288
342
  return nodes
289
343
  }
290
344
 
345
+ /**
346
+ * Expanded parent with a collapsible submenu. The label/icon navigates to the
347
+ * parent destination; a trailing chevron toggles the child list open/closed.
348
+ * Defaults to open, and auto-opens when the parent or one of its children is
349
+ * the active route so the current page is never hidden behind a collapsed
350
+ * section.
351
+ */
352
+ function NavSection({ item, ctx }: { item: NavItem; ctx: NavRenderContext }) {
353
+ const Icon = item.icon
354
+ const isActive = isPathActive(ctx.currentPath, item.path)
355
+ const childActive = item.children!.some((c) => isPathActive(ctx.currentPath, c.path))
356
+ const [open, setOpen] = useState(true)
357
+ const childListId = `nav-section-${item.path.replace(/[^a-z0-9]+/gi, '-')}`
358
+
359
+ // Keep the active section visible: if the user is on this section's route
360
+ // (parent or child) it stays expanded regardless of the manual toggle.
361
+ const expanded = open || isActive || childActive
362
+
363
+ return (
364
+ <div>
365
+ <div
366
+ className={`flex items-center rounded-lg transition-colors ${
367
+ isActive
368
+ ? 'bg-sidebar-accent text-sidebar-primary'
369
+ : 'text-sidebar-foreground hover:bg-sidebar-accent'
370
+ }`}
371
+ >
372
+ <button
373
+ onClick={() => ctx.onNavigate(item.path)}
374
+ className="flex min-w-0 flex-1 items-center gap-3 rounded-lg py-2.5 pr-1 pl-3 text-left"
375
+ aria-current={isActive ? 'page' : undefined}
376
+ >
377
+ <Icon className="h-5 w-5 shrink-0" aria-hidden />
378
+ <span className="truncate text-sm font-medium">{item.label}</span>
379
+ </button>
380
+ <button
381
+ onClick={() => setOpen((v) => !v)}
382
+ className="text-sidebar-foreground/55 hover:text-sidebar-foreground mr-1 flex shrink-0 items-center rounded-md p-1.5 transition-colors"
383
+ aria-label={`${expanded ? 'Collapse' : 'Expand'} ${item.label}`}
384
+ aria-expanded={expanded}
385
+ aria-controls={childListId}
386
+ >
387
+ <ChevronDown
388
+ className={`h-4 w-4 transition-transform duration-200 ${expanded ? '' : '-rotate-90'}`}
389
+ aria-hidden
390
+ />
391
+ </button>
392
+ </div>
393
+
394
+ {/* Tight (gap-less) connector container so the vertical tree guides of
395
+ adjacent rows touch and read as one continuous line with curved
396
+ elbows into each child. */}
397
+ {expanded && (
398
+ <div id={childListId} className="relative">
399
+ {item.children!.map((child, i) => (
400
+ <NavLink
401
+ key={`child:${item.path}:${child.path}`}
402
+ item={child}
403
+ ctx={ctx}
404
+ nested
405
+ isLast={i === item.children!.length - 1}
406
+ />
407
+ ))}
408
+ </div>
409
+ )}
410
+ </div>
411
+ )
412
+ }
413
+
291
414
  function NavLink({
292
415
  item,
293
416
  ctx,
294
417
  nested = false,
418
+ isLast = false,
295
419
  }: {
296
420
  item: NavItem
297
421
  ctx: NavRenderContext
298
422
  nested?: boolean
423
+ /** Last child in its group — the elbow stops here (no continuation line). */
424
+ isLast?: boolean
299
425
  }) {
300
426
  const Icon = item.icon
301
427
  const isActive = isPathActive(ctx.currentPath, item.path)
302
428
 
303
- return (
429
+ const badge = item.badge && item.badge > 0 ? item.badge : 0
430
+
431
+ const button = (
304
432
  <button
305
433
  onClick={() => ctx.onNavigate(item.path)}
306
434
  className={`flex w-full items-center gap-3 rounded-lg text-left transition-colors ${
@@ -313,17 +441,165 @@ function NavLink({
313
441
  title={ctx.collapsed ? item.label : ''}
314
442
  aria-current={isActive ? 'page' : undefined}
315
443
  >
316
- {!nested && <Icon className="h-5 w-5 shrink-0" aria-hidden />}
317
- {nested && !ctx.collapsed && (
318
- <Icon className="text-sidebar-foreground/60 h-4 w-4 shrink-0" aria-hidden />
319
- )}
444
+ <span className="relative flex shrink-0 items-center">
445
+ {!nested && <Icon className="h-5 w-5 shrink-0" aria-hidden />}
446
+ {nested && !ctx.collapsed && (
447
+ <Icon className="text-sidebar-foreground/60 h-4 w-4 shrink-0" aria-hidden />
448
+ )}
449
+ {/* Collapsed rail: a dot signals unread without room for a number. */}
450
+ {ctx.collapsed && badge > 0 && (
451
+ <span
452
+ className="bg-destructive absolute -top-0.5 -right-0.5 h-2 w-2 rounded-full"
453
+ aria-hidden
454
+ />
455
+ )}
456
+ </span>
320
457
  {!ctx.collapsed && (
321
458
  <span className={`truncate font-medium ${nested ? 'text-[13px]' : 'text-sm'}`}>
322
459
  {item.label}
323
460
  </span>
324
461
  )}
462
+ {!ctx.collapsed && badge > 0 && (
463
+ <span
464
+ className="bg-destructive text-destructive-foreground ml-auto inline-flex min-w-5 items-center justify-center rounded-full px-1.5 py-0.5 text-xs font-medium tabular-nums"
465
+ aria-label={`${badge} unread`}
466
+ >
467
+ {badge > 99 ? '99+' : badge}
468
+ </span>
469
+ )}
325
470
  </button>
326
471
  )
472
+
473
+ if (!nested || ctx.collapsed) return button
474
+
475
+ // Curved-elbow tree connector (expanded only). The elbow draws the vertical
476
+ // run from the row top to its vertical center, curves at the bottom-left
477
+ // corner, then runs horizontally into the row. Non-last rows continue the
478
+ // vertical line to the row bottom so it meets the next sibling's elbow.
479
+ // The guide is centered (~21px) under the parent item's 20px icon.
480
+ return (
481
+ <div className="relative">
482
+ <span
483
+ aria-hidden
484
+ className="border-sidebar-border absolute top-0 left-[21px] h-1/2 w-[15px] rounded-bl-lg border-b border-l"
485
+ />
486
+ {!isLast && (
487
+ <span
488
+ aria-hidden
489
+ className="border-sidebar-border absolute top-1/2 bottom-0 left-[21px] border-l"
490
+ />
491
+ )}
492
+ {button}
493
+ </div>
494
+ )
495
+ }
496
+
497
+ /**
498
+ * Collapsed-rail parent: shows the icon button and, on hover or keyboard
499
+ * focus, a flyout listing the parent's children. Built on Radix
500
+ * DropdownMenu so the panel is portaled (escaping the nav's `overflow`
501
+ * clip), positioned, dismissable, and keyboard-navigable. Hover-opening is
502
+ * controlled manually; we suppress Radix's auto-focus when opened by pointer
503
+ * so hovering doesn't steal focus, while keyboard opening still moves focus
504
+ * into the menu.
505
+ */
506
+ function CollapsedFlyout({ item, ctx }: { item: NavItem; ctx: NavRenderContext }) {
507
+ const Icon = item.icon
508
+ const isActive = isPathActive(ctx.currentPath, item.path)
509
+ const { resolvedTheme } = useTheme()
510
+ const [open, setOpen] = useState(false)
511
+ const openedByPointer = useRef(false)
512
+ const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
513
+
514
+ const cancelClose = () => {
515
+ if (closeTimer.current) {
516
+ clearTimeout(closeTimer.current)
517
+ closeTimer.current = null
518
+ }
519
+ }
520
+ const scheduleClose = () => {
521
+ cancelClose()
522
+ closeTimer.current = setTimeout(() => setOpen(false), 120)
523
+ }
524
+
525
+ return (
526
+ <DropdownMenu.Root
527
+ open={open}
528
+ onOpenChange={(next) => {
529
+ if (!next) openedByPointer.current = false
530
+ setOpen(next)
531
+ }}
532
+ modal={false}
533
+ >
534
+ <div
535
+ onMouseEnter={() => {
536
+ openedByPointer.current = true
537
+ cancelClose()
538
+ setOpen(true)
539
+ }}
540
+ onMouseLeave={scheduleClose}
541
+ >
542
+ <DropdownMenu.Trigger asChild>
543
+ <button
544
+ onClick={() => ctx.onNavigate(item.path)}
545
+ className={`flex w-full items-center justify-center rounded-lg px-3 py-2.5 transition-colors ${
546
+ isActive
547
+ ? 'bg-sidebar-accent text-sidebar-primary'
548
+ : 'text-sidebar-foreground hover:bg-sidebar-accent'
549
+ }`}
550
+ aria-label={item.label}
551
+ title={item.label}
552
+ aria-current={isActive ? 'page' : undefined}
553
+ >
554
+ <Icon className="h-5 w-5 shrink-0" aria-hidden />
555
+ </button>
556
+ </DropdownMenu.Trigger>
557
+ </div>
558
+
559
+ <DropdownMenu.Portal>
560
+ <DropdownMenu.Content
561
+ side="right"
562
+ align="start"
563
+ sideOffset={10}
564
+ onMouseEnter={cancelClose}
565
+ onMouseLeave={scheduleClose}
566
+ onCloseAutoFocus={(e) => {
567
+ // After a pointer-driven open, don't yank focus back to the rail
568
+ // trigger (it would leave a stray focus ring once the cursor moves
569
+ // away). Keyboard-driven opens keep the default focus-return.
570
+ if (openedByPointer.current) e.preventDefault()
571
+ }}
572
+ // Radix portals to <body>, outside the `.actuate-admin` scope where
573
+ // the theme tokens (`--popover`, `--radius`, …) live — without the
574
+ // scope class the panel renders transparent with square corners.
575
+ // Re-assert the scope (and current dark state) on the panel itself so
576
+ // it inherits the tokens regardless of portal position.
577
+ className={`actuate-admin ${resolvedTheme === 'dark' ? 'dark' : ''} bg-popover text-popover-foreground border-border motion-safe:animate-in motion-safe:fade-in z-50 min-w-44 overflow-hidden rounded-lg border p-1 shadow-lg`}
578
+ >
579
+ <div className="text-muted-foreground border-border mb-1 border-b px-3 py-2 text-xs font-medium">
580
+ {item.label}
581
+ </div>
582
+ {item.children!.map((child) => {
583
+ const ChildIcon = child.icon
584
+ const childActive = isPathActive(ctx.currentPath, child.path)
585
+ return (
586
+ <DropdownMenu.Item
587
+ key={`flyout-item:${item.path}:${child.path}`}
588
+ onSelect={() => ctx.onNavigate(child.path)}
589
+ className={`data-highlighted:bg-accent hover:bg-accent flex cursor-pointer items-center gap-2.5 rounded-md px-3 py-2 text-sm font-medium outline-none select-none ${
590
+ childActive ? 'text-primary' : 'text-foreground'
591
+ }`}
592
+ aria-current={childActive ? 'page' : undefined}
593
+ >
594
+ <ChildIcon className="text-muted-foreground h-4 w-4 shrink-0" aria-hidden />
595
+ <span className="truncate">{child.label}</span>
596
+ </DropdownMenu.Item>
597
+ )
598
+ })}
599
+ </DropdownMenu.Content>
600
+ </DropdownMenu.Portal>
601
+ </DropdownMenu.Root>
602
+ )
327
603
  }
328
604
 
329
605
  // ─── Config-driven nav builder ──────────────────────────────────────────────
@@ -346,6 +622,11 @@ export interface NavItem {
346
622
  group?: string
347
623
  /** Indented children rendered immediately after this item. */
348
624
  children?: NavItem[]
625
+ /**
626
+ * Optional unread/notification count. Rendered as a red pill (expanded)
627
+ * or a dot (collapsed). Falsy / zero values render nothing.
628
+ */
629
+ badge?: number
349
630
  }
350
631
 
351
632
  /**
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Forms event bus — a minimal pub/sub so views that mutate submission state
3
+ * (mark read/unread, star, archive) or form state (create, notify toggle,
4
+ * archive) can tell the sidebar to refresh its unread badges immediately,
5
+ * without threading a refresh callback through the whole component tree.
6
+ *
7
+ * Deliberately tiny and dependency-free: one event, synchronous fan-out,
8
+ * SSR-safe. Subscribers must unsubscribe on unmount via the returned
9
+ * disposer to avoid leaks.
10
+ */
11
+ type Listener = () => void
12
+
13
+ const listeners = new Set<Listener>()
14
+
15
+ /** Subscribe to "forms data changed" notifications. Returns an unsubscribe fn. */
16
+ export function onFormsChanged(listener: Listener): () => void {
17
+ listeners.add(listener)
18
+ return () => {
19
+ listeners.delete(listener)
20
+ }
21
+ }
22
+
23
+ /** Notify all subscribers that forms/submission counts may have changed. */
24
+ export function emitFormsChanged(): void {
25
+ for (const listener of listeners) {
26
+ try {
27
+ listener()
28
+ } catch {
29
+ // A misbehaving subscriber must not break sibling subscribers.
30
+ }
31
+ }
32
+ }
@@ -0,0 +1,244 @@
1
+ /**
2
+ * Forms service — typed client over the cms-core Forms REST endpoints.
3
+ *
4
+ * Every Forms admin component reads/writes through this module (never raw
5
+ * `cmsApi` / `fetch`), so the network contract lives in one place. Read
6
+ * fetchers re-surface `cmsApi`'s `{ error }` as a thrown error (so the
7
+ * resource hook can show error/retry UI); mutations return `{ error? }`.
8
+ *
9
+ * The endpoint paths here are the contract implemented by cms-core
10
+ * `packages/cms-core/src/api/handlers.ts` (PR 2). Domain types are re-used
11
+ * from `@actuate-media/cms-core` so the admin can never drift from the server.
12
+ */
13
+ import { cmsApi } from './api.js'
14
+ import type {
15
+ FormDefinition,
16
+ FormSchemaVersion,
17
+ FormField,
18
+ FormNotificationSettings,
19
+ FormEmbedSettings,
20
+ FormStatus,
21
+ FormStats,
22
+ FormsSidebarCount,
23
+ FetchFormsParams,
24
+ } from '@actuate-media/cms-core'
25
+
26
+ export type {
27
+ FormDefinition,
28
+ FormSchemaVersion,
29
+ FormField,
30
+ FormFieldType,
31
+ FormFieldOption,
32
+ FieldValidationRules,
33
+ FormNotificationSettings,
34
+ FormEmbedSettings,
35
+ FormStatus,
36
+ FormStats,
37
+ FormsSidebarCount,
38
+ } from '@actuate-media/cms-core'
39
+
40
+ /**
41
+ * `cmsApi` never throws — it returns `{ error, status }` on a network/HTTP
42
+ * failure. Read (GET) fetchers must re-surface that as a thrown error so the
43
+ * resource hook can distinguish "request failed" from "empty result".
44
+ */
45
+ function throwIfError(res: { error?: string }): void {
46
+ if (res.error) throw new Error(res.error)
47
+ }
48
+
49
+ /**
50
+ * A form as returned by `GET /forms` — the typed definition plus the legacy
51
+ * numeric aliases the list endpoint adds for backward compatibility.
52
+ */
53
+ export interface FormListItem extends FormDefinition {
54
+ /** `fields.length` precomputed by the server. */
55
+ fieldCount?: number
56
+ /** Alias of `totalEntries`. */
57
+ submissions?: number
58
+ }
59
+
60
+ export interface FormSchemaResult {
61
+ fields: FormField[]
62
+ activeVersionId: string | null
63
+ versions: FormSchemaVersion[]
64
+ }
65
+
66
+ // ─── Forms list / stats / sidebar ────────────────────────────────────────
67
+
68
+ function buildFormsQuery(params: FetchFormsParams): string {
69
+ const qs = new URLSearchParams()
70
+ if (params.search) qs.set('search', params.search)
71
+ if (params.status) qs.set('status', params.status)
72
+ if (typeof params.notifyEnabled === 'boolean') {
73
+ qs.set('notifyEnabled', String(params.notifyEnabled))
74
+ }
75
+ if (params.sortBy) qs.set('sortBy', params.sortBy)
76
+ if (params.sortDirection) qs.set('sortDirection', params.sortDirection)
77
+ if (params.page) qs.set('page', String(params.page))
78
+ if (params.pageSize) qs.set('pageSize', String(params.pageSize))
79
+ const str = qs.toString()
80
+ return str ? `?${str}` : ''
81
+ }
82
+
83
+ export async function fetchForms(params: FetchFormsParams = {}): Promise<FormListItem[]> {
84
+ const res = await cmsApi<FormListItem[]>(`/forms${buildFormsQuery(params)}`)
85
+ throwIfError(res)
86
+ return res.data ?? []
87
+ }
88
+
89
+ export async function fetchFormById(id: string): Promise<FormDefinition | null> {
90
+ const res = await cmsApi<FormDefinition>(`/forms/${encodeURIComponent(id)}`)
91
+ throwIfError(res)
92
+ return res.data ?? null
93
+ }
94
+
95
+ const EMPTY_STATS: FormStats = {
96
+ totalForms: 0,
97
+ activeForms: 0,
98
+ totalEntries: 0,
99
+ unreadEntries: 0,
100
+ }
101
+
102
+ export async function fetchFormStats(): Promise<FormStats> {
103
+ const res = await cmsApi<FormStats>('/forms/stats')
104
+ throwIfError(res)
105
+ return res.data ?? EMPTY_STATS
106
+ }
107
+
108
+ export async function fetchFormsSidebarCounts(): Promise<FormsSidebarCount[]> {
109
+ const res = await cmsApi<FormsSidebarCount[]>('/forms/sidebar-counts')
110
+ throwIfError(res)
111
+ return res.data ?? []
112
+ }
113
+
114
+ // ─── Form mutations ──────────────────────────────────────────────────────
115
+
116
+ export interface CreateFormPayload {
117
+ name: string
118
+ slug?: string
119
+ description?: string
120
+ status?: FormStatus
121
+ template?: string
122
+ fields?: FormField[]
123
+ }
124
+
125
+ export async function createForm(
126
+ payload: CreateFormPayload,
127
+ ): Promise<{ data?: FormDefinition; error?: string }> {
128
+ const res = await cmsApi<FormDefinition>('/forms', {
129
+ method: 'POST',
130
+ body: JSON.stringify(payload),
131
+ })
132
+ return { data: res.data, error: res.error }
133
+ }
134
+
135
+ export async function updateForm(
136
+ id: string,
137
+ payload: Partial<Pick<FormDefinition, 'name' | 'slug' | 'description' | 'status'>>,
138
+ ): Promise<{ data?: FormDefinition; error?: string }> {
139
+ const res = await cmsApi<FormDefinition>(`/forms/${encodeURIComponent(id)}`, {
140
+ method: 'PATCH',
141
+ body: JSON.stringify(payload),
142
+ })
143
+ return { data: res.data, error: res.error }
144
+ }
145
+
146
+ export async function duplicateForm(
147
+ id: string,
148
+ ): Promise<{ data?: FormDefinition; error?: string }> {
149
+ const res = await cmsApi<FormDefinition>(`/forms/${encodeURIComponent(id)}/duplicate`, {
150
+ method: 'POST',
151
+ })
152
+ return { data: res.data, error: res.error }
153
+ }
154
+
155
+ /** Move a form to the archived state (hidden from embed pickers, no new submissions). */
156
+ export async function archiveForm(id: string): Promise<{ error?: string }> {
157
+ return updateForm(id, { status: 'archived' })
158
+ }
159
+
160
+ /** Restore an archived form back to draft. */
161
+ export async function restoreForm(id: string): Promise<{ error?: string }> {
162
+ return updateForm(id, { status: 'draft' })
163
+ }
164
+
165
+ export async function deleteForm(
166
+ id: string,
167
+ opts: { force?: boolean } = {},
168
+ ): Promise<{ error?: string }> {
169
+ const qs = opts.force ? '?force=true' : ''
170
+ const res = await cmsApi(`/forms/${encodeURIComponent(id)}${qs}`, { method: 'DELETE' })
171
+ return { error: res.error }
172
+ }
173
+
174
+ // ─── Schema ──────────────────────────────────────────────────────────────
175
+
176
+ export async function fetchFormSchema(id: string): Promise<FormSchemaResult> {
177
+ const res = await cmsApi<FormSchemaResult>(`/forms/${encodeURIComponent(id)}/schema`)
178
+ throwIfError(res)
179
+ return res.data ?? { fields: [], activeVersionId: null, versions: [] }
180
+ }
181
+
182
+ export async function saveFormSchema(
183
+ id: string,
184
+ fields: FormField[],
185
+ notes?: string,
186
+ ): Promise<{ data?: { form: FormDefinition; version: FormSchemaVersion }; error?: string }> {
187
+ const res = await cmsApi<{ form: FormDefinition; version: FormSchemaVersion }>(
188
+ `/forms/${encodeURIComponent(id)}/schema`,
189
+ { method: 'PUT', body: JSON.stringify({ fields, notes }) },
190
+ )
191
+ return { data: res.data, error: res.error }
192
+ }
193
+
194
+ // ─── Notifications ─────────────────────────────────────────────────────────
195
+
196
+ export async function fetchNotificationSettings(id: string): Promise<FormNotificationSettings> {
197
+ const res = await cmsApi<FormNotificationSettings>(
198
+ `/forms/${encodeURIComponent(id)}/notifications`,
199
+ )
200
+ throwIfError(res)
201
+ return res.data ?? { enabled: false, recipients: [] }
202
+ }
203
+
204
+ export async function updateNotificationSettings(
205
+ id: string,
206
+ payload: Partial<FormNotificationSettings>,
207
+ ): Promise<{ data?: FormNotificationSettings; error?: string }> {
208
+ const res = await cmsApi<FormNotificationSettings>(
209
+ `/forms/${encodeURIComponent(id)}/notifications`,
210
+ { method: 'PUT', body: JSON.stringify(payload) },
211
+ )
212
+ return { data: res.data, error: res.error }
213
+ }
214
+
215
+ /** Lightweight notify on/off toggle used by the forms-list switch. */
216
+ export async function setNotifyEnabled(
217
+ id: string,
218
+ enabled: boolean,
219
+ ): Promise<{ data?: { notifyEnabled: boolean }; error?: string }> {
220
+ const res = await cmsApi<{ notifyEnabled: boolean }>(`/forms/${encodeURIComponent(id)}/notify`, {
221
+ method: 'PUT',
222
+ body: JSON.stringify({ enabled }),
223
+ })
224
+ return { data: res.data, error: res.error }
225
+ }
226
+
227
+ // ─── Embed ───────────────────────────────────────────────────────────────
228
+
229
+ export async function fetchEmbedSettings(id: string): Promise<FormEmbedSettings> {
230
+ const res = await cmsApi<FormEmbedSettings>(`/forms/${encodeURIComponent(id)}/embed`)
231
+ throwIfError(res)
232
+ return res.data ?? {}
233
+ }
234
+
235
+ export async function updateEmbedSettings(
236
+ id: string,
237
+ payload: Partial<FormEmbedSettings>,
238
+ ): Promise<{ data?: FormEmbedSettings; error?: string }> {
239
+ const res = await cmsApi<FormEmbedSettings>(`/forms/${encodeURIComponent(id)}/embed`, {
240
+ method: 'PUT',
241
+ body: JSON.stringify(payload),
242
+ })
243
+ return { data: res.data, error: res.error }
244
+ }