From fa59e5ce533d195240a5fc79de5112a1622dd828 Mon Sep 17 00:00:00 2001 From: Samuel Reichert Date: Mon, 20 Apr 2026 13:17:46 +0200 Subject: [PATCH 01/14] 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 7116de275601f33587a0f4e1edc5298735c19556 Mon Sep 17 00:00:00 2001 From: Samuel Reichert Date: Mon, 20 Apr 2026 13:34:05 +0200 Subject: [PATCH 02/14] 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 b5fca2fd5f7de23dae4100c0f483ddb26dd576ba Mon Sep 17 00:00:00 2001 From: Samuel Reichert Date: Mon, 20 Apr 2026 13:36:24 +0200 Subject: [PATCH 03/14] 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 9c210a45bed47bc900d1077926f05f6d38fa0378 Mon Sep 17 00:00:00 2001 From: Samuel Reichert Date: Mon, 20 Apr 2026 13:37:47 +0200 Subject: [PATCH 04/14] 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 6c0fd7ef14d5808541e94d2b87ca6d8a70f077d4 Mon Sep 17 00:00:00 2001 From: Samuel Reichert Date: Mon, 20 Apr 2026 13:40:43 +0200 Subject: [PATCH 05/14] 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 d5358dc58fd3816a988a840d5783d8d779db2d03 Mon Sep 17 00:00:00 2001 From: Samuel Reichert Date: Mon, 20 Apr 2026 13:42:08 +0200 Subject: [PATCH 06/14] 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 e4dc3d6e26aaeaeccb124e5db19987ad294cdf1f Mon Sep 17 00:00:00 2001 From: Samuel Reichert Date: Mon, 20 Apr 2026 13:43:38 +0200 Subject: [PATCH 07/14] 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 c07f3f65f110204637da3d29a5ed0d1f92a86acc Mon Sep 17 00:00:00 2001 From: Samuel Reichert Date: Mon, 20 Apr 2026 13:44:14 +0200 Subject: [PATCH 08/14] 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 d1e59233acb913022fb366f9d28c37d9de98f26a Mon Sep 17 00:00:00 2001 From: Samuel Reichert Date: Mon, 20 Apr 2026 14:11:31 +0200 Subject: [PATCH 09/14] 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 e4022203ba224a136f1e35e872c0aa515303dbfc Mon Sep 17 00:00:00 2001 From: Samuel Reichert Date: Fri, 1 May 2026 11:24:11 +0200 Subject: [PATCH 10/14] 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 50efa7a018e735356c78075551a4ee72fd4df4cd Mon Sep 17 00:00:00 2001 From: Samuel Reichert Date: Tue, 12 May 2026 15:44:33 +0200 Subject: [PATCH 11/14] 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); From 1fde655312c3f0955babb7f0262aae47bfc0b313 Mon Sep 17 00:00:00 2001 From: Samuel Reichert Date: Tue, 19 May 2026 14:57:41 +0200 Subject: [PATCH 12/14] fix(datagrid-web): harden whitespace guard and locale-tag stripping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Number(" ") === 0, so the old value !== "" check silently exported whitespace strings as 0. Use value.trim() !== "" instead. hasTimeComponent matched "S" in locale tags like [$-en-US], causing date-only formats to incorrectly retain a time component. Strip bracket-delimited tokens before testing. Also documents that Mendix numeric attributes are always Big — the plain-number branch was not accidentally omitted. Co-Authored-By: Claude Sonnet 4.6 --- .../datagrid-web/src/features/data-export/cell-readers.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 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 eb763c8849..12f5bb14c5 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 @@ -86,7 +86,9 @@ export function excelBoolean(value: boolean): ExcelCell { } function hasTimeComponent(format: string): boolean { - return /[hs]/i.test(format); + // Strip locale tags like [$-en-US] before checking — "S" in locale codes would otherwise match. + const stripped = format.replace(/\[.*?\]/g, ""); + return /[hs]/i.test(stripped); } function stripTime(date: Date): Date { @@ -130,6 +132,7 @@ const readers: ReadersByType = { return excelBoolean(value); } + // Mendix numeric attributes always surface as Big; plain JS number is not expected here. if (value instanceof Big) { if (countSignificantDigits(value) > MAX_SAFE_SIGNIFICANT_DIGITS) { return excelString(value.toFixed(), format); @@ -161,7 +164,7 @@ const readers: ReadersByType = { exportNumberFormat: props.exportNumberFormat }); - if (props.exportType === "number" && value !== "") { + if (props.exportType === "number" && value.trim() !== "") { const parsed = Number(value); if (!Number.isNaN(parsed)) { return excelNumber(parsed, format); From 2d9c271d84a791f4a1f292eddd3d83aa6df07408 Mon Sep 17 00:00:00 2001 From: Samuel Reichert Date: Tue, 19 May 2026 14:57:48 +0200 Subject: [PATCH 13/14] test(datagrid-web): cover whitespace number fallback and unavailable dynamicText Co-Authored-By: Claude Sonnet 4.6 --- .../__tests__/cell-readers.spec.ts | 21 +++++++++++++++++++ 1 file changed, 21 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 98ebc20c45..5b8e15ff10 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 @@ -121,6 +121,16 @@ describe("cell-readers", () => { const cell = readSingleCell(col); expect(cell).toEqual({ t: "s", v: "" }); }); + + it("returns n/a cell when dynamicText is unavailable", () => { + const col = column("Label", c => { + c.showContentAs = "dynamicText"; + c.dynamicText = listExpression(() => "text", "unavailable"); + }); + const cell = readSingleCell(col); + expect(cell.t).toBe("s"); + expect(cell.v).toBe("n/a"); + }); }); describe("customContent reader", () => { @@ -192,6 +202,17 @@ describe("cell-readers", () => { expect(cell.v).toBe(""); }); + it("falls back to string for whitespace-only value with number exportType", () => { + const col = column("Ws", 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"; From e9df53e1bbdfc588ddb2fcbdf3e814064d32bea9 Mon Sep 17 00:00:00 2001 From: Samuel Reichert Date: Tue, 19 May 2026 14:57:53 +0200 Subject: [PATCH 14/14] =?UTF-8?q?docs(datagrid-web):=20reorder=20CHANGELOG?= =?UTF-8?q?=20to=20Added=20=E2=86=92=20Fixed=20per=20Keep=20a=20Changelog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- packages/pluggableWidgets/datagrid-web/CHANGELOG.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/pluggableWidgets/datagrid-web/CHANGELOG.md b/packages/pluggableWidgets/datagrid-web/CHANGELOG.md index 579dc5b3d5..d886960772 100644 --- a/packages/pluggableWidgets/datagrid-web/CHANGELOG.md +++ b/packages/pluggableWidgets/datagrid-web/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] +### 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. + ### 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. @@ -16,10 +20,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - 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. - ## [3.9.0] - 2026-03-23 ### Changed