Compare commits

...
Author SHA1 Message Date
rusackasandClaude Opus 4.8 406c89d17f fix(pivot-table): guard numeric restoration against thousands-separator locales
A "." thousands-separator D3_FORMAT locale (e.g. Spanish) renders 1234 as
"1.234", which also round-trips losslessly through Number() as 1.234, so
the restoration check misread it as a decimal instead of leaving it as
text. Skip restoration when the active locale's thousands separator is "."
and the cell contains one.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-14 08:38:06 -07:00
rusackasandClaude Opus 4.8 c36bc470e2 fix(pivot-table): stop reinterpreting date-shaped Excel export cells as dates
A rendered cell string can't be reliably distinguished from a
coincidentally date-shaped D3_FORMAT output (e.g. a custom grouping/
thousands locale can render a metric like 20240101 as "2024-01-01"),
so restoring it to a native Excel date risked silently changing the
exported value. Drop the date-restoration path; date-shaped text now
stays exactly as rendered, like every other ambiguous format.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-05 23:49:33 -07:00
rusackasandClaude Opus 4.8 c40f2a1520 test(pivot-table): cover invalid ISO date components in Excel export
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-04 23:14:10 -07:00
Evan RusackasandClaude Opus 4.8 b143b0f734 fix(pivot-table): serialize restored Excel dates via UTC to avoid a timezone shift
The ISO date round-trip check built the cell's Date from local
date/time components, so SheetJS's UTC-epoch-based serialization
could silently export a different calendar day depending on the
export machine's timezone (e.g. a midnight value in a positive-UTC-
offset zone would export as the previous day). Construct and
validate the Date via UTC instead, and add a regression asserting
the stored Date represents the exact intended UTC instant.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-03 15:14:22 -07:00
rusackasandClaude Opus 4.8 46b69ddb2e test(pivot-table): dedupe repeated table setup in Excel export tests
Extract the shared "build a table row, export, grab the sheet"
boilerplate into a small helper so each test only states its input
cells and assertions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-03 15:14:21 -07:00
rusackasandClaude Opus 4.8 9d1ebdfffa fix(pivot-table): validate time components in ISO round-trip check
The Excel export's ISO date/datetime restoration only confirmed the
date (year/month/day) round-tripped through the Date constructor, not
the time. An out-of-range time component like "13:60:30" silently
rolls over to "14:00:30" instead of being rejected, so it was restored
to a native Excel date cell holding the wrong time instead of staying
as text. Now hours/minutes/seconds are checked too.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-03 15:14:21 -07:00
rusackasandClaude 6f646e3223 fix(pivot-table): fix tsc/prettier CI failures in Excel export helper
Explicitly type the regex-match callback parameter to fix a TS7006
implicit-any error, and apply prettier's line-wrap for the ISO date
regex constant.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-03 15:14:20 -07:00
rusackasandClaude Opus 4.8 acc73f9f99 fix(pivot-table): restore native Excel dates for ISO-formatted pivot cells
raw: true disables all SheetJS type inference, so date dimension labels
(e.g. "2024-01-01") that previously became native Excel date cells were
exported as plain text too. ISO 8601 date/datetime strings are
unambiguous under any locale, so restore them to native date cells the
same way plain numbers are already restored.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-03 15:14:20 -07:00
rusackasandClaude Opus 4.8 8a0c27f041 fix(pivot-table): restore native numeric type for unambiguous Excel export cells
`raw: true` (added to fix #38555) keeps every pivot-table cell as text on
export, which also strips native Excel numeric typing from ordinary
metric/total values, not just the locale-formatted ones it targets. Restore
numeric type for cells whose text round-trips losslessly through Number()
(e.g. "42", "-3.5") since those are unambiguous under any locale; leave
everything else (grouped thousands, trailing zero padding, percent
suffixes, etc.) as text to avoid reintroducing the original misparsing bug.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-03 15:14:16 -07:00
rusackas 83c1f2c809 fix(pivot-table): keep D3_FORMAT-styled numbers intact in pivoted Excel export
The "Export to Pivoted Excel" download runs entirely client-side via
SheetJS's utils.table_to_book(), reading the rendered pivot table DOM.
By default SheetJS tries to infer numbers/dates from each cell's
displayed text, which mis-parses non-US D3_FORMAT strings: "1.234,56"
(Spanish decimal formatting) silently became the number 1.23456,
"3.500" became 3.5, and "12,50%" became 12.5.

Pass raw: true so every cell is kept as the literal text already
formatted by D3_FORMAT, instead of being reinterpreted by SheetJS.

Fixes #38555
2026-08-03 15:14:16 -07:00
2 changed files with 167 additions and 1 deletions
@@ -0,0 +1,116 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import type { WorkBook } from 'xlsx';
import { getNumberFormatterRegistry } from '@superset-ui/core';
import exportPivotExcel from './downloadAsPivotExcel';
const mockWriteFile = jest.fn();
jest.mock('xlsx', () => {
const actual = jest.requireActual('xlsx');
return {
...actual,
writeFile: (...args: unknown[]) => mockWriteFile(...args),
};
});
// Renders a single-row pivot table with the given cell values, runs the
// export, and returns the resulting sheet so each test only has to state
// its input cells and assertions.
function exportRowAndGetSheet(cells: string[]): WorkBook['Sheets'][string] {
document.body.innerHTML = `
<table id="pivot-table">
<tbody>
<tr>
${cells.map(cell => `<td>${cell}</td>`).join('\n ')}
</tr>
</tbody>
</table>
`;
exportPivotExcel('#pivot-table', 'export');
const workbook = mockWriteFile.mock.calls.at(-1)?.[0] as WorkBook;
return workbook.Sheets[workbook.SheetNames[0]];
}
test('preserves locale-formatted numbers exactly as rendered, without SheetJS reinterpreting them', () => {
const sheet = exportRowAndGetSheet(['1.234,56', '12,50%', '3.500']);
expect(mockWriteFile).toHaveBeenCalledTimes(1);
// These are Spanish-locale D3_FORMAT strings ("." as thousands separator,
// "," as decimal separator). Each must survive the export untouched, as a
// text cell, rather than being silently reparsed as a different number
// (SheetJS's default HTML table parsing would otherwise turn "1.234,56"
// into the number 1.23456, "3.500" into 3.5, and "12,50%" into 12.5).
expect(sheet.A1).toMatchObject({ t: 's', v: '1.234,56' });
expect(sheet.B1).toMatchObject({ t: 's', v: '12,50%' });
expect(sheet.C1).toMatchObject({ t: 's', v: '3.500' });
});
test('restores unambiguous plain numbers to native Excel numeric cells', () => {
const sheet = exportRowAndGetSheet(['42', '-3.5', '3.500', '1,234']);
// "42" and "-3.5" round-trip exactly through Number(), so they're
// unambiguous under any locale and are restored to real numbers.
expect(sheet.A1).toMatchObject({ t: 'n', v: 42 });
expect(sheet.B1).toMatchObject({ t: 'n', v: -3.5 });
// "3.500" (trailing zero padding) and "1,234" (grouped thousands) don't
// round-trip, so they stay as text rather than risk misparsing them.
expect(sheet.C1).toMatchObject({ t: 's', v: '3.500' });
expect(sheet.D1).toMatchObject({ t: 's', v: '1,234' });
});
test('does not restore grouped-thousands numbers under a "." thousands-separator locale', () => {
const registry = getNumberFormatterRegistry();
const original = registry.d3Format;
registry.setD3Format({ decimal: ',', thousands: '.', grouping: [3] });
try {
// Under a Spanish-style D3_FORMAT, "1.234" is the plain integer 1234
// rendered with a "." group separator, not the decimal 1.234. It also
// round-trips cleanly through Number(), so without the locale check it
// would be misrestored to the number 1.234, silently corrupting the
// value this PR exists to preserve. "42" has no "." and still round
// trips safely, so it's still restored.
const sheet = exportRowAndGetSheet(['1.234', '42']);
expect(sheet.A1).toMatchObject({ t: 's', v: '1.234' });
expect(sheet.B1).toMatchObject({ t: 'n', v: 42 });
} finally {
registry.setD3Format(original);
}
});
test('leaves date-shaped strings as text rather than reinterpreting them as dates', () => {
const sheet = exportRowAndGetSheet([
'2024-01-01',
'2024-01-01 13:45:30',
'not-a-date',
]);
// A rendered string can't be reliably classified as a genuine date rather
// than a coincidentally date-shaped formatted number (e.g. a custom
// D3_FORMAT grouping/thousands locale can render a plain metric like
// 20240101 as "2024-01-01"), so date-shaped cells are left exactly as
// rendered instead of being reinterpreted as native Excel dates.
expect(sheet.A1).toMatchObject({ t: 's', v: '2024-01-01' });
expect(sheet.B1).toMatchObject({ t: 's', v: '2024-01-01 13:45:30' });
expect(sheet.C1).toMatchObject({ t: 's', v: 'not-a-date' });
});
@@ -16,13 +16,63 @@
* specific language governing permissions and limitations
* under the License.
*/
import { getNumberFormatterRegistry } from '@superset-ui/core';
import { utils, writeFile } from 'xlsx';
import type { WorkSheet } from 'xlsx';
// `raw: true` (used below) keeps every table cell as text, so ordinary
// numbers lose their native Excel type along with the locale-formatted
// values. A cell's text is only restored to a real number when it is
// unambiguous under the active D3_FORMAT locale: a plain number that
// round-trips losslessly through Number() (e.g. "42" or "-3.5"). Restoring
// those can't reintroduce the misparsing raw: true guards against. Anything
// else (grouped thousands, percent suffixes, trailing zero padding,
// date-shaped text, other D3_FORMAT output, etc.) stays as text, exactly as
// rendered: a rendered string can't be reliably classified as a genuine date
// rather than a coincidentally date-shaped formatted number (e.g. a custom
// D3_FORMAT grouping/thousands locale can render a plain metric like
// 20240101 as "2024-01-01"), so cells are never reinterpreted as dates.
//
// Number()'s round-trip check assumes "." is a decimal point, which isn't
// true under every locale: a Spanish D3_FORMAT (thousands: '.') renders the
// plain integer 1234 as "1.234", which also round-trips through Number() as
// the decimal 1.234. When the active locale uses "." as its thousands
// separator, a cell containing "." can't be trusted as an unambiguous
// decimal, so it's left as text instead.
function restoreUnambiguousNumbers(sheet: WorkSheet): void {
const { thousands } = getNumberFormatterRegistry().d3Format;
Object.keys(sheet).forEach(cellRef => {
if (cellRef.startsWith('!')) {
return;
}
const cell = sheet[cellRef];
if (!cell || cell.t !== 's' || typeof cell.v !== 'string') {
return;
}
if (thousands === '.' && cell.v.includes('.')) {
return;
}
const value = Number(cell.v);
if (cell.v !== '' && Number.isFinite(value) && String(value) === cell.v) {
cell.t = 'n';
cell.v = value;
}
});
}
export default function exportPivotExcel(
tableSelector: string,
fileName: string,
) {
const table = document.querySelector(tableSelector);
const workbook = utils.table_to_book(table);
// `raw: true` keeps every cell as the literal text rendered in the DOM.
// Without it, SheetJS tries to infer numbers/dates from the displayed
// string, which mangles values that were formatted using a non-US
// D3_FORMAT (e.g. "1.234,56" gets misread as a date or truncated number).
const workbook = utils.table_to_book(table, { raw: true });
const sheet = workbook.Sheets[workbook.SheetNames[0]];
if (sheet) {
restoreUnambiguousNumbers(sheet);
}
writeFile(workbook, `${fileName}.xlsx`);
}