Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/pluggableWidgets/datagrid-web/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-18
Original file line number Diff line number Diff line change
@@ -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<string>`.

**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.
Original file line number Diff line number Diff line change
@@ -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

<!-- No existing spec-level requirements change — this is a purely additive capability -->

## Impact

- **`src/Datagrid.xml`** — two new `<property>` blocks with `<actionVariables>` added to the Events `<propertyGroup>`
- **`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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
## 1. XML — Declare action properties

- [ ] 1.1 Add `onBeforeExport` property block (type="action", required="false") to the Events `<propertyGroup>` in `src/Datagrid.xml`, with `<actionVariables>` 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 `<propertyGroup>` in `src/Datagrid.xml`, with `<actionVariables>` 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<K>(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
Loading
Loading