From dc6ef669fa0fafabd1b7c0f68099811e895347e2 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Mon, 28 Sep 2026 09:31:59 +1000 Subject: [PATCH] Add Forms tab with submission reports and complete CSV exports --- docs/forms-reporting.md | 34 +++ src/apps/reports/src/config/routes.config.ts | 1 + .../src/lib/components/NavTabs/NavTabs.tsx | 8 +- .../src/lib/services/forms.service.spec.ts | 46 ++++ .../reports/src/lib/services/forms.service.ts | 99 ++++++++ .../src/pages/forms/FormsPage.module.scss | 34 +++ .../src/pages/forms/FormsPage.spec.tsx | 103 ++++++++ .../reports/src/pages/forms/FormsPage.tsx | 227 ++++++++++++++++++ src/apps/reports/src/reports-app.routes.tsx | 9 + 9 files changed, 560 insertions(+), 1 deletion(-) create mode 100644 docs/forms-reporting.md create mode 100644 src/apps/reports/src/lib/services/forms.service.spec.ts create mode 100644 src/apps/reports/src/lib/services/forms.service.ts create mode 100644 src/apps/reports/src/pages/forms/FormsPage.module.scss create mode 100644 src/apps/reports/src/pages/forms/FormsPage.spec.tsx create mode 100644 src/apps/reports/src/pages/forms/FormsPage.tsx diff --git a/docs/forms-reporting.md b/docs/forms-reporting.md new file mode 100644 index 000000000..2e1926737 --- /dev/null +++ b/docs/forms-reporting.md @@ -0,0 +1,34 @@ +# Forms reporting + +The Reports portal includes an administrator-only Forms tab at `/reports/forms` +on the combined host and `/forms` on the dedicated Reports host. The API continues +to enforce its own Administrator / Forms Reporter / `read:forms-submissions` +authorization. Talent Manager access does not expose submission PII. + +Select a named form (the contact form is `Let’s talk`) to load 25 rows +per page. Columns include every field across published and retired revisions, plus +submission time, source page, member identity, and revision metadata. Drafts are +excluded from the selector. Empty forms and failed requests have explicit UI states. + +Start/end dates include the entire UTC day. Either boundary may be omitted. Changing +forms or dates resets pagination; reversed ranges are rejected locally and by the +API. Stale responses are ignored after navigation or filter changes. Previous/Next +navigate with API cursors and the result shows the total matching submissions. + +**Export all CSV** ignores date inputs; **Export filtered CSV** applies them. Both +export all matching submissions regardless of the current table page. The API +streams batches with one header and spreadsheet-safe escaping. The browser receives +the result as a Blob, so very large downloads are bounded by browser memory. + +`forms.service.ts` uses the shared authenticated HTTP client and `EnvironmentConfig.API.V6`: + +- `GET /forms/reports/directory` (follows all directory pages) +- `GET /forms/:key/submissions` (inclusive dates, limit, cursor) +- `GET /forms/:key/submissions/export` (dates only) + +Deploy the companion forms-api-v6 reporting change before the portal. Add the exact +Reports/combined portal origins to the API CORS allowlist; website-only CORS is +insufficient. No new UI secrets or environment variables are needed. + +Validation: `yarn lint`, `yarn run build`, and focused Forms page/service and Reports +navigation/route tests via `yarn test:no-watch --runInBand --testPathPattern=apps/reports/src`. diff --git a/src/apps/reports/src/config/routes.config.ts b/src/apps/reports/src/config/routes.config.ts index 2c8be7069..a19682752 100644 --- a/src/apps/reports/src/config/routes.config.ts +++ b/src/apps/reports/src/config/routes.config.ts @@ -24,6 +24,7 @@ export const dashboardDetailRoute = `${dashboardsPageRouteId}/:dashboardSlug` export const bulkMemberLookupRouteId = 'bulk-member-lookup' export const billingAccountsPageRouteId = 'sfdc-payments' export const talentPageRouteId = 'talent' +export const formsPageRouteId = 'forms' export const dashboardRouteSlugs = { challengeParticipation: 'challenge-participation', diff --git a/src/apps/reports/src/lib/components/NavTabs/NavTabs.tsx b/src/apps/reports/src/lib/components/NavTabs/NavTabs.tsx index 81e32cbcd..feaf787a1 100644 --- a/src/apps/reports/src/lib/components/NavTabs/NavTabs.tsx +++ b/src/apps/reports/src/lib/components/NavTabs/NavTabs.tsx @@ -17,6 +17,7 @@ import { buildReportsPath, bulkMemberLookupRouteId, dashboardsPageRouteId, + formsPageRouteId, reportsPageRouteId, talentPageRouteId, } from '../../../config/routes.config' @@ -57,6 +58,11 @@ const NavTabs: FC = () => { }, ] + if (loginUserInfo?.roles?.some(role => role.trim() + .toLowerCase() === 'administrator')) { + baseTabs.push({ id: formsPageRouteId, title: 'Forms' }) + } + return canAccessTalent ? [ ...baseTabs, @@ -66,7 +72,7 @@ const NavTabs: FC = () => { }, ] : baseTabs - }, [canAccessTalent]) + }, [canAccessTalent, loginUserInfo]) const activeTabPathName: string = useMemo(() => { const matchingTabs = tabs diff --git a/src/apps/reports/src/lib/services/forms.service.spec.ts b/src/apps/reports/src/lib/services/forms.service.spec.ts new file mode 100644 index 000000000..27d01d9fb --- /dev/null +++ b/src/apps/reports/src/lib/services/forms.service.spec.ts @@ -0,0 +1,46 @@ +/* eslint-disable ordered-imports/ordered-imports, unicorn/no-null */ +import { xhrGetAsync } from '~/libs/core' +import { downloadFormSubmissions, fetchFormSubmissions, fetchReportForms, formatFormValue } from './forms.service' + +const mockGet = jest.fn() +jest.mock('~/config', () => ({ EnvironmentConfig: { API: { V6: 'https://api.test/v6' } } }), { virtual: true }) +jest.mock('~/libs/core', () => ({ + xhrCreateInstance: () => ({ get: (...args: unknown[]) => mockGet(...args) }), + xhrGetAsync: jest.fn(), +}), { virtual: true }) + +describe('Forms reporting service', () => { + beforeEach(() => jest.clearAllMocks()) + + it('follows directory cursors and preserves date filters on a submission page', async () => { + (xhrGetAsync as jest.Mock).mockResolvedValueOnce({ data: [{ key: 'a', title: 'A' }], nextCursor: 'a' }) + .mockResolvedValueOnce({ data: [{ key: 'b', title: 'B' }], nextCursor: null }) + expect(await fetchReportForms()) + .toHaveLength(2) + expect(xhrGetAsync) + .toHaveBeenLastCalledWith('https://api.test/v6/forms/reports/directory?after=a', expect.anything()) + await fetchFormSubmissions('lets_talk', { endDate: '2026-09-30', startDate: '2026-09-01' }, 'cursor') + expect(xhrGetAsync) + .toHaveBeenLastCalledWith( + 'https://api.test/v6/forms/lets_talk/submissions?startDate=2026-09-01' + + '&endDate=2026-09-30&limit=25&after=cursor', + expect.anything(), + ) + }) + + it('exports without pagination and keeps false, zero, and choices visible', async () => { + mockGet.mockResolvedValue({ data: new Blob(['csv']) }) + await downloadFormSubmissions('lets_talk', { endDate: '2026-09-30' }) + expect(mockGet) + .toHaveBeenCalledWith( + 'https://api.test/v6/forms/lets_talk/submissions/export?endDate=2026-09-30', + { headers: { Accept: 'text/csv' }, responseType: 'blob' }, + ) + expect(formatFormValue(false)) + .toBe('false') + expect(formatFormValue(0)) + .toBe('0') + expect(formatFormValue(['a', 'b'])) + .toBe('a, b') + }) +}) diff --git a/src/apps/reports/src/lib/services/forms.service.ts b/src/apps/reports/src/lib/services/forms.service.ts new file mode 100644 index 000000000..bcd4917fd --- /dev/null +++ b/src/apps/reports/src/lib/services/forms.service.ts @@ -0,0 +1,99 @@ +import { EnvironmentConfig } from '~/config' +import { xhrCreateInstance, xhrGetAsync } from '~/libs/core' + +export interface ReportForm { + key: string + title: string +} + +export interface FormDates { + startDate?: string + endDate?: string +} + +export interface FormReport { + form: string + columns: string[] + labels: Record + data: Record[] + total: number + nextCursor: string | null +} + +interface FormsDirectoryPage { + data: ReportForm[] + nextCursor: string | null +} + +const client = xhrCreateInstance() +const base = `${EnvironmentConfig.API.V6}/forms` + +/** + * Encodes shared inclusive UTC date filters for table and CSV requests. + * @param dates Optional calendar date bounds. @returns URL query parameters. + * @throws Does not throw. + */ +export function formDateParams(dates: FormDates): URLSearchParams { + const params = new URLSearchParams() + if (dates.startDate) params.set('startDate', dates.startDate) + if (dates.endDate) params.set('endDate', dates.endDate) + return params +} + +/** + * Loads all reportable form names using the reporting-only directory endpoint. + * @returns Forms available in the selector, including retired revisions. + * @throws Propagates authenticated API errors; rejects repeated server cursors. + */ +export async function fetchReportForms(): Promise { + const forms: ReportForm[] = [] + const seen = new Set() + let after: string | null | undefined + do { + const suffix = after ? `?after=${encodeURIComponent(after)}` : '' + // Each request needs the cursor returned by the previous page. + // eslint-disable-next-line no-await-in-loop + const page = await xhrGetAsync(`${base}/reports/directory${suffix}`, client) + forms.push(...page.data) + after = page.nextCursor + if (after && seen.has(after)) throw new Error('Invalid forms directory cursor') + if (after) seen.add(after) + } while (after) + + return forms +} + +/** + * Fetches one 25-row submission page across every published revision. + * @param key Selected form key. @param dates Inclusive UTC dates. @param after Previous page cursor. + * @returns Columns, rows, total, and continuation cursor. @throws Propagates authenticated API/validation errors. + */ +export function fetchFormSubmissions(key: string, dates: FormDates, after?: string): Promise { + const params = formDateParams(dates) + params.set('limit', '25') + if (after) params.set('after', after) + return xhrGetAsync(`${base}/${encodeURIComponent(key)}/submissions?${params}`, client) +} + +/** + * Downloads all matching submissions, independently of table pagination. + * @param key Selected form key. @param dates Inclusive UTC bounds; omit to export all submissions. + * @returns Spreadsheet-safe CSV blob. @throws Propagates authenticated API/network errors. + */ +export async function downloadFormSubmissions(key: string, dates: FormDates = {}): Promise { + const response = await client.get( + `${base}/${encodeURIComponent(key)}/submissions/export?${formDateParams(dates)}`, + { headers: { Accept: 'text/csv' }, responseType: 'blob' }, + ) + return response.data +} + +/** + * Formats a typed submission cell without dropping false, zero, or multi-select answers. + * @param value API report value. @returns Plain text for a React table cell; never HTML. + * @throws Does not throw for API JSON values. + */ +export function formatFormValue(value: unknown): string { + if (value === null || value === undefined) return '—' + return Array.isArray(value) ? value.join(', ') : String(value) +} diff --git a/src/apps/reports/src/pages/forms/FormsPage.module.scss b/src/apps/reports/src/pages/forms/FormsPage.module.scss new file mode 100644 index 000000000..adba37200 --- /dev/null +++ b/src/apps/reports/src/pages/forms/FormsPage.module.scss @@ -0,0 +1,34 @@ +.page { + padding: 24px; + max-width: 100%; + + h2 { margin-bottom: 16px; } + p { margin: 12px 0; } + button, select, input { + border: 1px solid #767676; + border-radius: 4px; + background: #fff; + color: #2a2a2a; + padding: 10px 14px; + font: inherit; + } + button { cursor: pointer; } + button:disabled { cursor: default; opacity: .5; } + button:focus-visible, select:focus-visible, input:focus-visible { outline: 2px solid #137d60; outline-offset: 2px; } +} +.filters, .actions { + display: flex; + flex-wrap: wrap; + gap: 16px; + align-items: end; + margin: 20px 0; +} +.filters label { display: flex; flex-direction: column; gap: 8px; max-width: 100%; } +.filters select { max-width: 100%; } +.tableScroll { + overflow-x: auto; + table { border-collapse: collapse; width: 100%; } + th, td { padding: 12px; border-bottom: 1px solid #d4d4d4; text-align: left; min-width: 140px; max-width: 400px; overflow-wrap: anywhere; } + th { background: #f4f4f4; font-weight: 600; } + td { white-space: pre-wrap; } +} diff --git a/src/apps/reports/src/pages/forms/FormsPage.spec.tsx b/src/apps/reports/src/pages/forms/FormsPage.spec.tsx new file mode 100644 index 000000000..5d92968fe --- /dev/null +++ b/src/apps/reports/src/pages/forms/FormsPage.spec.tsx @@ -0,0 +1,103 @@ +/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports, unicorn/no-null */ +import '@testing-library/jest-dom' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' + +import FormsPage from './FormsPage' +import { fetchReportForms, fetchFormSubmissions, downloadFormSubmissions } from '../../lib/services/forms.service' +import { downloadBlobFile } from '../../lib/services/reports.service' + +jest.mock('~/libs/ui', () => ({ PageTitle: () => null }), { virtual: true }) +jest.mock('../../lib/services/forms.service', () => ({ + downloadFormSubmissions: jest.fn(), + fetchFormSubmissions: jest.fn(), + fetchReportForms: jest.fn(), + formatFormValue: (value: unknown) => String(value), +})) +jest.mock('../../lib/services/reports.service', () => ({ downloadBlobFile: jest.fn() })) + +const report = { + columns: ['email', 'updates'], + data: [{ email: 'first@example.com', submission_id: 'one', updates: false }], + form: 'lets_talk', + labels: { email: 'Work Email', updates: 'Updates' }, + nextCursor: 'one', + total: 26, +} + +/** + * Loads the form selector and chooses the contact form in page interaction tests. + * @returns Completion once the first submission is displayed. @throws Testing Library errors on missing UI. + */ +async function selectForm(): Promise { + await screen.findByRole('option', { name: 'Let’s talk (/lets-talk) (lets_talk)' }) + fireEvent.change(screen.getByLabelText('Form'), { target: { value: 'lets_talk' } }) + await screen.findByText('first@example.com') +} + +describe('Forms reporting page', () => { + beforeEach(() => { + jest.clearAllMocks(); + (fetchReportForms as jest.Mock).mockResolvedValue([{ key: 'lets_talk', title: 'Let’s talk (/lets-talk)' }]); + (fetchFormSubmissions as jest.Mock).mockResolvedValue(report); + (downloadFormSubmissions as jest.Mock).mockResolvedValue(new Blob(['csv'])) + }) + + it('paginates and resets the cursor when dates change', async () => { + render() + await selectForm() + expect(screen.getByText('false')) + .toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Next' })) + await waitFor(() => expect(fetchFormSubmissions) + .toHaveBeenLastCalledWith('lets_talk', expect.anything(), 'one')) + fireEvent.change(screen.getByLabelText('Start date (UTC)'), { target: { value: '2026-09-01' } }) + await waitFor(() => expect(fetchFormSubmissions) + .toHaveBeenLastCalledWith('lets_talk', expect.objectContaining({ startDate: '2026-09-01' }), undefined)) + await screen.findByText('26 submissions · Page 1') + expect(screen.getByRole('button', { name: 'Previous' })) + .toBeDisabled() + }) + + it('exports all or filtered data independently of the current page and rejects reversed dates', async () => { + render() + await selectForm() + fireEvent.change(screen.getByLabelText('Start date (UTC)'), { target: { value: '2026-09-01' } }) + fireEvent.change(screen.getByLabelText('End date (UTC)'), { target: { value: '2026-09-30' } }) + fireEvent.click(screen.getByRole('button', { name: 'Export filtered CSV' })) + await waitFor(() => expect(downloadBlobFile) + .toHaveBeenCalledWith(expect.any(Blob), 'lets_talk-filtered.csv')) + expect(downloadFormSubmissions) + .toHaveBeenCalledWith('lets_talk', expect.objectContaining({ + endDate: '2026-09-30', startDate: '2026-09-01', + })) + await waitFor(() => expect(screen.getByRole('button', { name: 'Export all CSV' })) + .toBeEnabled()) + fireEvent.click(screen.getByRole('button', { name: 'Export all CSV' })) + await waitFor(() => expect(downloadFormSubmissions) + .toHaveBeenLastCalledWith('lets_talk', {})) + await waitFor(() => expect(screen.getByLabelText('End date (UTC)')) + .toBeEnabled()) + fireEvent.change(screen.getByLabelText('End date (UTC)'), { target: { value: '2026-08-31' } }) + expect(screen.getByRole('alert')) + .toHaveTextContent('Start date must be on or before end date.') + expect(screen.getByRole('button', { name: 'Export filtered CSV' })) + .toBeDisabled() + expect(screen.getByRole('button', { name: 'Export all CSV' })) + .toBeEnabled() + }) + + it('ignores an old page response after filters change and displays retryable failures', async () => { + render() + await selectForm() + let finishOldPage: (value: typeof report) => void = () => undefined; + (fetchFormSubmissions as jest.Mock).mockReturnValueOnce(new Promise(resolve => { finishOldPage = resolve })) + fireEvent.click(screen.getByRole('button', { name: 'Next' })); + (fetchFormSubmissions as jest.Mock).mockRejectedValueOnce(new Error('Forbidden')) + fireEvent.change(screen.getByLabelText('Start date (UTC)'), { target: { value: '2026-09-01' } }) + await screen.findByText('Unable to load submissions. Check your access or retry.') + finishOldPage(report) + expect(screen.queryByText('first@example.com')).not.toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Retry' })) + await screen.findByText('first@example.com') + }) +}) diff --git a/src/apps/reports/src/pages/forms/FormsPage.tsx b/src/apps/reports/src/pages/forms/FormsPage.tsx new file mode 100644 index 000000000..0b4b8ccfa --- /dev/null +++ b/src/apps/reports/src/pages/forms/FormsPage.tsx @@ -0,0 +1,227 @@ +/* Native DOM controls do not depend on stable callback identity. */ +/* eslint-disable react/jsx-no-bind */ +import { FC, useEffect, useState } from 'react' + +import { PageTitle } from '~/libs/ui' + +import { + downloadFormSubmissions, + fetchFormSubmissions, + fetchReportForms, + formatFormValue, + FormDates, + FormReport, + ReportForm, +} from '../../lib/services/forms.service' +import { downloadBlobFile } from '../../lib/services/reports.service' + +import styles from './FormsPage.module.scss' + +interface Selection extends FormDates { + key: string + cursors: (string | undefined)[] +} + +/** + * Displays private form submissions with date filtering, cursor pagination, and complete CSV exports. + * @returns Reports portal content. No props; access is restricted by the administrator route. + * @throws Does not throw intentionally; API failures are shown inline with a retry action. + */ +const FormsPage: FC = () => { + const [forms, setForms] = useState([]) + const [selection, setSelection] = useState({ cursors: [undefined], key: '' }) + const [report, setReport] = useState() + const [loadingForms, setLoadingForms] = useState(true) + const [loading, setLoading] = useState(false) + const [exporting, setExporting] = useState(false) + const [directoryError, setDirectoryError] = useState('') + const [reportError, setReportError] = useState('') + const [exportError, setExportError] = useState('') + const [retry, setRetry] = useState(0) + const invalidRange = !!(selection.startDate && selection.endDate && selection.startDate > selection.endDate) + const page = selection.cursors.length + + useEffect(() => { + let active = true + setLoadingForms(true) + setDirectoryError('') + fetchReportForms() + .then(result => { if (active) setForms(result) }) + .catch(() => { if (active) setDirectoryError('Unable to load forms. Check your access or retry.') }) + .finally(() => { if (active) setLoadingForms(false) }) + return () => { active = false } + }, [retry]) + + useEffect(() => { + let active = true + setReport(undefined) + setReportError('') + if (!selection.key || invalidRange) { + setLoading(false) + return () => { active = false } + } + + setLoading(true) + fetchFormSubmissions(selection.key, selection, selection.cursors[selection.cursors.length - 1]) + .then(result => { if (active) setReport(result) }) + .catch(() => { if (active) setReportError('Unable to load submissions. Check your access or retry.') }) + .finally(() => { if (active) setLoading(false) }) + return () => { active = false } + }, [selection, invalidRange, retry]) + + /** + * Applies a form/date selection and returns pagination to the first page. + * @param changes Changed selector values. @returns Nothing. @throws Does not throw. + */ + function updateSelection(changes: Partial): void { + setReport(undefined) + setExportError('') + setSelection(current => ({ ...current, ...changes, cursors: [undefined] })) + } + + /** + * Saves all selected-form submissions or all rows in the current date range as CSV. + * @param filtered Whether to include the date inputs. @returns Completion after browser download. + * @throws Does not throw; download errors are displayed inline. + */ + async function exportCsv(filtered: boolean): Promise { + setExporting(true) + setExportError('') + try { + const blob = await downloadFormSubmissions(selection.key, filtered ? selection : {}) + downloadBlobFile(blob, `${selection.key}${filtered ? '-filtered' : '-all'}.csv`) + } catch { + setExportError('Unable to export submissions. Please try again.') + } finally { + setExporting(false) + } + } + + return ( +
+ Forms +

Forms

+

View submissions from all published form versions. Dates include the entire day in UTC.

+ {directoryError &&

{directoryError}

} + {loadingForms &&

Loading forms…

} + {!loadingForms && !directoryError && forms.length === 0 &&

No published forms are available.

} +
+ + + + +
+ {invalidRange &&

Start date must be on or before end date.

} +
+ + + {exporting && Preparing CSV…} +
+ {exportError &&

{exportError}

} + {reportError &&

{reportError}

} + {(directoryError || reportError) && ( + + )} + {loading &&

Loading submissions…

} + {!selection.key &&

Select a form to view submissions.

} + {report && !loading && ( + <> +

{`${report.total} submissions · Page ${page}`}

+ {report.data.length === 0 ?

No submissions match these dates.

: ( + // Keyboard users must be able to scroll to offscreen columns. + // eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex +
+ + + + {report.columns.map(column => ( + + ))} + + + + {report.data.map(row => ( + + {report.columns.map(column => ( + + ))} + + ))} + +
+ {report.labels[column] || column.replace(/_/g, ' ')} +
{formatFormValue(row[column])}
+
+ )} + + + )} +
+ ) +} + +export default FormsPage diff --git a/src/apps/reports/src/reports-app.routes.tsx b/src/apps/reports/src/reports-app.routes.tsx index 0bb9e41a9..fcfc34eb7 100644 --- a/src/apps/reports/src/reports-app.routes.tsx +++ b/src/apps/reports/src/reports-app.routes.tsx @@ -15,6 +15,7 @@ import { bulkMemberLookupRouteId, dashboardDetailRoute, dashboardsPageRouteId, + formsPageRouteId, reportsPageRouteId, rootRoute, talentPageRouteId, @@ -45,6 +46,8 @@ const TalentPage: LazyLoadedComponent = lazyLoad( 'TalentPage', ) +const FormsPage: LazyLoadedComponent = lazyLoad(() => import('./pages/forms/FormsPage')) + export const toolTitle: string = ToolTitle.reports export const reportsRoutes: ReadonlyArray = [ @@ -52,6 +55,12 @@ export const reportsRoutes: ReadonlyArray = [ { authRequired: true, children: [ + { + authRequired: true, + element: , + rolesRequired: [UserRole.administrator], + route: formsPageRouteId, + }, { authRequired: true, element: ,