Skip to content

Commit 858f0fe

Browse files
committed
fix(plugin-report): guard the CSV header row too — a matrix's across headers are data values
Claude-Session: https://claude.ai/code/session_01JQwxbYfDeaywgrS5N1PZfg Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2809f49 commit 858f0fe

2 files changed

Lines changed: 32 additions & 1 deletion

File tree

‎ui/packages/plugin-report/src/ReportExport.tsx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,9 @@ function csvField(text: string): string {
123123
/** The CSV text of a snapshot (CRLF rows, RFC 4180 quoting). */
124124
export function buildReportCsv(snapshot: ReportTableSnapshot, includeHeaders: boolean): string {
125125
const lines: string[] = [];
126-
if (includeHeaders) lines.push(snapshot.header.map(csvField).join(','));
126+
// A matrix's across-axis headers ARE dimension values from the data, so every
127+
// header cell takes the guard (an authored label never opens with = + - @ |).
128+
if (includeHeaders) lines.push(snapshot.header.map((h) => csvField(guardFormula({ text: h, fromData: true }))).join(','));
127129
for (const row of snapshot.rows) lines.push(row.map((c) => csvField(guardFormula(c))).join(','));
128130
return lines.join('\r\n');
129131
}

‎ui/packages/plugin-report/src/__tests__/DatasetReportRenderer.export-25873.test.tsx‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,35 @@ describe('report export control (exportOptions, #25873)', () => {
283283
expect(downloads[0].fileName).toMatch(/^by_stage_quarter-/);
284284
});
285285

286+
it('a matrix across-axis value opening with a formula character is guarded in the CSV header', async () => {
287+
const result = {
288+
rows: [
289+
{ stage: 'A', quarter: '=HYPERLINK("x")', amount: 1 },
290+
{ stage: 'A', quarter: 'Q2', amount: 2 },
291+
],
292+
fields: [
293+
{ name: 'stage', type: 'string', label: 'Stage' },
294+
{ name: 'quarter', type: 'string', label: 'Quarter' },
295+
{ name: 'amount', type: 'number', label: 'Amount' },
296+
],
297+
object: OBJECT,
298+
};
299+
renderReport(
300+
{ name: 'by_stage_quarter', type: 'matrix', dataset: 'opportunity_metrics', rows: ['stage'], columns: ['quarter'], values: ['amount'] },
301+
{ result },
302+
);
303+
await screen.findByTestId('dataset-matrix');
304+
await openMenu();
305+
fireEvent.click(await screen.findByTestId('report-export-csv'));
306+
await waitFor(() => expect(downloads).toHaveLength(1));
307+
const header = parseCsv(await blobText(downloads[0].blob))[0];
308+
expect(drawnTable()[0]).toContain('=HYPERLINK("x")');
309+
expect(header).toContain('\t=HYPERLINK("x")');
310+
expect(header).not.toContain('=HYPERLINK("x")');
311+
// Authored labels pass through unchanged.
312+
expect(header[0]).toBe('Stage');
313+
});
314+
286315
it('a joined report draws no export control', async () => {
287316
renderReport({
288317
name: 'joined_one',

0 commit comments

Comments
 (0)