@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.
Files changed (40) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/__tests__/components/attribution-rows.test.d.ts +2 -0
  3. package/dist/__tests__/components/attribution-rows.test.d.ts.map +1 -0
  4. package/dist/__tests__/components/attribution-rows.test.js +82 -0
  5. package/dist/__tests__/components/attribution-rows.test.js.map +1 -0
  6. package/dist/__tests__/views/form-entries.render.test.js +20 -0
  7. package/dist/__tests__/views/form-entries.render.test.js.map +1 -1
  8. package/dist/__tests__/views/form-submissions.render.test.js +12 -0
  9. package/dist/__tests__/views/form-submissions.render.test.js.map +1 -1
  10. package/dist/components/forms/ExportMenu.d.ts +8 -0
  11. package/dist/components/forms/ExportMenu.d.ts.map +1 -0
  12. package/dist/components/forms/ExportMenu.js +32 -0
  13. package/dist/components/forms/ExportMenu.js.map +1 -0
  14. package/dist/components/forms/FormEntryDetailPane.d.ts +13 -0
  15. package/dist/components/forms/FormEntryDetailPane.d.ts.map +1 -1
  16. package/dist/components/forms/FormEntryDetailPane.js +74 -8
  17. package/dist/components/forms/FormEntryDetailPane.js.map +1 -1
  18. package/dist/components/forms/WebhooksPanel.d.ts.map +1 -1
  19. package/dist/components/forms/WebhooksPanel.js +1 -1
  20. package/dist/components/forms/WebhooksPanel.js.map +1 -1
  21. package/dist/lib/forms-service.d.ts +14 -0
  22. package/dist/lib/forms-service.d.ts.map +1 -1
  23. package/dist/lib/forms-service.js +37 -1
  24. package/dist/lib/forms-service.js.map +1 -1
  25. package/dist/views/FormEntries.d.ts.map +1 -1
  26. package/dist/views/FormEntries.js +8 -36
  27. package/dist/views/FormEntries.js.map +1 -1
  28. package/dist/views/FormSubmissions.d.ts.map +1 -1
  29. package/dist/views/FormSubmissions.js +7 -36
  30. package/dist/views/FormSubmissions.js.map +1 -1
  31. package/package.json +2 -2
  32. package/src/__tests__/components/attribution-rows.test.ts +93 -0
  33. package/src/__tests__/views/form-entries.render.test.tsx +29 -0
  34. package/src/__tests__/views/form-submissions.render.test.tsx +17 -0
  35. package/src/components/forms/ExportMenu.tsx +70 -0
  36. package/src/components/forms/FormEntryDetailPane.tsx +82 -8
  37. package/src/components/forms/WebhooksPanel.tsx +5 -1
  38. package/src/lib/forms-service.ts +45 -1
  39. package/src/views/FormEntries.tsx +7 -46
  40. 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
- function attributionRows(entry: FormSubmission): AttrRow[] {
62
- return [
63
- { label: 'Source', value: entry.utmSource },
64
- { label: 'Medium', value: entry.utmMedium },
65
- { label: 'Campaign', value: entry.utmCampaign },
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
- ].filter((r) => r.value)
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 ? attributionRows(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
@@ -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, CSV export,
6
- * pagination, and inline detail pane as the per-form Submissions view, plus a
7
- * Form filter and a Form column so editors can triage every form from one
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 { Download, Inbox, Search } from 'lucide-react'
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
- <button
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 detail pane that
10
- * opens on row click (marking the entry read). Loading / empty /
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, Download, Inbox, Search } from 'lucide-react'
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
- <button
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