From 3f9945b0336edfe2fd38475276a5067b6c5e9faa Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Tue, 20 Oct 2026 06:02:27 +1100 Subject: [PATCH] PM-6619: Make Let's talk reports easier to read What was broken The Let's talk report rendered repetitive campaign metadata and long inquiries in a wide table, forcing horizontal scrolling and excessively tall text columns. Root cause The generic reports table displayed every API column in the same narrow row layout. What was changed Display Let's talk submissions as responsive cards in API order, retaining member ID, contact details and clear UTM labels above the full-width inquiry. Preserve line breaks and wrap long URLs. Hide the seven redundant metadata fields from this view while retaining complete CSV exports and other forms' tables. Update reporting documentation for the companion API and website changes. Any added/updated tests Add page tests for hidden metadata, retained UTM/member values, full-width multiline inquiries, API ordering and unchanged tables for other forms. All 74 reports tests pass. Browser checks at 1440px and 390px confirm readable cards and no horizontal overflow. Validation: yarn lint; yarn test:no-watch --runInBand --testPathPattern=apps/reports/src; NODE_OPTIONS=--max-old-space-size=16384 yarn run build, after nvm use. --- docs/forms-reporting.md | 15 +++-- .../src/pages/forms/FormsPage.module.scss | 27 ++++++++ .../src/pages/forms/FormsPage.spec.tsx | 63 ++++++++++++++++++- .../reports/src/pages/forms/FormsPage.tsx | 8 ++- .../src/pages/forms/LetsTalkSubmissions.tsx | 50 +++++++++++++++ 5 files changed, 157 insertions(+), 6 deletions(-) create mode 100644 src/apps/reports/src/pages/forms/LetsTalkSubmissions.tsx diff --git a/docs/forms-reporting.md b/docs/forms-reporting.md index 2e1926737..bd0fca130 100644 --- a/docs/forms-reporting.md +++ b/docs/forms-reporting.md @@ -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 @@ -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. @@ -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`. diff --git a/src/apps/reports/src/pages/forms/FormsPage.module.scss b/src/apps/reports/src/pages/forms/FormsPage.module.scss index adba37200..ac88c2ede 100644 --- a/src/apps/reports/src/pages/forms/FormsPage.module.scss +++ b/src/apps/reports/src/pages/forms/FormsPage.module.scss @@ -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%; } diff --git a/src/apps/reports/src/pages/forms/FormsPage.spec.tsx b/src/apps/reports/src/pages/forms/FormsPage.spec.tsx index 5d92968fe..fe9609026 100644 --- a/src/apps/reports/src/pages/forms/FormsPage.spec.tsx +++ b/src/apps/reports/src/pages/forms/FormsPage.spec.tsx @@ -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' @@ -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() + 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() + 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() await selectForm() diff --git a/src/apps/reports/src/pages/forms/FormsPage.tsx b/src/apps/reports/src/pages/forms/FormsPage.tsx index 0b4b8ccfa..0d6ca5612 100644 --- a/src/apps/reports/src/pages/forms/FormsPage.tsx +++ b/src/apps/reports/src/pages/forms/FormsPage.tsx @@ -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 { @@ -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. */ @@ -172,7 +174,11 @@ const FormsPage: FC = () => { {report && !loading && ( <>

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

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

No submissions match these dates.

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

No submissions match these dates.

+ ) : report.form === 'lets_talk' ? ( + + ) : ( // Keyboard users must be able to scroll to offscreen columns. // eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
diff --git a/src/apps/reports/src/pages/forms/LetsTalkSubmissions.tsx b/src/apps/reports/src/pages/forms/LetsTalkSubmissions.tsx new file mode 100644 index 000000000..fed0a54a4 --- /dev/null +++ b/src/apps/reports/src/pages/forms/LetsTalkSubmissions.tsx @@ -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 = { + 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 ( +
+ {report.data.map(row => ( +
+
+ {columns.map(column => ( +
+
{labels[column] || report.labels[column] || column.replace(/_/g, ' ')}
+
{formatFormValue(row[column])}
+
+ ))} +
+
+ ))} +
+ ) +} + +export default LetsTalkSubmissions