diff --git a/packages/pluggableWidgets/datagrid-web/CHANGELOG.md b/packages/pluggableWidgets/datagrid-web/CHANGELOG.md index 758ba241cb..54bcb9808e 100644 --- a/packages/pluggableWidgets/datagrid-web/CHANGELOG.md +++ b/packages/pluggableWidgets/datagrid-web/CHANGELOG.md @@ -6,6 +6,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] +### Fixed + +- We fixed an issue where custom content columns ignored the export type setting, causing numbers and dates to always export as text in Excel. + +- We fixed an issue where exported date values included a hidden time component even when the format specified date-only parts. + +- We fixed an issue where boolean values exported as TRUE/FALSE instead of Yes/No to match the display in the grid. + +- We fixed an issue where numbers with more than 15 significant digits lost precision during Excel export. Such values are now exported as text to preserve all digits. + ## [3.9.0] - 2026-03-23 ### Changed diff --git a/packages/pluggableWidgets/datagrid-web/src/features/data-export/DSExportRequest.ts b/packages/pluggableWidgets/datagrid-web/src/features/data-export/DSExportRequest.ts index a1f9e9ca4f..9932db5eaf 100644 --- a/packages/pluggableWidgets/datagrid-web/src/features/data-export/DSExportRequest.ts +++ b/packages/pluggableWidgets/datagrid-web/src/features/data-export/DSExportRequest.ts @@ -1,33 +1,8 @@ import { isAvailable } from "@mendix/widget-plugin-platform/framework/is-available"; -import Big from "big.js"; -import { DynamicValue, ListValue, ObjectItem, ValueStatus } from "mendix"; +import { ListValue, ObjectItem, ValueStatus } from "mendix"; import { createNanoEvents, Emitter, Unsubscribe } from "nanoevents"; -import { ColumnsType, ShowContentAsEnum } from "../../../typings/DatagridProps"; - -/** Represents a single Excel cell (SheetJS compatible) */ -interface ExcelCell { - /** Cell type: 's' = string, 'n' = number, 'b' = boolean, 'd' = date */ - t: "s" | "n" | "b" | "d"; - /** Underlying value */ - v: string | number | boolean | Date; - /** Optional Excel number/date format, e.g. "yyyy-mm-dd" or "$0.00" */ - z?: string; - /** Optional pre-formatted display text */ - w?: string; -} - -type RowData = ExcelCell[]; - -type HeaderDefinition = { - name: string; - type: string; -}; - -type ValueReader = (item: ObjectItem, props: ColumnsType) => ExcelCell; - -type ReadersByType = Record; - -type RowReader = (item: ObjectItem) => RowData; +import { ColumnsType } from "../../../typings/DatagridProps"; +import { HeaderDefinition, RowData, readChunk } from "./cell-readers"; type ColumnReader = (props: ColumnsType) => HeaderDefinition; @@ -262,132 +237,6 @@ export class DSExportRequest { } } -const readers: ReadersByType = { - attribute(item, props) { - const data = props.attribute?.get(item); - - if (data?.status !== "available") { - return makeEmptyCell(); - } - - const value = data.value; - const format = getCellFormat({ - exportType: props.exportType, - exportDateFormat: props.exportDateFormat, - exportNumberFormat: props.exportNumberFormat - }); - - if (value instanceof Date) { - return excelDate(format === undefined ? data.displayValue : value, format); - } - - if (typeof value === "boolean") { - return excelBoolean(value); - } - - if (value instanceof Big || typeof value === "number") { - const num = value instanceof Big ? value.toNumber() : value; - return excelNumber(num, format); - } - - return excelString(data.displayValue ?? ""); - }, - - dynamicText(item, props) { - const data = props.dynamicText?.get(item); - - switch (data?.status) { - case "available": - const format = getCellFormat({ - exportType: props.exportType, - exportDateFormat: props.exportDateFormat, - exportNumberFormat: props.exportNumberFormat - }); - - return excelString(data.value ?? "", format); - case "unavailable": - return excelString("n/a"); - default: - return makeEmptyCell(); - } - }, - - customContent(item, props) { - const value = props.exportValue?.get(item).value ?? ""; - const format = getCellFormat({ - exportType: props.exportType, - exportDateFormat: props.exportDateFormat, - exportNumberFormat: props.exportNumberFormat - }); - - return excelString(value, format); - } -}; - -function makeEmptyCell(): ExcelCell { - return { t: "s", v: "" }; -} - -function excelNumber(value: number, format?: string): ExcelCell { - return { - t: "n", - v: value, - z: format - }; -} - -function excelString(value: string, format?: string): ExcelCell { - return { - t: "s", - v: value, - z: format ?? undefined - }; -} - -function excelDate(value: string | Date, format?: string): ExcelCell { - return { - t: format === undefined ? "s" : "d", - v: value, - z: format - }; -} - -function excelBoolean(value: boolean): ExcelCell { - return { - t: "b", - v: value, - w: value ? "TRUE" : "FALSE" - }; -} - -interface DataExportProps { - exportType: "default" | "number" | "date" | "boolean"; - exportDateFormat?: DynamicValue; - exportNumberFormat?: DynamicValue; -} - -function getCellFormat({ exportType, exportDateFormat, exportNumberFormat }: DataExportProps): string | undefined { - switch (exportType) { - case "date": - return exportDateFormat?.status === "available" ? exportDateFormat.value : undefined; - case "number": - return exportNumberFormat?.status === "available" ? exportNumberFormat.value : undefined; - default: - return undefined; - } -} - -function createRowReader(columns: ColumnsType[]): RowReader { - return item => - columns.map(col => { - return readers[col.showContentAs](item, col); - }); -} - -function readChunk(data: ObjectItem[], columns: ColumnsType[]): RowData[] { - return data.map(createRowReader(columns)); -} - declare global { interface Window { scheduler: { diff --git a/packages/pluggableWidgets/datagrid-web/src/features/data-export/__tests__/cell-readers.spec.ts b/packages/pluggableWidgets/datagrid-web/src/features/data-export/__tests__/cell-readers.spec.ts new file mode 100644 index 0000000000..98ebc20c45 --- /dev/null +++ b/packages/pluggableWidgets/datagrid-web/src/features/data-export/__tests__/cell-readers.spec.ts @@ -0,0 +1,340 @@ +import Big from "big.js"; +import { listAttribute, listExpression, dynamic, obj } from "@mendix/widget-plugin-test-utils"; +import { ObjectItem } from "mendix"; +import { column } from "../../../utils/test-utils"; +import { readChunk, ExcelCell } from "../cell-readers"; + +function readSingleCell(col: ReturnType, item?: ObjectItem): ExcelCell { + const items = [item ?? obj()]; + const result = readChunk(items, [col]); + return result[0][0]; +} + +describe("cell-readers", () => { + describe("attribute reader", () => { + it("exports string attribute as string cell (displayValue)", () => { + const col = column("Name", c => { + c.showContentAs = "attribute"; + c.attribute = listAttribute(() => "hello"); + }); + const cell = readSingleCell(col); + expect(cell.t).toBe("s"); + // attribute reader returns displayValue for strings, not raw value + expect(cell.v).toBe("Formatted hello"); + }); + + it("exports number attribute as number cell", () => { + const col = column("Amount", c => { + c.showContentAs = "attribute"; + c.attribute = listAttribute(() => new Big("42.5")); + }); + const cell = readSingleCell(col); + expect(cell.t).toBe("n"); + expect(cell.v).toBe(42.5); + }); + + it("exports number attribute with format", () => { + const col = column("Amount", c => { + c.showContentAs = "attribute"; + c.attribute = listAttribute(() => new Big("1234.56")); + c.exportType = "number"; + c.exportNumberFormat = dynamic("#,##0.00"); + }); + const cell = readSingleCell(col); + expect(cell.t).toBe("n"); + expect(cell.v).toBe(1234.56); + expect(cell.z).toBe("#,##0.00"); + }); + + it("exports boolean attribute as Yes/No string cell", () => { + const col = column("Active", c => { + c.showContentAs = "attribute"; + c.attribute = listAttribute(() => true); + }); + const cell = readSingleCell(col); + expect(cell.t).toBe("s"); + expect(cell.v).toBe("Yes"); + }); + + it("exports false boolean attribute as No", () => { + const col = column("Active", c => { + c.showContentAs = "attribute"; + c.attribute = listAttribute(() => false); + }); + const cell = readSingleCell(col); + expect(cell.t).toBe("s"); + expect(cell.v).toBe("No"); + }); + + it("exports date attribute with format as date cell", () => { + const testDate = new Date("2024-06-15T10:30:00Z"); + const col = column("Created", c => { + c.showContentAs = "attribute"; + c.attribute = listAttribute(() => testDate); + c.exportType = "date"; + c.exportDateFormat = dynamic("yyyy-mm-dd"); + }); + const cell = readSingleCell(col); + expect(cell.t).toBe("d"); + expect(cell.v).toEqual(new Date(Date.UTC(2024, 5, 15))); + expect(cell.z).toBe("yyyy-mm-dd"); + }); + + it("exports date attribute without format as string cell (displayValue)", () => { + const testDate = new Date("2024-06-15T10:30:00Z"); + const col = column("Created", c => { + c.showContentAs = "attribute"; + c.attribute = listAttribute(() => testDate); + c.exportType = "default"; + }); + const cell = readSingleCell(col); + expect(cell.t).toBe("s"); + expect(cell.v).toBe(`Formatted ${testDate}`); + }); + + it("returns empty cell when attribute is not available", () => { + const col = column("Missing", c => { + c.showContentAs = "attribute"; + c.attribute = undefined; + }); + const cell = readSingleCell(col); + expect(cell).toEqual({ t: "s", v: "" }); + }); + }); + + describe("dynamicText reader", () => { + it("exports dynamic text as string cell", () => { + const col = column("Label", c => { + c.showContentAs = "dynamicText"; + c.dynamicText = listExpression(() => "formatted text"); + }); + const cell = readSingleCell(col); + expect(cell.t).toBe("s"); + expect(cell.v).toBe("formatted text"); + }); + + it("returns empty cell when dynamicText is undefined", () => { + const col = column("Label", c => { + c.showContentAs = "dynamicText"; + c.dynamicText = undefined; + }); + const cell = readSingleCell(col); + expect(cell).toEqual({ t: "s", v: "" }); + }); + }); + + describe("customContent reader", () => { + it("exports custom content as string cell (current baseline)", () => { + const col = column("Custom", c => { + c.showContentAs = "customContent"; + c.exportValue = listExpression(() => "42.50"); + c.exportType = "default"; + }); + const cell = readSingleCell(col); + expect(cell.t).toBe("s"); + expect(cell.v).toBe("42.50"); + }); + + it("exports empty string when exportValue is undefined", () => { + const col = column("Custom", c => { + c.showContentAs = "customContent"; + c.exportValue = undefined; + c.exportType = "default"; + }); + const cell = readSingleCell(col); + expect(cell.t).toBe("s"); + expect(cell.v).toBe(""); + }); + + it("exports as number cell when exportType is number", () => { + const col = column("Price", c => { + c.showContentAs = "customContent"; + c.exportValue = listExpression(() => "1234.56"); + c.exportType = "number"; + c.exportNumberFormat = dynamic("#,##0.00"); + }); + const cell = readSingleCell(col); + expect(cell.t).toBe("n"); + expect(cell.v).toBe(1234.56); + expect(cell.z).toBe("#,##0.00"); + }); + + it("exports as number cell without format", () => { + const col = column("Count", c => { + c.showContentAs = "customContent"; + c.exportValue = listExpression(() => "99"); + c.exportType = "number"; + }); + const cell = readSingleCell(col); + expect(cell.t).toBe("n"); + expect(cell.v).toBe(99); + }); + + it("falls back to string when number parse fails", () => { + const col = column("Bad", c => { + c.showContentAs = "customContent"; + c.exportValue = listExpression(() => "not-a-number"); + c.exportType = "number"; + }); + const cell = readSingleCell(col); + expect(cell.t).toBe("s"); + expect(cell.v).toBe("not-a-number"); + }); + + it("falls back to string for empty value with number exportType", () => { + const col = column("Empty", c => { + c.showContentAs = "customContent"; + c.exportValue = listExpression(() => ""); + c.exportType = "number"; + }); + const cell = readSingleCell(col); + expect(cell.t).toBe("s"); + expect(cell.v).toBe(""); + }); + + it("exports as date cell when exportType is date", () => { + const col = column("Created", c => { + c.showContentAs = "customContent"; + c.exportValue = listExpression(() => "2024-06-15T00:00:00.000Z"); + c.exportType = "date"; + c.exportDateFormat = dynamic("yyyy-mm-dd"); + }); + const cell = readSingleCell(col); + expect(cell.t).toBe("d"); + expect(cell.v).toEqual(new Date("2024-06-15T00:00:00.000Z")); + expect(cell.z).toBe("yyyy-mm-dd"); + }); + + it("exports date as string when no format provided", () => { + const col = column("Created", c => { + c.showContentAs = "customContent"; + c.exportValue = listExpression(() => "2024-06-15T10:30:00Z"); + c.exportType = "date"; + }); + const cell = readSingleCell(col); + expect(cell.t).toBe("s"); + expect(cell.v).toBe("2024-06-15T10:30:00Z"); + }); + + it("falls back to string when date parse fails", () => { + const col = column("Bad", c => { + c.showContentAs = "customContent"; + c.exportValue = listExpression(() => "not-a-date"); + c.exportType = "date"; + c.exportDateFormat = dynamic("yyyy-mm-dd"); + }); + const cell = readSingleCell(col); + expect(cell.t).toBe("s"); + expect(cell.v).toBe("not-a-date"); + }); + + it("falls back to string for empty value with date exportType", () => { + const col = column("Empty", c => { + c.showContentAs = "customContent"; + c.exportValue = listExpression(() => ""); + c.exportType = "date"; + c.exportDateFormat = dynamic("yyyy-mm-dd"); + }); + const cell = readSingleCell(col); + expect(cell.t).toBe("s"); + expect(cell.v).toBe(""); + }); + }); + + describe("long number precision", () => { + it("exports Big with >15 significant digits as string to preserve precision", () => { + const col = column("LongId", c => { + c.showContentAs = "attribute"; + c.attribute = listAttribute(() => new Big("1234567890123456789")); + }); + const cell = readSingleCell(col); + expect(cell.t).toBe("s"); + expect(cell.v).toBe("1234567890123456789"); + }); + + it("exports Big with <=15 significant digits as number", () => { + const col = column("NormalNum", c => { + c.showContentAs = "attribute"; + c.attribute = listAttribute(() => new Big("123456789012345")); + }); + const cell = readSingleCell(col); + expect(cell.t).toBe("n"); + expect(cell.v).toBe(123456789012345); + }); + + it("exports Big with >15 digits and format as string with format", () => { + const col = column("LongFormatted", c => { + c.showContentAs = "attribute"; + c.attribute = listAttribute(() => new Big("9999999999999999999")); + c.exportType = "number"; + c.exportNumberFormat = dynamic("0"); + }); + const cell = readSingleCell(col); + expect(cell.t).toBe("s"); + expect(cell.v).toBe("9999999999999999999"); + }); + + it("handles Big decimal with many significant digits", () => { + const col = column("Precise", c => { + c.showContentAs = "attribute"; + c.attribute = listAttribute(() => new Big("1234567890.1234567890")); + }); + const cell = readSingleCell(col); + expect(cell.t).toBe("s"); + expect(cell.v).toBe("1234567890.123456789"); + }); + }); + + describe("date time stripping", () => { + it("strips time from attribute date when format has no time components", () => { + const testDate = new Date("2024-06-15T10:30:00Z"); + const col = column("DateOnly", c => { + c.showContentAs = "attribute"; + c.attribute = listAttribute(() => testDate); + c.exportType = "date"; + c.exportDateFormat = dynamic("dd-mmm-yyyy"); + }); + const cell = readSingleCell(col); + expect(cell.t).toBe("d"); + expect(cell.v).toEqual(new Date(Date.UTC(2024, 5, 15))); + expect(cell.z).toBe("dd-mmm-yyyy"); + }); + + it("preserves time in attribute date when format has time components", () => { + const testDate = new Date("2024-06-15T10:30:00Z"); + const col = column("DateTime", c => { + c.showContentAs = "attribute"; + c.attribute = listAttribute(() => testDate); + c.exportType = "date"; + c.exportDateFormat = dynamic("yyyy-mm-dd hh:mm:ss"); + }); + const cell = readSingleCell(col); + expect(cell.t).toBe("d"); + expect(cell.v).toEqual(testDate); + }); + + it("strips time from customContent date when format has no time components", () => { + const col = column("DateOnly", c => { + c.showContentAs = "customContent"; + c.exportValue = listExpression(() => "2024-06-15T10:30:00Z"); + c.exportType = "date"; + c.exportDateFormat = dynamic("dd-mmm-yyyy"); + }); + const cell = readSingleCell(col); + expect(cell.t).toBe("d"); + expect(cell.v).toEqual(new Date(Date.UTC(2024, 5, 15))); + }); + + it("preserves time in customContent date when format has time components", () => { + const col = column("DateTime", c => { + c.showContentAs = "customContent"; + c.exportValue = listExpression(() => "2024-06-15T10:30:00Z"); + c.exportType = "date"; + c.exportDateFormat = dynamic("yyyy-mm-dd hh:mm:ss"); + }); + const cell = readSingleCell(col); + expect(cell.t).toBe("d"); + expect(cell.v).toEqual(new Date("2024-06-15T10:30:00Z")); + }); + }); +}); diff --git a/packages/pluggableWidgets/datagrid-web/src/features/data-export/cell-readers.ts b/packages/pluggableWidgets/datagrid-web/src/features/data-export/cell-readers.ts new file mode 100644 index 0000000000..524f3e5246 --- /dev/null +++ b/packages/pluggableWidgets/datagrid-web/src/features/data-export/cell-readers.ts @@ -0,0 +1,200 @@ +import Big from "big.js"; +import { DynamicValue, ObjectItem } from "mendix"; +import { ColumnsType, ShowContentAsEnum } from "../../../typings/DatagridProps"; + +/** Represents a single Excel cell (SheetJS compatible) */ +export interface ExcelCell { + /** Cell type: 's' = string, 'n' = number, 'd' = date */ + t: "s" | "n" | "d"; + /** Underlying value */ + v: string | number | Date; + /** Optional Excel number/date format, e.g. "yyyy-mm-dd" or "$0.00" */ + z?: string; + /** Optional pre-formatted display text */ + w?: string; +} + +export type RowData = ExcelCell[]; + +export type HeaderDefinition = { + name: string; + type: string; +}; + +type ValueReader = (item: ObjectItem, props: ColumnsType) => ExcelCell; + +type ReadersByType = Record; + +type RowReader = (item: ObjectItem) => RowData; + +export interface DataExportProps { + exportType: "default" | "number" | "date" | "boolean"; + exportDateFormat?: DynamicValue; + exportNumberFormat?: DynamicValue; +} + +export function getCellFormat({ + exportType, + exportDateFormat, + exportNumberFormat +}: DataExportProps): string | undefined { + switch (exportType) { + case "date": + return exportDateFormat?.status === "available" ? exportDateFormat.value : undefined; + case "number": + return exportNumberFormat?.status === "available" ? exportNumberFormat.value : undefined; + default: + return undefined; + } +} + +export function makeEmptyCell(): ExcelCell { + return { t: "s", v: "" }; +} + +export function excelNumber(value: number, format?: string): ExcelCell { + return { + t: "n", + v: value, + z: format + }; +} + +export function excelString(value: string, format?: string): ExcelCell { + return { + t: "s", + v: value, + z: format ?? undefined + }; +} + +export function excelDate(value: string | Date, format?: string): ExcelCell { + return { + t: format === undefined ? "s" : "d", + v: value, + z: format + }; +} + +export function excelBoolean(value: boolean): ExcelCell { + return { + t: "s", + v: value ? "Yes" : "No" + }; +} + +function hasTimeComponent(format: string): boolean { + return /[hs]/i.test(format); +} + +function stripTime(date: Date): Date { + return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())); +} + +const MAX_SAFE_SIGNIFICANT_DIGITS = 15; + +function countSignificantDigits(value: Big): number { + const str = value.toFixed(); + const unsigned = str.replace("-", ""); + const noDecimal = unsigned.replace(".", ""); + const stripped = noDecimal.replace(/^0+/, ""); + return stripped.length || 1; +} + +const readers: ReadersByType = { + attribute(item, props) { + const data = props.attribute?.get(item); + + if (data?.status !== "available") { + return makeEmptyCell(); + } + + const value = data.value; + const format = getCellFormat({ + exportType: props.exportType, + exportDateFormat: props.exportDateFormat, + exportNumberFormat: props.exportNumberFormat + }); + + if (value instanceof Date) { + if (format === undefined) { + return excelDate(data.displayValue, format); + } + const dateValue = hasTimeComponent(format) ? value : stripTime(value); + return excelDate(dateValue, format); + } + + if (typeof value === "boolean") { + return excelBoolean(value); + } + + if (value instanceof Big || typeof value === "number") { + if (value instanceof Big && countSignificantDigits(value) > MAX_SAFE_SIGNIFICANT_DIGITS) { + return excelString(value.toFixed(), format); + } + const num = value instanceof Big ? value.toNumber() : value; + return excelNumber(num, format); + } + + return excelString(data.displayValue ?? ""); + }, + + dynamicText(item, props) { + const data = props.dynamicText?.get(item); + + switch (data?.status) { + case "available": + const format = getCellFormat({ + exportType: props.exportType, + exportDateFormat: props.exportDateFormat, + exportNumberFormat: props.exportNumberFormat + }); + + return excelString(data.value ?? "", format); + case "unavailable": + return excelString("n/a"); + default: + return makeEmptyCell(); + } + }, + + customContent(item, props) { + const value = props.exportValue?.get(item).value ?? ""; + const format = getCellFormat({ + exportType: props.exportType, + exportDateFormat: props.exportDateFormat, + exportNumberFormat: props.exportNumberFormat + }); + + if (props.exportType === "number" && value !== "") { + const parsed = Number(value); + if (!Number.isNaN(parsed)) { + return excelNumber(parsed, format); + } + } + + if (props.exportType === "date" && value !== "") { + const parsed = new Date(value); + if (!isNaN(parsed.getTime())) { + if (format === undefined) { + return excelDate(value, format); + } + const dateValue = hasTimeComponent(format) ? parsed : stripTime(parsed); + return excelDate(dateValue, format); + } + } + + return excelString(value, format); + } +}; + +function createRowReader(columns: ColumnsType[]): RowReader { + return item => + columns.map(col => { + return readers[col.showContentAs](item, col); + }); +} + +export function readChunk(data: ObjectItem[], columns: ColumnsType[]): RowData[] { + return data.map(createRowReader(columns)); +}