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
15 changes: 11 additions & 4 deletions docs/forms-reporting.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,14 @@ on the combined host and `/forms` on the dedicated Reports host. The API continu
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.
Select a named form (the contact form is `Let’s talk`) to load 25 submissions
per page, newest first (submission time descending, then UUID descending for ties).
Let’s talk uses responsive cards with contact details, member ID and UTM values
above the full-width inquiry, preserving line breaks and wrapping long text/URLs.
The cards omit source page, form version, Leadsource, Landing Page Link, Lead Source
For Campaign C, Primary Campaign C and Insta Page Name C. Other forms retain tables
with every field across published and retired revisions and submission 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
Expand All @@ -17,6 +21,7 @@ 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
retains all fields in CSV, including those hidden from the contact cards, in newest-first order. It
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.

Expand All @@ -29,6 +34,8 @@ the result as a Blob, so very large downloads are bounded by browser memory.
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.
Deploy the companion website change to capture URL UTM values and authenticated
member IDs on new Let’s talk submissions. Previously missing values remain empty.

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`.
27 changes: 27 additions & 0 deletions src/apps/reports/src/pages/forms/FormsPage.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,33 @@
}
.filters label { display: flex; flex-direction: column; gap: 8px; max-width: 100%; }
.filters select { max-width: 100%; }
.submissions {
display: grid;
gap: 16px;
}
.submission {
min-width: 0;
padding: 20px;
border: 1px solid #d4d4d4;
border-radius: 8px;
background: #fff;
}
.details {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(220px, 100%), 1fr));
gap: 20px 24px;
margin: 0;

> div { min-width: 0; }
dt { font-size: 14px; font-weight: 600; color: #555; margin-bottom: 6px; }
dd { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; }
}
.inquiry {
grid-column: 1 / -1;
border-top: 1px solid #d4d4d4;
padding-top: 16px;
line-height: 1.6;
}
.tableScroll {
overflow-x: auto;
table { border-collapse: collapse; width: 100%; }
Expand Down
63 changes: 62 additions & 1 deletion src/apps/reports/src/pages/forms/FormsPage.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/* 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 { fireEvent, render, screen, waitFor, within } from '@testing-library/react'

import FormsPage from './FormsPage'
import { fetchReportForms, fetchFormSubmissions, downloadFormSubmissions } from '../../lib/services/forms.service'
Expand Down Expand Up @@ -58,6 +58,67 @@ describe('Forms reporting page', () => {
.toBeDisabled()
})

it('shows contact details and UTM values above full-width inquiries, hiding repetitive fields', async () => {
const hidden = ['source_page', 'form_version', 'leadsource', 'source__c',
'lead_source_for_campaign__c', 'primary_campaign__c', 'insta_page_name__c']
const inquiry = `A detailed request.\nAnother paragraph with a very long link: ${'x'.repeat(500)}`;
(fetchFormSubmissions as jest.Mock).mockResolvedValue({
...report,
columns: [
'submitted_at', 'member_id', 'email', 'briefly_describe_your_inquiry', 'utm_source__c', ...hidden,
],
data: [
{ ...report.data[0],
briefly_describe_your_inquiry: inquiry,
member_id: '12345',
submitted_at: '2026-10-19T12:00:00Z',
utm_source__c: 'newsletter' },
{ email: 'older@example.com', submission_id: 'two', submitted_at: '2026-10-18T12:00:00Z' },
],
labels: { ...report.labels, briefly_describe_your_inquiry: 'What can we help you with?' },
})
render(<FormsPage />)
await selectForm()
const region = screen.getByRole('region', { name: 'Form submissions' })
const cards = within(region)
.getAllByRole('article')
expect(cards)
.toHaveLength(2)
expect(cards[0])
.toHaveTextContent('first@example.com')
expect(cards[1])
.toHaveTextContent('older@example.com')
expect(within(cards[0])
.getByText('12345'))
.toBeInTheDocument()
expect(within(cards[0])
.getByText('UTM source'))
.toBeInTheDocument()
expect(within(cards[0])
.getByText('newsletter'))
.toBeInTheDocument()
const inquiryDetails = cards[0].querySelector('dl > div:last-child')
expect(inquiryDetails)
.toHaveClass('inquiry')
expect(inquiryDetails?.querySelector('dd')?.textContent)
.toBe(inquiry)
hidden.forEach(column => expect(within(region)
.queryByText(column.replace(/_/g, ' '))).not.toBeInTheDocument())
expect(screen.queryByRole('table')).not.toBeInTheDocument()
})

it('retains all table columns for other forms', async () => {
(fetchFormSubmissions as jest.Mock).mockResolvedValue({
...report, columns: ['email', 'source_page'], form: 'other_form',
})
render(<FormsPage />)
await selectForm()
expect(screen.getByRole('table'))
.toBeInTheDocument()
expect(screen.getByRole('columnheader', { name: 'source page' }))
.toBeInTheDocument()
})

it('exports all or filtered data independently of the current page and rejects reversed dates', async () => {
render(<FormsPage />)
await selectForm()
Expand Down
8 changes: 7 additions & 1 deletion src/apps/reports/src/pages/forms/FormsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
} from '../../lib/services/forms.service'
import { downloadBlobFile } from '../../lib/services/reports.service'

import LetsTalkSubmissions from './LetsTalkSubmissions'
import styles from './FormsPage.module.scss'

interface Selection extends FormDates {
Expand All @@ -24,6 +25,7 @@ interface Selection extends FormDates {

/**
* Displays private form submissions with date filtering, cursor pagination, and complete CSV exports.
* Uses contact cards for Let's talk so inquiries wrap below metadata; other forms retain their tables.
* @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.
*/
Expand Down Expand Up @@ -172,7 +174,11 @@ const FormsPage: FC = () => {
{report && !loading && (
<>
<p role='status'>{`${report.total} submissions · Page ${page}`}</p>
{report.data.length === 0 ? <p>No submissions match these dates.</p> : (
{report.data.length === 0 ? (
<p>No submissions match these dates.</p>
) : report.form === 'lets_talk' ? (
<LetsTalkSubmissions report={report} />
) : (
// Keyboard users must be able to scroll to offscreen columns.
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
<div className={styles.tableScroll} tabIndex={0} role='region' aria-label='Form submissions'>
Expand Down
50 changes: 50 additions & 0 deletions src/apps/reports/src/pages/forms/LetsTalkSubmissions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { FC } from 'react'

import { formatFormValue, FormReport } from '../../lib/services/forms.service'

import styles from './FormsPage.module.scss'

const hiddenColumns = new Set([
'source_page', 'form_version', 'leadsource', 'source__c',
'lead_source_for_campaign__c', 'primary_campaign__c', 'insta_page_name__c',
])
const inquiryColumn = 'briefly_describe_your_inquiry'
const labels: Record<string, string> = {
member_id: 'Member ID',
submission_id: 'Submission ID',
submitted_at: 'Submitted at (UTC)',
utm_campaign__c: 'UTM campaign',
utm_medium__c: 'UTM medium',
utm_source__c: 'UTM source',
}

/**
* Displays contact submissions in API order with compact details above the full-width inquiry.
* @param props Report page with published field labels, typed answers, and submission metadata.
* @returns Responsive, labelled submissions without repetitive campaign fields; CSV data is unchanged.
* @throws Does not throw for API JSON reports.
*/
const LetsTalkSubmissions: FC<{ report: FormReport }> = (props: { report: FormReport }) => {
const report = props.report
const columns = report.columns.filter(column => !hiddenColumns.has(column) && column !== inquiryColumn)
if (report.columns.includes(inquiryColumn)) columns.push(inquiryColumn)

return (
<section className={styles.submissions} aria-label='Form submissions'>
{report.data.map(row => (
<article className={styles.submission} key={String(row.submission_id)}>
<dl className={styles.details}>
{columns.map(column => (
<div key={column} className={column === inquiryColumn ? styles.inquiry : undefined}>
<dt>{labels[column] || report.labels[column] || column.replace(/_/g, ' ')}</dt>
<dd>{formatFormValue(row[column])}</dd>
</div>
))}
</dl>
</article>
))}
</section>
)
}

export default LetsTalkSubmissions
Loading