@actuate-media/cms-admin 0.77.0 → 0.78.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/CHANGELOG.md +24 -0
- package/dist/__tests__/components/attribution-rows.test.d.ts +2 -0
- package/dist/__tests__/components/attribution-rows.test.d.ts.map +1 -0
- package/dist/__tests__/components/attribution-rows.test.js +82 -0
- package/dist/__tests__/components/attribution-rows.test.js.map +1 -0
- package/dist/__tests__/views/form-entries.render.test.js +20 -0
- package/dist/__tests__/views/form-entries.render.test.js.map +1 -1
- package/dist/__tests__/views/form-submissions.render.test.js +12 -0
- package/dist/__tests__/views/form-submissions.render.test.js.map +1 -1
- package/dist/components/forms/ExportMenu.d.ts +8 -0
- package/dist/components/forms/ExportMenu.d.ts.map +1 -0
- package/dist/components/forms/ExportMenu.js +32 -0
- package/dist/components/forms/ExportMenu.js.map +1 -0
- package/dist/components/forms/FormEntryDetailPane.d.ts +13 -0
- package/dist/components/forms/FormEntryDetailPane.d.ts.map +1 -1
- package/dist/components/forms/FormEntryDetailPane.js +74 -8
- package/dist/components/forms/FormEntryDetailPane.js.map +1 -1
- package/dist/components/forms/WebhooksPanel.d.ts.map +1 -1
- package/dist/components/forms/WebhooksPanel.js +1 -1
- package/dist/components/forms/WebhooksPanel.js.map +1 -1
- package/dist/lib/forms-service.d.ts +14 -0
- package/dist/lib/forms-service.d.ts.map +1 -1
- package/dist/lib/forms-service.js +37 -1
- package/dist/lib/forms-service.js.map +1 -1
- package/dist/views/FormEntries.d.ts.map +1 -1
- package/dist/views/FormEntries.js +8 -36
- package/dist/views/FormEntries.js.map +1 -1
- package/dist/views/FormSubmissions.d.ts.map +1 -1
- package/dist/views/FormSubmissions.js +7 -36
- package/dist/views/FormSubmissions.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/components/attribution-rows.test.ts +93 -0
- package/src/__tests__/views/form-entries.render.test.tsx +29 -0
- package/src/__tests__/views/form-submissions.render.test.tsx +17 -0
- package/src/components/forms/ExportMenu.tsx +70 -0
- package/src/components/forms/FormEntryDetailPane.tsx +82 -8
- package/src/components/forms/WebhooksPanel.tsx +5 -1
- package/src/lib/forms-service.ts +45 -1
- package/src/views/FormEntries.tsx +7 -46
- package/src/views/FormSubmissions.tsx +6 -46
|
@@ -59,6 +59,7 @@ const fetchEntryCounts = vi.fn(async (_p?: unknown) => ({
|
|
|
59
59
|
}))
|
|
60
60
|
const fetchEntry = vi.fn(async (_id?: string) => ({ entry: ENTRIES[0], fields: [] }))
|
|
61
61
|
const updateEntry = vi.fn(async (_id?: string, _patch?: unknown) => ({ data: ENTRIES[0] }))
|
|
62
|
+
const downloadEntriesExport = vi.fn(async (_opts?: unknown) => ({}))
|
|
62
63
|
|
|
63
64
|
vi.mock('../../lib/forms-service.js', () => ({
|
|
64
65
|
fetchFormById: (id?: string) => fetchFormById(id),
|
|
@@ -68,6 +69,7 @@ vi.mock('../../lib/forms-service.js', () => ({
|
|
|
68
69
|
updateEntry: (id?: string, patch?: unknown) => updateEntry(id, patch),
|
|
69
70
|
deleteEntry: vi.fn(async () => ({})),
|
|
70
71
|
bulkUpdateEntries: vi.fn(async () => ({ data: { updated: 1 } })),
|
|
72
|
+
downloadEntriesExport: (opts?: unknown) => downloadEntriesExport(opts),
|
|
71
73
|
}))
|
|
72
74
|
|
|
73
75
|
vi.mock('../../lib/forms-events.js', () => ({
|
|
@@ -138,4 +140,19 @@ describe('FormSubmissions table', () => {
|
|
|
138
140
|
fireEvent.click(screen.getByRole('button', { name: 'Close entry details' }))
|
|
139
141
|
expect(screen.queryByRole('region', { name: 'Entry details' })).toBeNull()
|
|
140
142
|
})
|
|
143
|
+
|
|
144
|
+
it('downloads the server-side export scoped to this form', async () => {
|
|
145
|
+
downloadEntriesExport.mockClear()
|
|
146
|
+
render(<FormSubmissions formId="f1" onNavigate={() => {}} />)
|
|
147
|
+
await screen.findByText('Jordan Avery')
|
|
148
|
+
|
|
149
|
+
// Radix DropdownMenu opens on pointerdown (not click), which happy-dom
|
|
150
|
+
// doesn't synthesize from fireEvent.click — open via keyboard instead.
|
|
151
|
+
fireEvent.keyDown(screen.getByRole('button', { name: /Export/ }), { key: 'Enter' })
|
|
152
|
+
fireEvent.click(await screen.findByRole('menuitem', { name: /Excel/ }))
|
|
153
|
+
|
|
154
|
+
await waitFor(() =>
|
|
155
|
+
expect(downloadEntriesExport).toHaveBeenCalledWith({ formId: 'f1', format: 'xlsx' }),
|
|
156
|
+
)
|
|
157
|
+
})
|
|
141
158
|
})
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Export dropdown shared by the per-form Submissions view and the All
|
|
5
|
+
* Entries inbox. Downloads the *server-side* export (every page, decrypted
|
|
6
|
+
* values, attribution columns — up to 5,000 rows), not just the rows
|
|
7
|
+
* currently rendered, in CSV or Excel format.
|
|
8
|
+
*/
|
|
9
|
+
import { useState } from 'react'
|
|
10
|
+
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
|
|
11
|
+
import { ChevronDown, Download, FileSpreadsheet, FileText } from 'lucide-react'
|
|
12
|
+
import { toast } from 'sonner'
|
|
13
|
+
import { adminPortalContainer } from '../../lib/portal-container.js'
|
|
14
|
+
import { downloadEntriesExport, type EntriesExportFormat } from '../../lib/forms-service.js'
|
|
15
|
+
import { btnSecondary } from './primitives.js'
|
|
16
|
+
|
|
17
|
+
export interface ExportMenuProps {
|
|
18
|
+
/** Scope the export to one form; omit to export entries across all forms. */
|
|
19
|
+
formId?: string
|
|
20
|
+
/** Disable the trigger (e.g. no entries to export). */
|
|
21
|
+
disabled?: boolean
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const itemClass =
|
|
25
|
+
'text-card-foreground data-highlighted:bg-accent flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-none'
|
|
26
|
+
|
|
27
|
+
export function ExportMenu({ formId, disabled }: ExportMenuProps) {
|
|
28
|
+
const [busy, setBusy] = useState(false)
|
|
29
|
+
|
|
30
|
+
const run = async (format: EntriesExportFormat) => {
|
|
31
|
+
setBusy(true)
|
|
32
|
+
try {
|
|
33
|
+
const res = await downloadEntriesExport({ formId, format })
|
|
34
|
+
if (res.error) toast.error(res.error)
|
|
35
|
+
} finally {
|
|
36
|
+
setBusy(false)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<DropdownMenu.Root>
|
|
42
|
+
<DropdownMenu.Trigger asChild>
|
|
43
|
+
<button type="button" className={btnSecondary} disabled={disabled || busy}>
|
|
44
|
+
<Download className="h-4 w-4" aria-hidden />
|
|
45
|
+
{busy ? 'Exporting…' : 'Export'}
|
|
46
|
+
<ChevronDown className="h-4 w-4" aria-hidden />
|
|
47
|
+
</button>
|
|
48
|
+
</DropdownMenu.Trigger>
|
|
49
|
+
<DropdownMenu.Portal container={adminPortalContainer()}>
|
|
50
|
+
<DropdownMenu.Content
|
|
51
|
+
align="end"
|
|
52
|
+
sideOffset={4}
|
|
53
|
+
className="border-border bg-card z-50 min-w-52 rounded-lg border p-1 shadow-md"
|
|
54
|
+
>
|
|
55
|
+
<DropdownMenu.Label className="text-muted-foreground px-2 py-1.5 text-xs">
|
|
56
|
+
Exports all submissions with attribution
|
|
57
|
+
</DropdownMenu.Label>
|
|
58
|
+
<DropdownMenu.Item onSelect={() => void run('csv')} className={itemClass}>
|
|
59
|
+
<FileText size={14} aria-hidden />
|
|
60
|
+
CSV (.csv)
|
|
61
|
+
</DropdownMenu.Item>
|
|
62
|
+
<DropdownMenu.Item onSelect={() => void run('xlsx')} className={itemClass}>
|
|
63
|
+
<FileSpreadsheet size={14} aria-hidden />
|
|
64
|
+
Excel (.xlsx)
|
|
65
|
+
</DropdownMenu.Item>
|
|
66
|
+
</DropdownMenu.Content>
|
|
67
|
+
</DropdownMenu.Portal>
|
|
68
|
+
</DropdownMenu.Root>
|
|
69
|
+
)
|
|
70
|
+
}
|
|
@@ -58,14 +58,88 @@ interface AttrRow {
|
|
|
58
58
|
value: string | null | undefined
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
61
|
+
const CHANNEL_LABELS: Record<string, string> = {
|
|
62
|
+
paid: 'Paid',
|
|
63
|
+
search_organic: 'Organic search',
|
|
64
|
+
social: 'Social',
|
|
65
|
+
email: 'Email',
|
|
66
|
+
ai: 'AI',
|
|
67
|
+
referral: 'Referral',
|
|
68
|
+
direct: 'Direct',
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const CLICK_ID_LABELS: Record<string, string> = {
|
|
72
|
+
gclid: 'GCLID (Google Ads)',
|
|
73
|
+
fbclid: 'FBCLID (Meta)',
|
|
74
|
+
msclkid: 'MSCLKID (Microsoft)',
|
|
75
|
+
ttclid: 'TTCLID (TikTok)',
|
|
76
|
+
li_fat_id: 'LinkedIn click ID',
|
|
77
|
+
dclid: 'DCLID (Display)',
|
|
78
|
+
twclid: 'TWCLID (X Ads)',
|
|
79
|
+
rdt_cid: 'Reddit click ID',
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const DEVICE_LABELS: Record<string, string> = {
|
|
83
|
+
desktop: 'Desktop',
|
|
84
|
+
tablet: 'Tablet',
|
|
85
|
+
mobile: 'Mobile',
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function attrString(blob: Record<string, unknown>, key: string): string {
|
|
89
|
+
const v = blob[key]
|
|
90
|
+
return typeof v === 'string' ? v : ''
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Build the attribution rows for an entry. Prefers the full client-captured
|
|
95
|
+
* blob (channel, click IDs, first/last touch) and falls back to the
|
|
96
|
+
* denormalized UTM columns for entries captured before attribution shipped.
|
|
97
|
+
* Exported for tests.
|
|
98
|
+
*/
|
|
99
|
+
export function buildAttributionRows(entry: FormSubmission): AttrRow[] {
|
|
100
|
+
const blob =
|
|
101
|
+
entry.attribution && typeof entry.attribution === 'object' && !Array.isArray(entry.attribution)
|
|
102
|
+
? entry.attribution
|
|
103
|
+
: {}
|
|
104
|
+
|
|
105
|
+
const channel = attrString(blob, 'channel')
|
|
106
|
+
const referrerName = attrString(blob, 'referrerName')
|
|
107
|
+
const channelLabel = channel
|
|
108
|
+
? `${CHANNEL_LABELS[channel] ?? channel}${referrerName ? ` · ${referrerName}` : ''}`
|
|
109
|
+
: ''
|
|
110
|
+
|
|
111
|
+
const rows: AttrRow[] = [
|
|
112
|
+
{ label: 'Channel', value: channelLabel },
|
|
113
|
+
{ label: 'Source', value: attrString(blob, 'source') || entry.utmSource },
|
|
114
|
+
{ label: 'Medium', value: attrString(blob, 'medium') || entry.utmMedium },
|
|
115
|
+
{ label: 'Campaign', value: attrString(blob, 'campaign') || entry.utmCampaign },
|
|
116
|
+
{ label: 'Term', value: attrString(blob, 'term') },
|
|
117
|
+
{ label: 'Content', value: attrString(blob, 'content') },
|
|
118
|
+
{ label: 'Landing page', value: attrString(blob, 'landingPage') },
|
|
66
119
|
{ label: 'Page', value: entry.pageUrl },
|
|
67
|
-
{ label: 'Referrer', value: entry.referrer },
|
|
68
|
-
|
|
120
|
+
{ label: 'Referrer', value: attrString(blob, 'referrer') || entry.referrer },
|
|
121
|
+
{
|
|
122
|
+
label: 'Device',
|
|
123
|
+
value: DEVICE_LABELS[attrString(blob, 'deviceType')] ?? '',
|
|
124
|
+
},
|
|
125
|
+
{ label: 'First touch', value: formatDateTimeOrEmpty(attrString(blob, 'firstTouchAt')) },
|
|
126
|
+
]
|
|
127
|
+
|
|
128
|
+
const clickIds = blob.clickIds
|
|
129
|
+
if (clickIds && typeof clickIds === 'object' && !Array.isArray(clickIds)) {
|
|
130
|
+
for (const [key, label] of Object.entries(CLICK_ID_LABELS)) {
|
|
131
|
+
const value = (clickIds as Record<string, unknown>)[key]
|
|
132
|
+
if (typeof value === 'string' && value) rows.push({ label, value })
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return rows.filter((r) => r.value)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function formatDateTimeOrEmpty(iso: string): string {
|
|
140
|
+
if (!iso) return ''
|
|
141
|
+
const formatted = formatDateTime(iso)
|
|
142
|
+
return formatted === '—' ? '' : formatted
|
|
69
143
|
}
|
|
70
144
|
|
|
71
145
|
export interface FormEntryDetailPaneProps {
|
|
@@ -155,7 +229,7 @@ export function FormEntryDetailPane({
|
|
|
155
229
|
|
|
156
230
|
const entry = detail?.entry
|
|
157
231
|
const senderName = entry?.senderName || 'Anonymous'
|
|
158
|
-
const attrRows = entry ?
|
|
232
|
+
const attrRows = entry ? buildAttributionRows(entry) : []
|
|
159
233
|
|
|
160
234
|
return (
|
|
161
235
|
<>
|
|
@@ -155,7 +155,11 @@ export function WebhooksPanel({ formId, formSlug }: { formId: string; formSlug:
|
|
|
155
155
|
<li>
|
|
156
156
|
Map fields in your action step — submission values arrive under{' '}
|
|
157
157
|
<span className="font-mono text-xs">data.email</span>,{' '}
|
|
158
|
-
<span className="font-mono text-xs">data.name</span>, etc.
|
|
158
|
+
<span className="font-mono text-xs">data.name</span>, etc. Marketing attribution
|
|
159
|
+
(when captured) arrives under{' '}
|
|
160
|
+
<span className="font-mono text-xs">attribution.source</span>,{' '}
|
|
161
|
+
<span className="font-mono text-xs">attribution.medium</span>,{' '}
|
|
162
|
+
<span className="font-mono text-xs">attribution.channel</span>, etc.
|
|
159
163
|
</li>
|
|
160
164
|
<li>
|
|
161
165
|
Use event <span className="font-mono text-xs">form.submitted</span> only (spam hits
|
package/src/lib/forms-service.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* `packages/cms-core/src/api/handlers.ts` (PR 2). Domain types are re-used
|
|
11
11
|
* from `@actuate-media/cms-core` so the admin can never drift from the server.
|
|
12
12
|
*/
|
|
13
|
-
import { cmsApi } from './api.js'
|
|
13
|
+
import { cmsApi, getApiBase } from './api.js'
|
|
14
14
|
import type {
|
|
15
15
|
FormDefinition,
|
|
16
16
|
FormSchemaVersion,
|
|
@@ -437,3 +437,47 @@ export async function bulkUpdateEntries(
|
|
|
437
437
|
})
|
|
438
438
|
return { data: res.data, error: res.error }
|
|
439
439
|
}
|
|
440
|
+
|
|
441
|
+
export type EntriesExportFormat = 'csv' | 'xlsx'
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Download the full server-side entries export (all pages, decrypted values,
|
|
445
|
+
* attribution columns — bounded server-side to 5,000 rows). Scoped to one
|
|
446
|
+
* form when `formId` is set, otherwise exports across all forms. Uses raw
|
|
447
|
+
* `fetch` because the response is a file, not the JSON envelope `cmsApi`
|
|
448
|
+
* unwraps.
|
|
449
|
+
*/
|
|
450
|
+
export async function downloadEntriesExport(
|
|
451
|
+
options: {
|
|
452
|
+
formId?: string
|
|
453
|
+
format?: EntriesExportFormat
|
|
454
|
+
} = {},
|
|
455
|
+
): Promise<{ error?: string }> {
|
|
456
|
+
const format: EntriesExportFormat = options.format ?? 'csv'
|
|
457
|
+
const qs = new URLSearchParams({ format })
|
|
458
|
+
if (options.formId) qs.set('formId', options.formId)
|
|
459
|
+
|
|
460
|
+
let res: Response
|
|
461
|
+
try {
|
|
462
|
+
res = await fetch(`${getApiBase()}/forms/entries/export?${qs.toString()}`, {
|
|
463
|
+
credentials: 'include',
|
|
464
|
+
})
|
|
465
|
+
} catch (err) {
|
|
466
|
+
return { error: err instanceof Error ? err.message : 'Network error' }
|
|
467
|
+
}
|
|
468
|
+
if (!res.ok) {
|
|
469
|
+
const body = (await res.json().catch(() => ({}))) as { error?: string }
|
|
470
|
+
return { error: body.error || `Export failed (${res.status})` }
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const blob = await res.blob()
|
|
474
|
+
const disposition = res.headers.get('content-disposition') ?? ''
|
|
475
|
+
const filename = /filename="([^"]+)"/.exec(disposition)?.[1] ?? `entries.${format}`
|
|
476
|
+
const url = URL.createObjectURL(blob)
|
|
477
|
+
const a = document.createElement('a')
|
|
478
|
+
a.href = url
|
|
479
|
+
a.download = filename
|
|
480
|
+
a.click()
|
|
481
|
+
URL.revokeObjectURL(url)
|
|
482
|
+
return {}
|
|
483
|
+
}
|
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* All Entries — a cross-form submissions inbox. Same token-only table, search,
|
|
5
|
-
* status pills (All / Unread / Starred / Archived), bulk actions,
|
|
6
|
-
* pagination, and inline detail pane as the per-form
|
|
7
|
-
* Form filter and a Form column so editors can
|
|
8
|
-
* place.
|
|
5
|
+
* status pills (All / Unread / Starred / Archived), bulk actions, server-side
|
|
6
|
+
* CSV/Excel export, pagination, and inline detail pane as the per-form
|
|
7
|
+
* Submissions view, plus a Form filter and a Form column so editors can
|
|
8
|
+
* triage every form from one place.
|
|
9
9
|
*/
|
|
10
10
|
import { useCallback, useEffect, useState } from 'react'
|
|
11
|
-
import {
|
|
11
|
+
import { Inbox, Search } from 'lucide-react'
|
|
12
12
|
import { toast } from 'sonner'
|
|
13
13
|
import {
|
|
14
14
|
bulkUpdateEntries,
|
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
import { emitFormsChanged, onFormsChanged } from '../lib/forms-events.js'
|
|
26
26
|
import { FormEntryDetailPane } from '../components/forms/FormEntryDetailPane.js'
|
|
27
27
|
import { EntriesTable } from '../components/forms/EntriesTable.js'
|
|
28
|
+
import { ExportMenu } from '../components/forms/ExportMenu.js'
|
|
28
29
|
import { ConfirmDialog } from '../components/ui/ConfirmDialog.js'
|
|
29
30
|
import { InspectorPane, SplitPaneLayout, SplitPaneList } from '../components/ui/InspectorPane.js'
|
|
30
31
|
import {
|
|
@@ -33,7 +34,6 @@ import {
|
|
|
33
34
|
FormsLoading,
|
|
34
35
|
FormsPagination,
|
|
35
36
|
SummaryChip,
|
|
36
|
-
btnSecondary,
|
|
37
37
|
} from '../components/forms/primitives.js'
|
|
38
38
|
|
|
39
39
|
export interface FormEntriesProps {
|
|
@@ -64,37 +64,6 @@ function filterToParams(filter: StatusFilter): Partial<FetchFormEntriesParams> {
|
|
|
64
64
|
}
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
function csvEscape(value: unknown): string {
|
|
68
|
-
const s = value == null ? '' : typeof value === 'object' ? JSON.stringify(value) : String(value)
|
|
69
|
-
return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
function exportCsv(entries: FormSubmission[]) {
|
|
73
|
-
const dataKeys = [...new Set(entries.flatMap((e) => Object.keys(e.data ?? {})))]
|
|
74
|
-
const header = ['Entry', 'Form', 'Submitted', 'Name', 'Email', 'Phone', 'Status', ...dataKeys]
|
|
75
|
-
const lines = [header.map(csvEscape).join(',')]
|
|
76
|
-
for (const e of entries) {
|
|
77
|
-
const row = [
|
|
78
|
-
e.entryNumber ?? '',
|
|
79
|
-
e.formName ?? '',
|
|
80
|
-
e.submittedAt,
|
|
81
|
-
e.senderName ?? '',
|
|
82
|
-
e.senderEmail ?? '',
|
|
83
|
-
e.senderPhone ?? '',
|
|
84
|
-
e.unread ? 'unread' : 'read',
|
|
85
|
-
...dataKeys.map((k) => e.data?.[k]),
|
|
86
|
-
]
|
|
87
|
-
lines.push(row.map(csvEscape).join(','))
|
|
88
|
-
}
|
|
89
|
-
const blob = new Blob([lines.join('\n')], { type: 'text/csv;charset=utf-8' })
|
|
90
|
-
const url = URL.createObjectURL(blob)
|
|
91
|
-
const a = document.createElement('a')
|
|
92
|
-
a.href = url
|
|
93
|
-
a.download = 'all-entries.csv'
|
|
94
|
-
a.click()
|
|
95
|
-
URL.revokeObjectURL(url)
|
|
96
|
-
}
|
|
97
|
-
|
|
98
67
|
export function FormEntries({ onNavigate }: FormEntriesProps) {
|
|
99
68
|
const [entries, setEntries] = useState<FormSubmission[]>([])
|
|
100
69
|
const [total, setTotal] = useState(0)
|
|
@@ -240,15 +209,7 @@ export function FormEntries({ onNavigate }: FormEntriesProps) {
|
|
|
240
209
|
{total} {total === 1 ? 'submission' : 'submissions'} across your forms
|
|
241
210
|
</p>
|
|
242
211
|
</div>
|
|
243
|
-
<
|
|
244
|
-
type="button"
|
|
245
|
-
className={btnSecondary}
|
|
246
|
-
onClick={() => exportCsv(entries)}
|
|
247
|
-
disabled={entries.length === 0}
|
|
248
|
-
>
|
|
249
|
-
<Download className="h-4 w-4" aria-hidden />
|
|
250
|
-
Export CSV
|
|
251
|
-
</button>
|
|
212
|
+
<ExportMenu formId={scopedFormId} disabled={counts !== null && counts.total === 0} />
|
|
252
213
|
</div>
|
|
253
214
|
|
|
254
215
|
{counts && (
|
|
@@ -6,12 +6,12 @@
|
|
|
6
6
|
*
|
|
7
7
|
* Features: summary chips, search + status pills (All / Unread / Starred /
|
|
8
8
|
* Archived), row selection with bulk actions (mark read, star, archive,
|
|
9
|
-
* delete), per-row star toggle, CSV export, and an inline
|
|
10
|
-
* opens on row click (marking the entry read). Loading /
|
|
11
|
-
* filtered-empty / error states are all handled.
|
|
9
|
+
* delete), per-row star toggle, server-side CSV/Excel export, and an inline
|
|
10
|
+
* detail pane that opens on row click (marking the entry read). Loading /
|
|
11
|
+
* empty / filtered-empty / error states are all handled.
|
|
12
12
|
*/
|
|
13
13
|
import { useCallback, useEffect, useState } from 'react'
|
|
14
|
-
import { ArrowLeft,
|
|
14
|
+
import { ArrowLeft, Inbox, Search } from 'lucide-react'
|
|
15
15
|
import { toast } from 'sonner'
|
|
16
16
|
import {
|
|
17
17
|
bulkUpdateEntries,
|
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
import { emitFormsChanged, onFormsChanged } from '../lib/forms-events.js'
|
|
28
28
|
import { FormEntryDetailPane } from '../components/forms/FormEntryDetailPane.js'
|
|
29
29
|
import { EntriesTable } from '../components/forms/EntriesTable.js'
|
|
30
|
+
import { ExportMenu } from '../components/forms/ExportMenu.js'
|
|
30
31
|
import { ConfirmDialog } from '../components/ui/ConfirmDialog.js'
|
|
31
32
|
import { InspectorPane, SplitPaneLayout, SplitPaneList } from '../components/ui/InspectorPane.js'
|
|
32
33
|
import {
|
|
@@ -35,7 +36,6 @@ import {
|
|
|
35
36
|
FormsLoading,
|
|
36
37
|
FormsPagination,
|
|
37
38
|
SummaryChip,
|
|
38
|
-
btnSecondary,
|
|
39
39
|
} from '../components/forms/primitives.js'
|
|
40
40
|
|
|
41
41
|
export interface FormSubmissionsProps {
|
|
@@ -67,39 +67,8 @@ function filterToParams(filter: StatusFilter): Partial<FetchFormEntriesParams> {
|
|
|
67
67
|
}
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
function csvEscape(value: unknown): string {
|
|
71
|
-
const s = value == null ? '' : typeof value === 'object' ? JSON.stringify(value) : String(value)
|
|
72
|
-
return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
function exportCsv(entries: FormSubmission[], formSlug: string) {
|
|
76
|
-
const dataKeys = [...new Set(entries.flatMap((e) => Object.keys(e.data ?? {})))]
|
|
77
|
-
const header = ['Entry', 'Submitted', 'Name', 'Email', 'Phone', 'Status', ...dataKeys]
|
|
78
|
-
const lines = [header.map(csvEscape).join(',')]
|
|
79
|
-
for (const e of entries) {
|
|
80
|
-
const row = [
|
|
81
|
-
e.entryNumber ?? '',
|
|
82
|
-
e.submittedAt,
|
|
83
|
-
e.senderName ?? '',
|
|
84
|
-
e.senderEmail ?? '',
|
|
85
|
-
e.senderPhone ?? '',
|
|
86
|
-
e.unread ? 'unread' : 'read',
|
|
87
|
-
...dataKeys.map((k) => e.data?.[k]),
|
|
88
|
-
]
|
|
89
|
-
lines.push(row.map(csvEscape).join(','))
|
|
90
|
-
}
|
|
91
|
-
const blob = new Blob([lines.join('\n')], { type: 'text/csv;charset=utf-8' })
|
|
92
|
-
const url = URL.createObjectURL(blob)
|
|
93
|
-
const a = document.createElement('a')
|
|
94
|
-
a.href = url
|
|
95
|
-
a.download = `${formSlug || 'form'}-submissions.csv`
|
|
96
|
-
a.click()
|
|
97
|
-
URL.revokeObjectURL(url)
|
|
98
|
-
}
|
|
99
|
-
|
|
100
70
|
export function FormSubmissions({ formId, onNavigate }: FormSubmissionsProps) {
|
|
101
71
|
const [formName, setFormName] = useState<string>('')
|
|
102
|
-
const [formSlug, setFormSlug] = useState<string>('')
|
|
103
72
|
const [entries, setEntries] = useState<FormSubmission[]>([])
|
|
104
73
|
const [total, setTotal] = useState(0)
|
|
105
74
|
const [counts, setCounts] = useState<EntryCounts | null>(null)
|
|
@@ -119,7 +88,6 @@ export function FormSubmissions({ formId, onNavigate }: FormSubmissionsProps) {
|
|
|
119
88
|
.then((form) => {
|
|
120
89
|
if (cancelled || !form) return
|
|
121
90
|
setFormName(form.name)
|
|
122
|
-
setFormSlug(form.slug)
|
|
123
91
|
})
|
|
124
92
|
.catch(() => {})
|
|
125
93
|
return () => {
|
|
@@ -261,15 +229,7 @@ export function FormSubmissions({ formId, onNavigate }: FormSubmissionsProps) {
|
|
|
261
229
|
{total} {total === 1 ? 'submission' : 'submissions'}
|
|
262
230
|
</p>
|
|
263
231
|
</div>
|
|
264
|
-
<
|
|
265
|
-
type="button"
|
|
266
|
-
className={btnSecondary}
|
|
267
|
-
onClick={() => exportCsv(entries, formSlug)}
|
|
268
|
-
disabled={entries.length === 0}
|
|
269
|
-
>
|
|
270
|
-
<Download className="h-4 w-4" aria-hidden />
|
|
271
|
-
Export CSV
|
|
272
|
-
</button>
|
|
232
|
+
<ExportMenu formId={formId} disabled={counts !== null && counts.total === 0} />
|
|
273
233
|
</div>
|
|
274
234
|
</div>
|
|
275
235
|
|