mirror of
https://github.com/apache/superset.git
synced 2026-08-21 15:41:16 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
406c89d17f | ||
|
|
c36bc470e2 | ||
|
|
c40f2a1520 | ||
|
|
b143b0f734 | ||
|
|
46b69ddb2e | ||
|
|
9d1ebdfffa | ||
|
|
6f646e3223 | ||
|
|
acc73f9f99 | ||
|
|
8a0c27f041 | ||
|
|
83c1f2c809 |
@@ -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`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user