Skip to content
Draft
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
2 changes: 2 additions & 0 deletions packages/pluggableWidgets/datagrid-web/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),

- We fixed an issue where export type and format properties were visible in Studio Pro for dynamic text columns, even though they have no effect.

- We fixed an issue where the "Clear Selection" JavaScript action did not clear the selection when the "Keep selection" property was enabled. The selection now clears, and keep selection resumes on subsequent paging or refresh.

## [3.11.1] - 2026-06-18

### Fixed
Expand Down
2 changes: 2 additions & 0 deletions packages/pluggableWidgets/gallery-web/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),

- We fixed an issue where "Reset All Filters" and "Clear Selection" JavaScript actions did not work with the Gallery widget. Event listeners were not registered despite the required infrastructure being in place.

- We fixed an issue where the "Clear Selection" JavaScript action did not clear the selection when the "Keep selection" property was enabled. The selection now clears, and keep selection resumes on subsequent paging or refresh.

## [3.11.0] - 2026-05-27

### Added
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: tdd-refactor
created: 2026-07-21
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
## Test Cases

Tests live in `packages/shared/widget-plugin-grid/src/selection/__tests__/` (Jest). The keep
predicate is exercised via the updated `SelectionMultiValueBuilder`, which must capture the
function passed to `setKeepSelection` and expose it (e.g. `getKeepSelectionPredicate()`).

### Reproduction Tests

- clear-beats-keep (multi) - Clear empties selection even when keep is enabled (unit)
- **Given**: A `MultiSelectionHelper` created with `keepSelection: true`, `setup()` run, and
two items selected.
- **When**: `clearSelection()` is called.
- **Then**: `selection.setSelection([])` was called AND, at the moment of the
reconciliation, the captured keep predicate returns `false` (i.e. the runtime would not
restore). Selection ends empty.

- clear-beats-keep (single) - Clear/remove empties single selection when keep is enabled (unit)
- **Given**: A `SingleSelectionHelper` created with `keepSelection: true`, `setup()` run, one
item selected.
- **When**: the clear path (`remove()`) is called.
- **Then**: `setSelection(undefined)` called and the keep predicate returns `false` during
the clear cycle.

### Edge Cases

- keep-resumes-after-clear - Keep selection re-arms after a clear completes (unit)
- **Given**: keep enabled, items selected, `clearSelection()` called.
- **When**: a datasource reconciliation delivers a new selection ref (the `when` guard fires
on ref change).
- **Then**: the keep predicate returns `true` again, so a subsequent datasource update would
retain selection.

- rearm-on-reselect - Keep re-arms even if the user re-selects before the clear lands (unit)
- **Given**: keep enabled, items selected, `clearSelection()` called (keep now yields).
- **When**: the next reconciliation delivers a _non-empty_ new selection (the user picked a
different item before the empty selection ever arrived).
- **Then**: the keep predicate returns `true` — re-arm keys off the reconciliation (ref
change), not off emptiness, so the new selection is retained on later paging/refresh.

- double-clear-reentrancy - Two clears before the first reconciliation lands do not leave keep off (unit)
- **Given**: keep enabled, `clearSelection()` called twice in succession.
- **When**: the next reconciliation delivers a new selection ref.
- **Then**: the earlier pending `when` is disposed, exactly one re-arm occurs, and the
predicate returns `true` at the end.

- clear-without-keep - Clear behaves as today when keep is disabled (unit)
- **Given**: helper created with `keepSelection: false`.
- **When**: `clearSelection()` is called.
- **Then**: `setSelection([])` called, no keep predicate installed, no `when` watcher created.

- dispose-mid-clear - Pending re-arm watcher is cleaned up on teardown (unit)
- **Given**: keep enabled, `clearSelection()` called, next reconciliation not yet observed.
- **When**: the disposer returned by `setup()` runs.
- **Then**: the pending `when` is disposed; no reaction leaks (no re-arm fires afterwards).

### Regression Tests

- existing selection ops unchanged - `selectAll`, `selectNone`, `add`, `remove`, range select,
`selectionStatus` keep current behavior (unit) — existing `helpers.spec.ts` cases remain green.
- **Given**: existing selection helper specs.
- **When**: run after the change.
- **Then**: all pass unchanged.

- predicate installed once - keep predicate is installed via `setup()`, not the constructor (unit)
- **Given**: helper created with `keepSelection: true` but `setup()` not yet called.
- **When**: constructor completes.
- **Then**: `setKeepSelection` has not been called; calling `setup()` installs it.

## Notes

- Correctness hinges on the transient flag staying `false` long enough to cover the runtime's
restore. The re-arm fires on the next reconciliation (a new `selection` ref), which is
necessarily _after_ the runtime has already made its keep decision for that reconciliation — so
re-arm can never let keep win over the clear it is paired with. This is a Mendix runtime timing
detail not fully verifiable from unit tests; a manual/E2E verification in the real widget is
planned (drive a JS clear-selection action with keep enabled) before merge.
- Re-arm compares the observed `selection` **reference** to the one captured at clear time rather
than inspecting emptiness. Reason: the user may re-select a different item before the empty
selection ever reconciles; keying off "empty" would strand keep in the `false` state and drop
that new selection on the next page/refresh. A ref change is the reconciliation signal itself,
independent of the resulting selection contents.
- The keep flag (`keepActive` observable) and the re-arm `when` live directly on each helper; no
separate class. `beforeClear()` snapshots the current ref, drops the flag, and installs a fresh
`when` (disposing any prior pending one, covering re-entrant/double clears).
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
## Why

The "Clear selection" JS action (used by both Data Grid 2 and Gallery) does not clear the
selection when the widget's `Keep selection` property is enabled. Keep selection always wins.

- **Observed**: With `Keep selection` on, invoking the `Clear_Selection` JS action empties the
selection momentarily, but the previous selection is restored on the next datasource
reconciliation. The user sees the selection stay put.
- **Expected**: An explicit clear action empties the selection, and keep selection continues to
apply on subsequent paging/refresh. This matches the `Keep selection` property description:
"selected items will stay selected unless cleared by the user or a Nanoflow."

## Root Cause

`createSelectionHelper.ts` installs an unconditional keep-selection predicate at helper creation:

```ts
if (config.keepSelection) {
selection?.setKeepSelection(() => true);
}
```

`setKeepSelection` registers a predicate the Mendix runtime evaluates lazily during each
datasource reconciliation to decide whether to retain the prior selection. Because it returns
`true` unconditionally, the clear action's `setSelection([])` (in `helpers.ts`) is undone on the
next reconciliation — the runtime re-applies the kept selection. There is no path for the clear
action to make the predicate yield.

No existing test caught this: the test util (`SelectionMultiValueBuilder`) stubs
`setKeepSelection` as a no-op, so the keep-vs-clear interaction is never exercised.

## What Changes

Scope: `packages/shared/widget-plugin-grid` only. Both Data Grid 2 and Gallery inherit the fix
through the shared `createSelectionHelper` — no per-widget code changes.

- Replace the constant predicate with one backed by observable state owned by the selection
helper: a stable `keepSelection` config flag plus a transient "retain now" observable the
predicate reads live (`setKeepSelection(() => active.get())`).
- `clearSelection()` (Multi) and `remove()`/clear path (Single) drop the transient flag to
`false` around `setSelection([])`, then re-arm it on the next datasource reconciliation — a
`when` guard that compares the observed `selection` **reference** against the one snapshotted at
clear time (the runtime schedules new props, so a fresh ref means the reconciliation ran and the
runtime's keep decision is already past). This re-arms whether the reconciliation delivers an
empty selection (clear landed) or a new one (the user re-selected before the clear landed).
- The keep flag and re-arm `when` live directly on each selection helper (`MultiSelectionHelper`,
`SingleSelectionHelper`); no separate armer class. Install the predicate and manage the `when`
disposer through the standard `setup()` host lifecycle instead of eagerly in
`createSelectionHelper`.
- Make the test util capture the predicate so the interaction is testable.

## Impact

- **Widgets affected**: Data Grid 2 (`datagrid-web`), Gallery (`gallery-web`) — behavior only,
no source changes in those packages.
- **API**: No public API change. `createSelectionHelper` signature unchanged (config still
carries `keepSelection`).
- **Behavior**: Bug fix — clear action now wins over keep selection, then keep resumes. Not
breaking; restores the documented contract.
- **Tests**: `widget-plugin-test-utils` `SelectionMultiValueBuilder` gains a real
`setKeepSelection` capture (was a no-op stub).
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
## 1. Test Setup

<!-- RED: Write failing tests first -->

- [x] 1.1 Update `SelectionMultiValueBuilder` (and single builder if present) in
`widget-plugin-test-utils` to capture the `setKeepSelection` predicate and expose it
- [x] 1.2 Write failing test: clear-beats-keep for multi selection (predicate returns `false`
during the clear cycle, selection ends empty)
- [x] 1.3 Write failing test: clear-beats-keep for single selection (`remove()` path)
- [x] 1.4 Add edge-case tests: keep-resumes-after-clear, rearm-on-reselect,
double-clear-reentrancy, clear-without-keep, dispose-mid-clear
- [x] 1.5 Add regression test: predicate installed via `setup()`, not constructor

## 2. Implementation

<!-- GREEN: Make tests pass with minimal code -->

- [x] 2.1 Add `keepSelection` (stable config) + `keepActive` (observable box) state directly to
`MultiSelectionHelper`; pass `keepSelection` in via constructor
- [x] 2.2 Implement `setup()` on the helper: install `setKeepSelection(() => keepActive.get())`
when configured; return disposer that cancels any pending re-arm `when`
- [x] 2.3 Update `clearSelection()` to snapshot the current `selection` ref, drop `keepActive`,
`setSelection([])`, then re-arm via `when(() => selectionValue !== clearedRef, () =>
keepActive.set(true))` guarded by the config flag
- [x] 2.4 Apply the same flag + re-arm logic to `SingleSelectionHelper` clear path (`remove()`)
- [x] 2.5 Update `createSelectionHelper.ts`: pass `config.keepSelection` to constructors, remove
the unconditional `setKeepSelection(() => true)`, and `host.add(helper)`
- [x] 2.6 Delete `KeepSelectionArmer.ts` and remove `onReconcile` calls from `updateProps`
- [x] 2.7 Verify no regressions in existing selection specs

## 3. Refactoring

<!-- REFACTOR: Clean up while keeping tests green -->

- [x] 3.1 Clean up implementation; ensure re-entrancy disposer handling is clear (dispose prior
pending `when` on each `beforeClear`)
- [x] 3.2 Keep the inlined keep/clear logic minimal and consistent across Multi and Single

## 4. Verification

- [x] 4.1 All tests passing (including new tests) — `pnpm run test` in `widget-plugin-grid`
- [x] 4.2 Full test suite passes for `datagrid-web` and `gallery-web` (no regressions) —
selection-touching suites (row-interaction, item-keyboard) all green; the remaining failing
suites fail identically on clean `main` due to a pre-existing dual-mendix-version type
conflict (10.24 vs 11.10) in test helpers, unrelated to this change
- [ ] 4.3 Manual/E2E verify: drive `Clear_Selection` JS action with `Keep selection` enabled in a
real widget; confirm selection clears and keep resumes on next page change
- [x] 4.4 Add per-widget CHANGELOG entries for `datagrid-web` and `gallery-web`
- [x] 4.5 Code review ready (clean, documented)

## Notes

<!-- Track test failures, refactoring decisions, blockers. -->

- Runtime timing (does the re-arm fire before/after the runtime's keep restore?) is
the main risk; task 4.3 is the gate that confirms the unit-level approach holds in the real
runtime.
- Final design: the keep logic is inlined into each helper (no separate class). `beforeClear()`
snapshots the current `selection` ref, drops the `keepActive` flag, and installs a
`when(() => selectionValue !== clearedRef, () => keepActive.set(true))`. Rationale: the runtime
does not mutate the selection in place on `setSelection([])`; it schedules new props, so the
next reconciliation delivers a fresh `selectionValue` ref. Comparing the ref (rather than
inspecting emptiness) re-arms on the reconciliation signal itself — correct whether the delivered
selection is empty (clear landed) or a new selection (the user re-selected before the empty
landed). Emptiness-based re-arm would strand keep off in the re-select case. The `when`
self-disposes after firing; `beforeClear()` disposes any prior pending `when` (double/re-entrant
clears), and `setup()`'s disposer cancels a pending `when` on teardown.
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
name: tdd-refactor
version: 1
description: Test-driven development for refactoring and fixes - proposal → design → tasks
artifacts:
- id: proposal
generates: proposal.md
description: Problem statement and change overview
template: proposal.md
instruction: |
Create a focused proposal for the refactoring or fix.

For bugs:
- **Why**: What's broken? Observed vs. expected behavior
- **Root Cause**: Technical reason for the bug (if known)
- **What Changes**: What will be fixed? Which files/components?
- **Impact**: Who/what is affected? Is this breaking?

For refactoring:
- **Why**: What problem does current code have? Tech debt, maintainability, performance?
- **What Changes**: What will be restructured? Be specific about scope
- **Impact**: Affected code, APIs, or behavior changes (should be minimal for pure refactoring)

Keep it concise (1 page max). This is about fixing or improving existing code,
not adding new features. If you're adding features, use spec-driven instead.
requires: []

- id: design
generates: design.md
description: Test plan defining what needs to pass
template: design.md
instruction: |
Define the test cases that will verify the fix or refactoring.

For bugs:
- Reproduction test (currently failing)
- Edge cases related to the bug
- Regression tests for related functionality

For refactoring:
- Existing behavior preservation tests
- Tests for improved cases (if applicable)
- Performance/maintainability verification (if relevant)

Format:
```
## Test Cases

### Category Name

- Test name - Description (unit/integration/e2e)
- **Given**: Setup/preconditions
- **When**: Action/trigger
- **Then**: Expected outcome
```

Write tests that will FAIL initially (for bugs) or verify existing behavior (for refactoring).
Tests define the contract - implementation must make them pass without breaking anything.
requires:
- proposal

- id: tasks
generates: tasks.md
description: Implementation task breakdown
template: tasks.md
instruction: |
Break down the implementation into trackable TDD tasks.

**IMPORTANT: Follow the template below exactly.** Use checkbox format: `- [ ] X.Y Description`

Group tasks by phase:

## 1. Test Setup
- [ ] 1.1 Write failing test for main issue
- [ ] 1.2 Add edge case tests

## 2. Implementation
- [ ] 2.1 Fix core issue (make main test pass)
- [ ] 2.2 Handle edge cases (make edge tests pass)

## 3. Refactoring
- [ ] 3.1 Clean up implementation while keeping tests green
- [ ] 3.2 Extract common logic/improve structure

## 4. Verification
- [ ] 4.1 All tests passing
- [ ] 4.2 No regressions (run full test suite)

Order tasks by TDD cycle: Red (failing test) → Green (make it pass) → Refactor (clean up).
Each task should be completable in one session.
requires:
- design

apply:
requires: [design, tasks]
tracks: tasks.md
instruction: |
Follow TDD workflow:
1. Review proposal.md - understand what's broken or needs refactoring
2. Review design.md - understand what tests need to pass
3. Work through tasks.md in order:
- Write failing tests first (Red)
- Implement minimal fix to pass tests (Green)
- Refactor while keeping tests green
4. Mark task checkboxes as you complete them

All tests must pass before marking complete. Pause if blocked or need clarification.
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
## Test Cases

<!-- For bugs: start with reproduction test. For refactoring: verify existing behavior preserved. -->

### Reproduction Tests

- Test name - Description (unit/integration/e2e)
- **Given**: Setup/preconditions
- **When**: Action/trigger
- **Then**: Expected outcome

### Edge Cases

- Test name - Description (unit/integration/e2e)
- **Given**: Setup/preconditions
- **When**: Action/trigger
- **Then**: Expected outcome

### Regression Tests

- Test name - Description (unit/integration/e2e)
- **Given**: Setup/preconditions
- **When**: Action/trigger
- **Then**: Expected outcome

## Notes

<!-- Track unexpected behaviors, additional edge cases found, test failures and resolutions. -->
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
## Why

<!-- For bugs: observed vs expected behavior. For refactoring: tech debt or maintainability issue. -->

## Root Cause

<!-- Technical reason for the bug or what makes current code problematic. -->

## What Changes

<!-- Which files/components affected? Mark breaking changes with **BREAKING**. -->

## Impact

<!-- Affected code, APIs, users. Breaking changes? Migration needed? -->
Loading
Loading