@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
@@ -0,0 +1,141 @@
1
+ // @vitest-environment happy-dom
2
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
3
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react'
4
+
5
+ // Drive the Forms list through a mocked forms-service so the test owns the
6
+ // data and can assert filtering, search, the notify toggle, and the schema
7
+ // drawer without a real backend.
8
+ function makeForm(over: Record<string, unknown> = {}) {
9
+ return {
10
+ id: 'f1',
11
+ name: 'Contact Us',
12
+ slug: 'contact-us',
13
+ description: 'General contact and inquiries',
14
+ status: 'active',
15
+ fields: [
16
+ { id: 'c1', label: 'Full Name', key: 'name', type: 'text', required: true, sortOrder: 0 },
17
+ { id: 'c2', label: 'Email', key: 'email', type: 'email', required: true, sortOrder: 1 },
18
+ ],
19
+ fieldCount: 2,
20
+ totalEntries: 47,
21
+ unreadEntries: 8,
22
+ submissions: 47,
23
+ notifyEnabled: true,
24
+ lastEntryAt: new Date().toISOString(),
25
+ ...over,
26
+ }
27
+ }
28
+
29
+ const FORMS = [
30
+ makeForm(),
31
+ makeForm({
32
+ id: 'f2',
33
+ name: 'Event RSVP',
34
+ slug: 'event-rsvp',
35
+ description: 'Webinar registration',
36
+ status: 'draft',
37
+ totalEntries: 0,
38
+ unreadEntries: 0,
39
+ notifyEnabled: false,
40
+ lastEntryAt: null,
41
+ }),
42
+ ]
43
+
44
+ const fetchForms = vi.fn(async () => FORMS)
45
+ const fetchFormStats = vi.fn(async () => ({
46
+ totalForms: 2,
47
+ activeForms: 1,
48
+ totalEntries: 47,
49
+ unreadEntries: 8,
50
+ }))
51
+ const setNotifyEnabled = vi.fn(async (_id: string, _v: boolean) => ({
52
+ data: { notifyEnabled: false },
53
+ }))
54
+ const fetchFormSchema = vi.fn(async () => ({
55
+ fields: FORMS[0]!.fields,
56
+ activeVersionId: null,
57
+ versions: [],
58
+ }))
59
+ const fetchEmbedSettings = vi.fn(async () => ({}))
60
+ const fetchNotificationSettings = vi.fn(async () => ({ enabled: true, recipients: [] }))
61
+
62
+ vi.mock('../../lib/forms-service.js', () => ({
63
+ fetchForms: () => fetchForms(),
64
+ fetchFormStats: () => fetchFormStats(),
65
+ setNotifyEnabled: (id: string, v: boolean) => setNotifyEnabled(id, v),
66
+ fetchFormSchema: () => fetchFormSchema(),
67
+ saveFormSchema: vi.fn(async () => ({ data: {} })),
68
+ fetchEmbedSettings: () => fetchEmbedSettings(),
69
+ updateEmbedSettings: vi.fn(async () => ({ data: {} })),
70
+ fetchNotificationSettings: () => fetchNotificationSettings(),
71
+ updateNotificationSettings: vi.fn(async () => ({ data: {} })),
72
+ }))
73
+
74
+ vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn(), loading: vi.fn() } }))
75
+
76
+ const { Forms } = await import('../../views/Forms.js')
77
+
78
+ beforeEach(() => {
79
+ fetchForms.mockClear()
80
+ fetchFormStats.mockClear()
81
+ setNotifyEnabled.mockClear()
82
+ })
83
+
84
+ describe('Forms list', () => {
85
+ it('renders forms, summary chips, and field badges from service data', async () => {
86
+ render(<Forms onNavigate={() => {}} />)
87
+
88
+ expect(await screen.findByText('Contact Us')).toBeTruthy()
89
+ expect(screen.getByText('Event RSVP')).toBeTruthy()
90
+ // Summary chips reflect stats (not hardcoded).
91
+ expect(screen.getByText('total entries')).toBeTruthy()
92
+ // Field-type badges render per form.
93
+ expect(screen.getAllByText('EMAIL').length).toBeGreaterThan(0)
94
+ })
95
+
96
+ it('filters by status pill', async () => {
97
+ render(<Forms onNavigate={() => {}} />)
98
+ await screen.findByText('Contact Us')
99
+
100
+ fireEvent.click(screen.getByRole('button', { name: 'Draft', pressed: false }))
101
+
102
+ // Active-only form is filtered out; the draft remains.
103
+ await waitFor(() => {
104
+ expect(screen.queryByText('Contact Us')).toBeNull()
105
+ expect(screen.getByText('Event RSVP')).toBeTruthy()
106
+ })
107
+ })
108
+
109
+ it('searches across name and field labels', async () => {
110
+ render(<Forms onNavigate={() => {}} />)
111
+ await screen.findByText('Contact Us')
112
+
113
+ fireEvent.change(screen.getByLabelText('Search forms'), { target: { value: 'rsvp' } })
114
+
115
+ await waitFor(() => {
116
+ expect(screen.getByText('Event RSVP')).toBeTruthy()
117
+ expect(screen.queryByText('Contact Us')).toBeNull()
118
+ })
119
+ })
120
+
121
+ it('optimistically persists the notify toggle', async () => {
122
+ render(<Forms onNavigate={() => {}} />)
123
+ await screen.findByText('Contact Us')
124
+
125
+ const toggles = screen.getAllByLabelText(/Notifications for/)
126
+ fireEvent.click(toggles[0]!)
127
+
128
+ await waitFor(() => expect(setNotifyEnabled).toHaveBeenCalledWith('f1', false))
129
+ })
130
+
131
+ it('opens the schema drawer from the Schema action', async () => {
132
+ render(<Forms onNavigate={() => {}} />)
133
+ await screen.findByText('Contact Us')
134
+
135
+ fireEvent.click(screen.getAllByRole('button', { name: 'Schema' })[0]!)
136
+
137
+ await waitFor(() => expect(fetchFormSchema).toHaveBeenCalled())
138
+ // Drawer tabs are present.
139
+ expect(await screen.findByRole('tab', { name: 'Fields' })).toBeTruthy()
140
+ })
141
+ })
@@ -0,0 +1,70 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * Right-side drawer built on Radix Dialog — focus-trapped, escape-closable,
5
+ * token-only styling. Shared by the Forms schema / entry-detail drawers.
6
+ */
7
+ import * as Dialog from '@radix-ui/react-dialog'
8
+ import { X } from 'lucide-react'
9
+ import type { ReactNode } from 'react'
10
+ import { btnGhostIcon } from './primitives.js'
11
+
12
+ export function Drawer({
13
+ open,
14
+ onOpenChange,
15
+ title,
16
+ description,
17
+ headerExtra,
18
+ footer,
19
+ children,
20
+ width = 'max-w-xl',
21
+ }: {
22
+ open: boolean
23
+ onOpenChange: (open: boolean) => void
24
+ title: ReactNode
25
+ description?: ReactNode
26
+ /** Optional content rendered between the header text and the close button. */
27
+ headerExtra?: ReactNode
28
+ footer?: ReactNode
29
+ children: ReactNode
30
+ width?: string
31
+ }) {
32
+ return (
33
+ <Dialog.Root open={open} onOpenChange={onOpenChange}>
34
+ <Dialog.Portal>
35
+ <Dialog.Overlay className="bg-foreground/40 motion-safe:animate-in motion-safe:fade-in fixed inset-0 z-50" />
36
+ <Dialog.Content
37
+ {...(description ? {} : { 'aria-describedby': undefined })}
38
+ className={`bg-card text-foreground fixed inset-y-0 right-0 z-50 flex w-full ${width} motion-safe:animate-in motion-safe:slide-in-from-right flex-col shadow-xl outline-none`}
39
+ >
40
+ <header className="border-border flex items-start justify-between gap-3 border-b px-5 py-4">
41
+ <div className="min-w-0">
42
+ <Dialog.Title className="text-foreground truncate text-lg font-medium">
43
+ {title}
44
+ </Dialog.Title>
45
+ {description && (
46
+ <Dialog.Description className="text-muted-foreground mt-0.5 truncate text-sm">
47
+ {description}
48
+ </Dialog.Description>
49
+ )}
50
+ </div>
51
+ <div className="flex shrink-0 items-center gap-1">
52
+ {headerExtra}
53
+ <Dialog.Close asChild>
54
+ <button className={btnGhostIcon} aria-label="Close" data-shortcut="close">
55
+ <X size={20} aria-hidden />
56
+ </button>
57
+ </Dialog.Close>
58
+ </div>
59
+ </header>
60
+ <div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">{children}</div>
61
+ {footer && (
62
+ <footer className="border-border bg-card flex items-center justify-end gap-2 border-t px-5 py-4">
63
+ {footer}
64
+ </footer>
65
+ )}
66
+ </Dialog.Content>
67
+ </Dialog.Portal>
68
+ </Dialog.Root>
69
+ )
70
+ }
@@ -0,0 +1,173 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * Embed tab of the Form Schema drawer. Shows the public submission/schema
5
+ * endpoints and copy-paste embed snippets, and lets the editor restrict which
6
+ * origins may host the embedded form. Snippets are derived from the form slug;
7
+ * the live public endpoints land in a later phase (PR 5) but the paths shown
8
+ * here are the committed contract.
9
+ */
10
+ import { Check, Copy } from 'lucide-react'
11
+ import { useEffect, useState } from 'react'
12
+ import { toast } from 'sonner'
13
+ import {
14
+ fetchEmbedSettings,
15
+ updateEmbedSettings,
16
+ type FormEmbedSettings,
17
+ type FormStatus,
18
+ } from '../../lib/forms-service.js'
19
+ import { FormsErrorState, FormsLoading, btnSecondary } from './primitives.js'
20
+
21
+ function siteOrigin(): string {
22
+ if (typeof window !== 'undefined') return window.location.origin
23
+ return 'https://your-site.com'
24
+ }
25
+
26
+ export function EmbedPanel({
27
+ formId,
28
+ slug,
29
+ status,
30
+ }: {
31
+ formId: string
32
+ slug: string
33
+ status: FormStatus
34
+ }) {
35
+ const [settings, setSettings] = useState<FormEmbedSettings | null>(null)
36
+ const [loading, setLoading] = useState(true)
37
+ const [error, setError] = useState<string | null>(null)
38
+ const [domains, setDomains] = useState('')
39
+ const [saving, setSaving] = useState(false)
40
+
41
+ useEffect(() => {
42
+ let active = true
43
+ setLoading(true)
44
+ setError(null)
45
+ fetchEmbedSettings(formId)
46
+ .then((s) => {
47
+ if (!active) return
48
+ setSettings(s)
49
+ setDomains((s.allowedDomains ?? []).join('\n'))
50
+ })
51
+ .catch((e: unknown) => {
52
+ if (active) setError(e instanceof Error ? e.message : 'Failed to load embed settings')
53
+ })
54
+ .finally(() => {
55
+ if (active) setLoading(false)
56
+ })
57
+ return () => {
58
+ active = false
59
+ }
60
+ }, [formId])
61
+
62
+ const origin = siteOrigin()
63
+ const submitUrl = `${origin}/api/forms/${slug}/submit`
64
+ const schemaUrl = `${origin}/api/forms/${slug}/schema`
65
+ const embedUrl = `${origin}/forms/embed/${slug}`
66
+ const iframeSnippet = `<iframe src="${embedUrl}" title="${slug}" style="width:100%;border:0;" loading="lazy"></iframe>`
67
+ const scriptSnippet = `<div data-actuate-form="${slug}"></div>\n<script src="${origin}/api/forms/embed.js" async></script>`
68
+
69
+ async function saveDomains() {
70
+ setSaving(true)
71
+ const list = domains
72
+ .split('\n')
73
+ .map((d) => d.trim())
74
+ .filter(Boolean)
75
+ const res = await updateEmbedSettings(formId, { allowedDomains: list })
76
+ setSaving(false)
77
+ if (res.error) {
78
+ toast.error(res.error)
79
+ return
80
+ }
81
+ if (res.data) setSettings(res.data)
82
+ toast.success('Embed settings saved.')
83
+ }
84
+
85
+ if (loading) return <FormsLoading label="Loading embed settings…" />
86
+ if (error) return <FormsErrorState message={error} />
87
+
88
+ return (
89
+ <div className="space-y-5">
90
+ {status !== 'active' && (
91
+ <p className="border-warning/30 bg-warning/10 text-warning rounded-md border px-3 py-2 text-sm">
92
+ This form is <span className="font-medium">{status}</span> and won&apos;t accept public
93
+ submissions until it&apos;s set to Active.
94
+ </p>
95
+ )}
96
+
97
+ <CopyField label="Public submission endpoint" value={submitUrl} />
98
+ <CopyField label="Schema endpoint" value={schemaUrl} />
99
+ <CopyField label="Iframe embed" value={iframeSnippet} multiline />
100
+ <CopyField label="Script embed" value={scriptSnippet} multiline />
101
+
102
+ <div>
103
+ <label className="text-foreground mb-1 block text-sm font-medium">
104
+ Allowed domains
105
+ <span className="text-muted-foreground ml-1 font-normal">
106
+ (one per line — leave empty to allow any)
107
+ </span>
108
+ </label>
109
+ <textarea
110
+ rows={3}
111
+ value={domains}
112
+ onChange={(e) => setDomains(e.target.value)}
113
+ placeholder="example.com"
114
+ className="border-border bg-input-background text-foreground focus-visible:ring-ring w-full rounded-md border px-2.5 py-1.5 text-sm focus-visible:ring-2 focus-visible:outline-none"
115
+ />
116
+ <div className="mt-2 flex justify-end">
117
+ <button className={btnSecondary} onClick={saveDomains} disabled={saving}>
118
+ {saving ? 'Saving…' : 'Save domains'}
119
+ </button>
120
+ </div>
121
+ </div>
122
+ {settings === null && null}
123
+ </div>
124
+ )
125
+ }
126
+
127
+ function CopyField({
128
+ label,
129
+ value,
130
+ multiline = false,
131
+ }: {
132
+ label: string
133
+ value: string
134
+ multiline?: boolean
135
+ }) {
136
+ const [copied, setCopied] = useState(false)
137
+
138
+ async function copy() {
139
+ try {
140
+ await navigator.clipboard.writeText(value)
141
+ setCopied(true)
142
+ setTimeout(() => setCopied(false), 1500)
143
+ } catch {
144
+ toast.error('Copy failed — your browser blocked clipboard access.')
145
+ }
146
+ }
147
+
148
+ return (
149
+ <div>
150
+ <div className="mb-1 flex items-center justify-between">
151
+ <span className="text-foreground text-sm font-medium">{label}</span>
152
+ <button
153
+ type="button"
154
+ onClick={copy}
155
+ className="text-muted-foreground hover:text-foreground focus-visible:ring-ring inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs font-medium focus-visible:ring-2 focus-visible:outline-none"
156
+ aria-label={`Copy ${label}`}
157
+ >
158
+ {copied ? (
159
+ <Check className="h-3.5 w-3.5" aria-hidden />
160
+ ) : (
161
+ <Copy className="h-3.5 w-3.5" aria-hidden />
162
+ )}
163
+ {copied ? 'Copied' : 'Copy'}
164
+ </button>
165
+ </div>
166
+ <pre
167
+ className={`border-border bg-muted text-foreground overflow-x-auto rounded-md border px-2.5 py-2 font-mono text-xs ${multiline ? 'whitespace-pre-wrap' : 'whitespace-nowrap'}`}
168
+ >
169
+ {value}
170
+ </pre>
171
+ </div>
172
+ )
173
+ }