diff --git a/packages/pluggableWidgets/datagrid-web/CHANGELOG.md b/packages/pluggableWidgets/datagrid-web/CHANGELOG.md index 5dc605b003..22b86c4c67 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 two optional export event actions — **On before export** and **On after export** — so developers can log export operations via a microflow or nanoflow. `On before export` fires just before the export starts and provides the grid name, visible column titles, chunk size, file name, sheet name, and start time. `On after export` fires after the export finishes (whether completed or canceled) and also provides the total number of exported rows, a status string (`"success"` or `"aborted"`), and an end time. + ## [3.11.3] - 2026-07-27 ### Added diff --git a/packages/pluggableWidgets/datagrid-web/openspec/changes/datagrid-export-events/.openspec.yaml b/packages/pluggableWidgets/datagrid-web/openspec/changes/datagrid-export-events/.openspec.yaml new file mode 100644 index 0000000000..95672402a2 --- /dev/null +++ b/packages/pluggableWidgets/datagrid-web/openspec/changes/datagrid-export-events/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-18 diff --git a/packages/pluggableWidgets/datagrid-web/openspec/changes/datagrid-export-events/design.md b/packages/pluggableWidgets/datagrid-web/openspec/changes/datagrid-export-events/design.md new file mode 100644 index 0000000000..556853edd5 --- /dev/null +++ b/packages/pluggableWidgets/datagrid-web/openspec/changes/datagrid-export-events/design.md @@ -0,0 +1,85 @@ +## Context + +Data Grid 2 has a `data-export` feature (`src/features/data-export/`) that lets external modules (e.g. `data-exporter-web`) stream rows out of the widget. The flow: + +1. `useDataExport` creates an `ExportController` on mount, registered in a global window map keyed by `props.name`. +2. An external caller does `getExportRegistry().get("widgetName").exportData(handler, opts)`. +3. `ExportController.exportData()` creates a `DSExportRequest`, streams pages from the Mendix datasource, then restores the datasource view state. +4. `DSExportRequest` tracks `loaded` (rows streamed) and `limit` (rows per page) internally but exposes neither publicly. + +There is currently no hook for the widget to observe the start or end of an export. The widget owns `ExportController`, which is the right place to add these hooks, but `ExportController` should not be coupled to Mendix `ActionValue` directly. + +## Goals / Non-Goals + +**Goals:** + +- Fire `onBeforeExport` with context variables just before `req.send()` is called +- Fire `onAfterExport` with outcome variables after the export resolves (success or abort) +- Keep `ExportController` Mendix-API-agnostic (plain callbacks, not `ActionValue`) +- Keep the export path itself unchanged in behavior and performance + +**Non-Goals:** + +- Awaiting the action callbacks before proceeding (fire-and-forget only) +- Providing an ability to cancel the export from the callback +- Surfacing chunk-level (per-page) events — only start and end +- Changing how external callers trigger the export + +## Decisions + +### D1 — NanoEvents on ExportController, not stored callbacks + +`ExportController` already uses a NanoEvents emitter for all internal communication (`sourcechange`, `propertieschange`, `columnschange`, `abort`, `exportend`). Storing plain callback fields and exposing setter methods would break this pattern and add a parallel, less composable mechanism. + +**Decision**: Add `beforeexport` and `afterexport` to `ControllerEvents`. `exportData()` emits them via the existing emitter. `ExportController` exposes a public `on()` method (returning `Unsubscribe`) that mirrors the existing public `emit()`. `useDataExport` subscribes via `controller.on(...)` and uses React's `useEffect` cleanup to unsubscribe. This keeps `ExportController` fully Mendix-API-agnostic and testable without Mendix mocks. + +**Alternative considered**: Store `onBeforeExport`/`onAfterExport` as plain callback fields with setter methods. Rejected because it breaks the existing NanoEvents communication pattern and makes the controller hold mutable state for what is fundamentally an event subscription. + +### D2 — Subscribe once, read latest ActionValue from ref + +Props can change between renders (e.g. action configuration changed in Studio Pro). The subscription handler must always invoke the current `ActionValue`, not the one captured at subscribe time. + +**Decision**: Subscribe in a `useEffect` with `[entry]` deps (once per controller lifetime). Store `props.onBeforeExport` / `props.onAfterExport` in `useRef`s that are updated on every render (outside the effect). The handler closure reads from the ref at call time, so it always sees the latest `ActionValue` without resubscribing. This avoids unnecessary unsubscribe/resubscribe cycles when Mendix re-renders the widget with a new `ActionValue` reference. + +### D3 — startTime captured in ExportController, shared between both callbacks + +Both `onBeforeExport` and `onAfterExport` receive `startTime` (so `onAfterExport` callers can compute duration in a single microflow without storing intermediate state). The timestamp must be identical in both calls. + +**Decision**: Capture `startTime = new Date()` in `ExportController.exportData()` before calling `onBeforeExport`, then pass the same `Date` object to `onAfterExport`. + +### D4 — status: "success" | "aborted" via DSExportRequest.status + +`DSExportRequest` already tracks its internal status (`"end"` vs `"aborted"`). After `await req.send()` resolves, the request's final status is readable. Map `"end"` → `"success"` and `"aborted"` → `"aborted"` for the `onAfterExport` variable. + +**Decision**: Read `req.status` after `send()` resolves, before nulling `req`. No new state needed on `ExportController`. + +### D5 — columnTitles from filtered column properties + +The exported columns are the result of `filter(this.properties)` in `exportData()` — only visible, exportable columns. Column headers are in `ColumnsType.header` as `DynamicValue`. + +**Decision**: After computing `filter(this.properties)`, derive `columnTitles` as `columns.map(c => c.header?.value ?? "").join(",")`. This runs once per export start, not per page. + +### D6 — fileName and sheetName passed from the export caller + +The datagrid widget does not know the target file or sheet name — those are decided by the external module that calls `exportData()`. Adding them as widget props would duplicate state that already exists in the caller. + +**Decision**: Extend `exportData()` options to accept `fileName?: string` and `sheetName?: string`. Both default to `""` when not provided. `ExportController` forwards them unchanged to the callbacks. + +**Alternative considered**: Expose `fileName`/`sheetName` as widget XML properties (configurable in Studio Pro). Rejected because the file name is typically set by the export module, not the grid configuration. + +### D7 — DSExportRequest public getters + +`exportedItemCount` requires `DSExportRequest.loaded` (currently private). `chunkSize` requires the effective limit (currently private). Both are needed after `send()` resolves, before `req = null`. + +**Decision**: Add `get loaded(): number` and `get limit(): number` as public getters on `DSExportRequest`. No behavior change, just access. + +## Risks / Trade-offs + +- **Action execution order** — `onBeforeExport.execute()` calls are fire-and-forget and may outlive the export itself if they trigger a slow microflow. This is intentional and documented. [Risk: developer expects synchronous "before" semantics] → Mitigation: document clearly that the action fires concurrently with the export. +- **Missing header values** — if a column's `header` DynamicValue is not yet available (status `"loading"`), its title will be an empty string in `columnTitles`. [Risk: incomplete column title list] → Mitigation: acceptable — the export itself has the same constraint on column headers; we use the same value. +- **Empty fileName/sheetName** — when the export caller does not provide these values, they arrive in the action as empty strings. Microflow logic must guard against empty strings if it uses these values to route or name files. +- **ActionValue change during export** — if `props.onAfterExport` changes while an export is in progress (e.g. a re-render updates the ref), the handler reads the new `ActionValue`. [Risk: unexpected microflow called] → Mitigation: during an export the datasource is locked, so re-renders that change action configuration are extremely unlikely in practice. + +## Open Questions + +- None. All design decisions were finalized during the exploration phase. diff --git a/packages/pluggableWidgets/datagrid-web/openspec/changes/datagrid-export-events/proposal.md b/packages/pluggableWidgets/datagrid-web/openspec/changes/datagrid-export-events/proposal.md new file mode 100644 index 0000000000..f9a754ea43 --- /dev/null +++ b/packages/pluggableWidgets/datagrid-web/openspec/changes/datagrid-export-events/proposal.md @@ -0,0 +1,32 @@ +## Why + +Developers using the Data Grid 2 export feature have no built-in way to observe export lifecycle — they cannot log when an export starts, how long it takes, how many rows were exported, or under what filter conditions. Adding `onBeforeExport` and `onAfterExport` action properties fills this gap with zero impact on the export path itself. + +## What Changes + +- Add `onBeforeExport` action property (optional) to Data Grid 2, firing just before the first datasource page fetch, with variables: `gridName`, `columnTitles`, `chunkSize`, `fileName`, `sheetName`, `startTime` +- Add `onAfterExport` action property (optional) to Data Grid 2, firing after the export completes (success or abort), with variables: `gridName`, `columnTitles`, `chunkSize`, `fileName`, `sheetName`, `exportedItemCount`, `status`, `startTime`, `endTime` +- Both actions are fire-and-forget — they do not block the export flow +- `onAfterExport` fires on both successful completion and user abort; the `status` variable ("success" | "aborted") distinguishes them +- `columnTitles` reflects only the visible (exported) columns at the time of export, comma-separated +- `fileName` and `sheetName` are passed through from the export caller's options; both default to empty string when not provided +- Expose public `loaded` and `limit` getters on `DSExportRequest` (internal refactor, not a public API change) + +## Capabilities + +### New Capabilities + +- `export-events`: Two lifecycle action hooks (`onBeforeExport`, `onAfterExport`) on the Data Grid 2 widget for observing and logging export operations + +### Modified Capabilities + + + +## Impact + +- **`src/Datagrid.xml`** — two new `` blocks with `` added to the Events `` +- **`typings/DatagridProps.d.ts`** — auto-regenerated from XML; new `ActionValue` typed props appear +- **`src/features/data-export/ExportController.ts`** — accepts two optional plain-function callbacks; calls them at the right points in `exportData()` +- **`src/features/data-export/DSExportRequest.ts`** — adds `get loaded(): number` and `get limit(): number` public getters +- **`src/features/data-export/useDataExport.ts`** — wires `props.onBeforeExport` / `props.onAfterExport` into `ExportController` callbacks +- No new dependencies; no breaking changes; no runtime performance impact on the export itself diff --git a/packages/pluggableWidgets/datagrid-web/openspec/changes/datagrid-export-events/specs/export-events/spec.md b/packages/pluggableWidgets/datagrid-web/openspec/changes/datagrid-export-events/specs/export-events/spec.md new file mode 100644 index 0000000000..8c61a5ff90 --- /dev/null +++ b/packages/pluggableWidgets/datagrid-web/openspec/changes/datagrid-export-events/specs/export-events/spec.md @@ -0,0 +1,104 @@ +## ADDED Requirements + +### Requirement: onBeforeExport action fires before export starts + +The widget SHALL expose an optional `onBeforeExport` action property. When configured, the widget MUST call `onBeforeExport.execute(args)` once, fire-and-forget, immediately before the first datasource page fetch of an export operation. + +The action MUST receive the following variables: + +- `gridName` (String) — the Studio Pro widget name (`props.name`) +- `columnTitles` (String) — comma-separated header captions of the visible, exported columns in their current display order (e.g. `"First name,Last Name,Date of Birth"`). Columns hidden by the user SHALL NOT be included. +- `chunkSize` (Integer) — the effective number of rows fetched per datasource request during the export (`Math.max(requestedLimit, 10)`). +- `fileName` (String) — the target file name for the export (e.g. `"export.xlsx"`), as provided by the export caller. SHALL be an empty string when not provided. +- `sheetName` (String) — the target sheet/tab name within the export file (e.g. `"Sheet1"`), as provided by the export caller. SHALL be an empty string when not provided. +- `startTime` (DateTime) — the timestamp captured immediately before `req.send()` is called. + +The action execution MUST NOT block or delay the export flow. + +#### Scenario: onBeforeExport fires with correct variables on normal export + +- **WHEN** a configured `onBeforeExport` action exists and `canExecute` is true +- **AND** an export is triggered on the grid +- **THEN** `onBeforeExport.execute` is called once with `gridName`, `columnTitles`, `chunkSize`, `fileName`, `sheetName`, and `startTime` before any datasource page is fetched + +#### Scenario: onBeforeExport is skipped when not configured + +- **WHEN** `onBeforeExport` is not configured (optional property absent) +- **AND** an export is triggered +- **THEN** the export proceeds normally with no errors + +#### Scenario: onBeforeExport columnTitles excludes hidden columns + +- **WHEN** the user has hidden one or more columns +- **AND** an export is triggered +- **THEN** `columnTitles` contains only the headers of the currently visible, exported columns + +--- + +### Requirement: onAfterExport action fires after export completes + +The widget SHALL expose an optional `onAfterExport` action property. When configured, the widget MUST call `onAfterExport.execute(args)` once, fire-and-forget, after the export request resolves — whether it completed successfully or was aborted by the user. + +The action MUST receive the following variables: + +- `gridName` (String) — same as `onBeforeExport.gridName` +- `columnTitles` (String) — same as `onBeforeExport.columnTitles` +- `chunkSize` (Integer) — same as `onBeforeExport.chunkSize` +- `fileName` (String) — same as `onBeforeExport.fileName` +- `sheetName` (String) — same as `onBeforeExport.sheetName` +- `exportedItemCount` (Integer) — total number of rows actually streamed to the export handler before the request ended +- `status` (String) — `"success"` if all rows were exported; `"aborted"` if the user cancelled mid-export +- `startTime` (DateTime) — the same timestamp passed to `onBeforeExport` (enables duration calculation in a single microflow) +- `endTime` (DateTime) — the timestamp captured after the export request's `loadend` event fires + +#### Scenario: onAfterExport fires with success status after complete export + +- **WHEN** `onAfterExport` is configured and `canExecute` is true +- **AND** the export completes without interruption +- **THEN** `onAfterExport.execute` is called once with `status` equal to `"success"` and `exportedItemCount` equal to the total rows streamed + +#### Scenario: onAfterExport fires with aborted status when user cancels + +- **WHEN** the user clicks cancel on the export progress dialog mid-export +- **THEN** `onAfterExport.execute` is called once with `status` equal to `"aborted"` and `exportedItemCount` equal to the number of rows streamed before cancellation + +#### Scenario: onAfterExport is skipped when not configured + +- **WHEN** `onAfterExport` is not configured +- **AND** an export completes or is aborted +- **THEN** no error occurs and the export lifecycle completes normally + +#### Scenario: onAfterExport startTime matches onBeforeExport startTime + +- **WHEN** both `onBeforeExport` and `onAfterExport` are configured +- **AND** an export runs to completion +- **THEN** the `startTime` value in `onAfterExport` is identical to the `startTime` value in `onBeforeExport` + +#### Scenario: onAfterExport endTime is after startTime + +- **WHEN** `onAfterExport` fires after a completed export +- **THEN** `endTime` is greater than or equal to `startTime` + +--- + +### Requirement: Both export event actions are optional and independent + +The widget SHALL allow `onBeforeExport` and `onAfterExport` to be configured independently. Configuring one MUST NOT require configuring the other. + +#### Scenario: Only onBeforeExport configured + +- **WHEN** `onBeforeExport` is configured and `onAfterExport` is not +- **AND** an export runs to completion +- **THEN** `onBeforeExport` fires once and no error occurs for the missing `onAfterExport` + +#### Scenario: Only onAfterExport configured + +- **WHEN** `onAfterExport` is configured and `onBeforeExport` is not +- **AND** an export runs to completion +- **THEN** `onAfterExport` fires once and no error occurs for the missing `onBeforeExport` + +#### Scenario: Neither action configured + +- **WHEN** neither `onBeforeExport` nor `onAfterExport` is configured +- **AND** an export runs +- **THEN** the export behaves identically to before this feature was introduced diff --git a/packages/pluggableWidgets/datagrid-web/openspec/changes/datagrid-export-events/tasks.md b/packages/pluggableWidgets/datagrid-web/openspec/changes/datagrid-export-events/tasks.md new file mode 100644 index 0000000000..736082ed59 --- /dev/null +++ b/packages/pluggableWidgets/datagrid-web/openspec/changes/datagrid-export-events/tasks.md @@ -0,0 +1,40 @@ +## 1. XML — Declare action properties + +- [ ] 1.1 Add `onBeforeExport` property block (type="action", required="false") to the Events `` in `src/Datagrid.xml`, with `` for: `gridName` (String), `columnTitles` (String), `chunkSize` (Integer), `fileName` (String), `sheetName` (String), `startTime` (DateTime) +- [ ] 1.2 Add `onAfterExport` property block (type="action", required="false") to the same Events `` in `src/Datagrid.xml`, with `` for: `gridName` (String), `columnTitles` (String), `chunkSize` (Integer), `fileName` (String), `sheetName` (String), `exportedItemCount` (Integer), `status` (String), `startTime` (DateTime), `endTime` (DateTime) +- [ ] 1.3 Regenerate `typings/DatagridProps.d.ts` by running `pnpm run build` (or the pluggable-widgets-tools codegen step) and verify `onBeforeExport` and `onAfterExport` appear as `ActionValue<...> | undefined` in `DatagridContainerProps` + +## 2. DSExportRequest — Expose public getters + +- [ ] 2.1 Add `get loaded(): number` public getter to `DSExportRequest` returning `this.loaded` (the count of rows streamed so far) +- [ ] 2.2 Add `get limit(): number` public getter to `DSExportRequest` returning `this.limit` (the effective rows-per-page after `Math.max`) + +## 3. ExportController — Add NanoEvents and public on() + +- [ ] 3.1 Add `beforeexport: (args: BeforeExportArgs) => void` and `afterexport: (args: AfterExportArgs) => void` to the `ControllerEvents` interface +- [ ] 3.2 Add a public `on(event: K, handler: ControllerEvents[K]): Unsubscribe` method (mirrors existing `emit()`) +- [ ] 3.3 In `exportData()`, before `handler(req)`: capture `startTime = new Date()`, derive `columnTitles` from the filtered column properties, read `fileName`/`sheetName` from options (default `""`), then `this.emitter.emit("beforeexport", { ... })` +- [ ] 3.4 In `exportData()`, after `await req.send()` resolves but before `req = null`: read `req.loaded`/`req.status` (map `"end"` → `"success"`, `"aborted"` → `"aborted"`), capture `endTime = new Date()`, then `this.emitter.emit("afterexport", { ... })` +- [ ] 3.5 Ensure the same `startTime` Date object is passed to both `beforeexport` and `afterexport` within a single `exportData()` invocation + +## 4. useDataExport — Wire props to controller via ref + subscription + +- [ ] 4.1 Expand the `Props` type alias in `useDataExport.ts` to include `onBeforeExport` and `onAfterExport` from `DatagridContainerProps` +- [ ] 4.2 Store `props.onBeforeExport` and `props.onAfterExport` in `useRef`s updated on every render (outside effects) +- [ ] 4.3 Add a `useEffect([entry])` that subscribes to `"beforeexport"` via `entry.controller.on(...)` — the handler reads the latest `ActionValue` from the ref and calls `action.execute(...)` when `action.canExecute` is true; return the unsubscribe function as cleanup +- [ ] 4.4 Add a `useEffect([entry])` that subscribes to `"afterexport"` via `entry.controller.on(...)` — same pattern; return the unsubscribe function as cleanup +- [ ] 4.5 Pass `onBeforeExport` and `onAfterExport` from `props` into `useDataExport` call in `Datagrid.tsx` + +## 5. Tests + +- [ ] 5.1 Add unit test: `ExportController` emits `"beforeexport"` once before `send()` is called (subscriber fires before send) +- [ ] 5.2 Add unit test: `ExportController` emits `"afterexport"` once after `req.send()` resolves on success, with `status: "success"` +- [ ] 5.3 Add unit test: `ExportController` emits `"afterexport"` with `status: "aborted"` when request ends in aborted state +- [ ] 5.4 Add unit test: `startTime` in `"beforeexport"` args is the same object reference as `startTime` in `"afterexport"` args +- [ ] 5.5 Add unit test: when neither callback is set, `exportData()` completes without errors + +## 6. Verify & Cleanup + +- [ ] 6.1 Run `pnpm run test` in `packages/pluggableWidgets/datagrid-web` — all tests pass +- [ ] 6.2 Run `pnpm run lint` — no new lint errors +- [ ] 6.3 Update `CHANGELOG.md` with a user-facing entry describing the two new export event actions diff --git a/packages/pluggableWidgets/datagrid-web/src/Datagrid.xml b/packages/pluggableWidgets/datagrid-web/src/Datagrid.xml index c807c824fa..42f3483b13 100644 --- a/packages/pluggableWidgets/datagrid-web/src/Datagrid.xml +++ b/packages/pluggableWidgets/datagrid-web/src/Datagrid.xml @@ -197,6 +197,33 @@ On selection change + + On before export + + + + + + + + + + + + On after export + + + + + + + + + + + + + Filters placeholder 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 69d42a2454..8484c22c42 100644 --- a/packages/pluggableWidgets/datagrid-web/src/features/data-export/DSExportRequest.ts +++ b/packages/pluggableWidgets/datagrid-web/src/features/data-export/DSExportRequest.ts @@ -37,8 +37,8 @@ export class DSExportRequest { private datasource: ListValue; private columns: ColumnsType[]; private offset = 0; - private loaded = 0; - private limit = 10; + private _loaded = 0; + private _limit = 10; private totalCount: number | undefined = undefined; private shouldSendHeaders = false; private emitter: Emitter; @@ -47,7 +47,7 @@ export class DSExportRequest { constructor(params: RequestParams) { const { ds, columns, withHeaders = false, limit = 0 } = params; - this.limit = Math.max(limit, this.limit); + this._limit = Math.max(limit, this._limit); this.emitter = createNanoEvents(); this.datasource = ds; this.totalCount = ds.totalCount; @@ -60,6 +60,14 @@ export class DSExportRequest { return this._status; } + get loaded(): number { + return this._loaded; + } + + get limit(): number { + return this._limit; + } + on(event: K, cb: ExportRequestEvents[K]): Unsubscribe { return this.emitter.on(event, cb); } @@ -95,7 +103,7 @@ export class DSExportRequest { private createProgressEvent(type: string): ProgressEvent { return new ProgressEvent(type, { lengthComputable: typeof this.totalCount === "number", - loaded: this.loaded, + loaded: this._loaded, total: this.totalCount }); } @@ -104,7 +112,7 @@ export class DSExportRequest { this.emitLoadStart(); this._status = "awaiting"; this.offset = 0; - this.datasource.setLimit(this.limit); + this.datasource.setLimit(this._limit); this.datasource.setOffset(this.offset); this.datasource.reload(); return new Promise(res => this.on("loadend", () => res())); @@ -119,7 +127,7 @@ export class DSExportRequest { }; onsourcechange = (ds: ListValue): void => { - const isReady = ds.offset === this.offset && ds.limit === this.limit && ds.status === "available"; + const isReady = ds.offset === this.offset && ds.limit === this._limit && ds.status === "available"; if (this._status === "awaiting" && isReady) { this.datasource = ds; if (this.shouldSendHeaders) { @@ -201,14 +209,14 @@ export class DSExportRequest { private sendChunk(chunk: RowData[]): void { this._status = "sending"; - this.loaded += chunk.length; + this._loaded += chunk.length; this.emitData(chunk); this.emitProgress(); } private fetchNext(): void { this._status = "awaiting"; - this.offset += this.limit; + this.offset += this._limit; this.datasource.setOffset(this.offset); } diff --git a/packages/pluggableWidgets/datagrid-web/src/features/data-export/ExportController.ts b/packages/pluggableWidgets/datagrid-web/src/features/data-export/ExportController.ts index efd219947c..23f7b51a53 100644 --- a/packages/pluggableWidgets/datagrid-web/src/features/data-export/ExportController.ts +++ b/packages/pluggableWidgets/datagrid-web/src/features/data-export/ExportController.ts @@ -1,15 +1,32 @@ import { ListValue } from "mendix"; -import { createNanoEvents, Emitter } from "nanoevents"; +import { createNanoEvents, Emitter, Unsubscribe } from "nanoevents"; import { TaskProgressService } from "@mendix/widget-plugin-grid/main"; import { DSExportRequest } from "./DSExportRequest"; import { ColumnsType } from "../../../typings/DatagridProps"; +export type BeforeExportArgs = { + gridName: string; + columnTitles: string; + chunkSize: number; + fileName: string; + sheetName: string; + startTime: Date; +}; + +export type AfterExportArgs = BeforeExportArgs & { + exportedItemCount: number; + status: "success" | "aborted"; + endTime: Date; +}; + interface ControllerEvents { sourcechange: (ds: ListValue) => void; propertieschange: (ps: ColumnsType[]) => void; columnschange: (columns: number[]) => void; exportend: () => void; abort: () => void; + beforeexport: (args: BeforeExportArgs) => void; + afterexport: (args: AfterExportArgs) => void; } type RequestHandler = (req: DSExportRequest) => void; @@ -21,8 +38,10 @@ export class ExportController { private emitter: Emitter; private locked = false; private progressStore: TaskProgressService; + private name: string; - constructor(progress: TaskProgressService) { + constructor(name: string, progress: TaskProgressService) { + this.name = name; this.progressStore = progress; this.emitter = createNanoEvents(); this.emitter.on("columnschange", this.oncolumnschange); @@ -34,6 +53,10 @@ export class ExportController { this.emitter.emit(event, ...args); } + on(event: K, handler: ControllerEvents[K]): Unsubscribe { + return this.emitter.on(event, handler); + } + oncolumnschange = (columns: number[]): void => { if (this.locked === false) { this.columns = columns; @@ -57,7 +80,10 @@ export class ExportController { }); } - async exportData(handler: RequestHandler, options: { limit?: number; withHeaders?: boolean } = {}): Promise { + async exportData( + handler: RequestHandler, + options: { limit?: number; withHeaders?: boolean; fileName?: string; sheetName?: string } = {} + ): Promise { if (this.datasource === null) { console.error("Export controller: datasource is missing."); return; @@ -67,12 +93,17 @@ export class ExportController { } const filter = this.createFilter(this.columns.slice()); + const filteredColumns = filter(this.properties); const snapshot = { offset: this.datasource.offset, limit: this.datasource.limit }; + const columnTitles = filteredColumns.map(c => c.header?.value ?? "").join(","); + const fileName = options.fileName ?? ""; + const sheetName = options.sheetName ?? ""; + this.locked = true; let req: DSExportRequest | null = new DSExportRequest({ ds: this.datasource, - columns: filter(this.properties), + columns: filteredColumns, ...options }); @@ -86,9 +117,36 @@ export class ExportController { this.emitter.on("abort", req.abort) ]; + const startTime = new Date(); + const chunkSize = req.limit; + this.emitter.emit("beforeexport", { + gridName: this.name, + columnTitles, + chunkSize, + fileName, + sheetName, + startTime + }); + handler(req); + await req.send(); + const endTime = new Date(); + const exportedItemCount = req.loaded; + const status = req.status === "end" ? "success" : "aborted"; + this.emitter.emit("afterexport", { + gridName: this.name, + columnTitles, + chunkSize, + fileName, + sheetName, + exportedItemCount, + status, + startTime, + endTime + }); + // Dispose request requestBindings.forEach(unsubscribe => unsubscribe()); req = null; diff --git a/packages/pluggableWidgets/datagrid-web/src/features/data-export/__tests__/ExportController.spec.ts b/packages/pluggableWidgets/datagrid-web/src/features/data-export/__tests__/ExportController.spec.ts new file mode 100644 index 0000000000..362f8ad3cc --- /dev/null +++ b/packages/pluggableWidgets/datagrid-web/src/features/data-export/__tests__/ExportController.spec.ts @@ -0,0 +1,128 @@ +jest.mock("mendix", () => ({}), { virtual: true }); +jest.mock("../DSExportRequest"); + +import { list } from "@mendix/widget-plugin-test-utils"; +import { TaskProgressService } from "@mendix/widget-plugin-grid/main"; +import { ExportController } from "../ExportController"; +import { DSExportRequest } from "../DSExportRequest"; +import { column } from "../../../utils/test-utils"; + +const MockDSExportRequest = DSExportRequest as jest.MockedClass; + +function makeMockProgress(): TaskProgressService { + return { + inProgress: false, + lengthComputable: false, + loaded: 0, + total: 0, + onloadstart: jest.fn(), + onprogress: jest.fn(), + onloadend: jest.fn() + }; +} + +function makeMockRequest(overrides?: { status?: string; loaded?: number }): Partial { + return { + status: (overrides?.status ?? "end") as DSExportRequest["status"], + loaded: overrides?.loaded ?? 10, + limit: 100, + send: jest.fn().mockResolvedValue(undefined), + on: jest.fn().mockReturnValue(jest.fn()), + abort: jest.fn(), + onsourcechange: jest.fn(), + onpropertieschange: jest.fn() + }; +} + +function makeController(): ExportController { + const controller = new ExportController("test-grid", makeMockProgress()); + controller.emit("sourcechange", list(5)); + controller.emit("propertieschange", [column("Col1"), column("Col2")]); + controller.emit("columnschange", [0, 1]); + return controller; +} + +describe("ExportController export callbacks", () => { + beforeEach(() => { + MockDSExportRequest.mockImplementation(() => makeMockRequest() as DSExportRequest); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("calls onBeforeExport once before send() is called", async () => { + const callOrder: string[] = []; + + MockDSExportRequest.mockImplementationOnce(() => { + const req = makeMockRequest() as DSExportRequest; + (req.send as jest.Mock).mockImplementation(() => { + callOrder.push("send"); + return Promise.resolve(); + }); + return req; + }); + + const controller = makeController(); + controller.on("beforeexport", () => callOrder.push("onBefore")); + + await controller.exportData(jest.fn()); + + expect(callOrder).toEqual(["onBefore", "send"]); + }); + + it("calls onAfterExport once after send() resolves with status 'success'", async () => { + const controller = makeController(); + const onAfter = jest.fn(); + controller.on("afterexport", onAfter); + + await controller.exportData(jest.fn()); + + expect(onAfter).toHaveBeenCalledTimes(1); + expect(onAfter).toHaveBeenCalledWith( + expect.objectContaining({ + status: "success", + gridName: "test-grid", + columnTitles: "Col1,Col2", + chunkSize: 100, + exportedItemCount: 10 + }) + ); + }); + + it("calls onAfterExport with status 'aborted' when request ends with aborted status", async () => { + MockDSExportRequest.mockImplementationOnce( + () => makeMockRequest({ status: "aborted", loaded: 5 }) as DSExportRequest + ); + + const controller = makeController(); + const onAfter = jest.fn(); + controller.on("afterexport", onAfter); + + await controller.exportData(jest.fn()); + + expect(onAfter).toHaveBeenCalledTimes(1); + expect(onAfter).toHaveBeenCalledWith(expect.objectContaining({ status: "aborted", exportedItemCount: 5 })); + }); + + it("passes the same startTime object to both onBeforeExport and onAfterExport", async () => { + const controller = makeController(); + + let capturedStartTime: Date | undefined; + controller.on("beforeexport", args => { + capturedStartTime = args.startTime; + }); + const onAfter = jest.fn(); + controller.on("afterexport", onAfter); + + await controller.exportData(jest.fn()); + + expect(capturedStartTime).toBeInstanceOf(Date); + expect(onAfter.mock.calls[0][0].startTime).toBe(capturedStartTime); + }); + + it("completes exportData without errors when no callbacks are set", async () => { + const controller = makeController(); + await expect(controller.exportData(jest.fn())).resolves.toBeUndefined(); + }); +}); diff --git a/packages/pluggableWidgets/datagrid-web/src/features/data-export/__tests__/useDataExport.spec.ts b/packages/pluggableWidgets/datagrid-web/src/features/data-export/__tests__/useDataExport.spec.ts new file mode 100644 index 0000000000..71ef47df70 --- /dev/null +++ b/packages/pluggableWidgets/datagrid-web/src/features/data-export/__tests__/useDataExport.spec.ts @@ -0,0 +1,187 @@ +jest.mock("mendix", () => ({}), { virtual: true }); + +import { act, renderHook } from "@testing-library/react"; +import { actionValue, list } from "@mendix/widget-plugin-test-utils"; +import { TaskProgressService } from "@mendix/widget-plugin-grid/main"; +import { IColumnGroupStore } from "../../../helpers/state/ColumnGroupStore"; +import { useDataExport } from "../useDataExport"; +import { getExportRegistry } from "../registry"; +import type { AfterExportArgs, BeforeExportArgs } from "../ExportController"; +import Big from "big.js"; + +function makeMockProgress(): TaskProgressService { + return { + inProgress: false, + lengthComputable: false, + loaded: 0, + total: 0, + onloadstart: jest.fn(), + onprogress: jest.fn(), + onloadend: jest.fn() + }; +} + +function makeColumnsStore(): IColumnGroupStore { + return { + loaded: true, + availableColumns: [], + visibleColumns: [], + columnFilters: [], + swapColumns: jest.fn(), + setIsResizing: jest.fn() + }; +} + +const GRID_NAME = "test-grid"; + +const BEFORE_ARGS: BeforeExportArgs = { + gridName: GRID_NAME, + columnTitles: "Col1,Col2", + chunkSize: 100, + fileName: "export.xlsx", + sheetName: "Sheet1", + startTime: new Date("2026-01-01T00:00:00Z") +}; + +const AFTER_ARGS: AfterExportArgs = { + ...BEFORE_ARGS, + exportedItemCount: 42, + status: "success", + endTime: new Date("2026-01-01T00:01:00Z") +}; + +describe("useDataExport subscription wiring", () => { + afterEach(() => { + jest.clearAllMocks(); + getExportRegistry().clear(); + }); + + function renderExportHook(overrides?: { + onBeforeExport?: ReturnType; + onAfterExport?: ReturnType; + }) { + const columnsStore = makeColumnsStore(); + const progress = makeMockProgress(); + return renderHook(() => + useDataExport( + { + name: GRID_NAME, + datasource: list(0), + columns: [], + onBeforeExport: overrides?.onBeforeExport, + onAfterExport: overrides?.onAfterExport + }, + columnsStore, + progress + ) + ); + } + + it("calls onBeforeExport.execute with correct payload when canExecute is true", () => { + const action = actionValue(true); + renderExportHook({ onBeforeExport: action }); + + const controller = getExportRegistry().get(GRID_NAME)!; + act(() => { + controller.emit("beforeexport", BEFORE_ARGS); + }); + + expect(action.execute).toHaveBeenCalledTimes(1); + expect(action.execute).toHaveBeenCalledWith({ + gridName: GRID_NAME, + columnTitles: "Col1,Col2", + chunkSize: new Big(100), + fileName: "export.xlsx", + sheetName: "Sheet1", + startTime: BEFORE_ARGS.startTime + }); + }); + + it("does not call onBeforeExport.execute when canExecute is false", () => { + const action = actionValue(false); + renderExportHook({ onBeforeExport: action }); + + const controller = getExportRegistry().get(GRID_NAME)!; + act(() => { + controller.emit("beforeexport", BEFORE_ARGS); + }); + + expect(action.execute).not.toHaveBeenCalled(); + }); + + it("calls onAfterExport.execute with correct payload on success", () => { + const action = actionValue(true); + renderExportHook({ onAfterExport: action }); + + const controller = getExportRegistry().get(GRID_NAME)!; + act(() => { + controller.emit("afterexport", AFTER_ARGS); + }); + + expect(action.execute).toHaveBeenCalledTimes(1); + expect(action.execute).toHaveBeenCalledWith({ + gridName: GRID_NAME, + columnTitles: "Col1,Col2", + chunkSize: new Big(100), + fileName: "export.xlsx", + sheetName: "Sheet1", + exportedItemCount: new Big(42), + status: "success", + startTime: AFTER_ARGS.startTime, + endTime: AFTER_ARGS.endTime + }); + }); + + it("does not call onAfterExport.execute when canExecute is false", () => { + const action = actionValue(false); + renderExportHook({ onAfterExport: action }); + + const controller = getExportRegistry().get(GRID_NAME)!; + act(() => { + controller.emit("afterexport", AFTER_ARGS); + }); + + expect(action.execute).not.toHaveBeenCalled(); + }); + + it("unsubscribes on unmount — no calls after the component is removed", () => { + const action = actionValue(true); + const { unmount } = renderExportHook({ onBeforeExport: action }); + + const controller = getExportRegistry().get(GRID_NAME)!; + unmount(); + + act(() => { + controller.emit("beforeexport", BEFORE_ARGS); + }); + + expect(action.execute).not.toHaveBeenCalled(); + }); + + it("reads the latest ActionValue from ref without resubscribing", () => { + const firstAction = actionValue(true); + const secondAction = actionValue(true); + const columnsStore = makeColumnsStore(); + const progress = makeMockProgress(); + + const { rerender } = renderHook( + ({ onBeforeExport }: { onBeforeExport: ReturnType }) => + useDataExport( + { name: GRID_NAME, datasource: list(0), columns: [], onBeforeExport, onAfterExport: undefined }, + columnsStore, + progress + ), + { initialProps: { onBeforeExport: firstAction } } + ); + + rerender({ onBeforeExport: secondAction }); + + const controller = getExportRegistry().get(GRID_NAME)!; + act(() => { + controller.emit("beforeexport", BEFORE_ARGS); + }); + + expect(firstAction.execute).not.toHaveBeenCalled(); + expect(secondAction.execute).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/pluggableWidgets/datagrid-web/src/features/data-export/useDataExport.ts b/packages/pluggableWidgets/datagrid-web/src/features/data-export/useDataExport.ts index e2bdd06f71..6d76c8ac66 100644 --- a/packages/pluggableWidgets/datagrid-web/src/features/data-export/useDataExport.ts +++ b/packages/pluggableWidgets/datagrid-web/src/features/data-export/useDataExport.ts @@ -1,4 +1,5 @@ -import { useCallback, useEffect, useState } from "react"; +import { Big } from "big.js"; +import { useCallback, useEffect, useRef, useState } from "react"; import { TaskProgressService } from "@mendix/widget-plugin-grid/main"; import { ExportController } from "./ExportController"; import { getExportRegistry } from "./registry"; @@ -10,7 +11,7 @@ type ResourceEntry = { controller: ExportController; }; -type Props = Pick; +type Props = Pick; export function useDataExport( props: Props, @@ -19,6 +20,10 @@ export function useDataExport( ): [abort: () => void] { const [entry] = useState(() => createEntry(props.name, progress)); const abort = useCallback(() => entry?.controller.abort(), [entry]); + const onBeforeExportRef = useRef(props.onBeforeExport); + onBeforeExportRef.current = props.onBeforeExport; + const onAfterExportRef = useRef(props.onAfterExport); + onAfterExportRef.current = props.onAfterExport; // Remove entry when widget unmounted. useEffect(() => { @@ -44,13 +49,48 @@ export function useDataExport( ); }, [columnsStore.visibleColumns, entry]); + useEffect(() => { + return entry.controller.on("beforeexport", args => { + const action = onBeforeExportRef.current; + if (action?.canExecute) { + action.execute({ + gridName: args.gridName, + columnTitles: args.columnTitles, + chunkSize: new Big(args.chunkSize), + fileName: args.fileName, + sheetName: args.sheetName, + startTime: args.startTime + }); + } + }); + }, [entry]); + + useEffect(() => { + return entry.controller.on("afterexport", args => { + const action = onAfterExportRef.current; + if (action?.canExecute) { + action.execute({ + gridName: args.gridName, + columnTitles: args.columnTitles, + chunkSize: new Big(args.chunkSize), + fileName: args.fileName, + sheetName: args.sheetName, + exportedItemCount: new Big(args.exportedItemCount), + status: args.status, + startTime: args.startTime, + endTime: args.endTime + }); + } + }); + }, [entry]); + return [abort]; } function createEntry(name: string, progress: TaskProgressService): ResourceEntry { return { key: name, - controller: new ExportController(progress) + controller: new ExportController(name, progress) }; } diff --git a/packages/pluggableWidgets/datagrid-web/typings/DatagridProps.d.ts b/packages/pluggableWidgets/datagrid-web/typings/DatagridProps.d.ts index 24ef2b34de..6edf2ec82c 100644 --- a/packages/pluggableWidgets/datagrid-web/typings/DatagridProps.d.ts +++ b/packages/pluggableWidgets/datagrid-web/typings/DatagridProps.d.ts @@ -13,6 +13,7 @@ import { ListExpressionValue, ListValue, ListWidgetValue, + Option, SelectionMultiValue, SelectionSingleValue } from "mendix"; @@ -118,6 +119,25 @@ export interface DatagridContainerProps { onClickTrigger: OnClickTriggerEnum; onClick?: ListActionValue; onSelectionChange?: ActionValue; + onBeforeExport?: ActionValue<{ + gridName: Option; + columnTitles: Option; + chunkSize: Option; + fileName: Option; + sheetName: Option; + startTime: Option; + }>; + onAfterExport?: ActionValue<{ + gridName: Option; + columnTitles: Option; + chunkSize: Option; + fileName: Option; + sheetName: Option; + exportedItemCount: Option; + status: Option; + startTime: Option; + endTime: Option; + }>; filtersPlaceholder?: ReactNode; itemSelection?: SelectionSingleValue | SelectionMultiValue; itemSelectionMethod: ItemSelectionMethodEnum; @@ -186,6 +206,8 @@ export interface DatagridPreviewProps { onClickTrigger: OnClickTriggerEnum; onClick: {} | null; onSelectionChange: {} | null; + onBeforeExport: {} | null; + onAfterExport: {} | null; filtersPlaceholder: { widgetCount: number; renderer: ComponentType<{ children: ReactNode; caption?: string }> }; itemSelection: "None" | "Single" | "Multi"; itemSelectionMethod: ItemSelectionMethodEnum;