From c33cc0482f2e9bd7fa28038ceb0548320cd106d5 Mon Sep 17 00:00:00 2001 From: Roman Vyakhirev Date: Wed, 19 Aug 2026 09:30:18 +0200 Subject: [PATCH 1/4] feat: add new event for on before and after export --- .../datagrid-web/CHANGELOG.md | 4 + .../datagrid-export-events/.openspec.yaml | 2 + .../changes/datagrid-export-events/design.md | 83 ++++++++++++ .../datagrid-export-events/proposal.md | 32 +++++ .../specs/export-events/spec.md | 104 +++++++++++++++ .../changes/datagrid-export-events/tasks.md | 39 ++++++ .../datagrid-web/src/Datagrid.xml | 27 ++++ .../features/data-export/DSExportRequest.ts | 24 ++-- .../features/data-export/ExportController.ts | 61 ++++++++- .../__tests__/ExportController.spec.ts | 120 ++++++++++++++++++ .../src/features/data-export/useDataExport.ts | 48 ++++++- .../datagrid-web/typings/DatagridProps.d.ts | 5 + 12 files changed, 536 insertions(+), 13 deletions(-) create mode 100644 packages/pluggableWidgets/datagrid-web/openspec/changes/datagrid-export-events/.openspec.yaml create mode 100644 packages/pluggableWidgets/datagrid-web/openspec/changes/datagrid-export-events/design.md create mode 100644 packages/pluggableWidgets/datagrid-web/openspec/changes/datagrid-export-events/proposal.md create mode 100644 packages/pluggableWidgets/datagrid-web/openspec/changes/datagrid-export-events/specs/export-events/spec.md create mode 100644 packages/pluggableWidgets/datagrid-web/openspec/changes/datagrid-export-events/tasks.md create mode 100644 packages/pluggableWidgets/datagrid-web/src/features/data-export/__tests__/ExportController.spec.ts 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..6f5bdc1103 --- /dev/null +++ b/packages/pluggableWidgets/datagrid-web/openspec/changes/datagrid-export-events/design.md @@ -0,0 +1,83 @@ +## 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 — Plain callbacks on ExportController, not ActionValue + +`ExportController` already holds a `ListValue` (Mendix API), so coupling it further with `ActionValue` is technically feasible. However the existing pattern keeps the controller as a data coordinator: it reacts to events, not Mendix props. `useDataExport` is the right place to bridge Mendix props to the controller. + +**Decision**: Store `onBeforeExport` and `onAfterExport` as `(() => void) | undefined` on `ExportController`. `useDataExport` creates the closures that call `actionValue.execute(args)` and assigns them via setter methods on the controller. This keeps `ExportController` testable without Mendix mocks. + +**Alternative considered**: Pass `ActionValue` directly into the constructor. Rejected because it couples the controller to Mendix types and makes the constructor dependent on optional props that may change between renders. + +### D2 — Callback assignment via setter methods, updated on every render + +Props can change between renders (e.g. action configuration changed in Studio Pro). The callbacks must always reflect the latest prop values. + +**Decision**: `ExportController` exposes `setOnBeforeExport(cb)` and `setOnAfterExport(cb)` setters. `useDataExport` calls these in a `useEffect` that runs whenever `props.onBeforeExport` / `props.onAfterExport` change. This matches the existing pattern of emitting `"sourcechange"` / `"propertieschange"` on every render. + +### 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 — filterCondition as JSON string from datasource.filter + +`this.datasource.filter` is a `FilterCondition | undefined` from `mendix/filters`. It is the same condition applied to the datasource (same shape stored in personalization). `JSON.stringify` produces a consistent, portable string. + +**Decision**: `filterCondition = JSON.stringify(this.datasource.filter ?? null)`. + +### 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. +- **filterCondition size** — complex nested filters can produce large JSON strings passed to a microflow string parameter. [Risk: truncation in Mendix unlimited string fields] → Mitigation: Mendix unlimited string attributes can hold up to 200MB; this is not a practical concern. +- **Callback mutation during export** — if `setOnAfterExport` is called while an export is in progress (rare), the new callback fires. [Risk: unexpected microflow called] → Mitigation: callbacks are reassigned only when React props change, which requires a re-render; during an export the datasource is locked so this is extremely unlikely. + +## 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..e7c7fe5484 --- /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`, `filterCondition`, `chunkSize`, `startTime` +- Add `onAfterExport` action property (optional) to Data Grid 2, firing after the export completes (success or abort), with variables: `gridName`, `columnTitles`, `filterCondition`, `chunkSize`, `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 +- `filterCondition` is a JSON string in the same format as the personalization storage +- 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..36fa186a10 --- /dev/null +++ b/packages/pluggableWidgets/datagrid-web/openspec/changes/datagrid-export-events/tasks.md @@ -0,0 +1,39 @@ +## 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), `filterCondition` (String), `chunkSize` (Integer), `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), `filterCondition` (String), `chunkSize` (Integer), `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 callback slots and invocation + +- [ ] 3.1 Add `private _onBeforeExport: (() => void) | undefined` and `private _onAfterExport: (() => void) | undefined` fields to `ExportController` +- [ ] 3.2 Add `setOnBeforeExport(cb: (() => void) | undefined): void` and `setOnAfterExport(cb: (() => void) | undefined): void` setter methods +- [ ] 3.3 In `exportData()`, before `req.send()`: capture `startTime = new Date()`, derive `columnTitles` from the filtered column properties (`filter(this.properties).map(c => c.header?.value ?? "").join(",")`), derive `filterCondition` as `JSON.stringify(this.datasource.filter ?? null)`, then call `this._onBeforeExport?.()` (fire-and-forget) +- [ ] 3.4 In `exportData()`, after `await req.send()` resolves but before `req = null`: read `req.loaded`, `req.limit`, `req.status` (map `"end"` → `"success"`, `"aborted"` → `"aborted"`), capture `endTime = new Date()`, then call `this._onAfterExport?.()` (fire-and-forget) +- [ ] 3.5 Ensure the same `startTime` Date object is closed over by both `_onBeforeExport` and `_onAfterExport` calls within a single `exportData()` invocation + +## 4. useDataExport — Wire props to controller callbacks + +- [ ] 4.1 Expand the `Props` type alias in `useDataExport.ts` to include `onBeforeExport` and `onAfterExport` from `DatagridContainerProps` +- [ ] 4.2 Add a `useEffect` that calls `entry.controller.setOnBeforeExport(...)` with a closure over `props.onBeforeExport` — the closure calls `action.execute({ gridName, columnTitles, filterCondition, chunkSize, startTime })` when `action.canExecute` is true; dependency array: `[entry, props.onBeforeExport]` +- [ ] 4.3 Add a `useEffect` that calls `entry.controller.setOnAfterExport(...)` with a closure over `props.onAfterExport` — the closure calls `action.execute({ gridName, columnTitles, filterCondition, chunkSize, exportedItemCount, status, startTime, endTime })` when `action.canExecute` is true; dependency array: `[entry, props.onAfterExport]` +- [ ] 4.4 Pass `onBeforeExport` and `onAfterExport` from `props` into `useDataExport` call in `Datagrid.tsx` + +## 5. Tests + +- [ ] 5.1 Add unit test: `ExportController` calls `_onBeforeExport` callback once before data is streamed +- [ ] 5.2 Add unit test: `ExportController` calls `_onAfterExport` callback once after `req.send()` resolves on success, with status `"success"` +- [ ] 5.3 Add unit test: `ExportController` calls `_onAfterExport` with status `"aborted"` when `abort()` is called +- [ ] 5.4 Add unit test: `startTime` passed to `_onBeforeExport` closure equals `startTime` passed to `_onAfterExport` closure +- [ ] 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..fdbfd057b5 100644 --- a/packages/pluggableWidgets/datagrid-web/src/features/data-export/ExportController.ts +++ b/packages/pluggableWidgets/datagrid-web/src/features/data-export/ExportController.ts @@ -4,6 +4,21 @@ 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: string; + endTime: Date; +}; + interface ControllerEvents { sourcechange: (ds: ListValue) => void; propertieschange: (ps: ColumnsType[]) => void; @@ -21,8 +36,12 @@ export class ExportController { private emitter: Emitter; private locked = false; private progressStore: TaskProgressService; + private name: string; + private _onBeforeExport: ((args: BeforeExportArgs) => void) | undefined; + private _onAfterExport: ((args: AfterExportArgs) => void) | undefined; - constructor(progress: TaskProgressService) { + constructor(name: string, progress: TaskProgressService) { + this.name = name; this.progressStore = progress; this.emitter = createNanoEvents(); this.emitter.on("columnschange", this.oncolumnschange); @@ -57,7 +76,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 +89,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 }); @@ -87,8 +114,28 @@ export class ExportController { ]; handler(req); + + const startTime = new Date(); + const chunkSize = req.limit; + this._onBeforeExport?.({ gridName: this.name, columnTitles, chunkSize, fileName, sheetName, startTime }); + await req.send(); + const endTime = new Date(); + const exportedItemCount = req.loaded; + const status = req.status === "end" ? "success" : "aborted"; + this._onAfterExport?.({ + gridName: this.name, + columnTitles, + chunkSize, + fileName, + sheetName, + exportedItemCount, + status, + startTime, + endTime + }); + // Dispose request requestBindings.forEach(unsubscribe => unsubscribe()); req = null; @@ -107,6 +154,14 @@ export class ExportController { }); } + setOnBeforeExport(cb: ((args: BeforeExportArgs) => void) | undefined): void { + this._onBeforeExport = cb; + } + + setOnAfterExport(cb: ((args: AfterExportArgs) => void) | undefined): void { + this._onAfterExport = cb; + } + abort = (): void => this.emitter.emit("abort"); createFilter(columns: number[]): (props: ColumnsType[]) => ColumnsType[] { 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..05ab530e8a --- /dev/null +++ b/packages/pluggableWidgets/datagrid-web/src/features/data-export/__tests__/ExportController.spec.ts @@ -0,0 +1,120 @@ +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.setOnBeforeExport(() => 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.setOnAfterExport(onAfter); + + await controller.exportData(jest.fn()); + + expect(onAfter).toHaveBeenCalledTimes(1); + expect(onAfter).toHaveBeenCalledWith(expect.objectContaining({ status: "success" })); + }); + + 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.setOnAfterExport(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.setOnBeforeExport(args => { + capturedStartTime = args.startTime; + }); + const onAfter = jest.fn(); + controller.setOnAfterExport(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/useDataExport.ts b/packages/pluggableWidgets/datagrid-web/src/features/data-export/useDataExport.ts index e2bdd06f71..5d1db5277f 100644 --- a/packages/pluggableWidgets/datagrid-web/src/features/data-export/useDataExport.ts +++ b/packages/pluggableWidgets/datagrid-web/src/features/data-export/useDataExport.ts @@ -1,3 +1,4 @@ +import { Big } from "big.js"; import { useCallback, useEffect, useState } from "react"; import { TaskProgressService } from "@mendix/widget-plugin-grid/main"; import { ExportController } from "./ExportController"; @@ -10,7 +11,7 @@ type ResourceEntry = { controller: ExportController; }; -type Props = Pick; +type Props = Pick; export function useDataExport( props: Props, @@ -44,13 +45,56 @@ export function useDataExport( ); }, [columnsStore.visibleColumns, entry]); + useEffect(() => { + const action = props.onBeforeExport; + if (!action) { + entry?.controller.setOnBeforeExport(undefined); + return; + } + entry?.controller.setOnBeforeExport(args => { + 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, props.onBeforeExport]); + + useEffect(() => { + const action = props.onAfterExport; + if (!action) { + entry?.controller.setOnAfterExport(undefined); + return; + } + entry?.controller.setOnAfterExport(args => { + 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, props.onAfterExport]); + 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..6d898a1b62 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,8 @@ 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 +189,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; From 3104043fbad08bb4e1bd3c00e1bf1ccf814f5b1b Mon Sep 17 00:00:00 2001 From: Roman Vyakhirev Date: Wed, 19 Aug 2026 12:17:05 +0200 Subject: [PATCH 2/4] fix(datagrid-web): address PR review feedback on export events - Narrow AfterExportArgs.status to "success" | "aborted" union type - Move onBeforeExport callback before handler(req) to match spec ordering - Update openspec artifacts to replace filterCondition with fileName/sheetName and document the intentional removal of filterCondition --- .../changes/datagrid-export-events/design.md | 10 +++++---- .../datagrid-export-events/proposal.md | 6 +++--- .../changes/datagrid-export-events/tasks.md | 10 ++++----- .../features/data-export/ExportController.ts | 6 +++--- .../datagrid-web/typings/DatagridProps.d.ts | 21 +++++++++++++++++-- 5 files changed, 36 insertions(+), 17 deletions(-) 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 index 6f5bdc1103..98dac692e8 100644 --- a/packages/pluggableWidgets/datagrid-web/openspec/changes/datagrid-export-events/design.md +++ b/packages/pluggableWidgets/datagrid-web/openspec/changes/datagrid-export-events/design.md @@ -59,11 +59,13 @@ The exported columns are the result of `filter(this.properties)` in `exportData( **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 — filterCondition as JSON string from datasource.filter +### D6 — fileName and sheetName passed from the export caller -`this.datasource.filter` is a `FilterCondition | undefined` from `mendix/filters`. It is the same condition applied to the datasource (same shape stored in personalization). `JSON.stringify` produces a consistent, portable string. +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**: `filterCondition = JSON.stringify(this.datasource.filter ?? null)`. +**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 @@ -75,7 +77,7 @@ The exported columns are the result of `filter(this.properties)` in `exportData( - **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. -- **filterCondition size** — complex nested filters can produce large JSON strings passed to a microflow string parameter. [Risk: truncation in Mendix unlimited string fields] → Mitigation: Mendix unlimited string attributes can hold up to 200MB; this is not a practical concern. +- **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. - **Callback mutation during export** — if `setOnAfterExport` is called while an export is in progress (rare), the new callback fires. [Risk: unexpected microflow called] → Mitigation: callbacks are reassigned only when React props change, which requires a re-render; during an export the datasource is locked so this is extremely unlikely. ## Open Questions 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 index e7c7fe5484..f9a754ea43 100644 --- a/packages/pluggableWidgets/datagrid-web/openspec/changes/datagrid-export-events/proposal.md +++ b/packages/pluggableWidgets/datagrid-web/openspec/changes/datagrid-export-events/proposal.md @@ -4,12 +4,12 @@ Developers using the Data Grid 2 export feature have no built-in way to observe ## What Changes -- Add `onBeforeExport` action property (optional) to Data Grid 2, firing just before the first datasource page fetch, with variables: `gridName`, `columnTitles`, `filterCondition`, `chunkSize`, `startTime` -- Add `onAfterExport` action property (optional) to Data Grid 2, firing after the export completes (success or abort), with variables: `gridName`, `columnTitles`, `filterCondition`, `chunkSize`, `exportedItemCount`, `status`, `startTime`, `endTime` +- 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 -- `filterCondition` is a JSON string in the same format as the personalization storage +- `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 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 index 36fa186a10..64d940fd5a 100644 --- a/packages/pluggableWidgets/datagrid-web/openspec/changes/datagrid-export-events/tasks.md +++ b/packages/pluggableWidgets/datagrid-web/openspec/changes/datagrid-export-events/tasks.md @@ -1,7 +1,7 @@ ## 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), `filterCondition` (String), `chunkSize` (Integer), `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), `filterCondition` (String), `chunkSize` (Integer), `exportedItemCount` (Integer), `status` (String), `startTime` (DateTime), `endTime` (DateTime) +- [ ] 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 @@ -13,15 +13,15 @@ - [ ] 3.1 Add `private _onBeforeExport: (() => void) | undefined` and `private _onAfterExport: (() => void) | undefined` fields to `ExportController` - [ ] 3.2 Add `setOnBeforeExport(cb: (() => void) | undefined): void` and `setOnAfterExport(cb: (() => void) | undefined): void` setter methods -- [ ] 3.3 In `exportData()`, before `req.send()`: capture `startTime = new Date()`, derive `columnTitles` from the filtered column properties (`filter(this.properties).map(c => c.header?.value ?? "").join(",")`), derive `filterCondition` as `JSON.stringify(this.datasource.filter ?? null)`, then call `this._onBeforeExport?.()` (fire-and-forget) +- [ ] 3.3 In `exportData()`, before `req.send()`: capture `startTime = new Date()`, derive `columnTitles` from the filtered column properties (`filter(this.properties).map(c => c.header?.value ?? "").join(",")`), read `fileName` and `sheetName` from options (default `""`), then call `this._onBeforeExport?.()` (fire-and-forget) — and do this BEFORE calling `handler(req)` so the callback literally precedes any handler side effects - [ ] 3.4 In `exportData()`, after `await req.send()` resolves but before `req = null`: read `req.loaded`, `req.limit`, `req.status` (map `"end"` → `"success"`, `"aborted"` → `"aborted"`), capture `endTime = new Date()`, then call `this._onAfterExport?.()` (fire-and-forget) - [ ] 3.5 Ensure the same `startTime` Date object is closed over by both `_onBeforeExport` and `_onAfterExport` calls within a single `exportData()` invocation ## 4. useDataExport — Wire props to controller callbacks - [ ] 4.1 Expand the `Props` type alias in `useDataExport.ts` to include `onBeforeExport` and `onAfterExport` from `DatagridContainerProps` -- [ ] 4.2 Add a `useEffect` that calls `entry.controller.setOnBeforeExport(...)` with a closure over `props.onBeforeExport` — the closure calls `action.execute({ gridName, columnTitles, filterCondition, chunkSize, startTime })` when `action.canExecute` is true; dependency array: `[entry, props.onBeforeExport]` -- [ ] 4.3 Add a `useEffect` that calls `entry.controller.setOnAfterExport(...)` with a closure over `props.onAfterExport` — the closure calls `action.execute({ gridName, columnTitles, filterCondition, chunkSize, exportedItemCount, status, startTime, endTime })` when `action.canExecute` is true; dependency array: `[entry, props.onAfterExport]` +- [ ] 4.2 Add a `useEffect` that calls `entry.controller.setOnBeforeExport(...)` with a closure over `props.onBeforeExport` — the closure calls `action.execute({ gridName, columnTitles, chunkSize, fileName, sheetName, startTime })` when `action.canExecute` is true; dependency array: `[entry, props.onBeforeExport]` +- [ ] 4.3 Add a `useEffect` that calls `entry.controller.setOnAfterExport(...)` with a closure over `props.onAfterExport` — the closure calls `action.execute({ gridName, columnTitles, chunkSize, fileName, sheetName, exportedItemCount, status, startTime, endTime })` when `action.canExecute` is true; dependency array: `[entry, props.onAfterExport]` - [ ] 4.4 Pass `onBeforeExport` and `onAfterExport` from `props` into `useDataExport` call in `Datagrid.tsx` ## 5. Tests 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 fdbfd057b5..5b5e43cfe9 100644 --- a/packages/pluggableWidgets/datagrid-web/src/features/data-export/ExportController.ts +++ b/packages/pluggableWidgets/datagrid-web/src/features/data-export/ExportController.ts @@ -15,7 +15,7 @@ export type BeforeExportArgs = { export type AfterExportArgs = BeforeExportArgs & { exportedItemCount: number; - status: string; + status: "success" | "aborted"; endTime: Date; }; @@ -113,12 +113,12 @@ export class ExportController { this.emitter.on("abort", req.abort) ]; - handler(req); - const startTime = new Date(); const chunkSize = req.limit; this._onBeforeExport?.({ gridName: this.name, columnTitles, chunkSize, fileName, sheetName, startTime }); + handler(req); + await req.send(); const endTime = new Date(); diff --git a/packages/pluggableWidgets/datagrid-web/typings/DatagridProps.d.ts b/packages/pluggableWidgets/datagrid-web/typings/DatagridProps.d.ts index 6d898a1b62..6edf2ec82c 100644 --- a/packages/pluggableWidgets/datagrid-web/typings/DatagridProps.d.ts +++ b/packages/pluggableWidgets/datagrid-web/typings/DatagridProps.d.ts @@ -119,8 +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 }>; + 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; From 158efd626dae83d0603b64a575e00692629499f3 Mon Sep 17 00:00:00 2001 From: Roman Vyakhirev Date: Thu, 20 Aug 2026 15:48:49 +0200 Subject: [PATCH 3/4] chore: use cleaner approach to get export events --- .../changes/datagrid-export-events/design.md | 16 +++++----- .../changes/datagrid-export-events/tasks.md | 29 +++++++++--------- .../features/data-export/ExportController.ts | 29 ++++++++++-------- .../__tests__/ExportController.spec.ts | 10 +++---- .../src/features/data-export/useDataExport.ts | 30 ++++++++----------- 5 files changed, 57 insertions(+), 57 deletions(-) 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 index 98dac692e8..556853edd5 100644 --- a/packages/pluggableWidgets/datagrid-web/openspec/changes/datagrid-export-events/design.md +++ b/packages/pluggableWidgets/datagrid-web/openspec/changes/datagrid-export-events/design.md @@ -27,19 +27,19 @@ There is currently no hook for the widget to observe the start or end of an expo ## Decisions -### D1 — Plain callbacks on ExportController, not ActionValue +### D1 — NanoEvents on ExportController, not stored callbacks -`ExportController` already holds a `ListValue` (Mendix API), so coupling it further with `ActionValue` is technically feasible. However the existing pattern keeps the controller as a data coordinator: it reacts to events, not Mendix props. `useDataExport` is the right place to bridge Mendix props to the controller. +`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**: Store `onBeforeExport` and `onAfterExport` as `(() => void) | undefined` on `ExportController`. `useDataExport` creates the closures that call `actionValue.execute(args)` and assigns them via setter methods on the controller. This keeps `ExportController` testable without Mendix mocks. +**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**: Pass `ActionValue` directly into the constructor. Rejected because it couples the controller to Mendix types and makes the constructor dependent on optional props that may change between renders. +**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 — Callback assignment via setter methods, updated on every render +### D2 — Subscribe once, read latest ActionValue from ref -Props can change between renders (e.g. action configuration changed in Studio Pro). The callbacks must always reflect the latest prop values. +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**: `ExportController` exposes `setOnBeforeExport(cb)` and `setOnAfterExport(cb)` setters. `useDataExport` calls these in a `useEffect` that runs whenever `props.onBeforeExport` / `props.onAfterExport` change. This matches the existing pattern of emitting `"sourcechange"` / `"propertieschange"` on every render. +**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 @@ -78,7 +78,7 @@ The datagrid widget does not know the target file or sheet name — those are de - **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. -- **Callback mutation during export** — if `setOnAfterExport` is called while an export is in progress (rare), the new callback fires. [Risk: unexpected microflow called] → Mitigation: callbacks are reassigned only when React props change, which requires a re-render; during an export the datasource is locked so this is extremely unlikely. +- **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 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 index 64d940fd5a..736082ed59 100644 --- a/packages/pluggableWidgets/datagrid-web/openspec/changes/datagrid-export-events/tasks.md +++ b/packages/pluggableWidgets/datagrid-web/openspec/changes/datagrid-export-events/tasks.md @@ -9,27 +9,28 @@ - [ ] 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 callback slots and invocation +## 3. ExportController — Add NanoEvents and public on() -- [ ] 3.1 Add `private _onBeforeExport: (() => void) | undefined` and `private _onAfterExport: (() => void) | undefined` fields to `ExportController` -- [ ] 3.2 Add `setOnBeforeExport(cb: (() => void) | undefined): void` and `setOnAfterExport(cb: (() => void) | undefined): void` setter methods -- [ ] 3.3 In `exportData()`, before `req.send()`: capture `startTime = new Date()`, derive `columnTitles` from the filtered column properties (`filter(this.properties).map(c => c.header?.value ?? "").join(",")`), read `fileName` and `sheetName` from options (default `""`), then call `this._onBeforeExport?.()` (fire-and-forget) — and do this BEFORE calling `handler(req)` so the callback literally precedes any handler side effects -- [ ] 3.4 In `exportData()`, after `await req.send()` resolves but before `req = null`: read `req.loaded`, `req.limit`, `req.status` (map `"end"` → `"success"`, `"aborted"` → `"aborted"`), capture `endTime = new Date()`, then call `this._onAfterExport?.()` (fire-and-forget) -- [ ] 3.5 Ensure the same `startTime` Date object is closed over by both `_onBeforeExport` and `_onAfterExport` calls within a single `exportData()` invocation +- [ ] 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 callbacks +## 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 Add a `useEffect` that calls `entry.controller.setOnBeforeExport(...)` with a closure over `props.onBeforeExport` — the closure calls `action.execute({ gridName, columnTitles, chunkSize, fileName, sheetName, startTime })` when `action.canExecute` is true; dependency array: `[entry, props.onBeforeExport]` -- [ ] 4.3 Add a `useEffect` that calls `entry.controller.setOnAfterExport(...)` with a closure over `props.onAfterExport` — the closure calls `action.execute({ gridName, columnTitles, chunkSize, fileName, sheetName, exportedItemCount, status, startTime, endTime })` when `action.canExecute` is true; dependency array: `[entry, props.onAfterExport]` -- [ ] 4.4 Pass `onBeforeExport` and `onAfterExport` from `props` into `useDataExport` call in `Datagrid.tsx` +- [ ] 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` calls `_onBeforeExport` callback once before data is streamed -- [ ] 5.2 Add unit test: `ExportController` calls `_onAfterExport` callback once after `req.send()` resolves on success, with status `"success"` -- [ ] 5.3 Add unit test: `ExportController` calls `_onAfterExport` with status `"aborted"` when `abort()` is called -- [ ] 5.4 Add unit test: `startTime` passed to `_onBeforeExport` closure equals `startTime` passed to `_onAfterExport` closure +- [ ] 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 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 5b5e43cfe9..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,5 +1,5 @@ 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"; @@ -25,6 +25,8 @@ interface ControllerEvents { columnschange: (columns: number[]) => void; exportend: () => void; abort: () => void; + beforeexport: (args: BeforeExportArgs) => void; + afterexport: (args: AfterExportArgs) => void; } type RequestHandler = (req: DSExportRequest) => void; @@ -37,8 +39,6 @@ export class ExportController { private locked = false; private progressStore: TaskProgressService; private name: string; - private _onBeforeExport: ((args: BeforeExportArgs) => void) | undefined; - private _onAfterExport: ((args: AfterExportArgs) => void) | undefined; constructor(name: string, progress: TaskProgressService) { this.name = name; @@ -53,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; @@ -115,7 +119,14 @@ export class ExportController { const startTime = new Date(); const chunkSize = req.limit; - this._onBeforeExport?.({ gridName: this.name, columnTitles, chunkSize, fileName, sheetName, startTime }); + this.emitter.emit("beforeexport", { + gridName: this.name, + columnTitles, + chunkSize, + fileName, + sheetName, + startTime + }); handler(req); @@ -124,7 +135,7 @@ export class ExportController { const endTime = new Date(); const exportedItemCount = req.loaded; const status = req.status === "end" ? "success" : "aborted"; - this._onAfterExport?.({ + this.emitter.emit("afterexport", { gridName: this.name, columnTitles, chunkSize, @@ -154,14 +165,6 @@ export class ExportController { }); } - setOnBeforeExport(cb: ((args: BeforeExportArgs) => void) | undefined): void { - this._onBeforeExport = cb; - } - - setOnAfterExport(cb: ((args: AfterExportArgs) => void) | undefined): void { - this._onAfterExport = cb; - } - abort = (): void => this.emitter.emit("abort"); createFilter(columns: number[]): (props: ColumnsType[]) => ColumnsType[] { 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 index 05ab530e8a..df9624dcad 100644 --- 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 @@ -64,7 +64,7 @@ describe("ExportController export callbacks", () => { }); const controller = makeController(); - controller.setOnBeforeExport(() => callOrder.push("onBefore")); + controller.on("beforeexport", () => callOrder.push("onBefore")); await controller.exportData(jest.fn()); @@ -74,7 +74,7 @@ describe("ExportController export callbacks", () => { it("calls onAfterExport once after send() resolves with status 'success'", async () => { const controller = makeController(); const onAfter = jest.fn(); - controller.setOnAfterExport(onAfter); + controller.on("afterexport", onAfter); await controller.exportData(jest.fn()); @@ -89,7 +89,7 @@ describe("ExportController export callbacks", () => { const controller = makeController(); const onAfter = jest.fn(); - controller.setOnAfterExport(onAfter); + controller.on("afterexport", onAfter); await controller.exportData(jest.fn()); @@ -101,11 +101,11 @@ describe("ExportController export callbacks", () => { const controller = makeController(); let capturedStartTime: Date | undefined; - controller.setOnBeforeExport(args => { + controller.on("beforeexport", args => { capturedStartTime = args.startTime; }); const onAfter = jest.fn(); - controller.setOnAfterExport(onAfter); + controller.on("afterexport", onAfter); await controller.exportData(jest.fn()); 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 5d1db5277f..d1834ebdae 100644 --- a/packages/pluggableWidgets/datagrid-web/src/features/data-export/useDataExport.ts +++ b/packages/pluggableWidgets/datagrid-web/src/features/data-export/useDataExport.ts @@ -1,5 +1,5 @@ import { Big } from "big.js"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { TaskProgressService } from "@mendix/widget-plugin-grid/main"; import { ExportController } from "./ExportController"; import { getExportRegistry } from "./registry"; @@ -20,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(() => { @@ -46,13 +50,9 @@ export function useDataExport( }, [columnsStore.visibleColumns, entry]); useEffect(() => { - const action = props.onBeforeExport; - if (!action) { - entry?.controller.setOnBeforeExport(undefined); - return; - } - entry?.controller.setOnBeforeExport(args => { - if (action.canExecute) { + return entry?.controller.on("beforeexport", args => { + const action = onBeforeExportRef.current; + if (action?.canExecute) { action.execute({ gridName: args.gridName, columnTitles: args.columnTitles, @@ -63,16 +63,12 @@ export function useDataExport( }); } }); - }, [entry, props.onBeforeExport]); + }, [entry]); useEffect(() => { - const action = props.onAfterExport; - if (!action) { - entry?.controller.setOnAfterExport(undefined); - return; - } - entry?.controller.setOnAfterExport(args => { - if (action.canExecute) { + return entry?.controller.on("afterexport", args => { + const action = onAfterExportRef.current; + if (action?.canExecute) { action.execute({ gridName: args.gridName, columnTitles: args.columnTitles, @@ -86,7 +82,7 @@ export function useDataExport( }); } }); - }, [entry, props.onAfterExport]); + }, [entry]); return [abort]; } From 18123f0b40bb502cd8d18d5ee427626168e366a2 Mon Sep 17 00:00:00 2001 From: Roman Vyakhirev Date: Fri, 21 Aug 2026 13:54:50 +0200 Subject: [PATCH 4/4] chore: review comments --- .../__tests__/ExportController.spec.ts | 10 +- .../__tests__/useDataExport.spec.ts | 187 ++++++++++++++++++ .../src/features/data-export/useDataExport.ts | 4 +- 3 files changed, 198 insertions(+), 3 deletions(-) create mode 100644 packages/pluggableWidgets/datagrid-web/src/features/data-export/__tests__/useDataExport.spec.ts 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 index df9624dcad..362f8ad3cc 100644 --- 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 @@ -79,7 +79,15 @@ describe("ExportController export callbacks", () => { await controller.exportData(jest.fn()); expect(onAfter).toHaveBeenCalledTimes(1); - expect(onAfter).toHaveBeenCalledWith(expect.objectContaining({ status: "success" })); + 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 () => { 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 d1834ebdae..6d76c8ac66 100644 --- a/packages/pluggableWidgets/datagrid-web/src/features/data-export/useDataExport.ts +++ b/packages/pluggableWidgets/datagrid-web/src/features/data-export/useDataExport.ts @@ -50,7 +50,7 @@ export function useDataExport( }, [columnsStore.visibleColumns, entry]); useEffect(() => { - return entry?.controller.on("beforeexport", args => { + return entry.controller.on("beforeexport", args => { const action = onBeforeExportRef.current; if (action?.canExecute) { action.execute({ @@ -66,7 +66,7 @@ export function useDataExport( }, [entry]); useEffect(() => { - return entry?.controller.on("afterexport", args => { + return entry.controller.on("afterexport", args => { const action = onAfterExportRef.current; if (action?.canExecute) { action.execute({