From 98fb98b8b3fbed955b5b5cacdf54d4d503aab7a5 Mon Sep 17 00:00:00 2001 From: Samuel Reichert Date: Mon, 20 Apr 2026 13:17:46 +0200 Subject: [PATCH 01/10] refactor: extract cell readers from DSExportRequest into separate module Co-Authored-By: Claude Sonnet 4.6 --- .../features/data-export/DSExportRequest.ts | 157 +---------------- .../src/features/data-export/cell-readers.ts | 158 ++++++++++++++++++ 2 files changed, 161 insertions(+), 154 deletions(-) create mode 100644 packages/pluggableWidgets/datagrid-web/src/features/data-export/cell-readers.ts diff --git a/packages/pluggableWidgets/datagrid-web/src/features/data-export/DSExportRequest.ts b/packages/pluggableWidgets/datagrid-web/src/features/data-export/DSExportRequest.ts index a1f9e9ca4f..9932db5eaf 100644 --- a/packages/pluggableWidgets/datagrid-web/src/features/data-export/DSExportRequest.ts +++ b/packages/pluggableWidgets/datagrid-web/src/features/data-export/DSExportRequest.ts @@ -1,33 +1,8 @@ import { isAvailable } from "@mendix/widget-plugin-platform/framework/is-available"; -import Big from "big.js"; -import { DynamicValue, ListValue, ObjectItem, ValueStatus } from "mendix"; +import { ListValue, ObjectItem, ValueStatus } from "mendix"; import { createNanoEvents, Emitter, Unsubscribe } from "nanoevents"; -import { ColumnsType, ShowContentAsEnum } from "../../../typings/DatagridProps"; - -/** Represents a single Excel cell (SheetJS compatible) */ -interface ExcelCell { - /** Cell type: 's' = string, 'n' = number, 'b' = boolean, 'd' = date */ - t: "s" | "n" | "b" | "d"; - /** Underlying value */ - v: string | number | boolean | Date; - /** Optional Excel number/date format, e.g. "yyyy-mm-dd" or "$0.00" */ - z?: string; - /** Optional pre-formatted display text */ - w?: string; -} - -type RowData = ExcelCell[]; - -type HeaderDefinition = { - name: string; - type: string; -}; - -type ValueReader = (item: ObjectItem, props: ColumnsType) => ExcelCell; - -type ReadersByType = Record; - -type RowReader = (item: ObjectItem) => RowData; +import { ColumnsType } from "../../../typings/DatagridProps"; +import { HeaderDefinition, RowData, readChunk } from "./cell-readers"; type ColumnReader = (props: ColumnsType) => HeaderDefinition; @@ -262,132 +237,6 @@ export class DSExportRequest { } } -const readers: ReadersByType = { - attribute(item, props) { - const data = props.attribute?.get(item); - - if (data?.status !== "available") { - return makeEmptyCell(); - } - - const value = data.value; - const format = getCellFormat({ - exportType: props.exportType, - exportDateFormat: props.exportDateFormat, - exportNumberFormat: props.exportNumberFormat - }); - - if (value instanceof Date) { - return excelDate(format === undefined ? data.displayValue : value, format); - } - - if (typeof value === "boolean") { - return excelBoolean(value); - } - - if (value instanceof Big || typeof value === "number") { - const num = value instanceof Big ? value.toNumber() : value; - return excelNumber(num, format); - } - - return excelString(data.displayValue ?? ""); - }, - - dynamicText(item, props) { - const data = props.dynamicText?.get(item); - - switch (data?.status) { - case "available": - const format = getCellFormat({ - exportType: props.exportType, - exportDateFormat: props.exportDateFormat, - exportNumberFormat: props.exportNumberFormat - }); - - return excelString(data.value ?? "", format); - case "unavailable": - return excelString("n/a"); - default: - return makeEmptyCell(); - } - }, - - customContent(item, props) { - const value = props.exportValue?.get(item).value ?? ""; - const format = getCellFormat({ - exportType: props.exportType, - exportDateFormat: props.exportDateFormat, - exportNumberFormat: props.exportNumberFormat - }); - - return excelString(value, format); - } -}; - -function makeEmptyCell(): ExcelCell { - return { t: "s", v: "" }; -} - -function excelNumber(value: number, format?: string): ExcelCell { - return { - t: "n", - v: value, - z: format - }; -} - -function excelString(value: string, format?: string): ExcelCell { - return { - t: "s", - v: value, - z: format ?? undefined - }; -} - -function excelDate(value: string | Date, format?: string): ExcelCell { - return { - t: format === undefined ? "s" : "d", - v: value, - z: format - }; -} - -function excelBoolean(value: boolean): ExcelCell { - return { - t: "b", - v: value, - w: value ? "TRUE" : "FALSE" - }; -} - -interface DataExportProps { - exportType: "default" | "number" | "date" | "boolean"; - exportDateFormat?: DynamicValue; - exportNumberFormat?: DynamicValue; -} - -function getCellFormat({ exportType, exportDateFormat, exportNumberFormat }: DataExportProps): string | undefined { - switch (exportType) { - case "date": - return exportDateFormat?.status === "available" ? exportDateFormat.value : undefined; - case "number": - return exportNumberFormat?.status === "available" ? exportNumberFormat.value : undefined; - default: - return undefined; - } -} - -function createRowReader(columns: ColumnsType[]): RowReader { - return item => - columns.map(col => { - return readers[col.showContentAs](item, col); - }); -} - -function readChunk(data: ObjectItem[], columns: ColumnsType[]): RowData[] { - return data.map(createRowReader(columns)); -} - declare global { interface Window { scheduler: { diff --git a/packages/pluggableWidgets/datagrid-web/src/features/data-export/cell-readers.ts b/packages/pluggableWidgets/datagrid-web/src/features/data-export/cell-readers.ts new file mode 100644 index 0000000000..0aab231674 --- /dev/null +++ b/packages/pluggableWidgets/datagrid-web/src/features/data-export/cell-readers.ts @@ -0,0 +1,158 @@ +import Big from "big.js"; +import { DynamicValue, ObjectItem } from "mendix"; +import { ColumnsType, ShowContentAsEnum } from "../../../typings/DatagridProps"; + +/** Represents a single Excel cell (SheetJS compatible) */ +export interface ExcelCell { + /** Cell type: 's' = string, 'n' = number, 'b' = boolean, 'd' = date */ + t: "s" | "n" | "b" | "d"; + /** Underlying value */ + v: string | number | boolean | Date; + /** Optional Excel number/date format, e.g. "yyyy-mm-dd" or "$0.00" */ + z?: string; + /** Optional pre-formatted display text */ + w?: string; +} + +export type RowData = ExcelCell[]; + +export type HeaderDefinition = { + name: string; + type: string; +}; + +type ValueReader = (item: ObjectItem, props: ColumnsType) => ExcelCell; + +type ReadersByType = Record; + +type RowReader = (item: ObjectItem) => RowData; + +export interface DataExportProps { + exportType: "default" | "number" | "date" | "boolean"; + exportDateFormat?: DynamicValue; + exportNumberFormat?: DynamicValue; +} + +export function getCellFormat({ + exportType, + exportDateFormat, + exportNumberFormat +}: DataExportProps): string | undefined { + switch (exportType) { + case "date": + return exportDateFormat?.status === "available" ? exportDateFormat.value : undefined; + case "number": + return exportNumberFormat?.status === "available" ? exportNumberFormat.value : undefined; + default: + return undefined; + } +} + +export function makeEmptyCell(): ExcelCell { + return { t: "s", v: "" }; +} + +export function excelNumber(value: number, format?: string): ExcelCell { + return { + t: "n", + v: value, + z: format + }; +} + +export function excelString(value: string, format?: string): ExcelCell { + return { + t: "s", + v: value, + z: format ?? undefined + }; +} + +export function excelDate(value: string | Date, format?: string): ExcelCell { + return { + t: format === undefined ? "s" : "d", + v: value, + z: format + }; +} + +export function excelBoolean(value: boolean): ExcelCell { + return { + t: "b", + v: value, + w: value ? "TRUE" : "FALSE" + }; +} + +const readers: ReadersByType = { + attribute(item, props) { + const data = props.attribute?.get(item); + + if (data?.status !== "available") { + return makeEmptyCell(); + } + + const value = data.value; + const format = getCellFormat({ + exportType: props.exportType, + exportDateFormat: props.exportDateFormat, + exportNumberFormat: props.exportNumberFormat + }); + + if (value instanceof Date) { + return excelDate(format === undefined ? data.displayValue : value, format); + } + + if (typeof value === "boolean") { + return excelBoolean(value); + } + + if (value instanceof Big || typeof value === "number") { + const num = value instanceof Big ? value.toNumber() : value; + return excelNumber(num, format); + } + + return excelString(data.displayValue ?? ""); + }, + + dynamicText(item, props) { + const data = props.dynamicText?.get(item); + + switch (data?.status) { + case "available": + const format = getCellFormat({ + exportType: props.exportType, + exportDateFormat: props.exportDateFormat, + exportNumberFormat: props.exportNumberFormat + }); + + return excelString(data.value ?? "", format); + case "unavailable": + return excelString("n/a"); + default: + return makeEmptyCell(); + } + }, + + customContent(item, props) { + const value = props.exportValue?.get(item).value ?? ""; + const format = getCellFormat({ + exportType: props.exportType, + exportDateFormat: props.exportDateFormat, + exportNumberFormat: props.exportNumberFormat + }); + + return excelString(value, format); + } +}; + +function createRowReader(columns: ColumnsType[]): RowReader { + return item => + columns.map(col => { + return readers[col.showContentAs](item, col); + }); +} + +export function readChunk(data: ObjectItem[], columns: ColumnsType[]): RowData[] { + return data.map(createRowReader(columns)); +} From fb29e99d4a5ba2fe2fc1d6c6592f780c35f674dc Mon Sep 17 00:00:00 2001 From: Samuel Reichert Date: Mon, 20 Apr 2026 13:34:05 +0200 Subject: [PATCH 02/10] test: add baseline tests for cell reader export behavior Documents current behavior of attribute, dynamicText, and customContent readers before bug-fix changes are applied. Co-Authored-By: Claude Sonnet 4.6 --- .../__tests__/cell-readers.spec.ts | 137 ++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 packages/pluggableWidgets/datagrid-web/src/features/data-export/__tests__/cell-readers.spec.ts diff --git a/packages/pluggableWidgets/datagrid-web/src/features/data-export/__tests__/cell-readers.spec.ts b/packages/pluggableWidgets/datagrid-web/src/features/data-export/__tests__/cell-readers.spec.ts new file mode 100644 index 0000000000..34e814f932 --- /dev/null +++ b/packages/pluggableWidgets/datagrid-web/src/features/data-export/__tests__/cell-readers.spec.ts @@ -0,0 +1,137 @@ +import Big from "big.js"; +import { listAttribute, listExpression, dynamic, obj } from "@mendix/widget-plugin-test-utils"; +import { ObjectItem } from "mendix"; +import { column } from "../../../utils/test-utils"; +import { readChunk, ExcelCell } from "../cell-readers"; + +function readSingleCell(col: ReturnType, item?: ObjectItem): ExcelCell { + const items = [item ?? obj()]; + const result = readChunk(items, [col]); + return result[0][0]; +} + +describe("cell-readers", () => { + describe("attribute reader", () => { + it("exports string attribute as string cell (displayValue)", () => { + const col = column("Name", c => { + c.showContentAs = "attribute"; + c.attribute = listAttribute(() => "hello"); + }); + const cell = readSingleCell(col); + expect(cell.t).toBe("s"); + // attribute reader returns displayValue for strings, not raw value + expect(cell.v).toBe("Formatted hello"); + }); + + it("exports number attribute as number cell", () => { + const col = column("Amount", c => { + c.showContentAs = "attribute"; + c.attribute = listAttribute(() => new Big("42.5")); + }); + const cell = readSingleCell(col); + expect(cell.t).toBe("n"); + expect(cell.v).toBe(42.5); + }); + + it("exports number attribute with format", () => { + const col = column("Amount", c => { + c.showContentAs = "attribute"; + c.attribute = listAttribute(() => new Big("1234.56")); + c.exportType = "number"; + c.exportNumberFormat = dynamic("#,##0.00"); + }); + const cell = readSingleCell(col); + expect(cell.t).toBe("n"); + expect(cell.v).toBe(1234.56); + expect(cell.z).toBe("#,##0.00"); + }); + + it("exports boolean attribute as boolean cell", () => { + const col = column("Active", c => { + c.showContentAs = "attribute"; + c.attribute = listAttribute(() => true); + }); + const cell = readSingleCell(col); + expect(cell.t).toBe("b"); + expect(cell.v).toBe(true); + }); + + it("exports date attribute with format as date cell", () => { + const testDate = new Date("2024-06-15T10:30:00Z"); + const col = column("Created", c => { + c.showContentAs = "attribute"; + c.attribute = listAttribute(() => testDate); + c.exportType = "date"; + c.exportDateFormat = dynamic("yyyy-mm-dd"); + }); + const cell = readSingleCell(col); + expect(cell.t).toBe("d"); + expect(cell.v).toEqual(testDate); + expect(cell.z).toBe("yyyy-mm-dd"); + }); + + it("exports date attribute without format as string cell (displayValue)", () => { + const col = column("Created", c => { + c.showContentAs = "attribute"; + c.attribute = listAttribute(() => new Date("2024-06-15T10:30:00Z")); + c.exportType = "default"; + }); + const cell = readSingleCell(col); + expect(cell.t).toBe("s"); + }); + + it("returns empty cell when attribute is not available", () => { + const col = column("Missing", c => { + c.showContentAs = "attribute"; + c.attribute = undefined; + }); + const cell = readSingleCell(col); + expect(cell).toEqual({ t: "s", v: "" }); + }); + }); + + describe("dynamicText reader", () => { + it("exports dynamic text as string cell", () => { + const col = column("Label", c => { + c.showContentAs = "dynamicText"; + c.dynamicText = listExpression(() => "formatted text"); + }); + const cell = readSingleCell(col); + expect(cell.t).toBe("s"); + expect(cell.v).toBe("formatted text"); + }); + + it("exports n/a when unavailable", () => { + const col = column("Label", c => { + c.showContentAs = "dynamicText"; + c.dynamicText = undefined; + }); + const cell = readSingleCell(col); + expect(cell).toEqual({ t: "s", v: "" }); + }); + }); + + describe("customContent reader", () => { + it("exports custom content as string cell (current baseline)", () => { + const col = column("Custom", c => { + c.showContentAs = "customContent"; + c.exportValue = listExpression(() => "42.50"); + c.exportType = "default"; + }); + const cell = readSingleCell(col); + expect(cell.t).toBe("s"); + expect(cell.v).toBe("42.50"); + }); + + it("exports empty string when exportValue is undefined", () => { + const col = column("Custom", c => { + c.showContentAs = "customContent"; + c.exportValue = undefined; + c.exportType = "default"; + }); + const cell = readSingleCell(col); + expect(cell.t).toBe("s"); + expect(cell.v).toBe(""); + }); + }); +}); From 935d7ba06870c2308b2b128fa1a3fae3fa48cb3e Mon Sep 17 00:00:00 2001 From: Samuel Reichert Date: Mon, 20 Apr 2026 13:36:24 +0200 Subject: [PATCH 03/10] fix: export customContent columns as number cells when exportType is number Co-Authored-By: Claude Sonnet 4.6 --- .../__tests__/cell-readers.spec.ts | 46 +++++++++++++++++++ .../src/features/data-export/cell-readers.ts | 7 +++ 2 files changed, 53 insertions(+) diff --git a/packages/pluggableWidgets/datagrid-web/src/features/data-export/__tests__/cell-readers.spec.ts b/packages/pluggableWidgets/datagrid-web/src/features/data-export/__tests__/cell-readers.spec.ts index 34e814f932..34717f080c 100644 --- a/packages/pluggableWidgets/datagrid-web/src/features/data-export/__tests__/cell-readers.spec.ts +++ b/packages/pluggableWidgets/datagrid-web/src/features/data-export/__tests__/cell-readers.spec.ts @@ -133,5 +133,51 @@ describe("cell-readers", () => { expect(cell.t).toBe("s"); expect(cell.v).toBe(""); }); + + it("exports as number cell when exportType is number", () => { + const col = column("Price", c => { + c.showContentAs = "customContent"; + c.exportValue = listExpression(() => "1234.56"); + c.exportType = "number"; + c.exportNumberFormat = dynamic("#,##0.00"); + }); + const cell = readSingleCell(col); + expect(cell.t).toBe("n"); + expect(cell.v).toBe(1234.56); + expect(cell.z).toBe("#,##0.00"); + }); + + it("exports as number cell without format", () => { + const col = column("Count", c => { + c.showContentAs = "customContent"; + c.exportValue = listExpression(() => "99"); + c.exportType = "number"; + }); + const cell = readSingleCell(col); + expect(cell.t).toBe("n"); + expect(cell.v).toBe(99); + }); + + it("falls back to string when number parse fails", () => { + const col = column("Bad", c => { + c.showContentAs = "customContent"; + c.exportValue = listExpression(() => "not-a-number"); + c.exportType = "number"; + }); + const cell = readSingleCell(col); + expect(cell.t).toBe("s"); + expect(cell.v).toBe("not-a-number"); + }); + + it("falls back to string for empty value with number exportType", () => { + const col = column("Empty", c => { + c.showContentAs = "customContent"; + c.exportValue = listExpression(() => ""); + c.exportType = "number"; + }); + const cell = readSingleCell(col); + expect(cell.t).toBe("s"); + expect(cell.v).toBe(""); + }); }); }); diff --git a/packages/pluggableWidgets/datagrid-web/src/features/data-export/cell-readers.ts b/packages/pluggableWidgets/datagrid-web/src/features/data-export/cell-readers.ts index 0aab231674..0bebed4d1d 100644 --- a/packages/pluggableWidgets/datagrid-web/src/features/data-export/cell-readers.ts +++ b/packages/pluggableWidgets/datagrid-web/src/features/data-export/cell-readers.ts @@ -142,6 +142,13 @@ const readers: ReadersByType = { exportNumberFormat: props.exportNumberFormat }); + if (props.exportType === "number" && value !== "") { + const parsed = Number(value); + if (!Number.isNaN(parsed)) { + return excelNumber(parsed, format); + } + } + return excelString(value, format); } }; From fb0a702664c998fea759124475731955abeab1a5 Mon Sep 17 00:00:00 2001 From: Samuel Reichert Date: Mon, 20 Apr 2026 13:37:47 +0200 Subject: [PATCH 04/10] fix: export customContent columns as date cells when exportType is date Co-Authored-By: Claude Sonnet 4.6 --- .../__tests__/cell-readers.spec.ts | 48 +++++++++++++++++++ .../src/features/data-export/cell-readers.ts | 7 +++ 2 files changed, 55 insertions(+) diff --git a/packages/pluggableWidgets/datagrid-web/src/features/data-export/__tests__/cell-readers.spec.ts b/packages/pluggableWidgets/datagrid-web/src/features/data-export/__tests__/cell-readers.spec.ts index 34717f080c..0f24c185c5 100644 --- a/packages/pluggableWidgets/datagrid-web/src/features/data-export/__tests__/cell-readers.spec.ts +++ b/packages/pluggableWidgets/datagrid-web/src/features/data-export/__tests__/cell-readers.spec.ts @@ -179,5 +179,53 @@ describe("cell-readers", () => { expect(cell.t).toBe("s"); expect(cell.v).toBe(""); }); + + it("exports as date cell when exportType is date", () => { + const col = column("Created", c => { + c.showContentAs = "customContent"; + c.exportValue = listExpression(() => "2024-06-15T00:00:00.000Z"); + c.exportType = "date"; + c.exportDateFormat = dynamic("yyyy-mm-dd"); + }); + const cell = readSingleCell(col); + expect(cell.t).toBe("d"); + expect(cell.v).toEqual(new Date("2024-06-15T00:00:00.000Z")); + expect(cell.z).toBe("yyyy-mm-dd"); + }); + + it("exports date as string when no format provided", () => { + const col = column("Created", c => { + c.showContentAs = "customContent"; + c.exportValue = listExpression(() => "2024-06-15T10:30:00Z"); + c.exportType = "date"; + }); + const cell = readSingleCell(col); + expect(cell.t).toBe("s"); + expect(cell.v).toBe("2024-06-15T10:30:00Z"); + }); + + it("falls back to string when date parse fails", () => { + const col = column("Bad", c => { + c.showContentAs = "customContent"; + c.exportValue = listExpression(() => "not-a-date"); + c.exportType = "date"; + c.exportDateFormat = dynamic("yyyy-mm-dd"); + }); + const cell = readSingleCell(col); + expect(cell.t).toBe("s"); + expect(cell.v).toBe("not-a-date"); + }); + + it("falls back to string for empty value with date exportType", () => { + const col = column("Empty", c => { + c.showContentAs = "customContent"; + c.exportValue = listExpression(() => ""); + c.exportType = "date"; + c.exportDateFormat = dynamic("yyyy-mm-dd"); + }); + const cell = readSingleCell(col); + expect(cell.t).toBe("s"); + expect(cell.v).toBe(""); + }); }); }); diff --git a/packages/pluggableWidgets/datagrid-web/src/features/data-export/cell-readers.ts b/packages/pluggableWidgets/datagrid-web/src/features/data-export/cell-readers.ts index 0bebed4d1d..a5a1a3a77c 100644 --- a/packages/pluggableWidgets/datagrid-web/src/features/data-export/cell-readers.ts +++ b/packages/pluggableWidgets/datagrid-web/src/features/data-export/cell-readers.ts @@ -149,6 +149,13 @@ const readers: ReadersByType = { } } + if (props.exportType === "date" && value !== "") { + const parsed = new Date(value); + if (!isNaN(parsed.getTime())) { + return excelDate(format === undefined ? value : parsed, format); + } + } + return excelString(value, format); } }; From 1fa79e7d5ce1ed1403d2a2be49df3c15f76e1400 Mon Sep 17 00:00:00 2001 From: Samuel Reichert Date: Mon, 20 Apr 2026 13:40:43 +0200 Subject: [PATCH 05/10] fix: strip time component from exported dates when format is date-only Co-Authored-By: Claude Sonnet 4.6 --- .../__tests__/cell-readers.spec.ts | 55 ++++++++++++++++++- .../src/features/data-export/cell-readers.ts | 20 ++++++- 2 files changed, 72 insertions(+), 3 deletions(-) diff --git a/packages/pluggableWidgets/datagrid-web/src/features/data-export/__tests__/cell-readers.spec.ts b/packages/pluggableWidgets/datagrid-web/src/features/data-export/__tests__/cell-readers.spec.ts index 0f24c185c5..f21cbc7f2c 100644 --- a/packages/pluggableWidgets/datagrid-web/src/features/data-export/__tests__/cell-readers.spec.ts +++ b/packages/pluggableWidgets/datagrid-web/src/features/data-export/__tests__/cell-readers.spec.ts @@ -66,7 +66,7 @@ describe("cell-readers", () => { }); const cell = readSingleCell(col); expect(cell.t).toBe("d"); - expect(cell.v).toEqual(testDate); + expect(cell.v).toEqual(new Date(Date.UTC(2024, 5, 15))); expect(cell.z).toBe("yyyy-mm-dd"); }); @@ -228,4 +228,57 @@ describe("cell-readers", () => { expect(cell.v).toBe(""); }); }); + + describe("date time stripping", () => { + it("strips time from attribute date when format has no time components", () => { + const testDate = new Date("2024-06-15T10:30:00Z"); + const col = column("DateOnly", c => { + c.showContentAs = "attribute"; + c.attribute = listAttribute(() => testDate); + c.exportType = "date"; + c.exportDateFormat = dynamic("dd-mmm-yyyy"); + }); + const cell = readSingleCell(col); + expect(cell.t).toBe("d"); + expect(cell.v).toEqual(new Date(Date.UTC(2024, 5, 15))); + expect(cell.z).toBe("dd-mmm-yyyy"); + }); + + it("preserves time in attribute date when format has time components", () => { + const testDate = new Date("2024-06-15T10:30:00Z"); + const col = column("DateTime", c => { + c.showContentAs = "attribute"; + c.attribute = listAttribute(() => testDate); + c.exportType = "date"; + c.exportDateFormat = dynamic("yyyy-mm-dd hh:mm:ss"); + }); + const cell = readSingleCell(col); + expect(cell.t).toBe("d"); + expect(cell.v).toEqual(testDate); + }); + + it("strips time from customContent date when format has no time components", () => { + const col = column("DateOnly", c => { + c.showContentAs = "customContent"; + c.exportValue = listExpression(() => "2024-06-15T10:30:00Z"); + c.exportType = "date"; + c.exportDateFormat = dynamic("dd-mmm-yyyy"); + }); + const cell = readSingleCell(col); + expect(cell.t).toBe("d"); + expect(cell.v).toEqual(new Date(Date.UTC(2024, 5, 15))); + }); + + it("preserves time in customContent date when format has time components", () => { + const col = column("DateTime", c => { + c.showContentAs = "customContent"; + c.exportValue = listExpression(() => "2024-06-15T10:30:00Z"); + c.exportType = "date"; + c.exportDateFormat = dynamic("yyyy-mm-dd hh:mm:ss"); + }); + const cell = readSingleCell(col); + expect(cell.t).toBe("d"); + expect(cell.v).toEqual(new Date("2024-06-15T10:30:00Z")); + }); + }); }); diff --git a/packages/pluggableWidgets/datagrid-web/src/features/data-export/cell-readers.ts b/packages/pluggableWidgets/datagrid-web/src/features/data-export/cell-readers.ts index a5a1a3a77c..41d0af614d 100644 --- a/packages/pluggableWidgets/datagrid-web/src/features/data-export/cell-readers.ts +++ b/packages/pluggableWidgets/datagrid-web/src/features/data-export/cell-readers.ts @@ -84,6 +84,14 @@ export function excelBoolean(value: boolean): ExcelCell { }; } +function hasTimeComponent(format: string): boolean { + return /[hs]/i.test(format); +} + +function stripTime(date: Date): Date { + return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())); +} + const readers: ReadersByType = { attribute(item, props) { const data = props.attribute?.get(item); @@ -100,7 +108,11 @@ const readers: ReadersByType = { }); if (value instanceof Date) { - return excelDate(format === undefined ? data.displayValue : value, format); + if (format === undefined) { + return excelDate(data.displayValue, format); + } + const dateValue = hasTimeComponent(format) ? value : stripTime(value); + return excelDate(dateValue, format); } if (typeof value === "boolean") { @@ -152,7 +164,11 @@ const readers: ReadersByType = { if (props.exportType === "date" && value !== "") { const parsed = new Date(value); if (!isNaN(parsed.getTime())) { - return excelDate(format === undefined ? value : parsed, format); + if (format === undefined) { + return excelDate(value, format); + } + const dateValue = hasTimeComponent(format) ? parsed : stripTime(parsed); + return excelDate(dateValue, format); } } From a50bd49a7866a1325caa9e4f3c17d9b9318ed023 Mon Sep 17 00:00:00 2001 From: Samuel Reichert Date: Mon, 20 Apr 2026 13:42:08 +0200 Subject: [PATCH 06/10] fix: export boolean values as Yes/No strings instead of TRUE/FALSE Co-Authored-By: Claude Sonnet 4.6 --- .../data-export/__tests__/cell-readers.spec.ts | 16 +++++++++++++--- .../src/features/data-export/cell-readers.ts | 5 ++--- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/packages/pluggableWidgets/datagrid-web/src/features/data-export/__tests__/cell-readers.spec.ts b/packages/pluggableWidgets/datagrid-web/src/features/data-export/__tests__/cell-readers.spec.ts index f21cbc7f2c..3b5844bd11 100644 --- a/packages/pluggableWidgets/datagrid-web/src/features/data-export/__tests__/cell-readers.spec.ts +++ b/packages/pluggableWidgets/datagrid-web/src/features/data-export/__tests__/cell-readers.spec.ts @@ -46,14 +46,24 @@ describe("cell-readers", () => { expect(cell.z).toBe("#,##0.00"); }); - it("exports boolean attribute as boolean cell", () => { + it("exports boolean attribute as Yes/No string cell", () => { const col = column("Active", c => { c.showContentAs = "attribute"; c.attribute = listAttribute(() => true); }); const cell = readSingleCell(col); - expect(cell.t).toBe("b"); - expect(cell.v).toBe(true); + expect(cell.t).toBe("s"); + expect(cell.v).toBe("Yes"); + }); + + it("exports false boolean attribute as No", () => { + const col = column("Active", c => { + c.showContentAs = "attribute"; + c.attribute = listAttribute(() => false); + }); + const cell = readSingleCell(col); + expect(cell.t).toBe("s"); + expect(cell.v).toBe("No"); }); it("exports date attribute with format as date cell", () => { diff --git a/packages/pluggableWidgets/datagrid-web/src/features/data-export/cell-readers.ts b/packages/pluggableWidgets/datagrid-web/src/features/data-export/cell-readers.ts index 41d0af614d..65f675f1d6 100644 --- a/packages/pluggableWidgets/datagrid-web/src/features/data-export/cell-readers.ts +++ b/packages/pluggableWidgets/datagrid-web/src/features/data-export/cell-readers.ts @@ -78,9 +78,8 @@ export function excelDate(value: string | Date, format?: string): ExcelCell { export function excelBoolean(value: boolean): ExcelCell { return { - t: "b", - v: value, - w: value ? "TRUE" : "FALSE" + t: "s", + v: value ? "Yes" : "No" }; } From 47ffeae56a08103a7ddd698838bf4a2f048633f2 Mon Sep 17 00:00:00 2001 From: Samuel Reichert Date: Mon, 20 Apr 2026 13:43:38 +0200 Subject: [PATCH 07/10] fix: export large numbers as strings to preserve precision beyond 15 digits Co-Authored-By: Claude Sonnet 4.6 --- .../__tests__/cell-readers.spec.ts | 44 +++++++++++++++++++ .../src/features/data-export/cell-readers.ts | 13 ++++++ 2 files changed, 57 insertions(+) diff --git a/packages/pluggableWidgets/datagrid-web/src/features/data-export/__tests__/cell-readers.spec.ts b/packages/pluggableWidgets/datagrid-web/src/features/data-export/__tests__/cell-readers.spec.ts index 3b5844bd11..204ef5e04d 100644 --- a/packages/pluggableWidgets/datagrid-web/src/features/data-export/__tests__/cell-readers.spec.ts +++ b/packages/pluggableWidgets/datagrid-web/src/features/data-export/__tests__/cell-readers.spec.ts @@ -239,6 +239,50 @@ describe("cell-readers", () => { }); }); + describe("long number precision", () => { + it("exports Big with >15 significant digits as string to preserve precision", () => { + const col = column("LongId", c => { + c.showContentAs = "attribute"; + c.attribute = listAttribute(() => new Big("1234567890123456789")); + }); + const cell = readSingleCell(col); + expect(cell.t).toBe("s"); + expect(cell.v).toBe("1234567890123456789"); + }); + + it("exports Big with <=15 significant digits as number", () => { + const col = column("NormalNum", c => { + c.showContentAs = "attribute"; + c.attribute = listAttribute(() => new Big("123456789012345")); + }); + const cell = readSingleCell(col); + expect(cell.t).toBe("n"); + expect(cell.v).toBe(123456789012345); + }); + + it("exports Big with >15 digits and format as string with format", () => { + const col = column("LongFormatted", c => { + c.showContentAs = "attribute"; + c.attribute = listAttribute(() => new Big("9999999999999999999")); + c.exportType = "number"; + c.exportNumberFormat = dynamic("0"); + }); + const cell = readSingleCell(col); + expect(cell.t).toBe("s"); + expect(cell.v).toBe("9999999999999999999"); + }); + + it("handles Big decimal with many significant digits", () => { + const col = column("Precise", c => { + c.showContentAs = "attribute"; + c.attribute = listAttribute(() => new Big("1234567890.1234567890")); + }); + const cell = readSingleCell(col); + expect(cell.t).toBe("s"); + expect(cell.v).toBe("1234567890.123456789"); + }); + }); + describe("date time stripping", () => { it("strips time from attribute date when format has no time components", () => { const testDate = new Date("2024-06-15T10:30:00Z"); diff --git a/packages/pluggableWidgets/datagrid-web/src/features/data-export/cell-readers.ts b/packages/pluggableWidgets/datagrid-web/src/features/data-export/cell-readers.ts index 65f675f1d6..6287db144b 100644 --- a/packages/pluggableWidgets/datagrid-web/src/features/data-export/cell-readers.ts +++ b/packages/pluggableWidgets/datagrid-web/src/features/data-export/cell-readers.ts @@ -91,6 +91,16 @@ function stripTime(date: Date): Date { return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())); } +const MAX_SAFE_SIGNIFICANT_DIGITS = 15; + +function countSignificantDigits(value: Big): number { + const str = value.toFixed(); + const unsigned = str.replace("-", ""); + const noDecimal = unsigned.replace(".", ""); + const stripped = noDecimal.replace(/^0+/, ""); + return stripped.length || 1; +} + const readers: ReadersByType = { attribute(item, props) { const data = props.attribute?.get(item); @@ -119,6 +129,9 @@ const readers: ReadersByType = { } if (value instanceof Big || typeof value === "number") { + if (value instanceof Big && countSignificantDigits(value) > MAX_SAFE_SIGNIFICANT_DIGITS) { + return excelString(value.toFixed(), format); + } const num = value instanceof Big ? value.toNumber() : value; return excelNumber(num, format); } From 73d40d30611750d47e5544f2983873064e0d3852 Mon Sep 17 00:00:00 2001 From: Samuel Reichert Date: Mon, 20 Apr 2026 13:44:14 +0200 Subject: [PATCH 08/10] docs: add changelog entries for data export bug fixes Co-Authored-By: Claude Sonnet 4.6 --- packages/pluggableWidgets/datagrid-web/CHANGELOG.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/pluggableWidgets/datagrid-web/CHANGELOG.md b/packages/pluggableWidgets/datagrid-web/CHANGELOG.md index 758ba241cb..54bcb9808e 100644 --- a/packages/pluggableWidgets/datagrid-web/CHANGELOG.md +++ b/packages/pluggableWidgets/datagrid-web/CHANGELOG.md @@ -6,6 +6,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] +### Fixed + +- We fixed an issue where custom content columns ignored the export type setting, causing numbers and dates to always export as text in Excel. + +- We fixed an issue where exported date values included a hidden time component even when the format specified date-only parts. + +- We fixed an issue where boolean values exported as TRUE/FALSE instead of Yes/No to match the display in the grid. + +- We fixed an issue where numbers with more than 15 significant digits lost precision during Excel export. Such values are now exported as text to preserve all digits. + ## [3.9.0] - 2026-03-23 ### Changed From 0bc22f540ce6847205b07f540a15473412e15186 Mon Sep 17 00:00:00 2001 From: Samuel Reichert Date: Mon, 20 Apr 2026 14:11:31 +0200 Subject: [PATCH 09/10] refactor: remove dead boolean cell type and fix test name Remove "b" from ExcelCell.t union and boolean from ExcelCell.v since excelBoolean now returns string cells. Fix misleading test name for undefined dynamicText case. Co-Authored-By: Claude Sonnet 4.6 --- .../src/features/data-export/__tests__/cell-readers.spec.ts | 2 +- .../datagrid-web/src/features/data-export/cell-readers.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/pluggableWidgets/datagrid-web/src/features/data-export/__tests__/cell-readers.spec.ts b/packages/pluggableWidgets/datagrid-web/src/features/data-export/__tests__/cell-readers.spec.ts index 204ef5e04d..0c7e3da4a4 100644 --- a/packages/pluggableWidgets/datagrid-web/src/features/data-export/__tests__/cell-readers.spec.ts +++ b/packages/pluggableWidgets/datagrid-web/src/features/data-export/__tests__/cell-readers.spec.ts @@ -111,7 +111,7 @@ describe("cell-readers", () => { expect(cell.v).toBe("formatted text"); }); - it("exports n/a when unavailable", () => { + it("returns empty cell when dynamicText is undefined", () => { const col = column("Label", c => { c.showContentAs = "dynamicText"; c.dynamicText = undefined; diff --git a/packages/pluggableWidgets/datagrid-web/src/features/data-export/cell-readers.ts b/packages/pluggableWidgets/datagrid-web/src/features/data-export/cell-readers.ts index 6287db144b..524f3e5246 100644 --- a/packages/pluggableWidgets/datagrid-web/src/features/data-export/cell-readers.ts +++ b/packages/pluggableWidgets/datagrid-web/src/features/data-export/cell-readers.ts @@ -4,10 +4,10 @@ import { ColumnsType, ShowContentAsEnum } from "../../../typings/DatagridProps"; /** Represents a single Excel cell (SheetJS compatible) */ export interface ExcelCell { - /** Cell type: 's' = string, 'n' = number, 'b' = boolean, 'd' = date */ - t: "s" | "n" | "b" | "d"; + /** Cell type: 's' = string, 'n' = number, 'd' = date */ + t: "s" | "n" | "d"; /** Underlying value */ - v: string | number | boolean | Date; + v: string | number | Date; /** Optional Excel number/date format, e.g. "yyyy-mm-dd" or "$0.00" */ z?: string; /** Optional pre-formatted display text */ From 66ebfc890ee71d92c504fd20b17f52e4f846d7d2 Mon Sep 17 00:00:00 2001 From: Samuel Reichert Date: Fri, 1 May 2026 11:24:11 +0200 Subject: [PATCH 10/10] test(datagrid-web): pin date reference and assert display value in cell-readers spec --- .../src/features/data-export/__tests__/cell-readers.spec.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/pluggableWidgets/datagrid-web/src/features/data-export/__tests__/cell-readers.spec.ts b/packages/pluggableWidgets/datagrid-web/src/features/data-export/__tests__/cell-readers.spec.ts index 0c7e3da4a4..98ebc20c45 100644 --- a/packages/pluggableWidgets/datagrid-web/src/features/data-export/__tests__/cell-readers.spec.ts +++ b/packages/pluggableWidgets/datagrid-web/src/features/data-export/__tests__/cell-readers.spec.ts @@ -81,13 +81,15 @@ describe("cell-readers", () => { }); it("exports date attribute without format as string cell (displayValue)", () => { + const testDate = new Date("2024-06-15T10:30:00Z"); const col = column("Created", c => { c.showContentAs = "attribute"; - c.attribute = listAttribute(() => new Date("2024-06-15T10:30:00Z")); + c.attribute = listAttribute(() => testDate); c.exportType = "default"; }); const cell = readSingleCell(col); expect(cell.t).toBe("s"); + expect(cell.v).toBe(`Formatted ${testDate}`); }); it("returns empty cell when attribute is not available", () => {