@actuate-media/cms-admin 0.22.0 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/AdminRoot.d.ts.map +1 -1
- package/dist/AdminRoot.js +4 -0
- package/dist/AdminRoot.js.map +1 -1
- package/dist/__tests__/components/forms-pagination.render.test.d.ts +2 -0
- package/dist/__tests__/components/forms-pagination.render.test.d.ts.map +1 -0
- package/dist/__tests__/components/forms-pagination.render.test.js +33 -0
- package/dist/__tests__/components/forms-pagination.render.test.js.map +1 -0
- package/dist/__tests__/layout/sidebar-forms-submenu.render.test.d.ts +2 -0
- package/dist/__tests__/layout/sidebar-forms-submenu.render.test.d.ts.map +1 -0
- package/dist/__tests__/layout/sidebar-forms-submenu.render.test.js +28 -0
- package/dist/__tests__/layout/sidebar-forms-submenu.render.test.js.map +1 -0
- package/dist/__tests__/views/form-entries.render.test.d.ts +2 -0
- package/dist/__tests__/views/form-entries.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/form-entries.render.test.js +112 -0
- package/dist/__tests__/views/form-entries.render.test.js.map +1 -0
- package/dist/__tests__/views/form-submissions.render.test.d.ts +2 -0
- package/dist/__tests__/views/form-submissions.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/form-submissions.render.test.js +110 -0
- package/dist/__tests__/views/form-submissions.render.test.js.map +1 -0
- package/dist/actuate-admin.css +1 -1
- package/dist/components/forms/EntriesTable.d.ts +28 -0
- package/dist/components/forms/EntriesTable.d.ts.map +1 -0
- package/dist/components/forms/EntriesTable.js +26 -0
- package/dist/components/forms/EntriesTable.js.map +1 -0
- package/dist/components/forms/EntryDetailDrawer.d.ts +11 -0
- package/dist/components/forms/EntryDetailDrawer.d.ts.map +1 -0
- package/dist/components/forms/EntryDetailDrawer.js +145 -0
- package/dist/components/forms/EntryDetailDrawer.js.map +1 -0
- package/dist/components/forms/NotificationsPanel.js +1 -1
- package/dist/components/forms/NotificationsPanel.js.map +1 -1
- package/dist/components/forms/primitives.d.ts +15 -0
- package/dist/components/forms/primitives.d.ts.map +1 -1
- package/dist/components/forms/primitives.js +36 -1
- package/dist/components/forms/primitives.js.map +1 -1
- package/dist/layout/Sidebar.d.ts.map +1 -1
- package/dist/layout/Sidebar.js +44 -21
- package/dist/layout/Sidebar.js.map +1 -1
- package/dist/lib/forms-service.d.ts +36 -2
- package/dist/lib/forms-service.d.ts.map +1 -1
- package/dist/lib/forms-service.js +66 -0
- package/dist/lib/forms-service.js.map +1 -1
- package/dist/views/FormEntries.d.ts +5 -0
- package/dist/views/FormEntries.d.ts.map +1 -0
- package/dist/views/FormEntries.js +211 -0
- package/dist/views/FormEntries.js.map +1 -0
- package/dist/views/FormSubmissions.d.ts.map +1 -1
- package/dist/views/FormSubmissions.js +204 -77
- package/dist/views/FormSubmissions.js.map +1 -1
- package/dist/views/Forms.d.ts.map +1 -1
- package/dist/views/Forms.js +26 -10
- package/dist/views/Forms.js.map +1 -1
- package/package.json +1 -1
- package/src/AdminRoot.tsx +4 -0
- package/src/__tests__/components/forms-pagination.render.test.tsx +49 -0
- package/src/__tests__/layout/sidebar-forms-submenu.render.test.tsx +38 -0
- package/src/__tests__/views/form-entries.render.test.tsx +133 -0
- package/src/__tests__/views/form-submissions.render.test.tsx +129 -0
- package/src/components/forms/EntriesTable.tsx +197 -0
- package/src/components/forms/EntryDetailDrawer.tsx +312 -0
- package/src/components/forms/NotificationsPanel.tsx +1 -1
- package/src/components/forms/primitives.tsx +101 -1
- package/src/layout/Sidebar.tsx +75 -17
- package/src/lib/forms-service.ts +99 -0
- package/src/views/FormEntries.tsx +449 -0
- package/src/views/FormSubmissions.tsx +391 -401
- package/src/views/Forms.tsx +145 -107
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Entry detail drawer — shows a single submission rendered against the schema
|
|
5
|
+
* version it was captured with, plus sender, attribution, and per-entry
|
|
6
|
+
* actions (star, archive, mark unread, delete). Opening an unread entry marks
|
|
7
|
+
* it read. Token-only styling; built on the shared `Drawer`.
|
|
8
|
+
*/
|
|
9
|
+
import { useCallback, useEffect, useState } from 'react'
|
|
10
|
+
import { Archive, ArchiveRestore, Loader2, Mail, Star, Trash2 } from 'lucide-react'
|
|
11
|
+
import { toast } from 'sonner'
|
|
12
|
+
import {
|
|
13
|
+
deleteEntry,
|
|
14
|
+
fetchEntry,
|
|
15
|
+
updateEntry,
|
|
16
|
+
type EntryDetail,
|
|
17
|
+
type FormField,
|
|
18
|
+
type FormSubmission,
|
|
19
|
+
} from '../../lib/forms-service.js'
|
|
20
|
+
import { emitFormsChanged } from '../../lib/forms-events.js'
|
|
21
|
+
import { ConfirmDialog } from '../ui/ConfirmDialog.js'
|
|
22
|
+
import { Drawer } from './Drawer.js'
|
|
23
|
+
import { FormsErrorState, btnGhostIcon } from './primitives.js'
|
|
24
|
+
|
|
25
|
+
function formatDateTime(iso: string | null | undefined): string {
|
|
26
|
+
if (!iso) return '—'
|
|
27
|
+
const d = new Date(iso)
|
|
28
|
+
if (Number.isNaN(d.getTime())) return '—'
|
|
29
|
+
return d.toLocaleString(undefined, {
|
|
30
|
+
month: 'short',
|
|
31
|
+
day: 'numeric',
|
|
32
|
+
year: 'numeric',
|
|
33
|
+
hour: 'numeric',
|
|
34
|
+
minute: '2-digit',
|
|
35
|
+
})
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Render a stored value for display, coping with arrays/objects/booleans. */
|
|
39
|
+
function displayValue(value: unknown): string {
|
|
40
|
+
if (value == null || value === '') return '—'
|
|
41
|
+
if (typeof value === 'boolean') return value ? 'Yes' : 'No'
|
|
42
|
+
if (Array.isArray(value)) return value.map((v) => String(v)).join(', ')
|
|
43
|
+
if (typeof value === 'object') return JSON.stringify(value)
|
|
44
|
+
return String(value)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface AttrRow {
|
|
48
|
+
label: string
|
|
49
|
+
value: string | null | undefined
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function attributionRows(entry: FormSubmission): AttrRow[] {
|
|
53
|
+
return [
|
|
54
|
+
{ label: 'Source', value: entry.utmSource },
|
|
55
|
+
{ label: 'Medium', value: entry.utmMedium },
|
|
56
|
+
{ label: 'Campaign', value: entry.utmCampaign },
|
|
57
|
+
{ label: 'Page', value: entry.pageUrl },
|
|
58
|
+
{ label: 'Referrer', value: entry.referrer },
|
|
59
|
+
].filter((r) => r.value)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface EntryDetailDrawerProps {
|
|
63
|
+
entryId: string | null
|
|
64
|
+
open: boolean
|
|
65
|
+
onOpenChange: (open: boolean) => void
|
|
66
|
+
/** Called after a mutation (star/archive/read/delete) so the list refreshes. */
|
|
67
|
+
onChanged?: () => void
|
|
68
|
+
/** Called specifically after a delete so the caller can drop the row/selection. */
|
|
69
|
+
onDeleted?: (id: string) => void
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function EntryDetailDrawer({
|
|
73
|
+
entryId,
|
|
74
|
+
open,
|
|
75
|
+
onOpenChange,
|
|
76
|
+
onChanged,
|
|
77
|
+
onDeleted,
|
|
78
|
+
}: EntryDetailDrawerProps) {
|
|
79
|
+
const [detail, setDetail] = useState<EntryDetail | null>(null)
|
|
80
|
+
const [loading, setLoading] = useState(false)
|
|
81
|
+
const [error, setError] = useState<string | null>(null)
|
|
82
|
+
const [busy, setBusy] = useState(false)
|
|
83
|
+
const [confirmDelete, setConfirmDelete] = useState(false)
|
|
84
|
+
|
|
85
|
+
const notifyChanged = useCallback(() => {
|
|
86
|
+
emitFormsChanged()
|
|
87
|
+
onChanged?.()
|
|
88
|
+
}, [onChanged])
|
|
89
|
+
|
|
90
|
+
// Load the entry (and its schema) when the drawer opens, marking it read.
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
if (!open || !entryId) return
|
|
93
|
+
let cancelled = false
|
|
94
|
+
setLoading(true)
|
|
95
|
+
setError(null)
|
|
96
|
+
setDetail(null)
|
|
97
|
+
void (async () => {
|
|
98
|
+
try {
|
|
99
|
+
const data = await fetchEntry(entryId)
|
|
100
|
+
if (cancelled) return
|
|
101
|
+
if (!data) {
|
|
102
|
+
setError('Entry not found')
|
|
103
|
+
return
|
|
104
|
+
}
|
|
105
|
+
setDetail(data)
|
|
106
|
+
// Mark read on open (fire-and-forget; refresh the list counts).
|
|
107
|
+
if (data.entry.unread) {
|
|
108
|
+
const res = await updateEntry(entryId, { unread: false })
|
|
109
|
+
if (!cancelled && !res.error) notifyChanged()
|
|
110
|
+
}
|
|
111
|
+
} catch (e) {
|
|
112
|
+
if (!cancelled) setError(e instanceof Error ? e.message : 'Failed to load entry')
|
|
113
|
+
} finally {
|
|
114
|
+
if (!cancelled) setLoading(false)
|
|
115
|
+
}
|
|
116
|
+
})()
|
|
117
|
+
return () => {
|
|
118
|
+
cancelled = true
|
|
119
|
+
}
|
|
120
|
+
}, [open, entryId, notifyChanged])
|
|
121
|
+
|
|
122
|
+
const mutate = useCallback(
|
|
123
|
+
async (patch: { unread?: boolean; starred?: boolean; archived?: boolean }) => {
|
|
124
|
+
if (!entryId) return
|
|
125
|
+
setBusy(true)
|
|
126
|
+
const res = await updateEntry(entryId, patch)
|
|
127
|
+
setBusy(false)
|
|
128
|
+
if (res.error) {
|
|
129
|
+
toast.error(res.error)
|
|
130
|
+
return
|
|
131
|
+
}
|
|
132
|
+
setDetail((cur) => (cur ? { ...cur, entry: { ...cur.entry, ...patch } } : cur))
|
|
133
|
+
notifyChanged()
|
|
134
|
+
},
|
|
135
|
+
[entryId, notifyChanged],
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
const handleDelete = useCallback(async () => {
|
|
139
|
+
if (!entryId) return
|
|
140
|
+
setBusy(true)
|
|
141
|
+
const res = await deleteEntry(entryId)
|
|
142
|
+
setBusy(false)
|
|
143
|
+
if (res.error) {
|
|
144
|
+
toast.error(res.error)
|
|
145
|
+
return
|
|
146
|
+
}
|
|
147
|
+
toast.success('Submission deleted')
|
|
148
|
+
notifyChanged()
|
|
149
|
+
onDeleted?.(entryId)
|
|
150
|
+
onOpenChange(false)
|
|
151
|
+
}, [entryId, notifyChanged, onDeleted, onOpenChange])
|
|
152
|
+
|
|
153
|
+
const entry = detail?.entry
|
|
154
|
+
const senderName = entry?.senderName || 'Anonymous'
|
|
155
|
+
const attrRows = entry ? attributionRows(entry) : []
|
|
156
|
+
|
|
157
|
+
return (
|
|
158
|
+
<>
|
|
159
|
+
<Drawer
|
|
160
|
+
open={open}
|
|
161
|
+
onOpenChange={onOpenChange}
|
|
162
|
+
title={entry ? `Entry${entry.entryNumber ? ` #${entry.entryNumber}` : ''}` : 'Entry'}
|
|
163
|
+
description={entry ? formatDateTime(entry.submittedAt) : undefined}
|
|
164
|
+
headerExtra={
|
|
165
|
+
entry ? (
|
|
166
|
+
<>
|
|
167
|
+
<button
|
|
168
|
+
type="button"
|
|
169
|
+
className={btnGhostIcon}
|
|
170
|
+
aria-pressed={entry.starred}
|
|
171
|
+
aria-label={entry.starred ? 'Unstar entry' : 'Star entry'}
|
|
172
|
+
disabled={busy}
|
|
173
|
+
onClick={() => void mutate({ starred: !entry.starred })}
|
|
174
|
+
>
|
|
175
|
+
<Star
|
|
176
|
+
size={18}
|
|
177
|
+
aria-hidden
|
|
178
|
+
className={entry.starred ? 'fill-warning text-warning' : ''}
|
|
179
|
+
/>
|
|
180
|
+
</button>
|
|
181
|
+
<button
|
|
182
|
+
type="button"
|
|
183
|
+
className={btnGhostIcon}
|
|
184
|
+
aria-pressed={entry.archived}
|
|
185
|
+
aria-label={entry.archived ? 'Unarchive entry' : 'Archive entry'}
|
|
186
|
+
disabled={busy}
|
|
187
|
+
onClick={() => void mutate({ archived: !entry.archived })}
|
|
188
|
+
>
|
|
189
|
+
{entry.archived ? (
|
|
190
|
+
<ArchiveRestore size={18} aria-hidden />
|
|
191
|
+
) : (
|
|
192
|
+
<Archive size={18} aria-hidden />
|
|
193
|
+
)}
|
|
194
|
+
</button>
|
|
195
|
+
</>
|
|
196
|
+
) : undefined
|
|
197
|
+
}
|
|
198
|
+
footer={
|
|
199
|
+
entry ? (
|
|
200
|
+
<>
|
|
201
|
+
<button
|
|
202
|
+
type="button"
|
|
203
|
+
className="text-destructive hover:bg-destructive/10 focus-visible:ring-ring inline-flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none disabled:opacity-60"
|
|
204
|
+
disabled={busy}
|
|
205
|
+
onClick={() => setConfirmDelete(true)}
|
|
206
|
+
>
|
|
207
|
+
<Trash2 size={16} aria-hidden />
|
|
208
|
+
Delete
|
|
209
|
+
</button>
|
|
210
|
+
<button
|
|
211
|
+
type="button"
|
|
212
|
+
className="border-border text-foreground hover:bg-accent focus-visible:ring-ring inline-flex items-center gap-2 rounded-md border px-3 py-2 text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none disabled:opacity-60"
|
|
213
|
+
disabled={busy}
|
|
214
|
+
onClick={() => void mutate({ unread: !entry.unread })}
|
|
215
|
+
>
|
|
216
|
+
<Mail size={16} aria-hidden />
|
|
217
|
+
Mark {entry.unread ? 'read' : 'unread'}
|
|
218
|
+
</button>
|
|
219
|
+
</>
|
|
220
|
+
) : undefined
|
|
221
|
+
}
|
|
222
|
+
>
|
|
223
|
+
{loading ? (
|
|
224
|
+
<div className="flex h-40 items-center justify-center" role="status" aria-live="polite">
|
|
225
|
+
<Loader2 className="text-muted-foreground h-5 w-5 animate-spin" aria-hidden />
|
|
226
|
+
<span className="sr-only">Loading entry…</span>
|
|
227
|
+
</div>
|
|
228
|
+
) : error ? (
|
|
229
|
+
<FormsErrorState message={error} />
|
|
230
|
+
) : entry ? (
|
|
231
|
+
<div className="space-y-6">
|
|
232
|
+
<section className="space-y-1">
|
|
233
|
+
<h3 className="text-foreground text-sm font-medium">{senderName}</h3>
|
|
234
|
+
{entry.senderEmail && (
|
|
235
|
+
<a
|
|
236
|
+
href={`mailto:${entry.senderEmail}`}
|
|
237
|
+
className="text-primary block text-sm hover:underline"
|
|
238
|
+
>
|
|
239
|
+
{entry.senderEmail}
|
|
240
|
+
</a>
|
|
241
|
+
)}
|
|
242
|
+
{entry.senderPhone && (
|
|
243
|
+
<span className="text-muted-foreground block text-sm">{entry.senderPhone}</span>
|
|
244
|
+
)}
|
|
245
|
+
</section>
|
|
246
|
+
|
|
247
|
+
<FieldValues entry={entry} fields={detail.fields} />
|
|
248
|
+
|
|
249
|
+
{attrRows.length > 0 && (
|
|
250
|
+
<section>
|
|
251
|
+
<h4 className="text-muted-foreground mb-2 text-xs font-medium tracking-wide uppercase">
|
|
252
|
+
Attribution
|
|
253
|
+
</h4>
|
|
254
|
+
<dl className="divide-border divide-y text-sm">
|
|
255
|
+
{attrRows.map((row) => (
|
|
256
|
+
<div key={row.label} className="flex justify-between gap-4 py-1.5">
|
|
257
|
+
<dt className="text-muted-foreground shrink-0">{row.label}</dt>
|
|
258
|
+
<dd className="text-foreground min-w-0 truncate text-right">{row.value}</dd>
|
|
259
|
+
</div>
|
|
260
|
+
))}
|
|
261
|
+
</dl>
|
|
262
|
+
</section>
|
|
263
|
+
)}
|
|
264
|
+
</div>
|
|
265
|
+
) : null}
|
|
266
|
+
</Drawer>
|
|
267
|
+
|
|
268
|
+
<ConfirmDialog
|
|
269
|
+
open={confirmDelete}
|
|
270
|
+
onClose={() => setConfirmDelete(false)}
|
|
271
|
+
onConfirm={() => void handleDelete()}
|
|
272
|
+
title="Delete submission?"
|
|
273
|
+
description="This permanently removes the submission and any attached files. This cannot be undone."
|
|
274
|
+
confirmLabel="Delete"
|
|
275
|
+
destructive
|
|
276
|
+
/>
|
|
277
|
+
</>
|
|
278
|
+
)
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Render the captured field values against the entry's schema. */
|
|
282
|
+
function FieldValues({ entry, fields }: { entry: FormSubmission; fields: FormField[] }) {
|
|
283
|
+
// Prefer the schema order; fall back to whatever keys exist on the data.
|
|
284
|
+
const inputFields = fields.filter(
|
|
285
|
+
(f) => f.type !== 'divider' && f.type !== 'richtext' && f.type !== 'honeypot',
|
|
286
|
+
)
|
|
287
|
+
const rows: { key: string; label: string }[] = inputFields.length
|
|
288
|
+
? inputFields.map((f) => ({ key: f.key, label: f.label }))
|
|
289
|
+
: Object.keys(entry.data ?? {}).map((k) => ({ key: k, label: k }))
|
|
290
|
+
|
|
291
|
+
if (rows.length === 0) {
|
|
292
|
+
return <p className="text-muted-foreground text-sm">No field data captured.</p>
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return (
|
|
296
|
+
<section>
|
|
297
|
+
<h4 className="text-muted-foreground mb-2 text-xs font-medium tracking-wide uppercase">
|
|
298
|
+
Submission
|
|
299
|
+
</h4>
|
|
300
|
+
<dl className="space-y-3">
|
|
301
|
+
{rows.map((row) => (
|
|
302
|
+
<div key={row.key}>
|
|
303
|
+
<dt className="text-muted-foreground text-xs">{row.label}</dt>
|
|
304
|
+
<dd className="text-foreground mt-0.5 text-sm wrap-break-word whitespace-pre-wrap">
|
|
305
|
+
{displayValue(entry.data?.[row.key])}
|
|
306
|
+
</dd>
|
|
307
|
+
</div>
|
|
308
|
+
))}
|
|
309
|
+
</dl>
|
|
310
|
+
</section>
|
|
311
|
+
)
|
|
312
|
+
}
|
|
@@ -213,7 +213,7 @@ function ToggleRow({
|
|
|
213
213
|
checked={checked}
|
|
214
214
|
onCheckedChange={onChange}
|
|
215
215
|
aria-label={label}
|
|
216
|
-
className="bg-
|
|
216
|
+
className="bg-destructive data-[state=checked]:bg-success focus-visible:ring-ring relative h-[18px] w-8 shrink-0 rounded-full transition-colors focus-visible:ring-2 focus-visible:outline-none"
|
|
217
217
|
>
|
|
218
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
219
|
</Switch.Root>
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* track light/dark mode and brand overrides automatically.
|
|
9
9
|
*/
|
|
10
10
|
import type { ReactNode } from 'react'
|
|
11
|
-
import { AlertTriangle, Loader2 } from 'lucide-react'
|
|
11
|
+
import { AlertTriangle, ChevronLeft, ChevronRight, Loader2 } from 'lucide-react'
|
|
12
12
|
import type { FormFieldType, FormStatus } from '../../lib/forms-service.js'
|
|
13
13
|
|
|
14
14
|
// ─── Field-type badges ─────────────────────────────────────────────────────
|
|
@@ -188,6 +188,106 @@ export function FormsErrorState({ message, onRetry }: { message: string; onRetry
|
|
|
188
188
|
)
|
|
189
189
|
}
|
|
190
190
|
|
|
191
|
+
// ─── Pagination ────────────────────────────────────────────────────────────
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Return up to 7 page-control entries: a sliding window around the current
|
|
195
|
+
* page with ellipsis markers, so the footer stays narrow on large datasets.
|
|
196
|
+
*/
|
|
197
|
+
function pageWindow(current: number, total: number): Array<number | 'gap'> {
|
|
198
|
+
if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1)
|
|
199
|
+
const out: Array<number | 'gap'> = [1]
|
|
200
|
+
const start = Math.max(2, current - 1)
|
|
201
|
+
const end = Math.min(total - 1, current + 1)
|
|
202
|
+
if (start > 2) out.push('gap')
|
|
203
|
+
for (let i = start; i <= end; i++) out.push(i)
|
|
204
|
+
if (end < total - 1) out.push('gap')
|
|
205
|
+
out.push(total)
|
|
206
|
+
return out
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Token-only pagination footer matching the Posts/Pages list styling. Renders
|
|
211
|
+
* a "start–end of total" summary plus prev / numbered / next controls. Hidden
|
|
212
|
+
* when there is a single page of results.
|
|
213
|
+
*/
|
|
214
|
+
export function FormsPagination({
|
|
215
|
+
page,
|
|
216
|
+
pageSize,
|
|
217
|
+
total,
|
|
218
|
+
onPageChange,
|
|
219
|
+
noun = 'result',
|
|
220
|
+
label = 'Pagination',
|
|
221
|
+
}: {
|
|
222
|
+
page: number
|
|
223
|
+
pageSize: number
|
|
224
|
+
total: number
|
|
225
|
+
onPageChange: (page: number) => void
|
|
226
|
+
/** Singular noun for the summary (pluralised automatically). */
|
|
227
|
+
noun?: string
|
|
228
|
+
/** Accessible label for the nav landmark. */
|
|
229
|
+
label?: string
|
|
230
|
+
}) {
|
|
231
|
+
const totalPages = Math.max(1, Math.ceil(total / pageSize))
|
|
232
|
+
if (totalPages <= 1) return null
|
|
233
|
+
const start = total === 0 ? 0 : (page - 1) * pageSize + 1
|
|
234
|
+
const end = Math.min(total, page * pageSize)
|
|
235
|
+
|
|
236
|
+
return (
|
|
237
|
+
<div className="border-border flex items-center justify-between border-t px-4 py-3 text-sm">
|
|
238
|
+
<span className="text-muted-foreground tabular-nums">
|
|
239
|
+
{start}–{end} of {total} {noun}
|
|
240
|
+
{total === 1 ? '' : 's'}
|
|
241
|
+
</span>
|
|
242
|
+
<nav className="flex items-center gap-1" aria-label={label}>
|
|
243
|
+
<button
|
|
244
|
+
type="button"
|
|
245
|
+
onClick={() => onPageChange(Math.max(1, page - 1))}
|
|
246
|
+
disabled={page === 1}
|
|
247
|
+
aria-label="Previous page"
|
|
248
|
+
className="border-border bg-background text-foreground hover:bg-accent focus-visible:ring-ring inline-flex h-8 w-8 items-center justify-center rounded-md border transition-colors focus-visible:ring-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-40"
|
|
249
|
+
>
|
|
250
|
+
<ChevronLeft className="h-4 w-4" aria-hidden />
|
|
251
|
+
</button>
|
|
252
|
+
{pageWindow(page, totalPages).map((p, idx) =>
|
|
253
|
+
p === 'gap' ? (
|
|
254
|
+
<span
|
|
255
|
+
key={`gap-${idx}`}
|
|
256
|
+
aria-hidden
|
|
257
|
+
className="text-muted-foreground inline-flex h-8 w-8 items-center justify-center"
|
|
258
|
+
>
|
|
259
|
+
…
|
|
260
|
+
</span>
|
|
261
|
+
) : (
|
|
262
|
+
<button
|
|
263
|
+
key={p}
|
|
264
|
+
type="button"
|
|
265
|
+
onClick={() => onPageChange(p)}
|
|
266
|
+
aria-current={p === page ? 'page' : undefined}
|
|
267
|
+
className={`focus-visible:ring-ring inline-flex h-8 w-8 items-center justify-center rounded-md text-sm tabular-nums transition-colors focus-visible:ring-2 focus-visible:outline-none ${
|
|
268
|
+
p === page
|
|
269
|
+
? 'bg-primary text-primary-foreground'
|
|
270
|
+
: 'border-border bg-background text-foreground hover:bg-accent border'
|
|
271
|
+
}`}
|
|
272
|
+
>
|
|
273
|
+
{p}
|
|
274
|
+
</button>
|
|
275
|
+
),
|
|
276
|
+
)}
|
|
277
|
+
<button
|
|
278
|
+
type="button"
|
|
279
|
+
onClick={() => onPageChange(Math.min(totalPages, page + 1))}
|
|
280
|
+
disabled={page === totalPages}
|
|
281
|
+
aria-label="Next page"
|
|
282
|
+
className="border-border bg-background text-foreground hover:bg-accent focus-visible:ring-ring inline-flex h-8 w-8 items-center justify-center rounded-md border transition-colors focus-visible:ring-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-40"
|
|
283
|
+
>
|
|
284
|
+
<ChevronRight className="h-4 w-4" aria-hidden />
|
|
285
|
+
</button>
|
|
286
|
+
</nav>
|
|
287
|
+
</div>
|
|
288
|
+
)
|
|
289
|
+
}
|
|
290
|
+
|
|
191
291
|
// ─── Button helpers (token-only) ─────────────────────────────────────────
|
|
192
292
|
|
|
193
293
|
export const btnPrimary =
|
package/src/layout/Sidebar.tsx
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
|
4
4
|
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
|
|
5
5
|
import { useTheme } from '../components/ThemeProvider.js'
|
|
6
|
-
import { fetchFormsSidebarCounts } from '../lib/forms-service.js'
|
|
6
|
+
import { fetchFormsSidebarCounts, type FormsSidebarCount } from '../lib/forms-service.js'
|
|
7
7
|
import { onFormsChanged } from '../lib/forms-events.js'
|
|
8
8
|
import {
|
|
9
9
|
LayoutDashboard,
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
ChevronLeft,
|
|
15
15
|
ChevronDown,
|
|
16
16
|
ClipboardList,
|
|
17
|
+
Inbox,
|
|
17
18
|
Search as SearchIcon,
|
|
18
19
|
Briefcase,
|
|
19
20
|
FolderOpen,
|
|
@@ -177,8 +178,8 @@ export function Sidebar({
|
|
|
177
178
|
onNavigate,
|
|
178
179
|
config,
|
|
179
180
|
}: SidebarProps) {
|
|
180
|
-
const
|
|
181
|
-
const navItems =
|
|
181
|
+
const formsSidebar = useFormsSidebar()
|
|
182
|
+
const navItems = applyFormsNav(buildNavItems(config), formsSidebar)
|
|
182
183
|
|
|
183
184
|
return (
|
|
184
185
|
<aside
|
|
@@ -228,21 +229,33 @@ export function Sidebar({
|
|
|
228
229
|
|
|
229
230
|
// ─── Dynamic Forms unread badge ───────────────────────────────────────────
|
|
230
231
|
|
|
232
|
+
interface FormsSidebarState {
|
|
233
|
+
/** Total unread submissions across active forms (parent badge). */
|
|
234
|
+
totalUnread: number
|
|
235
|
+
/** Per-form unread counts, used to build the Forms submenu children. */
|
|
236
|
+
forms: FormsSidebarCount[]
|
|
237
|
+
}
|
|
238
|
+
|
|
231
239
|
/**
|
|
232
|
-
* Live
|
|
233
|
-
*
|
|
234
|
-
*
|
|
235
|
-
*
|
|
240
|
+
* Live unread counts for the Forms submenu: a total surfaced on the Forms nav
|
|
241
|
+
* item plus per-form counts that drive the "All Entries" + per-form children.
|
|
242
|
+
* Refreshes on mount and whenever a forms/entries mutation fires the shared
|
|
243
|
+
* event (mark read, archive, notify toggle, …). Best-effort: a failed fetch
|
|
244
|
+
* keeps the last known value rather than surfacing an error in nav.
|
|
236
245
|
*/
|
|
237
|
-
function
|
|
238
|
-
const [
|
|
246
|
+
function useFormsSidebar(): FormsSidebarState {
|
|
247
|
+
const [state, setState] = useState<FormsSidebarState>({ totalUnread: 0, forms: [] })
|
|
239
248
|
|
|
240
249
|
useEffect(() => {
|
|
241
250
|
let active = true
|
|
242
251
|
const load = () => {
|
|
243
252
|
fetchFormsSidebarCounts()
|
|
244
253
|
.then((counts) => {
|
|
245
|
-
if (active)
|
|
254
|
+
if (!active) return
|
|
255
|
+
setState({
|
|
256
|
+
totalUnread: counts.reduce((sum, c) => sum + (c.unread ?? 0), 0),
|
|
257
|
+
forms: counts,
|
|
258
|
+
})
|
|
246
259
|
})
|
|
247
260
|
.catch(() => {
|
|
248
261
|
/* keep the last known value; nav must never break on a fetch error */
|
|
@@ -256,13 +269,32 @@ function useFormsUnread(): number {
|
|
|
256
269
|
}
|
|
257
270
|
}, [])
|
|
258
271
|
|
|
259
|
-
return
|
|
272
|
+
return state
|
|
260
273
|
}
|
|
261
274
|
|
|
262
|
-
/**
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
275
|
+
/**
|
|
276
|
+
* Return a copy of the nav tree with the Forms item expanded into a submenu:
|
|
277
|
+
* a total-unread badge on the parent, an "All Entries" child, and one child
|
|
278
|
+
* per active form carrying its own unread badge.
|
|
279
|
+
*/
|
|
280
|
+
function applyFormsNav(items: NavItem[], state: FormsSidebarState): NavItem[] {
|
|
281
|
+
return items.map((item) => {
|
|
282
|
+
if (item.path !== '/forms') return item
|
|
283
|
+
const children: NavItem[] = [
|
|
284
|
+
{ path: '/forms/entries', label: 'All Entries', icon: Inbox },
|
|
285
|
+
...state.forms.map((f) => ({
|
|
286
|
+
path: `/forms/${f.formId}/submissions`,
|
|
287
|
+
label: f.name,
|
|
288
|
+
icon: ClipboardList,
|
|
289
|
+
badge: f.unread,
|
|
290
|
+
})),
|
|
291
|
+
]
|
|
292
|
+
return {
|
|
293
|
+
...item,
|
|
294
|
+
badge: state.totalUnread > 0 ? state.totalUnread : undefined,
|
|
295
|
+
children,
|
|
296
|
+
}
|
|
297
|
+
})
|
|
266
298
|
}
|
|
267
299
|
|
|
268
300
|
// ─── Rendering ──────────────────────────────────────────────────────────────
|
|
@@ -377,6 +409,14 @@ function NavSection({ item, ctx }: { item: NavItem; ctx: NavRenderContext }) {
|
|
|
377
409
|
<Icon className="h-5 w-5 shrink-0" aria-hidden />
|
|
378
410
|
<span className="truncate text-sm font-medium">{item.label}</span>
|
|
379
411
|
</button>
|
|
412
|
+
{!!item.badge && item.badge > 0 && (
|
|
413
|
+
<span
|
|
414
|
+
className="bg-destructive text-destructive-foreground mr-1 inline-flex min-w-5 shrink-0 items-center justify-center rounded-full px-1.5 py-0.5 text-xs font-medium tabular-nums"
|
|
415
|
+
aria-label={`${item.badge} unread`}
|
|
416
|
+
>
|
|
417
|
+
{item.badge > 99 ? '99+' : item.badge}
|
|
418
|
+
</span>
|
|
419
|
+
)}
|
|
380
420
|
<button
|
|
381
421
|
onClick={() => setOpen((v) => !v)}
|
|
382
422
|
className="text-sidebar-foreground/55 hover:text-sidebar-foreground mr-1 flex shrink-0 items-center rounded-md p-1.5 transition-colors"
|
|
@@ -547,11 +587,21 @@ function CollapsedFlyout({ item, ctx }: { item: NavItem; ctx: NavRenderContext }
|
|
|
547
587
|
? 'bg-sidebar-accent text-sidebar-primary'
|
|
548
588
|
: 'text-sidebar-foreground hover:bg-sidebar-accent'
|
|
549
589
|
}`}
|
|
550
|
-
aria-label={
|
|
590
|
+
aria-label={
|
|
591
|
+
item.badge && item.badge > 0 ? `${item.label} (${item.badge} unread)` : item.label
|
|
592
|
+
}
|
|
551
593
|
title={item.label}
|
|
552
594
|
aria-current={isActive ? 'page' : undefined}
|
|
553
595
|
>
|
|
554
|
-
<
|
|
596
|
+
<span className="relative flex shrink-0 items-center">
|
|
597
|
+
<Icon className="h-5 w-5 shrink-0" aria-hidden />
|
|
598
|
+
{!!item.badge && item.badge > 0 && (
|
|
599
|
+
<span
|
|
600
|
+
className="bg-destructive absolute -top-0.5 -right-0.5 h-2 w-2 rounded-full"
|
|
601
|
+
aria-hidden
|
|
602
|
+
/>
|
|
603
|
+
)}
|
|
604
|
+
</span>
|
|
555
605
|
</button>
|
|
556
606
|
</DropdownMenu.Trigger>
|
|
557
607
|
</div>
|
|
@@ -593,6 +643,14 @@ function CollapsedFlyout({ item, ctx }: { item: NavItem; ctx: NavRenderContext }
|
|
|
593
643
|
>
|
|
594
644
|
<ChildIcon className="text-muted-foreground h-4 w-4 shrink-0" aria-hidden />
|
|
595
645
|
<span className="truncate">{child.label}</span>
|
|
646
|
+
{!!child.badge && child.badge > 0 && (
|
|
647
|
+
<span
|
|
648
|
+
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"
|
|
649
|
+
aria-label={`${child.badge} unread`}
|
|
650
|
+
>
|
|
651
|
+
{child.badge > 99 ? '99+' : child.badge}
|
|
652
|
+
</span>
|
|
653
|
+
)}
|
|
596
654
|
</DropdownMenu.Item>
|
|
597
655
|
)
|
|
598
656
|
})}
|