From 8afba9f89ccf39fd46db21af135c70828de27910 Mon Sep 17 00:00:00 2001 From: Samuel Reichert Date: Mon, 20 Apr 2026 13:17:46 +0200 Subject: [PATCH 01/20] 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 9ddac3a9229e340da763c8e05c060bc92342f641 Mon Sep 17 00:00:00 2001 From: Samuel Reichert Date: Mon, 20 Apr 2026 13:34:05 +0200 Subject: [PATCH 02/20] 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 79bbd2649fb6abc9c8358fba0744dbdcaae4b70d Mon Sep 17 00:00:00 2001 From: Samuel Reichert Date: Mon, 20 Apr 2026 13:36:24 +0200 Subject: [PATCH 03/20] 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 79898a2abc7ba0e13127e29ec9c8eb7379a10194 Mon Sep 17 00:00:00 2001 From: Samuel Reichert Date: Mon, 20 Apr 2026 13:37:47 +0200 Subject: [PATCH 04/20] 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 2c577510e549ed669a1f674257cf68d3b642f81d Mon Sep 17 00:00:00 2001 From: Samuel Reichert Date: Mon, 20 Apr 2026 13:40:43 +0200 Subject: [PATCH 05/20] 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 02537208aa946c0d8f8f78f44d8add8632a52e2e Mon Sep 17 00:00:00 2001 From: Samuel Reichert Date: Mon, 20 Apr 2026 13:42:08 +0200 Subject: [PATCH 06/20] 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 51c749968686892f8ad3bf904a450f5d214a9d83 Mon Sep 17 00:00:00 2001 From: Samuel Reichert Date: Mon, 20 Apr 2026 13:43:38 +0200 Subject: [PATCH 07/20] 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 7cb0d25067748b0bce52600c86c7abfbf6958c3a Mon Sep 17 00:00:00 2001 From: Samuel Reichert Date: Mon, 20 Apr 2026 13:44:14 +0200 Subject: [PATCH 08/20] 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 9ad253ede0..2b75b9051d 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 ee0d82892eaa9730344fc5a56eb9c01d2036daeb Mon Sep 17 00:00:00 2001 From: Samuel Reichert Date: Mon, 20 Apr 2026 14:11:31 +0200 Subject: [PATCH 09/20] 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 a60107d42b954bd0a07bb7cff3ab606c91115142 Mon Sep 17 00:00:00 2001 From: Samuel Reichert Date: Fri, 1 May 2026 11:24:11 +0200 Subject: [PATCH 10/20] 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 6ae2aea5865a75ed543233cff2d57add91bc6535 Mon Sep 17 00:00:00 2001 From: Samuel Reichert Date: Tue, 12 May 2026 15:44:33 +0200 Subject: [PATCH 11/20] 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 9724db32164cfa2490f664866b48d89db72810d0 Mon Sep 17 00:00:00 2001 From: Samuel Reichert Date: Tue, 19 May 2026 14:57:41 +0200 Subject: [PATCH 12/20] 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 45c6730955646ba653617aade68d7ad37fddc618 Mon Sep 17 00:00:00 2001 From: Samuel Reichert Date: Tue, 19 May 2026 14:57:48 +0200 Subject: [PATCH 13/20] 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 1d0c4b200ac150c98e2bcb294e21d6eee28534cb Mon Sep 17 00:00:00 2001 From: Samuel Reichert Date: Tue, 19 May 2026 14:57:53 +0200 Subject: [PATCH 14/20] =?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 | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/pluggableWidgets/datagrid-web/CHANGELOG.md b/packages/pluggableWidgets/datagrid-web/CHANGELOG.md index 2b75b9051d..ca31f64682 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,12 +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. - -### Fixed - - We fixed an issue where the vertical scrollbar disappeared after hiding a wide column while virtual scrolling was enabled. - We fixed an issue where only the first page loaded when the grid had enough columns to require horizontal scrolling. From 6757af1f8abfa500a0fa98e8d8d23cc1c67a3402 Mon Sep 17 00:00:00 2001 From: Samuel Reichert Date: Thu, 21 May 2026 16:27:58 +0200 Subject: [PATCH 15/20] fix(datagrid-web): export booleans as native cells and dates with locale format --- .../datagrid-web/CHANGELOG.md | 2 +- .../__tests__/cell-readers.spec.ts | 93 ++++++++++++++++--- .../src/features/data-export/cell-readers.ts | 57 +++++++----- .../datagrid-web/typings/DatagridProps.d.ts | 2 +- .../datagrid-web/typings/global.d.ts | 21 +++++ 5 files changed, 136 insertions(+), 39 deletions(-) create mode 100644 packages/pluggableWidgets/datagrid-web/typings/global.d.ts diff --git a/packages/pluggableWidgets/datagrid-web/CHANGELOG.md b/packages/pluggableWidgets/datagrid-web/CHANGELOG.md index ca31f64682..178fb4a63d 100644 --- a/packages/pluggableWidgets/datagrid-web/CHANGELOG.md +++ b/packages/pluggableWidgets/datagrid-web/CHANGELOG.md @@ -16,7 +16,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - 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 boolean values were not exported as proper Excel boolean cells. Both attribute and custom content columns now export as native booleans (TRUE/FALSE) recognized by Excel. - 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. 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 5b8e15ff10..73f8c2ef1f 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 @@ -1,6 +1,8 @@ -import Big from "big.js"; -import { listAttribute, listExpression, dynamic, obj } from "@mendix/widget-plugin-test-utils"; +jest.mock("mendix", () => ({}), { virtual: true }); + +import { Big } from "big.js"; import { ObjectItem } from "mendix"; +import { listAttribute, listExpression, dynamic, obj } from "@mendix/widget-plugin-test-utils"; import { column } from "../../../utils/test-utils"; import { readChunk, ExcelCell } from "../cell-readers"; @@ -46,24 +48,24 @@ describe("cell-readers", () => { expect(cell.z).toBe("#,##0.00"); }); - it("exports boolean attribute as Yes/No string cell", () => { + 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("s"); - expect(cell.v).toBe("Yes"); + expect(cell.t).toBe("b"); + expect(cell.v).toBe(true); }); - it("exports false boolean attribute as No", () => { + it("exports false boolean attribute as boolean cell", () => { 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"); + expect(cell.t).toBe("b"); + expect(cell.v).toBe(false); }); it("exports date attribute with format as date cell", () => { @@ -80,7 +82,7 @@ describe("cell-readers", () => { expect(cell.z).toBe("yyyy-mm-dd"); }); - it("exports date attribute without format as string cell (displayValue)", () => { + it("exports date attribute without format using default date format", () => { const testDate = new Date("2024-06-15T10:30:00Z"); const col = column("Created", c => { c.showContentAs = "attribute"; @@ -88,8 +90,9 @@ describe("cell-readers", () => { c.exportType = "default"; }); const cell = readSingleCell(col); - expect(cell.t).toBe("s"); - expect(cell.v).toBe(`Formatted ${testDate}`); + expect(cell.t).toBe("d"); + expect(cell.v).toEqual(new Date(Date.UTC(2024, 5, 15))); + expect(cell.z).toBe("dd-mm-yyyy"); }); it("returns empty cell when attribute is not available", () => { @@ -125,7 +128,7 @@ describe("cell-readers", () => { it("returns n/a cell when dynamicText is unavailable", () => { const col = column("Label", c => { c.showContentAs = "dynamicText"; - c.dynamicText = listExpression(() => "text", "unavailable"); + c.dynamicText = { get: () => ({ status: "unavailable", value: undefined }) } as any; }); const cell = readSingleCell(col); expect(cell.t).toBe("s"); @@ -226,15 +229,16 @@ describe("cell-readers", () => { expect(cell.z).toBe("yyyy-mm-dd"); }); - it("exports date as string when no format provided", () => { + it("exports date with default format 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"); + expect(cell.t).toBe("d"); + expect(cell.v).toEqual(new Date(Date.UTC(2024, 5, 15))); + expect(cell.z).toBe("dd-mm-yyyy"); }); it("falls back to string when date parse fails", () => { @@ -260,6 +264,65 @@ describe("cell-readers", () => { expect(cell.t).toBe("s"); expect(cell.v).toBe(""); }); + + it("exports as boolean true when exportType is boolean and value is 'true'", () => { + const col = column("Active", c => { + c.showContentAs = "customContent"; + c.exportValue = listExpression(() => "true"); + c.exportType = "boolean"; + }); + const cell = readSingleCell(col); + expect(cell.t).toBe("b"); + expect(cell.v).toBe(true); + }); + + it("exports as boolean false when exportType is boolean and value is 'false'", () => { + const col = column("Active", c => { + c.showContentAs = "customContent"; + c.exportValue = listExpression(() => "false"); + c.exportType = "boolean"; + }); + const cell = readSingleCell(col); + expect(cell.t).toBe("b"); + expect(cell.v).toBe(false); + }); + + it("exports boolean true for case-insensitive values", () => { + for (const val of ["True", "YES", "1"]) { + const col = column("Active", c => { + c.showContentAs = "customContent"; + c.exportValue = listExpression(() => val); + c.exportType = "boolean"; + }); + const cell = readSingleCell(col); + expect(cell.t).toBe("b"); + expect(cell.v).toBe(true); + } + }); + + it("exports boolean false for case-insensitive values", () => { + for (const val of ["False", "NO", "0"]) { + const col = column("Active", c => { + c.showContentAs = "customContent"; + c.exportValue = listExpression(() => val); + c.exportType = "boolean"; + }); + const cell = readSingleCell(col); + expect(cell.t).toBe("b"); + expect(cell.v).toBe(false); + } + }); + + it("falls back to string for unrecognized boolean value", () => { + const col = column("Active", c => { + c.showContentAs = "customContent"; + c.exportValue = listExpression(() => "maybe"); + c.exportType = "boolean"; + }); + const cell = readSingleCell(col); + expect(cell.t).toBe("s"); + expect(cell.v).toBe("maybe"); + }); }); describe("long number precision", () => { 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 12f5bb14c5..5a81fa3703 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 @@ -1,13 +1,13 @@ -import Big from "big.js"; +import { Big } from "big.js"; import { DynamicValue, ObjectItem } from "mendix"; import { ColumnsType, ShowContentAsEnum } from "../../../typings/DatagridProps"; /** Represents a single Excel cell (SheetJS compatible) */ export interface ExcelCell { - /** Cell type: 's' = string, 'n' = number, 'd' = date */ - t: "s" | "n" | "d"; + /** Cell type: 's' = string, 'n' = number, 'b' = boolean, 'd' = date */ + t: "s" | "n" | "b" | "d"; /** Underlying value */ - v: string | number | Date; + 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 */ @@ -68,20 +68,28 @@ export function excelString(value: string, format?: string): ExcelCell { }; } -export function excelDate(value: string): ExcelCell; -export function excelDate(value: Date, format: string): ExcelCell; -export function excelDate(value: string | Date, format?: string): ExcelCell { +const FALLBACK_DATE_FORMAT = "dd-mm-yyyy"; + +function getDefaultDateFormat(): string { + const pattern = window.mx?.session.getConfig().locale.patterns.date; + if (!pattern) { + return FALLBACK_DATE_FORMAT; + } + return pattern.replace(/M/g, "m"); +} + +export function excelDate(value: Date, format?: string): ExcelCell { return { - t: value instanceof Date && format !== undefined ? "d" : "s", + t: "d", v: value, - z: format + z: format ?? getDefaultDateFormat() }; } export function excelBoolean(value: boolean): ExcelCell { return { - t: "s", - v: value ? "Yes" : "No" + t: "b", + v: value }; } @@ -121,10 +129,7 @@ const readers: ReadersByType = { }); if (value instanceof Date) { - if (format === undefined) { - return excelDate(data.displayValue); - } - const dateValue = hasTimeComponent(format) ? value : stripTime(value); + const dateValue = format && hasTimeComponent(format) ? value : stripTime(value); return excelDate(dateValue, format); } @@ -158,30 +163,38 @@ const readers: ReadersByType = { customContent(item, props) { const value = props.exportValue?.get(item).value ?? ""; + const { exportType } = props; const format = getCellFormat({ - exportType: props.exportType, + exportType, exportDateFormat: props.exportDateFormat, exportNumberFormat: props.exportNumberFormat }); - if (props.exportType === "number" && value.trim() !== "") { + if (exportType === "number" && value.trim() !== "") { const parsed = Number(value); if (!Number.isNaN(parsed)) { return excelNumber(parsed, format); } } - if (props.exportType === "date" && value !== "") { + if (exportType === "date" && value !== "") { const parsed = new Date(value); if (!isNaN(parsed.getTime())) { - if (format === undefined) { - return excelDate(value); - } - const dateValue = hasTimeComponent(format) ? parsed : stripTime(parsed); + const dateValue = format && hasTimeComponent(format) ? parsed : stripTime(parsed); return excelDate(dateValue, format); } } + if (exportType === "boolean") { + const lower = value.trim().toLowerCase(); + if (lower === "true" || lower === "yes" || lower === "1") { + return excelBoolean(true); + } + if (lower === "false" || lower === "no" || lower === "0") { + return excelBoolean(false); + } + } + return excelString(value, format); } }; diff --git a/packages/pluggableWidgets/datagrid-web/typings/DatagridProps.d.ts b/packages/pluggableWidgets/datagrid-web/typings/DatagridProps.d.ts index c1da56eccf..d1a3761b3c 100644 --- a/packages/pluggableWidgets/datagrid-web/typings/DatagridProps.d.ts +++ b/packages/pluggableWidgets/datagrid-web/typings/DatagridProps.d.ts @@ -3,8 +3,8 @@ * WARNING: All changes made to this file will be overwritten * @author Mendix Widgets Framework Team */ +import { ActionValue, DynamicValue, EditableValue, ListActionValue, ListAttributeListValue, ListAttributeValue, ListExpressionValue, ListValue, ListWidgetValue, SelectionMultiValue, SelectionSingleValue } from "mendix"; import { ComponentType, CSSProperties, ReactNode } from "react"; -import { ActionValue, DynamicValue, EditableValue, ListValue, ListActionValue, ListAttributeValue, ListAttributeListValue, ListExpressionValue, ListWidgetValue, SelectionSingleValue, SelectionMultiValue } from "mendix"; import { Big } from "big.js"; export type ShowContentAsEnum = "attribute" | "dynamicText" | "customContent"; diff --git a/packages/pluggableWidgets/datagrid-web/typings/global.d.ts b/packages/pluggableWidgets/datagrid-web/typings/global.d.ts new file mode 100644 index 0000000000..d9c5fc4d9a --- /dev/null +++ b/packages/pluggableWidgets/datagrid-web/typings/global.d.ts @@ -0,0 +1,21 @@ +interface MXSessionLocale { + patterns: { + date: string; + datetime: string; + time: string; + }; +} + +interface MXGlobalObject { + session: { + getConfig(): { locale: MXSessionLocale }; + }; +} + +declare global { + interface Window { + mx?: MXGlobalObject; + } +} + +export {}; From d8f65756fa20dcdc41bf3433e7039d0fb00acff6 Mon Sep 17 00:00:00 2001 From: Samuel Reichert Date: Fri, 22 May 2026 09:35:23 +0200 Subject: [PATCH 16/20] fix(datagrid-web): export birth year as string in Excel output --- packages/pluggableWidgets/datagrid-web/e2e/DataGrid.spec.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/pluggableWidgets/datagrid-web/e2e/DataGrid.spec.js b/packages/pluggableWidgets/datagrid-web/e2e/DataGrid.spec.js index ab93e490c0..83acffebf8 100644 --- a/packages/pluggableWidgets/datagrid-web/e2e/DataGrid.spec.js +++ b/packages/pluggableWidgets/datagrid-web/e2e/DataGrid.spec.js @@ -20,20 +20,20 @@ test.describe("datagrid-web export to Excel", () => { // Read file and convert to JSON. const workbook = XLSX.readFile("./e2e/downloads/testFilename.xlsx"); const worksheet = workbook.Sheets[workbook.SheetNames[0]]; - const jsonData = XLSX.utils.sheet_to_json(worksheet); + const jsonData = XLSX.utils.sheet_to_json(worksheet, { raw: false }); expect(jsonData).toHaveLength(50); expect(jsonData[0]).toEqual({ "Birth date": "2/15/1983", - "Birth year": 1983, + "Birth year": "1983", "Color (enum)": "Black", "First name": "Loretta" }); expect(jsonData[1]).toEqual({ "Birth date": "9/30/1970", - "Birth year": 1970, + "Birth year": "1970", "Color (enum)": "Red", "First name": "Chad" }); From ccc7df93359c9849fa217ed7def52ccd9a935188 Mon Sep 17 00:00:00 2001 From: Samuel Reichert Date: Fri, 22 May 2026 13:48:47 +0200 Subject: [PATCH 17/20] fix(datagrid-web): guard customContent reader on status and drop z from string fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Check exportValue status before reading .value to avoid silent empty-string on loading/unavailable. Drop format arg from fallback excelString — SheetJS ignores z on t:"s" cells so passing it was misleading. Co-Authored-By: Claude Sonnet 4.6 --- .../datagrid-web/src/features/data-export/cell-readers.ts | 8 ++++++-- 1 file changed, 6 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 5a81fa3703..c1227592b9 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 @@ -162,7 +162,11 @@ const readers: ReadersByType = { }, customContent(item, props) { - const value = props.exportValue?.get(item).value ?? ""; + const raw = props.exportValue?.get(item); + if (!raw || raw.status !== "available") { + return makeEmptyCell(); + } + const value = raw.value ?? ""; const { exportType } = props; const format = getCellFormat({ exportType, @@ -195,7 +199,7 @@ const readers: ReadersByType = { } } - return excelString(value, format); + return excelString(value); } }; From 73d5735793bac1eaf98dbcc0fa3707b86204ef48 Mon Sep 17 00:00:00 2001 From: Samuel Reichert Date: Fri, 22 May 2026 13:48:57 +0200 Subject: [PATCH 18/20] test(datagrid-web): assert Birth year is a numeric cell, not a string Split sheet_to_json into raw:true and raw:false passes. The raw pass verifies t:"n" cell type (the core bug fix); the formatted pass verifies display values. Co-Authored-By: Claude Sonnet 4.6 --- .../datagrid-web/e2e/DataGrid.spec.js | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/packages/pluggableWidgets/datagrid-web/e2e/DataGrid.spec.js b/packages/pluggableWidgets/datagrid-web/e2e/DataGrid.spec.js index 83acffebf8..8caa1f8bad 100644 --- a/packages/pluggableWidgets/datagrid-web/e2e/DataGrid.spec.js +++ b/packages/pluggableWidgets/datagrid-web/e2e/DataGrid.spec.js @@ -20,18 +20,26 @@ test.describe("datagrid-web export to Excel", () => { // Read file and convert to JSON. const workbook = XLSX.readFile("./e2e/downloads/testFilename.xlsx"); const worksheet = workbook.Sheets[workbook.SheetNames[0]]; - const jsonData = XLSX.utils.sheet_to_json(worksheet, { raw: false }); + const rawData = XLSX.utils.sheet_to_json(worksheet, { raw: true }); + const formattedData = XLSX.utils.sheet_to_json(worksheet, { raw: false }); - expect(jsonData).toHaveLength(50); + expect(rawData).toHaveLength(50); - expect(jsonData[0]).toEqual({ + // Verify raw cell types — numbers must be t:"n", not t:"s" + expect(rawData[0]["Birth year"]).toBe(1983); + expect(typeof rawData[0]["Birth year"]).toBe("number"); + expect(rawData[1]["Birth year"]).toBe(1970); + expect(typeof rawData[1]["Birth year"]).toBe("number"); + + // Verify formatted display values + expect(formattedData[0]).toEqual({ "Birth date": "2/15/1983", "Birth year": "1983", "Color (enum)": "Black", "First name": "Loretta" }); - expect(jsonData[1]).toEqual({ + expect(formattedData[1]).toEqual({ "Birth date": "9/30/1970", "Birth year": "1970", "Color (enum)": "Red", From a37633512641114a3d0fff7d5c0e38dbdaf8872a Mon Sep 17 00:00:00 2001 From: Samuel Reichert Date: Fri, 22 May 2026 17:13:14 +0200 Subject: [PATCH 19/20] fix(line-chart-web): ensure elements are scrolled into view before visibility checks --- .../pluggableWidgets/line-chart-web/e2e/LineChart.spec.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/pluggableWidgets/line-chart-web/e2e/LineChart.spec.js b/packages/pluggableWidgets/line-chart-web/e2e/LineChart.spec.js index 0dd4e57447..f98891d1c3 100644 --- a/packages/pluggableWidgets/line-chart-web/e2e/LineChart.spec.js +++ b/packages/pluggableWidgets/line-chart-web/e2e/LineChart.spec.js @@ -1,15 +1,14 @@ import { test, expect } from "@mendix/run-e2e/fixtures"; -import { waitForMendixApp } from "@mendix/run-e2e/mendix-helpers"; test.describe("line-chart-web", () => { test.beforeEach(async ({ page }) => { await page.goto("/"); - await waitForMendixApp(page); }); test.describe("line style", () => { test("renders basic line chart and compares with a screenshot baseline", async ({ page }) => { const basicLineChartElement = await page.locator(".mx-name-containerBasic"); + await basicLineChartElement.scrollIntoViewIfNeeded(); await expect(basicLineChartElement).toBeVisible(); await expect( page.locator(".mx-name-containerBasic > .widget-chart > .mx-react-plotly-chart") @@ -137,6 +136,9 @@ test.describe("line-chart-web", () => { const dimensionPixelsElement = await page.locator(".mx-name-containerDimensionPixels"); await dimensionPixelsElement.scrollIntoViewIfNeeded(); await expect(dimensionPixelsElement).toBeVisible(); + await expect( + page.locator(".mx-name-containerDimensionPixels > .widget-chart > .mx-react-plotly-chart") + ).toBeVisible(); await expect(dimensionPixelsElement).toHaveScreenshot(`lineChartDimensionPixels.png`); }); From b7255d4fc080fc2db2082e5ea46e13eaa02ae67f Mon Sep 17 00:00:00 2001 From: Samuel Reichert Date: Fri, 22 May 2026 17:14:01 +0200 Subject: [PATCH 20/20] fix(video-player-web): ensure video widget is scrolled into view and wait for data readiness --- .../video-player-web/e2e/VideoPlayer.spec.js | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/packages/pluggableWidgets/video-player-web/e2e/VideoPlayer.spec.js b/packages/pluggableWidgets/video-player-web/e2e/VideoPlayer.spec.js index 5d593e4e21..8ba37034a7 100644 --- a/packages/pluggableWidgets/video-player-web/e2e/VideoPlayer.spec.js +++ b/packages/pluggableWidgets/video-player-web/e2e/VideoPlayer.spec.js @@ -1,4 +1,5 @@ import { test, expect } from "@mendix/run-e2e/fixtures"; +import { waitForDataReady } from "@mendix/run-e2e/mendix-helpers"; test.describe("Video Player", () => { test.beforeEach(async ({ page }) => { @@ -108,18 +109,10 @@ test.describe("External video", () => { test("renders a poster", async ({ page }) => { const widget = page.locator(".widget-video-player"); const videoLocator = page.locator(".widget-video-player video"); + await widget.scrollIntoViewIfNeeded(); await expect(widget).toBeVisible(); await expect(videoLocator).toHaveAttribute("poster", /.+/); - const posterUrl = await videoLocator.getAttribute("poster"); - await page.evaluate(url => { - return new Promise(resolve => { - const img = new Image(); - img.onload = () => resolve(undefined); - img.onerror = () => resolve(undefined); - img.src = url; - if (img.complete && img.naturalWidth !== 0) resolve(undefined); - }); - }, posterUrl); + await waitForDataReady(page); await expect(widget).toHaveScreenshot("videoPlayerExternalPoster.png"); });