From 439746fed1e8a160a4a51ce36428d960f5ff2668 Mon Sep 17 00:00:00 2001 From: Samuel Reichert Date: Mon, 20 Apr 2026 13:17:46 +0200 Subject: [PATCH 01/11] 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 c9dc4125b7ba2e3329b416117f1ed7f1955caab7 Mon Sep 17 00:00:00 2001 From: Samuel Reichert Date: Mon, 20 Apr 2026 13:34:05 +0200 Subject: [PATCH 02/11] 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 a10b26e1eb011b747a2afbd25e93a43d3c880f01 Mon Sep 17 00:00:00 2001 From: Samuel Reichert Date: Mon, 20 Apr 2026 13:36:24 +0200 Subject: [PATCH 03/11] 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 d7dd6ee3668bc62a5375a503d8a42884bdaa8a17 Mon Sep 17 00:00:00 2001 From: Samuel Reichert Date: Mon, 20 Apr 2026 13:37:47 +0200 Subject: [PATCH 04/11] 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 332230335dfa7b722d40cfaaf09b6018108e6a40 Mon Sep 17 00:00:00 2001 From: Samuel Reichert Date: Mon, 20 Apr 2026 13:40:43 +0200 Subject: [PATCH 05/11] 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 910413602f0808aadf229b3cdd21da28ded3c87e Mon Sep 17 00:00:00 2001 From: Samuel Reichert Date: Mon, 20 Apr 2026 13:42:08 +0200 Subject: [PATCH 06/11] 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 b5e80596ab7d32cdf6182f0ffe56a213a31ba63a Mon Sep 17 00:00:00 2001 From: Samuel Reichert Date: Mon, 20 Apr 2026 13:43:38 +0200 Subject: [PATCH 07/11] 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 894ca52ad32e15d1aeacb93d1125f4dce186d40d Mon Sep 17 00:00:00 2001 From: Samuel Reichert Date: Mon, 20 Apr 2026 13:44:14 +0200 Subject: [PATCH 08/11] 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 36635bfbe1..579dc5b3d5 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. + ### Added - We added a "Custom row key" property in the Advanced section to provide stable row identifiers when using view entities, preventing scroll position loss during data refresh. From 26ffd9318b47d5ba6388923009eeffb4b2c0ad34 Mon Sep 17 00:00:00 2001 From: Samuel Reichert Date: Mon, 20 Apr 2026 14:11:31 +0200 Subject: [PATCH 09/11] 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 f06707b3b8ff14eb6909d3cb1730ee4c75fd09c1 Mon Sep 17 00:00:00 2001 From: Samuel Reichert Date: Fri, 1 May 2026 11:24:11 +0200 Subject: [PATCH 10/11] 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", () => { From 5aef9f2339c49d385dd70b157bd029e24bcfeb17 Mon Sep 17 00:00:00 2001 From: Samuel Reichert Date: Tue, 12 May 2026 15:44:33 +0200 Subject: [PATCH 11/11] refactor(datagrid-web): tighten excelDate overloads and remove dead code Add overloads to excelDate so t:"d" is only produced when v is a Date, preventing invalid SheetJS cells. Remove dead plain-number branch in attribute reader (Mendix always returns Big for numeric types). Drop no-op getCellFormat call in dynamicText reader (pre-rendered strings have no raw typed value to coerce). Remove redundant `?? undefined` in excelString. Co-Authored-By: Claude Sonnet 4.6 --- .../src/features/data-export/cell-readers.ts | 25 ++++++++----------- 1 file changed, 10 insertions(+), 15 deletions(-) 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 524f3e5246..eb763c8849 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 @@ -64,13 +64,15 @@ export function excelString(value: string, format?: string): ExcelCell { return { t: "s", v: value, - z: format ?? undefined + z: format }; } +export function excelDate(value: string): ExcelCell; +export function excelDate(value: Date, format: string): ExcelCell; export function excelDate(value: string | Date, format?: string): ExcelCell { return { - t: format === undefined ? "s" : "d", + t: value instanceof Date && format !== undefined ? "d" : "s", v: value, z: format }; @@ -118,7 +120,7 @@ const readers: ReadersByType = { if (value instanceof Date) { if (format === undefined) { - return excelDate(data.displayValue, format); + return excelDate(data.displayValue); } const dateValue = hasTimeComponent(format) ? value : stripTime(value); return excelDate(dateValue, format); @@ -128,12 +130,11 @@ const readers: ReadersByType = { return excelBoolean(value); } - if (value instanceof Big || typeof value === "number") { - if (value instanceof Big && countSignificantDigits(value) > MAX_SAFE_SIGNIFICANT_DIGITS) { + if (value instanceof Big) { + if (countSignificantDigits(value) > MAX_SAFE_SIGNIFICANT_DIGITS) { return excelString(value.toFixed(), format); } - const num = value instanceof Big ? value.toNumber() : value; - return excelNumber(num, format); + return excelNumber(value.toNumber(), format); } return excelString(data.displayValue ?? ""); @@ -144,13 +145,7 @@ const readers: ReadersByType = { switch (data?.status) { case "available": - const format = getCellFormat({ - exportType: props.exportType, - exportDateFormat: props.exportDateFormat, - exportNumberFormat: props.exportNumberFormat - }); - - return excelString(data.value ?? "", format); + return excelString(data.value ?? ""); case "unavailable": return excelString("n/a"); default: @@ -177,7 +172,7 @@ const readers: ReadersByType = { const parsed = new Date(value); if (!isNaN(parsed.getTime())) { if (format === undefined) { - return excelDate(value, format); + return excelDate(value); } const dateValue = hasTimeComponent(format) ? parsed : stripTime(parsed); return excelDate(dateValue, format);