From d871006e1d3de82ba6900db940d0e3c492faa836 Mon Sep 17 00:00:00 2001 From: Samuel Reichert Date: Mon, 20 Apr 2026 13:17:46 +0200 Subject: [PATCH 01/10] refactor: extract cell readers from DSExportRequest into separate module Co-Authored-By: Claude Sonnet 4.6 --- .../features/data-export/DSExportRequest.ts | 157 +---------------- .../src/features/data-export/cell-readers.ts | 158 ++++++++++++++++++ 2 files changed, 161 insertions(+), 154 deletions(-) create mode 100644 packages/pluggableWidgets/datagrid-web/src/features/data-export/cell-readers.ts 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/cell-readers.ts b/packages/pluggableWidgets/datagrid-web/src/features/data-export/cell-readers.ts new file mode 100644 index 0000000000..0aab231674 --- /dev/null +++ b/packages/pluggableWidgets/datagrid-web/src/features/data-export/cell-readers.ts @@ -0,0 +1,158 @@ +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, '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; +} + +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: "b", + v: value, + w: value ? "TRUE" : "FALSE" + }; +} + +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 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)); +} From 56b0b87fdfe7a7bb9518fd5d164ea115fbef52b8 Mon Sep 17 00:00:00 2001 From: Samuel Reichert Date: Mon, 20 Apr 2026 13:34:05 +0200 Subject: [PATCH 02/10] test: add baseline tests for cell reader export behavior Documents current behavior of attribute, dynamicText, and customContent readers before bug-fix changes are applied. Co-Authored-By: Claude Sonnet 4.6 --- .../__tests__/cell-readers.spec.ts | 137 ++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 packages/pluggableWidgets/datagrid-web/src/features/data-export/__tests__/cell-readers.spec.ts 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..34e814f932 --- /dev/null +++ b/packages/pluggableWidgets/datagrid-web/src/features/data-export/__tests__/cell-readers.spec.ts @@ -0,0 +1,137 @@ +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 boolean cell", () => { + const col = column("Active", c => { + c.showContentAs = "attribute"; + c.attribute = listAttribute(() => true); + }); + const cell = readSingleCell(col); + expect(cell.t).toBe("b"); + expect(cell.v).toBe(true); + }); + + 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(testDate); + expect(cell.z).toBe("yyyy-mm-dd"); + }); + + it("exports date attribute without format as string cell (displayValue)", () => { + const col = column("Created", c => { + c.showContentAs = "attribute"; + c.attribute = listAttribute(() => new Date("2024-06-15T10:30:00Z")); + c.exportType = "default"; + }); + const cell = readSingleCell(col); + expect(cell.t).toBe("s"); + }); + + 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("exports n/a when unavailable", () => { + 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(""); + }); + }); +}); From 791995893980f09df208afac144c483a8fd864fe Mon Sep 17 00:00:00 2001 From: Samuel Reichert Date: Mon, 20 Apr 2026 13:36:24 +0200 Subject: [PATCH 03/10] fix: export customContent columns as number cells when exportType is number Co-Authored-By: Claude Sonnet 4.6 --- .../__tests__/cell-readers.spec.ts | 46 +++++++++++++++++++ .../src/features/data-export/cell-readers.ts | 7 +++ 2 files changed, 53 insertions(+) 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 index 34e814f932..34717f080c 100644 --- 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 @@ -133,5 +133,51 @@ describe("cell-readers", () => { 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(""); + }); }); }); 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 index 0aab231674..0bebed4d1d 100644 --- a/packages/pluggableWidgets/datagrid-web/src/features/data-export/cell-readers.ts +++ b/packages/pluggableWidgets/datagrid-web/src/features/data-export/cell-readers.ts @@ -142,6 +142,13 @@ const readers: ReadersByType = { exportNumberFormat: props.exportNumberFormat }); + if (props.exportType === "number" && value !== "") { + const parsed = Number(value); + if (!Number.isNaN(parsed)) { + return excelNumber(parsed, format); + } + } + return excelString(value, format); } }; From d64eb62c35f3fabee550b1ba18983919acc3a569 Mon Sep 17 00:00:00 2001 From: Samuel Reichert Date: Mon, 20 Apr 2026 13:37:47 +0200 Subject: [PATCH 04/10] fix: export customContent columns as date cells when exportType is date Co-Authored-By: Claude Sonnet 4.6 --- .../__tests__/cell-readers.spec.ts | 48 +++++++++++++++++++ .../src/features/data-export/cell-readers.ts | 7 +++ 2 files changed, 55 insertions(+) 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 index 34717f080c..0f24c185c5 100644 --- 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 @@ -179,5 +179,53 @@ describe("cell-readers", () => { 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(""); + }); }); }); 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 index 0bebed4d1d..a5a1a3a77c 100644 --- a/packages/pluggableWidgets/datagrid-web/src/features/data-export/cell-readers.ts +++ b/packages/pluggableWidgets/datagrid-web/src/features/data-export/cell-readers.ts @@ -149,6 +149,13 @@ const readers: ReadersByType = { } } + if (props.exportType === "date" && value !== "") { + const parsed = new Date(value); + if (!isNaN(parsed.getTime())) { + return excelDate(format === undefined ? value : parsed, format); + } + } + return excelString(value, format); } }; From 4cdf13eb41931fd763ba731863a0ed55dacb0a27 Mon Sep 17 00:00:00 2001 From: Samuel Reichert Date: Mon, 20 Apr 2026 13:40:43 +0200 Subject: [PATCH 05/10] fix: strip time component from exported dates when format is date-only Co-Authored-By: Claude Sonnet 4.6 --- .../__tests__/cell-readers.spec.ts | 55 ++++++++++++++++++- .../src/features/data-export/cell-readers.ts | 20 ++++++- 2 files changed, 72 insertions(+), 3 deletions(-) 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 index 0f24c185c5..f21cbc7f2c 100644 --- 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 @@ -66,7 +66,7 @@ describe("cell-readers", () => { }); const cell = readSingleCell(col); expect(cell.t).toBe("d"); - expect(cell.v).toEqual(testDate); + expect(cell.v).toEqual(new Date(Date.UTC(2024, 5, 15))); expect(cell.z).toBe("yyyy-mm-dd"); }); @@ -228,4 +228,57 @@ describe("cell-readers", () => { expect(cell.v).toBe(""); }); }); + + 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 index a5a1a3a77c..41d0af614d 100644 --- a/packages/pluggableWidgets/datagrid-web/src/features/data-export/cell-readers.ts +++ b/packages/pluggableWidgets/datagrid-web/src/features/data-export/cell-readers.ts @@ -84,6 +84,14 @@ export function excelBoolean(value: boolean): ExcelCell { }; } +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 readers: ReadersByType = { attribute(item, props) { const data = props.attribute?.get(item); @@ -100,7 +108,11 @@ const readers: ReadersByType = { }); if (value instanceof Date) { - return excelDate(format === undefined ? data.displayValue : value, format); + if (format === undefined) { + return excelDate(data.displayValue, format); + } + const dateValue = hasTimeComponent(format) ? value : stripTime(value); + return excelDate(dateValue, format); } if (typeof value === "boolean") { @@ -152,7 +164,11 @@ const readers: ReadersByType = { if (props.exportType === "date" && value !== "") { const parsed = new Date(value); if (!isNaN(parsed.getTime())) { - return excelDate(format === undefined ? value : parsed, format); + if (format === undefined) { + return excelDate(value, format); + } + const dateValue = hasTimeComponent(format) ? parsed : stripTime(parsed); + return excelDate(dateValue, format); } } From 65d4d9e0e7c72b7d50a036f26af4cac47ce21f66 Mon Sep 17 00:00:00 2001 From: Samuel Reichert Date: Mon, 20 Apr 2026 13:42:08 +0200 Subject: [PATCH 06/10] fix: export boolean values as Yes/No strings instead of TRUE/FALSE Co-Authored-By: Claude Sonnet 4.6 --- .../data-export/__tests__/cell-readers.spec.ts | 16 +++++++++++++--- .../src/features/data-export/cell-readers.ts | 5 ++--- 2 files changed, 15 insertions(+), 6 deletions(-) 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 index f21cbc7f2c..3b5844bd11 100644 --- 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 @@ -46,14 +46,24 @@ describe("cell-readers", () => { expect(cell.z).toBe("#,##0.00"); }); - it("exports boolean attribute as boolean cell", () => { + 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("b"); - expect(cell.v).toBe(true); + 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", () => { 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 index 41d0af614d..65f675f1d6 100644 --- a/packages/pluggableWidgets/datagrid-web/src/features/data-export/cell-readers.ts +++ b/packages/pluggableWidgets/datagrid-web/src/features/data-export/cell-readers.ts @@ -78,9 +78,8 @@ export function excelDate(value: string | Date, format?: string): ExcelCell { export function excelBoolean(value: boolean): ExcelCell { return { - t: "b", - v: value, - w: value ? "TRUE" : "FALSE" + t: "s", + v: value ? "Yes" : "No" }; } From a6a4534724af71dc4bc96dc7c93ad51be1272561 Mon Sep 17 00:00:00 2001 From: Samuel Reichert Date: Mon, 20 Apr 2026 13:43:38 +0200 Subject: [PATCH 07/10] fix: export large numbers as strings to preserve precision beyond 15 digits Co-Authored-By: Claude Sonnet 4.6 --- .../__tests__/cell-readers.spec.ts | 44 +++++++++++++++++++ .../src/features/data-export/cell-readers.ts | 13 ++++++ 2 files changed, 57 insertions(+) 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 index 3b5844bd11..204ef5e04d 100644 --- 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 @@ -239,6 +239,50 @@ describe("cell-readers", () => { }); }); + 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"); 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 index 65f675f1d6..6287db144b 100644 --- a/packages/pluggableWidgets/datagrid-web/src/features/data-export/cell-readers.ts +++ b/packages/pluggableWidgets/datagrid-web/src/features/data-export/cell-readers.ts @@ -91,6 +91,16 @@ 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); @@ -119,6 +129,9 @@ const readers: ReadersByType = { } 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); } From d8a822bc88c2caf692833e7b6724555f10f10cec Mon Sep 17 00:00:00 2001 From: Samuel Reichert Date: Mon, 20 Apr 2026 13:44:14 +0200 Subject: [PATCH 08/10] docs: add changelog entries for data export bug fixes Co-Authored-By: Claude Sonnet 4.6 --- packages/pluggableWidgets/datagrid-web/CHANGELOG.md | 10 ++++++++++ 1 file changed, 10 insertions(+) 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 From 22ec7f7b9549d1e54b15de6d6a6e7a2e0e5b0fb7 Mon Sep 17 00:00:00 2001 From: Samuel Reichert Date: Mon, 20 Apr 2026 14:11:31 +0200 Subject: [PATCH 09/10] refactor: remove dead boolean cell type and fix test name Remove "b" from ExcelCell.t union and boolean from ExcelCell.v since excelBoolean now returns string cells. Fix misleading test name for undefined dynamicText case. Co-Authored-By: Claude Sonnet 4.6 --- .../src/features/data-export/__tests__/cell-readers.spec.ts | 2 +- .../datagrid-web/src/features/data-export/cell-readers.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) 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 index 204ef5e04d..0c7e3da4a4 100644 --- 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 @@ -111,7 +111,7 @@ describe("cell-readers", () => { expect(cell.v).toBe("formatted text"); }); - it("exports n/a when unavailable", () => { + it("returns empty cell when dynamicText is undefined", () => { const col = column("Label", c => { c.showContentAs = "dynamicText"; c.dynamicText = undefined; 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 index 6287db144b..524f3e5246 100644 --- a/packages/pluggableWidgets/datagrid-web/src/features/data-export/cell-readers.ts +++ b/packages/pluggableWidgets/datagrid-web/src/features/data-export/cell-readers.ts @@ -4,10 +4,10 @@ import { ColumnsType, ShowContentAsEnum } from "../../../typings/DatagridProps"; /** Represents a single Excel cell (SheetJS compatible) */ export interface ExcelCell { - /** Cell type: 's' = string, 'n' = number, 'b' = boolean, 'd' = date */ - t: "s" | "n" | "b" | "d"; + /** Cell type: 's' = string, 'n' = number, 'd' = date */ + t: "s" | "n" | "d"; /** Underlying value */ - v: string | number | boolean | Date; + v: string | number | Date; /** Optional Excel number/date format, e.g. "yyyy-mm-dd" or "$0.00" */ z?: string; /** Optional pre-formatted display text */ From 4e940c2e32e67d809d46334ea799d8a74df66482 Mon Sep 17 00:00:00 2001 From: Samuel Reichert Date: Fri, 1 May 2026 11:24:11 +0200 Subject: [PATCH 10/10] test(datagrid-web): pin date reference and assert display value in cell-readers spec --- .../src/features/data-export/__tests__/cell-readers.spec.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 index 0c7e3da4a4..98ebc20c45 100644 --- 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 @@ -81,13 +81,15 @@ describe("cell-readers", () => { }); 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(() => new Date("2024-06-15T10:30:00Z")); + 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", () => {