@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.
- package/dist/AdminRoot.d.ts.map +1 -1
- package/dist/AdminRoot.js +24 -0
- package/dist/AdminRoot.js.map +1 -1
- package/dist/__tests__/layout/sidebar-forms-badge.render.test.d.ts +2 -0
- package/dist/__tests__/layout/sidebar-forms-badge.render.test.d.ts.map +1 -0
- package/dist/__tests__/layout/sidebar-forms-badge.render.test.js +30 -0
- package/dist/__tests__/layout/sidebar-forms-badge.render.test.js.map +1 -0
- package/dist/__tests__/layout/sidebar-submenu.render.test.d.ts +2 -0
- package/dist/__tests__/layout/sidebar-submenu.render.test.d.ts.map +1 -0
- package/dist/__tests__/layout/sidebar-submenu.render.test.js +66 -0
- package/dist/__tests__/layout/sidebar-submenu.render.test.js.map +1 -0
- package/dist/__tests__/views/forms-list.render.test.d.ts +2 -0
- package/dist/__tests__/views/forms-list.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/forms-list.render.test.js +122 -0
- package/dist/__tests__/views/forms-list.render.test.js.map +1 -0
- package/dist/actuate-admin.css +1 -1
- package/dist/components/ErrorBoundary.d.ts +1 -1
- package/dist/components/forms/Drawer.d.ts +13 -0
- package/dist/components/forms/Drawer.d.ts.map +1 -0
- package/dist/components/forms/Drawer.js +13 -0
- package/dist/components/forms/Drawer.js.map +1 -0
- package/dist/components/forms/EmbedPanel.d.ts +7 -0
- package/dist/components/forms/EmbedPanel.d.ts.map +1 -0
- package/dist/components/forms/EmbedPanel.js +91 -0
- package/dist/components/forms/EmbedPanel.js.map +1 -0
- package/dist/components/forms/FieldsPanel.d.ts +8 -0
- package/dist/components/forms/FieldsPanel.d.ts.map +1 -0
- package/dist/components/forms/FieldsPanel.js +123 -0
- package/dist/components/forms/FieldsPanel.js.map +1 -0
- package/dist/components/forms/FormSchemaDrawer.d.ts +9 -0
- package/dist/components/forms/FormSchemaDrawer.d.ts.map +1 -0
- package/dist/components/forms/FormSchemaDrawer.js +96 -0
- package/dist/components/forms/FormSchemaDrawer.js.map +1 -0
- package/dist/components/forms/NotificationsPanel.d.ts +6 -0
- package/dist/components/forms/NotificationsPanel.d.ts.map +1 -0
- package/dist/components/forms/NotificationsPanel.js +80 -0
- package/dist/components/forms/NotificationsPanel.js.map +1 -0
- package/dist/components/forms/primitives.d.ts +42 -0
- package/dist/components/forms/primitives.d.ts.map +1 -0
- package/dist/components/forms/primitives.js +96 -0
- package/dist/components/forms/primitives.js.map +1 -0
- package/dist/layout/Sidebar.d.ts +5 -0
- package/dist/layout/Sidebar.d.ts.map +1 -1
- package/dist/layout/Sidebar.js +143 -10
- package/dist/layout/Sidebar.js.map +1 -1
- package/dist/lib/forms-events.d.ts +17 -0
- package/dist/lib/forms-events.d.ts.map +1 -0
- package/dist/lib/forms-events.js +20 -0
- package/dist/lib/forms-events.js.map +1 -0
- package/dist/lib/forms-service.d.ts +80 -0
- package/dist/lib/forms-service.d.ts.map +1 -0
- package/dist/lib/forms-service.js +144 -0
- package/dist/lib/forms-service.js.map +1 -0
- package/dist/views/Forms.d.ts.map +1 -1
- package/dist/views/Forms.js +119 -16
- package/dist/views/Forms.js.map +1 -1
- package/package.json +2 -2
- package/src/AdminRoot.tsx +22 -0
- package/src/__tests__/layout/sidebar-forms-badge.render.test.tsx +50 -0
- package/src/__tests__/layout/sidebar-submenu.render.test.tsx +93 -0
- package/src/__tests__/views/forms-list.render.test.tsx +141 -0
- package/src/components/forms/Drawer.tsx +70 -0
- package/src/components/forms/EmbedPanel.tsx +173 -0
- package/src/components/forms/FieldsPanel.tsx +385 -0
- package/src/components/forms/FormSchemaDrawer.tsx +185 -0
- package/src/components/forms/NotificationsPanel.tsx +240 -0
- package/src/components/forms/primitives.tsx +200 -0
- package/src/layout/Sidebar.tsx +295 -14
- package/src/lib/forms-events.ts +32 -0
- package/src/lib/forms-service.ts +244 -0
- package/src/views/Forms.tsx +343 -104
package/src/layout/Sidebar.tsx
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import type
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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
|
+
}
|