From 6d9c2546163c007396085f077a881f7dd0cfa65e Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Thu, 17 Sep 2026 15:55:13 -0500 Subject: [PATCH 1/3] feat: show additional input fields in the purchase details lines view --- .../sponsors/reports/LinesManifestView.js | 26 ++++++ .../__tests__/LinesManifestView.test.js | 93 ++++++++++++++++++- src/i18n/en.json | 1 + 3 files changed, 117 insertions(+), 3 deletions(-) diff --git a/src/components/sponsors/reports/LinesManifestView.js b/src/components/sponsors/reports/LinesManifestView.js index 475b97127..de9112068 100644 --- a/src/components/sponsors/reports/LinesManifestView.js +++ b/src/components/sponsors/reports/LinesManifestView.js @@ -80,6 +80,28 @@ export const Destination = ({ name, booth }) => { ); }; +// One "Label: value" line per answered field. Values arrive display-ready from the +// API (option text resolved, DateTime already UTC, CheckBox already Yes/No), so the +// UI must not reformat or translate them: the screen, the print view and the CSV +// have to agree. Plain JSX text, never dangerouslySetInnerHTML — the values are +// sponsor-entered free text. +// +// Nullish-coalescing covers all three shapes the API can send: a list, null, and +// the key being absent entirely. +const AdditionalFields = ({ fields }) => ( + <> + {(fields ?? []).map((field, idx) => ( + // No stable id on an answer, and a form may legitimately carry two fields + // with the same label, so the label alone is not a safe key. Same + // composite-index approach the row key above uses, for the same reason. + {`${field.label}: ${field.value}`} + ))} + +); + // Buckets flat per-line rows into sponsor groups, preserving first-seen order. // // Do NOT rely on row adjacency: the backend orders lines by sponsor NAME @@ -119,6 +141,7 @@ const HEADERS = [ { key: "col_destination" }, { key: "col_checkout_at" }, { key: "col_notes" }, + { key: "col_additional_fields" }, { key: "col_quantity", align: "right" }, { key: "col_used_rate" }, { key: "col_status" }, @@ -229,6 +252,9 @@ const LinesManifestView = ({ {formatCheckoutTime(line.purchase?.checkout_at)} {line.notes} + + + {line.is_partially_canceled ? `${liveQuantity(line)} / ${line.quantity}` diff --git a/src/components/sponsors/reports/__tests__/LinesManifestView.test.js b/src/components/sponsors/reports/__tests__/LinesManifestView.test.js index eb755993d..5f9a60f26 100644 --- a/src/components/sponsors/reports/__tests__/LinesManifestView.test.js +++ b/src/components/sponsors/reports/__tests__/LinesManifestView.test.js @@ -117,11 +117,11 @@ describe("LinesManifestView", () => { // HEADER row, which is a separate array (HEADERS). If the two desync by // one, every column right of the break silently misaligns and stays // green. Assert exact header/cell cardinality directly. - it("has exactly 13 column headers matching 13 cells per row", () => { + it("has exactly 14 column headers matching 14 cells per row", () => { renderView(); - expect(screen.getAllByRole("columnheader")).toHaveLength(13); + expect(screen.getAllByRole("columnheader")).toHaveLength(14); const row = screen.getByText("AV1").closest("tr"); - expect(within(row).getAllByRole("cell")).toHaveLength(13); + expect(within(row).getAllByRole("cell")).toHaveLength(14); }); // Sponsor bucketing (formerly bucketLinesBySponsor, now a private helper). @@ -207,6 +207,93 @@ describe("Destination booth fallback", () => { }); }); +describe("Additional Fields column", () => { + it("renders every answered field as 'Label: value', one per line, in the order received", () => { + renderView({ + rows: [ + line({ + additional_fields: [ + { label: "Chair Color", value: "Blue" }, + { label: "Start Time", value: "08:30" } + ] + }) + ] + }); + const row = screen.getByText("AV1").closest("tr"); + const cells = within(row).getAllByRole("cell"); + // Immediately after Notes (index 6), so index 7. + const cell = cells[7]; + expect(cell).toHaveTextContent("Chair Color: Blue"); + expect(cell).toHaveTextContent("Start Time: 08:30"); + // Order received, not sorted: the backend preserves the source snapshot's order + // and the logistics manifest is read in that order. + expect(cell.textContent.indexOf("Chair Color")).toBeLessThan( + cell.textContent.indexOf("Start Time") + ); + // One element per entry, so each renders on its own line rather than as one run-on string. + expect(cell.querySelectorAll("div")).toHaveLength(2); + }); + + it("renders an empty cell for a null, missing or empty additional_fields", () => { + // Three shapes the API can legitimately send. A crash here takes down the whole + // report page, so all three are pinned in one test rather than left to chance. + [ + line({ additional_fields: null }), + line({ additional_fields: [] }), + (() => { + const l = line(); + delete l.additional_fields; + return l; + })() + ].forEach((row) => { + const { unmount } = renderView({ rows: [row] }); + const cells = within(screen.getByText("AV1").closest("tr")).getAllByRole( + "cell" + ); + expect(cells[7]).toBeEmptyDOMElement(); + unmount(); + }); + }); + + it("renders a value containing markup literally, never as HTML", () => { + // Sponsor-entered free text reaches this cell. If anyone reaches for + // dangerouslySetInnerHTML, this fails. + renderView({ + rows: [ + line({ + additional_fields: [ + { label: "Notes", value: "bold" } + ] + }) + ] + }); + const cell = within(screen.getByText("AV1").closest("tr")).getAllByRole( + "cell" + )[7]; + expect(cell).toHaveTextContent("Notes: bold"); + expect(cell.querySelector("b")).toBeNull(); + expect(cell.querySelector("script")).toBeNull(); + }); + + it("strikes the cell through on a fully canceled line, like every other cell", () => { + renderView({ + rows: [ + line({ + is_canceled: true, + additional_fields: [{ label: "Chair Color", value: "Blue" }] + }) + ] + }); + const row = screen.getByText("AV1").closest("tr"); + // The row-level sx targets "& td", so the new cell inherits the treatment only + // because it is a real TableCell. A Box or a fragment in the row would not. + expect(row).toHaveAttribute("data-canceled", "true"); + expect(within(row).getAllByRole("cell")[7]).toHaveTextContent( + "Chair Color: Blue" + ); + }); +}); + describe("lines_count copy", () => { it("says the count is of LIVE lines, not all rendered lines", () => { // The chip is fed liveLineCount, but canceled lines still RENDER, so a group diff --git a/src/i18n/en.json b/src/i18n/en.json index f67120702..be86b43e0 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -4397,6 +4397,7 @@ "col_destination": "Destination", "col_checkout_at": "Checked Out At", "col_notes": "Notes", + "col_additional_fields": "Additional Fields", "col_quantity": "Qty", "col_sponsor": "Sponsor", "col_sponsor_note": "Sponsor Note", From e7001aa1e7465e90d3b5b717de2c87310851f99d Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Thu, 17 Sep 2026 16:04:34 -0500 Subject: [PATCH 2/3] test: name the canceled-line additional fields test for what it asserts --- .../sponsors/reports/__tests__/LinesManifestView.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/sponsors/reports/__tests__/LinesManifestView.test.js b/src/components/sponsors/reports/__tests__/LinesManifestView.test.js index 5f9a60f26..e9f369bcb 100644 --- a/src/components/sponsors/reports/__tests__/LinesManifestView.test.js +++ b/src/components/sponsors/reports/__tests__/LinesManifestView.test.js @@ -275,7 +275,7 @@ describe("Additional Fields column", () => { expect(cell.querySelector("script")).toBeNull(); }); - it("strikes the cell through on a fully canceled line, like every other cell", () => { + it("keeps the Additional Fields cell populated on a fully canceled line", () => { renderView({ rows: [ line({ From 16ef2010e10f23b89ff5b49fbf9a552229911954 Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Thu, 17 Sep 2026 16:10:45 -0500 Subject: [PATCH 3/3] refactor: trim additional fields comments and drop two framework-level tests --- .../sponsors/reports/LinesManifestView.js | 14 ++----- .../__tests__/LinesManifestView.test.js | 38 ------------------- 2 files changed, 3 insertions(+), 49 deletions(-) diff --git a/src/components/sponsors/reports/LinesManifestView.js b/src/components/sponsors/reports/LinesManifestView.js index de9112068..d828650e0 100644 --- a/src/components/sponsors/reports/LinesManifestView.js +++ b/src/components/sponsors/reports/LinesManifestView.js @@ -80,21 +80,13 @@ export const Destination = ({ name, booth }) => { ); }; -// One "Label: value" line per answered field. Values arrive display-ready from the -// API (option text resolved, DateTime already UTC, CheckBox already Yes/No), so the -// UI must not reformat or translate them: the screen, the print view and the CSV -// have to agree. Plain JSX text, never dangerouslySetInnerHTML — the values are -// sponsor-entered free text. -// -// Nullish-coalescing covers all three shapes the API can send: a list, null, and -// the key being absent entirely. +// Values arrive display-ready from the API; reformatting here would desync the +// screen from the CSV. const AdditionalFields = ({ fields }) => ( <> {(fields ?? []).map((field, idx) => ( - // No stable id on an answer, and a form may legitimately carry two fields - // with the same label, so the label alone is not a safe key. Same - // composite-index approach the row key above uses, for the same reason. {`${field.label}: ${field.value}`} diff --git a/src/components/sponsors/reports/__tests__/LinesManifestView.test.js b/src/components/sponsors/reports/__tests__/LinesManifestView.test.js index e9f369bcb..ec7c41a51 100644 --- a/src/components/sponsors/reports/__tests__/LinesManifestView.test.js +++ b/src/components/sponsors/reports/__tests__/LinesManifestView.test.js @@ -254,44 +254,6 @@ describe("Additional Fields column", () => { unmount(); }); }); - - it("renders a value containing markup literally, never as HTML", () => { - // Sponsor-entered free text reaches this cell. If anyone reaches for - // dangerouslySetInnerHTML, this fails. - renderView({ - rows: [ - line({ - additional_fields: [ - { label: "Notes", value: "bold" } - ] - }) - ] - }); - const cell = within(screen.getByText("AV1").closest("tr")).getAllByRole( - "cell" - )[7]; - expect(cell).toHaveTextContent("Notes: bold"); - expect(cell.querySelector("b")).toBeNull(); - expect(cell.querySelector("script")).toBeNull(); - }); - - it("keeps the Additional Fields cell populated on a fully canceled line", () => { - renderView({ - rows: [ - line({ - is_canceled: true, - additional_fields: [{ label: "Chair Color", value: "Blue" }] - }) - ] - }); - const row = screen.getByText("AV1").closest("tr"); - // The row-level sx targets "& td", so the new cell inherits the treatment only - // because it is a real TableCell. A Box or a fragment in the row would not. - expect(row).toHaveAttribute("data-canceled", "true"); - expect(within(row).getAllByRole("cell")[7]).toHaveTextContent( - "Chair Color: Blue" - ); - }); }); describe("lines_count copy", () => {