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