@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,240 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * Notifications tab of the Form Schema drawer. Persists per-form notification
5
+ * settings (recipients, reply-to, subject/message templates, field inclusion,
6
+ * autoresponder). Actual email/webhook delivery + the "send test" action are
7
+ * wired in a later phase (PR 6); this panel owns the configuration only.
8
+ */
9
+ import { useEffect, useState } from 'react'
10
+ import * as Switch from '@radix-ui/react-switch'
11
+ import { toast } from 'sonner'
12
+ import {
13
+ fetchNotificationSettings,
14
+ updateNotificationSettings,
15
+ type FormNotificationSettings,
16
+ } from '../../lib/forms-service.js'
17
+ import { emitFormsChanged } from '../../lib/forms-events.js'
18
+ import { FormsErrorState, FormsLoading, btnPrimary } from './primitives.js'
19
+
20
+ const inputCls =
21
+ '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'
22
+
23
+ export function NotificationsPanel({
24
+ formId,
25
+ onChanged,
26
+ }: {
27
+ formId: string
28
+ /** Called after a successful save so the list can refresh the notify state. */
29
+ onChanged?: () => void
30
+ }) {
31
+ const [settings, setSettings] = useState<FormNotificationSettings | null>(null)
32
+ const [loading, setLoading] = useState(true)
33
+ const [error, setError] = useState<string | null>(null)
34
+ const [saving, setSaving] = useState(false)
35
+
36
+ useEffect(() => {
37
+ let active = true
38
+ setLoading(true)
39
+ setError(null)
40
+ fetchNotificationSettings(formId)
41
+ .then((s) => {
42
+ if (active) setSettings(s)
43
+ })
44
+ .catch((e: unknown) => {
45
+ if (active)
46
+ setError(e instanceof Error ? e.message : 'Failed to load notification settings')
47
+ })
48
+ .finally(() => {
49
+ if (active) setLoading(false)
50
+ })
51
+ return () => {
52
+ active = false
53
+ }
54
+ }, [formId])
55
+
56
+ function patch(p: Partial<FormNotificationSettings>) {
57
+ setSettings((cur) => (cur ? { ...cur, ...p } : cur))
58
+ }
59
+
60
+ async function save() {
61
+ if (!settings) return
62
+ setSaving(true)
63
+ const res = await updateNotificationSettings(formId, settings)
64
+ setSaving(false)
65
+ if (res.error) {
66
+ toast.error(res.error)
67
+ return
68
+ }
69
+ if (res.data) setSettings(res.data)
70
+ toast.success('Notification settings saved.')
71
+ emitFormsChanged()
72
+ onChanged?.()
73
+ }
74
+
75
+ if (loading) return <FormsLoading label="Loading notification settings…" />
76
+ if (error) return <FormsErrorState message={error} />
77
+ if (!settings) return null
78
+
79
+ return (
80
+ <div className="space-y-4">
81
+ <ToggleRow
82
+ label="Enable notifications"
83
+ help="Email the recipients below when a new entry arrives."
84
+ checked={settings.enabled}
85
+ onChange={(v) => patch({ enabled: v })}
86
+ />
87
+
88
+ <fieldset
89
+ disabled={!settings.enabled}
90
+ className="space-y-4 transition-opacity disabled:opacity-50"
91
+ >
92
+ <Field label="Recipients" hint="Comma-separated email addresses">
93
+ <input
94
+ type="text"
95
+ value={settings.recipients.join(', ')}
96
+ onChange={(e) =>
97
+ patch({
98
+ recipients: e.target.value
99
+ .split(',')
100
+ .map((s) => s.trim())
101
+ .filter(Boolean),
102
+ })
103
+ }
104
+ placeholder="team@example.com"
105
+ className={inputCls}
106
+ />
107
+ </Field>
108
+
109
+ <Field label="Reply-to field key" hint="Field whose value is used as the reply-to address">
110
+ <input
111
+ type="text"
112
+ value={settings.replyToFieldKey ?? ''}
113
+ onChange={(e) => patch({ replyToFieldKey: e.target.value || null })}
114
+ placeholder="email"
115
+ className={inputCls}
116
+ />
117
+ </Field>
118
+
119
+ <Field label="Subject template">
120
+ <input
121
+ type="text"
122
+ value={settings.subjectTemplate ?? ''}
123
+ onChange={(e) => patch({ subjectTemplate: e.target.value })}
124
+ placeholder="New submission from {{name}}"
125
+ className={inputCls}
126
+ />
127
+ </Field>
128
+
129
+ <Field label="Message template">
130
+ <textarea
131
+ rows={3}
132
+ value={settings.messageTemplate ?? ''}
133
+ onChange={(e) => patch({ messageTemplate: e.target.value })}
134
+ className={inputCls}
135
+ />
136
+ </Field>
137
+
138
+ <ToggleRow
139
+ label="Include all fields"
140
+ help="Append every submitted field to the notification body."
141
+ checked={settings.includeFields ?? true}
142
+ onChange={(v) => patch({ includeFields: v })}
143
+ />
144
+ <ToggleRow
145
+ label="Exclude sensitive fields"
146
+ help="Omit fields marked as sensitive/PII from the email."
147
+ checked={settings.excludeSensitiveFields ?? false}
148
+ onChange={(v) => patch({ excludeSensitiveFields: v })}
149
+ />
150
+
151
+ <div className="border-border border-t pt-4">
152
+ <ToggleRow
153
+ label="Auto-response email"
154
+ help="Send a confirmation email back to the submitter."
155
+ checked={settings.autoresponderEnabled ?? false}
156
+ onChange={(v) => patch({ autoresponderEnabled: v })}
157
+ />
158
+ {settings.autoresponderEnabled && (
159
+ <div className="mt-3 space-y-3">
160
+ <Field label="Auto-response subject">
161
+ <input
162
+ type="text"
163
+ value={settings.autoresponderSubject ?? ''}
164
+ onChange={(e) => patch({ autoresponderSubject: e.target.value })}
165
+ className={inputCls}
166
+ />
167
+ </Field>
168
+ <Field label="Auto-response body">
169
+ <textarea
170
+ rows={3}
171
+ value={settings.autoresponderBody ?? ''}
172
+ onChange={(e) => patch({ autoresponderBody: e.target.value })}
173
+ className={inputCls}
174
+ />
175
+ </Field>
176
+ </div>
177
+ )}
178
+ </div>
179
+ </fieldset>
180
+
181
+ <p className="text-muted-foreground text-xs">
182
+ Email &amp; webhook delivery (including the test action) is wired in an upcoming release.
183
+ Settings saved here apply automatically once delivery is enabled.
184
+ </p>
185
+
186
+ <div className="flex justify-end">
187
+ <button className={btnPrimary} onClick={save} disabled={saving}>
188
+ {saving ? 'Saving…' : 'Save notifications'}
189
+ </button>
190
+ </div>
191
+ </div>
192
+ )
193
+ }
194
+
195
+ function ToggleRow({
196
+ label,
197
+ help,
198
+ checked,
199
+ onChange,
200
+ }: {
201
+ label: string
202
+ help: string
203
+ checked: boolean
204
+ onChange: (v: boolean) => void
205
+ }) {
206
+ return (
207
+ <div className="flex items-center justify-between gap-3">
208
+ <span className="min-w-0">
209
+ <span className="text-foreground block text-sm font-medium">{label}</span>
210
+ <span className="text-muted-foreground block text-sm">{help}</span>
211
+ </span>
212
+ <Switch.Root
213
+ checked={checked}
214
+ onCheckedChange={onChange}
215
+ aria-label={label}
216
+ className="bg-input-background data-[state=checked]:bg-primary focus-visible:ring-ring relative h-[18px] w-8 shrink-0 rounded-full transition-colors focus-visible:ring-2 focus-visible:outline-none"
217
+ >
218
+ <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]" />
219
+ </Switch.Root>
220
+ </div>
221
+ )
222
+ }
223
+
224
+ function Field({
225
+ label,
226
+ hint,
227
+ children,
228
+ }: {
229
+ label: string
230
+ hint?: string
231
+ children: React.ReactNode
232
+ }) {
233
+ return (
234
+ <label className="block">
235
+ <span className="text-foreground mb-1 block text-sm font-medium">{label}</span>
236
+ {children}
237
+ {hint && <span className="text-muted-foreground mt-1 block text-xs">{hint}</span>}
238
+ </label>
239
+ )
240
+ }
@@ -0,0 +1,200 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * Shared Forms UI primitives — token-only, accessible building blocks used
5
+ * across the Forms list, schema drawer, and (later) the entries views. No raw
6
+ * palette colours: field-type and status hues map onto the theme's semantic
7
+ * tokens (success / warning / info / destructive / primary / muted) so they
8
+ * track light/dark mode and brand overrides automatically.
9
+ */
10
+ import type { ReactNode } from 'react'
11
+ import { AlertTriangle, Loader2 } from 'lucide-react'
12
+ import type { FormFieldType, FormStatus } from '../../lib/forms-service.js'
13
+
14
+ // ─── Field-type badges ─────────────────────────────────────────────────────
15
+
16
+ /** Map a field type onto a semantic colour pair (background + text). */
17
+ function fieldTypeTone(type: FormFieldType): string {
18
+ switch (type) {
19
+ case 'email':
20
+ case 'url':
21
+ case 'date':
22
+ case 'number':
23
+ return 'bg-info/10 text-info'
24
+ case 'phone':
25
+ case 'textarea':
26
+ return 'bg-primary/10 text-primary'
27
+ case 'select':
28
+ case 'multiselect':
29
+ case 'radio':
30
+ return 'bg-warning/10 text-warning'
31
+ case 'checkbox':
32
+ case 'consent':
33
+ return 'bg-success/10 text-success'
34
+ case 'file':
35
+ return 'bg-destructive/10 text-destructive'
36
+ default:
37
+ return 'bg-muted text-muted-foreground'
38
+ }
39
+ }
40
+
41
+ const FIELD_TYPE_LABELS: Partial<Record<FormFieldType, string>> = {
42
+ text: 'TEXT',
43
+ email: 'EMAIL',
44
+ phone: 'PHONE',
45
+ textarea: 'TEXTAREA',
46
+ select: 'SELECT',
47
+ multiselect: 'MULTI',
48
+ radio: 'RADIO',
49
+ checkbox: 'CHECKBOX',
50
+ date: 'DATE',
51
+ number: 'NUMBER',
52
+ url: 'URL',
53
+ hidden: 'HIDDEN',
54
+ file: 'FILE',
55
+ consent: 'CONSENT',
56
+ richtext: 'CONTENT',
57
+ divider: 'DIVIDER',
58
+ honeypot: 'HONEYPOT',
59
+ }
60
+
61
+ export function FieldTypeBadge({ type }: { type: FormFieldType }) {
62
+ return (
63
+ <span
64
+ className={`inline-flex items-center rounded-md px-1.5 py-0.5 text-[10px] font-medium tracking-wide uppercase ${fieldTypeTone(type)}`}
65
+ >
66
+ {FIELD_TYPE_LABELS[type] ?? type}
67
+ </span>
68
+ )
69
+ }
70
+
71
+ // ─── Status badges ─────────────────────────────────────────────────────────
72
+
73
+ const STATUS_META: Record<FormStatus, { label: string; cls: string; dot: string }> = {
74
+ active: { label: 'Active', cls: 'bg-success/10 text-success', dot: 'bg-success' },
75
+ draft: { label: 'Draft', cls: 'bg-warning/10 text-warning', dot: 'bg-warning' },
76
+ archived: {
77
+ label: 'Archived',
78
+ cls: 'bg-muted text-muted-foreground',
79
+ dot: 'bg-muted-foreground',
80
+ },
81
+ }
82
+
83
+ export function FormStatusBadge({ status }: { status: FormStatus }) {
84
+ const meta = STATUS_META[status] ?? STATUS_META.draft
85
+ return (
86
+ <span
87
+ className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium ${meta.cls}`}
88
+ >
89
+ <span className={`h-1.5 w-1.5 rounded-full ${meta.dot}`} aria-hidden />
90
+ {meta.label}
91
+ </span>
92
+ )
93
+ }
94
+
95
+ /** Small red count badge for unread submissions. Hidden when count is 0. */
96
+ export function UnreadBadge({ count, className = '' }: { count: number; className?: string }) {
97
+ if (count <= 0) return null
98
+ return (
99
+ <span
100
+ className={`bg-destructive text-destructive-foreground inline-flex min-w-5 items-center justify-center rounded-full px-1.5 py-0.5 text-xs font-medium tabular-nums ${className}`}
101
+ aria-label={`${count} unread`}
102
+ >
103
+ {count > 99 ? '99+' : count}
104
+ </span>
105
+ )
106
+ }
107
+
108
+ // ─── Summary chips ───────────────────────────────────────────────────────
109
+
110
+ export function SummaryChip({
111
+ value,
112
+ label,
113
+ tone = 'neutral',
114
+ }: {
115
+ value: ReactNode
116
+ label: string
117
+ tone?: 'neutral' | 'success' | 'warning' | 'destructive'
118
+ }) {
119
+ const valueCls =
120
+ tone === 'success'
121
+ ? 'text-success'
122
+ : tone === 'warning'
123
+ ? 'text-warning'
124
+ : tone === 'destructive'
125
+ ? 'text-destructive'
126
+ : 'text-foreground'
127
+ return (
128
+ <div className="border-border bg-card flex items-baseline gap-1.5 rounded-lg border px-3 py-2">
129
+ <span className={`text-base font-medium tabular-nums ${valueCls}`}>{value}</span>
130
+ <span className="text-muted-foreground text-sm">{label}</span>
131
+ </div>
132
+ )
133
+ }
134
+
135
+ // ─── States ──────────────────────────────────────────────────────────────
136
+
137
+ export function FormsLoading({ label = 'Loading…' }: { label?: string }) {
138
+ return (
139
+ <div className="flex h-64 items-center justify-center" role="status" aria-live="polite">
140
+ <Loader2 className="text-muted-foreground h-6 w-6 animate-spin" aria-hidden />
141
+ <span className="sr-only">{label}</span>
142
+ </div>
143
+ )
144
+ }
145
+
146
+ export function FormsEmptyState({
147
+ icon,
148
+ title,
149
+ description,
150
+ action,
151
+ }: {
152
+ icon?: ReactNode
153
+ title: string
154
+ description?: string
155
+ action?: ReactNode
156
+ }) {
157
+ return (
158
+ <div className="border-border flex flex-col items-center justify-center rounded-lg border border-dashed px-6 py-12 text-center">
159
+ {icon && (
160
+ <div className="text-muted-foreground mb-3" aria-hidden>
161
+ {icon}
162
+ </div>
163
+ )}
164
+ <h3 className="text-foreground text-base font-medium">{title}</h3>
165
+ {description && <p className="text-muted-foreground mt-1 max-w-md text-sm">{description}</p>}
166
+ {action && <div className="mt-4">{action}</div>}
167
+ </div>
168
+ )
169
+ }
170
+
171
+ export function FormsErrorState({ message, onRetry }: { message: string; onRetry?: () => void }) {
172
+ return (
173
+ <div
174
+ className="border-destructive/30 bg-destructive/5 flex items-center gap-3 rounded-lg border p-3"
175
+ role="alert"
176
+ >
177
+ <AlertTriangle className="text-destructive h-5 w-5 shrink-0" aria-hidden />
178
+ <span className="text-destructive flex-1 text-sm">{message}</span>
179
+ {onRetry && (
180
+ <button
181
+ onClick={onRetry}
182
+ className="border-border text-foreground hover:bg-accent rounded-md border px-3 py-1 text-sm transition-colors"
183
+ >
184
+ Retry
185
+ </button>
186
+ )}
187
+ </div>
188
+ )
189
+ }
190
+
191
+ // ─── Button helpers (token-only) ─────────────────────────────────────────
192
+
193
+ export const btnPrimary =
194
+ 'inline-flex items-center justify-center gap-2 rounded-md bg-primary px-4 py-2 text-base font-medium text-primary-foreground transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-60'
195
+
196
+ export const btnSecondary =
197
+ 'inline-flex items-center justify-center gap-2 rounded-md border border-border bg-card px-4 py-2 text-base font-medium text-foreground transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-60'
198
+
199
+ export const btnGhostIcon =
200
+ 'inline-flex items-center justify-center rounded-md p-2 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring'