Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions docs/forms-reporting.md
Original file line number Diff line number Diff line change
@@ -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`.
1 change: 1 addition & 0 deletions src/apps/reports/src/config/routes.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
8 changes: 7 additions & 1 deletion src/apps/reports/src/lib/components/NavTabs/NavTabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
buildReportsPath,
bulkMemberLookupRouteId,
dashboardsPageRouteId,
formsPageRouteId,
reportsPageRouteId,
talentPageRouteId,
} from '../../../config/routes.config'
Expand Down Expand Up @@ -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,
Expand All @@ -66,7 +72,7 @@ const NavTabs: FC = () => {
},
]
: baseTabs
}, [canAccessTalent])
}, [canAccessTalent, loginUserInfo])

const activeTabPathName: string = useMemo<string>(() => {
const matchingTabs = tabs
Expand Down
46 changes: 46 additions & 0 deletions src/apps/reports/src/lib/services/forms.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
99 changes: 99 additions & 0 deletions src/apps/reports/src/lib/services/forms.service.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>
data: Record<string, unknown>[]
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<ReportForm[]> {
const forms: ReportForm[] = []
const seen = new Set<string>()
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<FormsDirectoryPage>(`${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<FormReport> {
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<Blob> {
const response = await client.get<Blob>(
`${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)
}
34 changes: 34 additions & 0 deletions src/apps/reports/src/pages/forms/FormsPage.module.scss
Original file line number Diff line number Diff line change
@@ -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; }
}
103 changes: 103 additions & 0 deletions src/apps/reports/src/pages/forms/FormsPage.spec.tsx
Original file line number Diff line number Diff line change
@@ -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<void> {
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(<FormsPage />)
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(<FormsPage />)
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(<FormsPage />)
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')
})
})
Loading
Loading