Skip to content

Commit 66f0943

Browse files
committed
feat(datagrid-web): add untracked shared files, version bump, changelog update
1 parent 8e59fd2 commit 66f0943

File tree

7 files changed

+330
-5
lines changed

7 files changed

+330
-5
lines changed

packages/modules/data-widgets/src/themesource/datawidgets/web/_datagrid.scss

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -420,7 +420,8 @@ $root: ".widget-datagrid";
420420
align-items: center;
421421
}
422422

423-
&-exporting {
423+
&-exporting,
424+
&-selecting-all-pages {
424425
.widget-datagrid-top-bar,
425426
.widget-datagrid-header,
426427
.widget-datagrid-content,
@@ -433,6 +434,30 @@ $root: ".widget-datagrid";
433434
}
434435
}
435436

437+
// Better positioning for multi-page selection modal
438+
&-selecting-all-pages {
439+
.widget-datagrid-modal {
440+
&-overlay {
441+
position: fixed;
442+
top: 0;
443+
right: 0;
444+
bottom: 0;
445+
left: 0;
446+
}
447+
448+
&-main {
449+
position: fixed;
450+
top: 0;
451+
left: 0;
452+
right: 0;
453+
bottom: 0;
454+
display: flex;
455+
align-items: center;
456+
justify-content: center;
457+
}
458+
}
459+
}
460+
436461
&-col-select input:focus-visible {
437462
outline-offset: 0;
438463
}

packages/pluggableWidgets/datagrid-web/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66

77
## [Unreleased]
88

9+
### Added
10+
11+
- We added multi-page select all functionality for Datagrid widget with configurable batch processing, progress tracking, and page restoration to allow users to select all items across multiple pages with a single click.
12+
913
## [3.4.0] - 2025-09-12
1014

1115
### Fixed

packages/pluggableWidgets/datagrid-web/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@mendix/datagrid-web",
33
"widgetName": "Datagrid",
4-
"version": "3.4.0",
4+
"version": "3.5.0",
55
"description": "A powerful, flexible grid for displaying, sorting, and editing data collections in Mendix web apps.",
66
"copyright": "© Mendix Technology BV 2025. All rights reserved.",
77
"license": "Apache-2.0",

packages/pluggableWidgets/datagrid-web/src/package.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<?xml version="1.0" encoding="utf-8" ?>
22
<package xmlns="http://www.mendix.com/package/1.0/">
3-
<clientModule name="Datagrid" version="3.4.0" xmlns="http://www.mendix.com/clientModule/1.0/">
3+
<clientModule name="Datagrid" version="3.5.0" xmlns="http://www.mendix.com/clientModule/1.0/">
44
<widgetFiles>
55
<widgetFile path="Datagrid.xml" />
66
</widgetFiles>
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
import { ListValue, ObjectItem } from "mendix";
2+
import { createNanoEvents, Emitter } from "nanoevents";
3+
4+
export interface TraversalOptions {
5+
chunkSize?: number;
6+
signal?: AbortSignal;
7+
onProgress?: (processed: number, total?: number) => void;
8+
onChunk?: (items: ObjectItem[], offset: number) => void | Promise<void>;
9+
}
10+
11+
interface TraversalEvents {
12+
statechange: (state: { offset: number; limit: number; status: string }) => void;
13+
}
14+
15+
/**
16+
* Traverses all items in a ListValue datasource by paginating through chunks.
17+
* Mirrors the pattern used by ExportController - snapshots state, iterates, then restores.
18+
*/
19+
export async function traverseAllItems(ds: ListValue, options: TraversalOptions = {}): Promise<void> {
20+
// Use the datasource's current limit as chunk size to respect its pagination
21+
const naturalChunkSize = ds.limit ?? 25;
22+
const { chunkSize = naturalChunkSize, signal, onProgress, onChunk } = options;
23+
24+
// Snapshot current state
25+
const snapshot = {
26+
offset: ds.offset,
27+
limit: ds.limit
28+
};
29+
30+
// Create event emitter for tracking datasource changes
31+
const emitter: Emitter<TraversalEvents> = createNanoEvents();
32+
let processed = 0;
33+
34+
try {
35+
// Start from the beginning
36+
let currentOffset = 0;
37+
let hasMore = true;
38+
let chunkIndex = 0;
39+
40+
while (hasMore && !signal?.aborted) {
41+
// Set pagination parameters
42+
43+
ds.setOffset(currentOffset);
44+
if (ds.limit !== chunkSize) {
45+
ds.setLimit(chunkSize);
46+
}
47+
48+
// Trigger reload
49+
ds.reload();
50+
51+
// Wait for reload to complete with simpler polling
52+
await waitForReload(ds, currentOffset, chunkSize);
53+
54+
// Process items
55+
const items = ds.items ?? [];
56+
57+
if (items.length === 0) {
58+
break; // No more items
59+
}
60+
61+
// Handle the chunk
62+
if (onChunk) {
63+
await onChunk(items, currentOffset);
64+
}
65+
66+
// Update progress
67+
processed += items.length;
68+
onProgress?.(processed, ds.totalCount);
69+
70+
// Check if this was the last page
71+
// Stop if we've processed all items according to totalCount
72+
// or if we got fewer items than expected
73+
const totalCount = ds.totalCount;
74+
const reachedTotal = totalCount && processed >= totalCount;
75+
const gotPartialChunk = items.length < chunkSize;
76+
hasMore = !reachedTotal && !gotPartialChunk;
77+
currentOffset += chunkSize;
78+
chunkIndex++;
79+
80+
// Yield to UI
81+
await new Promise(resolve => setTimeout(resolve, 0));
82+
}
83+
84+
if (signal?.aborted) {
85+
// Traversal aborted
86+
} else {
87+
// Traversal completed
88+
}
89+
} finally {
90+
// Always restore original view state
91+
await restoreSnapshot(ds, snapshot, emitter);
92+
}
93+
}
94+
95+
/**
96+
* Reloads the datasource and waits for it to stabilize
97+
*/
98+
async function reloadAndWait(ds: ListValue, emitter: Emitter<TraversalEvents>): Promise<void> {
99+
const targetOffset = ds.offset;
100+
const targetLimit = ds.limit;
101+
102+
// Set up one-time listener for state change
103+
const statePromise = new Promise<void>(resolve => {
104+
const checkState = (): void => {
105+
if (ds.status !== "loading" && ds.offset === targetOffset && ds.limit === targetLimit) {
106+
resolve();
107+
}
108+
};
109+
110+
// Check immediately in case already loaded
111+
checkState();
112+
113+
// Otherwise wait for state change
114+
const unsubscribe = emitter.on("statechange", state => {
115+
if (state.status !== "loading" && state.offset === targetOffset && state.limit === targetLimit) {
116+
unsubscribe();
117+
resolve();
118+
}
119+
});
120+
});
121+
122+
// Trigger reload
123+
ds.reload();
124+
125+
// Poll for changes (fallback for when events aren't available)
126+
const pollPromise = pollDatasource(ds, targetOffset, targetLimit);
127+
128+
// Race between event-based and polling
129+
await Promise.race([statePromise, pollPromise]);
130+
}
131+
132+
/**
133+
* Simpler wait function that waits for datasource to be ready
134+
*/
135+
async function waitForReload(
136+
ds: ListValue,
137+
expectedOffset: number,
138+
expectedLimit: number,
139+
maxAttempts = 50
140+
): Promise<void> {
141+
for (let i = 0; i < maxAttempts; i++) {
142+
if (ds.status !== "loading" && ds.offset === expectedOffset && ds.limit === expectedLimit && ds.items) {
143+
return;
144+
}
145+
await new Promise(resolve => setTimeout(resolve, 100));
146+
}
147+
console.warn("Datasource wait timeout - proceeding anyway");
148+
}
149+
150+
/**
151+
* Polls the datasource until it reflects the expected state
152+
*/
153+
async function pollDatasource(
154+
ds: ListValue,
155+
expectedOffset: number,
156+
expectedLimit: number,
157+
maxAttempts = 50
158+
): Promise<void> {
159+
for (let i = 0; i < maxAttempts; i++) {
160+
if (ds.status !== "loading" && ds.offset === expectedOffset && ds.limit === expectedLimit) {
161+
return;
162+
}
163+
await new Promise(resolve => setTimeout(resolve, 100));
164+
}
165+
console.warn("Datasource polling timeout - proceeding anyway");
166+
}
167+
168+
/**
169+
* Restores the datasource to its original offset/limit
170+
*/
171+
async function restoreSnapshot(
172+
ds: ListValue,
173+
snapshot: { offset: number; limit: number },
174+
emitter: Emitter<TraversalEvents>
175+
): Promise<void> {
176+
// Restore original pagination
177+
ds.setLimit(snapshot.limit);
178+
ds.setOffset(snapshot.offset);
179+
180+
// Wait for restore to complete
181+
await reloadAndWait(ds, emitter);
182+
}

packages/shared/widget-plugin-grid/src/selection/helpers.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { executeAction } from "@mendix/widget-plugin-platform/framework/execute-
22
import type { ActionValue, ListValue, ObjectItem, SelectionMultiValue, SelectionSingleValue } from "mendix";
33
import { useEffect, useRef, useState } from "react";
44
import { Direction, MoveEvent1D, MoveEvent2D, MultiSelectionStatus, ScrollKeyCode, SelectionMode, Size } from "./types";
5+
import { traverseAllItems } from "../query/datasource-traversal";
56

67
class SingleSelectionHelper {
78
type = "Single" as const;
@@ -34,6 +35,10 @@ export class MultiSelectionHelper {
3435
this.rangeStart = undefined;
3536
}
3637

38+
get pageItemsCount(): number {
39+
return this.selectableItems.length;
40+
}
41+
3742
isSelected(value: ObjectItem): boolean {
3843
return this.selectionValue.selection.some(obj => obj.id === value.id);
3944
}
@@ -223,6 +228,93 @@ export class MultiSelectionHelper {
223228
this._resetRange();
224229
}
225230

231+
/**
232+
* Gets the currently selected item IDs
233+
*/
234+
getSelectedIds(): Set<string> {
235+
return new Set(this.selectionValue.selection.map(item => item.id));
236+
}
237+
238+
/**
239+
* Selects items by their IDs, preserving object references where possible
240+
*/
241+
selectByIds(ids: string[]): void {
242+
const currentSelection = new Map(this.selectionValue.selection.map(item => [item.id as string, item]));
243+
const pageItems = new Map(this.selectableItems.map(item => [item.id as string, item]));
244+
245+
// Build new selection preserving object references
246+
const newSelection: ObjectItem[] = [];
247+
248+
for (const id of ids) {
249+
// Prefer existing selection object, then current page object
250+
const item = currentSelection.get(id) || pageItems.get(id);
251+
if (item) {
252+
newSelection.push(item);
253+
}
254+
}
255+
256+
this.selectionValue.setSelection(newSelection);
257+
this._resetRange();
258+
}
259+
260+
/**
261+
* Selects all items across multiple pages by loading them in batches.
262+
* This method preserves existing selections and adds new items as pages are loaded.
263+
*
264+
* @param datasource - The data source to load items from
265+
* @param bufferSize - The batch size for loading items
266+
* @param onProgress - Optional callback to report progress (loaded, total)
267+
* @param abortSignal - Optional AbortSignal for cancellation
268+
* @returns Promise that resolves when selection is complete
269+
*/
270+
async selectAllPages(
271+
datasource: ListValue,
272+
bufferSize: number,
273+
onProgress?: (loaded: number, total: number) => void,
274+
abortSignal?: AbortSignal
275+
): Promise<void> {
276+
// If everything fits in current page, use regular selectAll
277+
if (
278+
this.selectableItems.length > 0 &&
279+
datasource.items?.length === this.selectableItems.length &&
280+
(!datasource.totalCount || datasource.totalCount <= this.selectableItems.length)
281+
) {
282+
this.selectAll();
283+
return;
284+
}
285+
286+
// Use the new traversal utility for better robustness
287+
// Accumulate all items across pages, avoiding duplicates
288+
const allItems: ObjectItem[] = [];
289+
const processedIds = new Set<string>();
290+
const existingSelection = new Map(this.selectionValue.selection.map(item => [item.id as string, item]));
291+
292+
await traverseAllItems(datasource, {
293+
chunkSize: bufferSize,
294+
signal: abortSignal,
295+
onProgress: processed => {
296+
const total = datasource.totalCount || processed;
297+
onProgress?.(processed, total);
298+
},
299+
onChunk: async items => {
300+
// Add new items, preserving existing selection objects and avoiding duplicates
301+
for (const item of items) {
302+
const itemId = item.id as string;
303+
if (!processedIds.has(itemId)) {
304+
processedIds.add(itemId);
305+
allItems.push(existingSelection.get(itemId) || item);
306+
}
307+
}
308+
}
309+
});
310+
311+
// Set the final selection if not aborted
312+
if (!abortSignal?.aborted) {
313+
this.selectionValue.setSelection(allItems);
314+
this._resetRange();
315+
}
316+
}
317+
226318
/**
227319
* Deselects all currently selected items by removing them from the selection.
228320
* Resets the selection range after clearing the selection.

0 commit comments

Comments
 (0)