From dc4ca3337870cd98e5a73946566dae729886f105 Mon Sep 17 00:00:00 2001 From: Rahman Date: Fri, 19 Sep 2025 14:51:31 +0200 Subject: [PATCH 01/35] feat(datagrid-web): add multipage selection to dg2 --- .../datagrid-web/src/Datagrid.editorConfig.ts | 16 +- .../src/Datagrid.editorPreview.tsx | 5 +- .../datagrid-web/src/Datagrid.tsx | 4 +- .../src/components/CheckboxColumnHeader.tsx | 28 +- .../components/SelectionProgressDialog.tsx | 35 +++ .../datagrid-web/src/components/Widget.tsx | 12 +- .../src/components/WidgetRoot.tsx | 8 +- .../MultiPageSelectionController.ts | 246 ++++++++++++++++++ .../SelectAllProgressStore.ts | 47 ++++ .../src/helpers/SelectActionHelper.ts | 69 +++-- .../datagrid-web/src/helpers/root-context.ts | 4 + .../src/helpers/state/GridBasicData.ts | 21 +- .../src/helpers/state/RootGridStore.ts | 46 ++++ .../datagrid-web/src/utils/test-utils.tsx | 14 +- .../datagrid-web/typings/DatagridProps.d.ts | 16 ++ 15 files changed, 536 insertions(+), 35 deletions(-) create mode 100644 packages/pluggableWidgets/datagrid-web/src/components/SelectionProgressDialog.tsx create mode 100644 packages/pluggableWidgets/datagrid-web/src/features/multi-page-selection/MultiPageSelectionController.ts create mode 100644 packages/pluggableWidgets/datagrid-web/src/features/multi-page-selection/SelectAllProgressStore.ts diff --git a/packages/pluggableWidgets/datagrid-web/src/Datagrid.editorConfig.ts b/packages/pluggableWidgets/datagrid-web/src/Datagrid.editorConfig.ts index abd7f1cfb2..c4b40ffd43 100644 --- a/packages/pluggableWidgets/datagrid-web/src/Datagrid.editorConfig.ts +++ b/packages/pluggableWidgets/datagrid-web/src/Datagrid.editorConfig.ts @@ -155,7 +155,7 @@ export function getProperties( } function hideSelectionProperties(defaultProperties: Properties, values: DatagridPreviewProps): void { - const { itemSelection, itemSelectionMethod } = values; + const { itemSelection, itemSelectionMethod, selectAllPagesEnabled } = values; if (itemSelection === "None") { hidePropertiesIn(defaultProperties, values, ["itemSelectionMethod", "itemSelectionMode", "onSelectionChange"]); @@ -170,11 +170,15 @@ function hideSelectionProperties(defaultProperties: Properties, values: Datagrid } if (itemSelection !== "Multi") { - hidePropertiesIn(defaultProperties, values, [ - "keepSelection", - "selectionCountPosition", - "clearSelectionButtonLabel" - ]); + hidePropertyIn(defaultProperties, values, "keepSelection"); + hidePropertyIn(defaultProperties, values, "selectAllPagesEnabled"); + } + + if (!selectAllPagesEnabled) { + hidePropertyIn(defaultProperties, values, "selectAllPagesBufferSize"); + hidePropertyIn(defaultProperties, values, "selectAllPagesLabel"); + hidePropertyIn(defaultProperties, values, "selectingAllLabel"); + hidePropertyIn(defaultProperties, values, "cancelSelectionLabel"); } } diff --git a/packages/pluggableWidgets/datagrid-web/src/Datagrid.editorPreview.tsx b/packages/pluggableWidgets/datagrid-web/src/Datagrid.editorPreview.tsx index 6086784e80..8d208afa33 100644 --- a/packages/pluggableWidgets/datagrid-web/src/Datagrid.editorPreview.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/Datagrid.editorPreview.tsx @@ -18,6 +18,7 @@ import { ColumnPreview } from "./helpers/ColumnPreview"; import { DatagridContext } from "./helpers/root-context"; import { useSelectActionHelper } from "./helpers/SelectActionHelper"; import { GridBasicData } from "./helpers/state/GridBasicData"; +import { SelectAllProgressStore } from "./features/multi-page-selection/SelectAllProgressStore"; import { SelectionCountStore } from "@mendix/widget-plugin-grid/selection/stores/SelectionCountStore"; import "./ui/DatagridPreview.scss"; @@ -97,7 +98,9 @@ export function preview(props: DatagridPreviewProps): ReactElement { cellEventsController: eventsController, checkboxEventsController: eventsController, focusController, - selectionCountStore + selectionCountStore, + selectAllProgressStore: new SelectAllProgressStore(), + rootStore: {} as any // Mock for preview }; }); diff --git a/packages/pluggableWidgets/datagrid-web/src/Datagrid.tsx b/packages/pluggableWidgets/datagrid-web/src/Datagrid.tsx index 82dcc6a041..e19562916f 100644 --- a/packages/pluggableWidgets/datagrid-web/src/Datagrid.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/Datagrid.tsx @@ -73,7 +73,9 @@ const Container = observer((props: Props): ReactElement => { cellEventsController, checkboxEventsController, focusController, - selectionCountStore: rootStore.selectionCountStore + selectionCountStore: rootStore.selectionCountStore, + selectAllProgressStore: rootStore.selectAllProgressStore, + rootStore }; }); diff --git a/packages/pluggableWidgets/datagrid-web/src/components/CheckboxColumnHeader.tsx b/packages/pluggableWidgets/datagrid-web/src/components/CheckboxColumnHeader.tsx index 130b4dce86..dcfa9c432d 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/CheckboxColumnHeader.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/CheckboxColumnHeader.tsx @@ -1,14 +1,12 @@ import { ThreeStateCheckBox } from "@mendix/widget-plugin-component-kit/ThreeStateCheckBox"; -import { Fragment, ReactElement, useCallback } from "react"; +import { Fragment, ReactElement } from "react"; import { useDatagridRootScope } from "../helpers/root-context"; export function CheckboxColumnHeader(): ReactElement { - const { selectActionHelper, basicData } = useDatagridRootScope(); + const { selectActionHelper, basicData, selectAllProgressStore, rootStore } = useDatagridRootScope(); const { showCheckboxColumn, showSelectAllToggle, onSelectAll } = selectActionHelper; const { selectionStatus, selectAllRowsLabel } = basicData; - const onChange = useCallback(() => onSelectAll(), [onSelectAll]); - if (showCheckboxColumn === false) { return ; } @@ -20,10 +18,30 @@ export function CheckboxColumnHeader(): ReactElement { throw new Error("Don't know how to render checkbox with selectionStatus=unknown"); } + const handleHeaderToggle = async (): Promise => { + if (selectAllProgressStore.selecting) { + return; + } + + if (selectActionHelper.canSelectAllPages && selectionStatus !== "none") { + // Toggle off still uses normal flow + onSelectAll(); + return; + } + + if (selectActionHelper.canSelectAllPages && selectionStatus === "none") { + // Delegate to root store orchestration + await rootStore?.startMultiPageSelectAll(selectActionHelper); + return; + } + + onSelectAll(); + }; + checkbox = ( ); diff --git a/packages/pluggableWidgets/datagrid-web/src/components/SelectionProgressDialog.tsx b/packages/pluggableWidgets/datagrid-web/src/components/SelectionProgressDialog.tsx new file mode 100644 index 0000000000..6babcc9b7b --- /dev/null +++ b/packages/pluggableWidgets/datagrid-web/src/components/SelectionProgressDialog.tsx @@ -0,0 +1,35 @@ +import { createElement, ReactElement } from "react"; +import { PseudoModal } from "./PseudoModal"; +import { ExportAlert } from "./ExportAlert"; + +export type SelectionProgressDialogProps = { + open: boolean; + selectingLabel: string; + cancelLabel: string; + onCancel: () => void; + progress: number; + total: number; +}; + +export function SelectionProgressDialog({ + open, + selectingLabel, + cancelLabel, + onCancel, + progress, + total +}: SelectionProgressDialogProps): ReactElement | null { + if (!open) return null; + return ( + + + + ); +} diff --git a/packages/pluggableWidgets/datagrid-web/src/components/Widget.tsx b/packages/pluggableWidgets/datagrid-web/src/components/Widget.tsx index 35ad6ac5a2..6b2a15e307 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/Widget.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/Widget.tsx @@ -25,6 +25,7 @@ import { WidgetContent } from "./WidgetContent"; import { WidgetFooter } from "./WidgetFooter"; import { WidgetHeader } from "./WidgetHeader"; import { WidgetRoot } from "./WidgetRoot"; +import { SelectionProgressDialog } from "./SelectionProgressDialog"; import { WidgetTopBar } from "./WidgetTopBar"; import { SelectionCounter } from "./SelectionCounter"; @@ -83,7 +84,7 @@ export interface WidgetProps(props: WidgetProps): ReactElement => { const { className, exporting, numberOfItems, onExportCancel, selectActionHelper } = props; - const { basicData } = useDatagridRootScope(); + const { basicData, selectAllProgressStore, rootStore } = useDatagridRootScope(); const selectionEnabled = selectActionHelper.selectionType !== "None"; @@ -94,8 +95,17 @@ export const Widget = observer((props: WidgetProps): Re selection={selectionEnabled} style={props.styles} exporting={exporting} + selectingAllPages={selectAllProgressStore.selecting} >
+ rootStore.abortMultiPageSelect()} + progress={selectAllProgressStore.loaded} + total={selectAllProgressStore.total} + /> {exporting && ( (null); - const { className, selectionMethod, selection, exporting, children, ...rest } = props; + const { className, selectionMethod, selection, exporting, selectingAllPages, children, ...rest } = props; const style = useMemo(() => { const s = { ...props.style }; - if (exporting && ref.current) { + if ((exporting || selectingAllPages) && ref.current) { s.height = ref.current.offsetHeight; } return s; - }, [props.style, exporting]); + }, [props.style, exporting, selectingAllPages]); return (
void { + // No setup needed for now + return () => { + // Cleanup + this.abort(); + }; + } + + /** + * Checks if multi-page selection is possible given the current state + */ + canSelectAllPages(enabled: boolean, selectionType: string): boolean { + return enabled && selectionType === "Multi"; + } + + /** + * Starts the multi-page selection process + */ + async selectAllPages(datasource: ListValue, selectionHelper: SelectionHelper): Promise { + if (this.locked) { + return false; + } + + if (selectionHelper.type !== "Multi") { + return false; + } + + this.locked = true; + let success = false; + + try { + // Ensure totalCount is available + const totalCount = await this.ensureTotalCount(datasource); + + if (!totalCount || totalCount <= 0) { + return false; + } + + // Check if everything fits in current page + const currentPageSize = datasource.items?.length ?? 0; + if (totalCount <= currentPageSize) { + return false; + } + + // Start progress tracking + this.progressStore.onloadstart(totalCount); + this.abortController = new AbortController(); + + // Use controller-based traversal to properly handle pagination + const naturalChunkSize = datasource.limit ?? 25; + if (selectionHelper.type !== "Multi") { + throw new Error("Expected MultiSelectionHelper"); + } + const multiHelper = selectionHelper; + await this.selectAllWithController(multiHelper, totalCount, naturalChunkSize); + + success = true; + } catch (_error) { + // Selection failed or was aborted + throw new Error("MultiPageSelectionController: Selection failed or was aborted", { cause: _error }); + } finally { + this.progressStore.onloadend(); + this.locked = false; + this.abortController = undefined; + } + + return success; + } + + /** + * Controller-based selection that properly handles datasource pagination + */ + private async selectAllWithController( + multiHelper: MultiSelectionHelper, + totalCount: number, + chunkSize: number + ): Promise { + // Snapshot current state for restoration + const originalOffset = this.query.offset; + const originalLimit = this.query.limit; + const allItems: ObjectItem[] = []; + + try { + const processedIds = new Set(); + let currentOffset = 0; + let processed = 0; + + while (processed < totalCount && !this.abortController?.signal.aborted) { + // Use controller to set pagination + this.query.setOffset(currentOffset); + + // Wait for the datasource to reflect the change + await this.waitForDatasourceUpdate(currentOffset, chunkSize); + + const items = this.query.datasource.items ?? []; + + if (items.length === 0) { + break; + } + + // Add new items, avoiding duplicates + for (const item of items) { + const itemId = item.id as string; + if (!processedIds.has(itemId)) { + processedIds.add(itemId); + allItems.push(item); + } + } + + processed += items.length; + this.progressStore.onprogress(processed); + + // Check if we got fewer items than expected (end of data) + if (items.length < chunkSize) { + break; + } + + currentOffset += chunkSize; + + // Small delay to yield to UI + await new Promise(resolve => setTimeout(resolve, 10)); + } + } finally { + // Always restore the original page, but set selection after restoration + await this.restoreOriginalStateAndSetSelection(originalOffset, originalLimit, allItems, multiHelper); + } + } + + /** + * Restores the original datasource state and then sets the selection + * This ensures the user stays on their original page while keeping all selected items + */ + private async restoreOriginalStateAndSetSelection( + originalOffset: number, + originalLimit: number, + allItems: ObjectItem[], + multiHelper: MultiSelectionHelper + ): Promise { + if (this.abortController?.signal.aborted) { + // If aborted, just restore state without setting selection + this.query.setOffset(originalOffset); + return; + } + + if (allItems.length === 0) { + // No items to select, just restore state + this.query.setOffset(originalOffset); + return; + } + + // First, set the selection while we have all the items + (multiHelper as any).selectionValue.setSelection(allItems); + (multiHelper as any)._resetRange(); + + // Small delay to ensure selection is committed + await new Promise(resolve => setTimeout(resolve, 50)); + + // Now restore the original page + this.query.setOffset(originalOffset); + + // Wait for the datasource to reflect the restored state + await this.waitForDatasourceUpdate(originalOffset, originalLimit); + } + + /** + * Waits for the datasource to reflect the controller changes + */ + private async waitForDatasourceUpdate( + expectedOffset: number, + expectedLimit: number, + maxAttempts = 20 + ): Promise { + for (let i = 0; i < maxAttempts; i++) { + const ds = this.query.datasource; + if (ds.status !== "loading" && ds.offset === expectedOffset && ds.limit === expectedLimit) { + return; + } + await new Promise(resolve => setTimeout(resolve, 50)); + } + } + + /** + * Ensures totalCount is available, requesting it if necessary + */ + private async ensureTotalCount(datasource: ListValue): Promise { + let totalCount = datasource.totalCount; + + if (typeof totalCount !== "number" || totalCount <= 0) { + // Request total count + this.query.requestTotalCount(true); + + // Wait for it to become available + const maxAttempts = 20; + for (let i = 0; i < maxAttempts; i++) { + await new Promise(resolve => setTimeout(resolve, 100)); + + totalCount = datasource.totalCount; + const status = datasource.status; + + if (typeof totalCount === "number" && totalCount > 0 && status !== "loading") { + break; + } + } + } + + return totalCount; + } + + /** + * Aborts the current selection process + */ + abort(): void { + if (this.abortController) { + this.abortController.abort(); + this.progressStore.oncancel(); + this.locked = false; + } + } +} diff --git a/packages/pluggableWidgets/datagrid-web/src/features/multi-page-selection/SelectAllProgressStore.ts b/packages/pluggableWidgets/datagrid-web/src/features/multi-page-selection/SelectAllProgressStore.ts new file mode 100644 index 0000000000..da733c0545 --- /dev/null +++ b/packages/pluggableWidgets/datagrid-web/src/features/multi-page-selection/SelectAllProgressStore.ts @@ -0,0 +1,47 @@ +import { makeAutoObservable } from "mobx"; + +export class SelectAllProgressStore { + selecting = false; + lengthComputable = false; + loaded = 0; + total = 0; + cancelled = false; + + constructor() { + makeAutoObservable(this); + } + + onloadstart = (total: number): void => { + this.selecting = true; + this.lengthComputable = true; + this.total = total; + this.loaded = 0; + this.cancelled = false; + }; + + onprogress = (loaded: number): void => { + this.loaded = loaded; + }; + + onloadend = (): void => { + this.selecting = false; + this.lengthComputable = false; + this.loaded = 0; + this.total = 0; + this.cancelled = false; + }; + + oncancel = (): void => { + this.cancelled = true; + this.onloadend(); + }; + + get progress(): number { + return this.total > 0 ? (this.loaded / this.total) * 100 : 0; + } + + get displayProgress(): string { + if (!this.selecting) return ""; + return `${this.loaded} of ${this.total}`; + } +} diff --git a/packages/pluggableWidgets/datagrid-web/src/helpers/SelectActionHelper.ts b/packages/pluggableWidgets/datagrid-web/src/helpers/SelectActionHelper.ts index 9b4b28a056..52b93077c8 100644 --- a/packages/pluggableWidgets/datagrid-web/src/helpers/SelectActionHelper.ts +++ b/packages/pluggableWidgets/datagrid-web/src/helpers/SelectActionHelper.ts @@ -5,6 +5,7 @@ import { SelectionMode, WidgetSelectionProperty } from "@mendix/widget-plugin-grid/selection"; +import { ListValue } from "mendix"; import { DatagridContainerProps, DatagridPreviewProps, ItemSelectionMethodEnum } from "../../typings/DatagridProps"; export type SelectionMethod = "rowClick" | "checkbox" | "none"; @@ -12,6 +13,9 @@ export class SelectActionHelper extends SelectActionHandler { pageSize: number; private _selectionMethod: ItemSelectionMethodEnum; private _showSelectAllToggle: boolean; + private _datasource: ListValue; + private _selectAllPagesEnabled: boolean; + private _selectAllPagesBufferSize: number; constructor( selection: WidgetSelectionProperty, @@ -19,12 +23,18 @@ export class SelectActionHelper extends SelectActionHandler { _selectionMethod: ItemSelectionMethodEnum, _showSelectAllToggle: boolean, pageSize: number, - private _selectionMode: SelectionMode + private _selectionMode: SelectionMode, + datasource: ListValue, + selectAllPagesEnabled?: boolean, + selectAllPagesBufferSize?: number ) { super(selection, selectionHelper); this._selectionMethod = _selectionMethod; this._showSelectAllToggle = _showSelectAllToggle; this.pageSize = pageSize; + this._datasource = datasource; + this._selectAllPagesEnabled = selectAllPagesEnabled ?? false; + this._selectAllPagesBufferSize = selectAllPagesBufferSize ?? 500; } get selectionMethod(): SelectionMethod { @@ -42,26 +52,55 @@ export class SelectActionHelper extends SelectActionHandler { get selectionMode(): SelectionMode { return this.selectionMethod === "checkbox" ? "toggle" : this._selectionMode; } + + get canSelectAllPages(): boolean { + return this._selectAllPagesEnabled && this.selectionType === "Multi"; + } + + get totalCount(): number | undefined { + return this._datasource?.totalCount; + } + + get selectAllPagesBufferSize(): number { + return this._selectAllPagesBufferSize; + } } export function useSelectActionHelper( props: Pick< DatagridContainerProps | DatagridPreviewProps, - "itemSelection" | "itemSelectionMethod" | "showSelectAllToggle" | "pageSize" | "itemSelectionMode" + | "itemSelection" + | "itemSelectionMethod" + | "showSelectAllToggle" + | "pageSize" + | "itemSelectionMode" + | "datasource" + | "selectAllPagesEnabled" + | "selectAllPagesBufferSize" >, selectionHelper?: SelectionHelper ): SelectActionHelper { - return useMemo( - () => - new SelectActionHelper( - props.itemSelection, - selectionHelper, - props.itemSelectionMethod, - props.showSelectAllToggle, - props.pageSize ?? 5, - props.itemSelectionMode - ), - // eslint-disable-next-line react-hooks/exhaustive-deps - [selectionHelper] - ); + return useMemo(() => { + return new SelectActionHelper( + props.itemSelection, + selectionHelper, + props.itemSelectionMethod, + props.showSelectAllToggle, + props.pageSize ?? 5, + props.itemSelectionMode, + props.datasource as ListValue, + props.selectAllPagesEnabled, + props.selectAllPagesBufferSize ?? 500 + ); + }, [ + props.itemSelection, + selectionHelper, + props.itemSelectionMethod, + props.showSelectAllToggle, + props.pageSize, + props.itemSelectionMode, + props.datasource, + props.selectAllPagesEnabled, + props.selectAllPagesBufferSize + ]); } diff --git a/packages/pluggableWidgets/datagrid-web/src/helpers/root-context.ts b/packages/pluggableWidgets/datagrid-web/src/helpers/root-context.ts index 51386f8d90..a0d9f4333c 100644 --- a/packages/pluggableWidgets/datagrid-web/src/helpers/root-context.ts +++ b/packages/pluggableWidgets/datagrid-web/src/helpers/root-context.ts @@ -2,7 +2,9 @@ import { FocusTargetController } from "@mendix/widget-plugin-grid/keyboard-navig import { SelectionHelper } from "@mendix/widget-plugin-grid/selection"; import { SelectionCountStore } from "@mendix/widget-plugin-grid/selection/stores/SelectionCountStore"; import { createContext, useContext } from "react"; +import { SelectAllProgressStore } from "../features/multi-page-selection/SelectAllProgressStore"; import { GridBasicData } from "../helpers/state/GridBasicData"; +import { RootGridStore } from "../helpers/state/RootGridStore"; import { EventsController } from "../typings/CellComponent"; import { SelectActionHelper } from "./SelectActionHelper"; @@ -15,6 +17,8 @@ export interface DatagridRootScope { checkboxEventsController: EventsController; focusController: FocusTargetController; selectionCountStore: SelectionCountStore; + selectAllProgressStore: SelectAllProgressStore; + rootStore: RootGridStore; } export const DatagridContext = createContext(null); diff --git a/packages/pluggableWidgets/datagrid-web/src/helpers/state/GridBasicData.ts b/packages/pluggableWidgets/datagrid-web/src/helpers/state/GridBasicData.ts index 1b0b1ed909..b67c9726ee 100644 --- a/packages/pluggableWidgets/datagrid-web/src/helpers/state/GridBasicData.ts +++ b/packages/pluggableWidgets/datagrid-web/src/helpers/state/GridBasicData.ts @@ -5,7 +5,14 @@ import { DatagridContainerProps } from "../../../typings/DatagridProps"; type Props = Pick< DatagridContainerProps, - "exportDialogLabel" | "cancelExportLabel" | "selectRowLabel" | "selectAllRowsLabel" | "itemSelection" | "onClick" + | "exportDialogLabel" + | "cancelExportLabel" + | "selectRowLabel" + | "selectAllRowsLabel" + | "itemSelection" + | "onClick" + | "selectingAllLabel" + | "cancelSelectionLabel" >; type Gate = DerivedPropsGate; @@ -36,6 +43,14 @@ export class GridBasicData { return this.gate.props.selectAllRowsLabel?.value; } + get selectingAllLabel(): string | undefined { + return this.gate.props.selectingAllLabel?.value; + } + + get cancelSelectionLabel(): string | undefined { + return this.gate.props.cancelSelectionLabel?.value; + } + get gridInteractive(): boolean { return !!(this.gate.props.itemSelection || this.gate.props.onClick); } @@ -44,6 +59,10 @@ export class GridBasicData { return this.selectionHelper?.type === "Multi" ? this.selectionHelper.selectionStatus : "none"; } + get currentSelectionHelper(): SelectionHelper | null { + return this.selectionHelper; + } + setSelectionHelper(selectionHelper: SelectionHelper | undefined): void { this.selectionHelper = selectionHelper ?? null; } diff --git a/packages/pluggableWidgets/datagrid-web/src/helpers/state/RootGridStore.ts b/packages/pluggableWidgets/datagrid-web/src/helpers/state/RootGridStore.ts index c1644d2ea0..a39991a822 100644 --- a/packages/pluggableWidgets/datagrid-web/src/helpers/state/RootGridStore.ts +++ b/packages/pluggableWidgets/datagrid-web/src/helpers/state/RootGridStore.ts @@ -2,6 +2,7 @@ import { createContextWithStub, FilterAPI } from "@mendix/widget-plugin-filterin import { CombinedFilter } from "@mendix/widget-plugin-filtering/stores/generic/CombinedFilter"; import { CustomFilterHost } from "@mendix/widget-plugin-filtering/stores/generic/CustomFilterHost"; import { DatasourceController } from "@mendix/widget-plugin-grid/query/DatasourceController"; +import { QueryController } from "@mendix/widget-plugin-grid/query/query-controller"; import { RefreshController } from "@mendix/widget-plugin-grid/query/RefreshController"; import { SelectionCountStore } from "@mendix/widget-plugin-grid/selection/stores/SelectionCountStore"; import { BaseControllerHost } from "@mendix/widget-plugin-mobx-kit/BaseControllerHost"; @@ -15,9 +16,12 @@ import { DatasourceParamsController } from "../../controllers/DatasourceParamsCo import { DerivedLoaderController } from "../../controllers/DerivedLoaderController"; import { PaginationController } from "../../controllers/PaginationController"; import { ProgressStore } from "../../features/data-export/ProgressStore"; +import { SelectAllProgressStore } from "../../features/multi-page-selection/SelectAllProgressStore"; +import { MultiPageSelectionController } from "../../features/multi-page-selection/MultiPageSelectionController"; import { StaticInfo } from "../../typings/static-info"; import { ColumnGroupStore } from "./ColumnGroupStore"; import { GridPersonalizationStore } from "./GridPersonalizationStore"; +import { SelectActionHelper } from "../SelectActionHelper"; type RequiredProps = Pick< DatagridContainerProps, @@ -51,9 +55,12 @@ export class RootGridStore extends BaseControllerHost { basicData: GridBasicData; staticInfo: StaticInfo; exportProgressCtrl: ProgressStore; + selectAllProgressStore: SelectAllProgressStore; + multiPageSelectionCtrl: MultiPageSelectionController; loaderCtrl: DerivedLoaderController; paginationCtrl: PaginationController; readonly filterAPI: FilterAPI; + query!: QueryController; private gate: Gate; @@ -71,6 +78,7 @@ export class RootGridStore extends BaseControllerHost { const filterHost = new CustomFilterHost(); const query = new DatasourceController(this, { gate }); + this.query = query; this.filterAPI = createContextWithStub({ filterObserver: filterHost, @@ -94,6 +102,13 @@ export class RootGridStore extends BaseControllerHost { this.exportProgressCtrl = exportCtrl; + this.selectAllProgressStore = new SelectAllProgressStore(); + + this.multiPageSelectionCtrl = new MultiPageSelectionController(this, { + query, + progressStore: this.selectAllProgressStore + }); + new DatasourceParamsController(this, { query, filterHost: combinedFilter, @@ -130,4 +145,35 @@ export class RootGridStore extends BaseControllerHost { this.columnsStore.updateProps(props); this.settingsStore.updateProps(props); } + + async startMultiPageSelectAll(selectActionHelper: SelectActionHelper): Promise { + const ds = this.gate.props.datasource; + const selectionHelper = this.basicData.currentSelectionHelper; + + if (!selectionHelper) { + return; + } + + // Check if multi-page selection is possible + const canSelect = this.multiPageSelectionCtrl.canSelectAllPages( + selectActionHelper.canSelectAllPages, + selectActionHelper.selectionType + ); + + if (!canSelect) { + selectActionHelper.onSelectAll("selectAll"); + return; + } + + // Delegate to the controller + const success = await this.multiPageSelectionCtrl.selectAllPages(ds, selectionHelper); + + if (!success) { + selectActionHelper.onSelectAll("selectAll"); + } + } + + abortMultiPageSelect(): void { + this.multiPageSelectionCtrl.abort(); + } } diff --git a/packages/pluggableWidgets/datagrid-web/src/utils/test-utils.tsx b/packages/pluggableWidgets/datagrid-web/src/utils/test-utils.tsx index bd16125514..2df9bff98a 100644 --- a/packages/pluggableWidgets/datagrid-web/src/utils/test-utils.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/utils/test-utils.tsx @@ -1,7 +1,7 @@ import { FocusTargetController } from "@mendix/widget-plugin-grid/keyboard-navigation/FocusTargetController"; import { PositionController } from "@mendix/widget-plugin-grid/keyboard-navigation/PositionController"; import { VirtualGridLayout } from "@mendix/widget-plugin-grid/keyboard-navigation/VirtualGridLayout"; -import { dynamicValue, listAttr, listExp } from "@mendix/widget-plugin-test-utils"; +import { dynamicValue, listAttr, listExp, ListValueBuilder } from "@mendix/widget-plugin-test-utils"; import { GUID, ObjectItem } from "mendix"; import { ColumnsType } from "../../typings/DatagridProps"; import { Cell } from "../components/Cell"; @@ -39,7 +39,17 @@ export const column = (header = "Test", patch?: (col: ColumnsType) => void): Col }; export function mockSelectionProps(patch?: (props: SelectActionHelper) => SelectActionHelper): SelectActionHelper { - const props = new SelectActionHelper("None", undefined, "checkbox", false, 5, "clear"); + const props = new SelectActionHelper( + "None", + undefined, + "checkbox", + false, + 5, + "clear", + new ListValueBuilder().build(), + false, + 500 + ); if (patch) { patch(props); diff --git a/packages/pluggableWidgets/datagrid-web/typings/DatagridProps.d.ts b/packages/pluggableWidgets/datagrid-web/typings/DatagridProps.d.ts index 513d2d6282..2627bb8526 100644 --- a/packages/pluggableWidgets/datagrid-web/typings/DatagridProps.d.ts +++ b/packages/pluggableWidgets/datagrid-web/typings/DatagridProps.d.ts @@ -98,8 +98,16 @@ export interface DatagridContainerProps { itemSelectionMode: ItemSelectionModeEnum; showSelectAllToggle: boolean; keepSelection: boolean; +<<<<<<< HEAD selectionCountPosition: SelectionCountPositionEnum; clearSelectionButtonLabel?: DynamicValue; +======= + selectAllPagesEnabled: boolean; + selectAllPagesBufferSize: number; + selectAllPagesLabel?: DynamicValue; + selectingAllLabel?: DynamicValue; + cancelSelectionLabel?: DynamicValue; +>>>>>>> 2e4671e66 (feat(datagrid-web): add multipage selection to dg2) loadingType: LoadingTypeEnum; refreshIndicator: boolean; columns: ColumnsType[]; @@ -152,8 +160,16 @@ export interface DatagridPreviewProps { itemSelectionMode: ItemSelectionModeEnum; showSelectAllToggle: boolean; keepSelection: boolean; +<<<<<<< HEAD selectionCountPosition: SelectionCountPositionEnum; clearSelectionButtonLabel: string; +======= + selectAllPagesEnabled: boolean; + selectAllPagesBufferSize: number | null; + selectAllPagesLabel: string; + selectingAllLabel: string; + cancelSelectionLabel: string; +>>>>>>> 2e4671e66 (feat(datagrid-web): add multipage selection to dg2) loadingType: LoadingTypeEnum; refreshIndicator: boolean; columns: ColumnsPreviewType[]; From eee9ad6484fd1240074e78592580c88d68002983 Mon Sep 17 00:00:00 2001 From: Rahman Date: Fri, 19 Sep 2025 15:01:46 +0200 Subject: [PATCH 02/35] feat(datagrid-web): add untracked shared files, version bump, changelog update --- .../datawidgets/web/_datagrid.scss | 27 ++- .../datagrid-web/CHANGELOG.md | 2 + .../src/query/datasource-traversal.ts | 182 ++++++++++++++++++ .../src/selection/helpers.ts | 92 +++++++++ .../src/selection/select-action-handler.ts | 26 ++- 5 files changed, 326 insertions(+), 3 deletions(-) create mode 100644 packages/shared/widget-plugin-grid/src/query/datasource-traversal.ts diff --git a/packages/modules/data-widgets/src/themesource/datawidgets/web/_datagrid.scss b/packages/modules/data-widgets/src/themesource/datawidgets/web/_datagrid.scss index 48aac22092..bcf966eb54 100644 --- a/packages/modules/data-widgets/src/themesource/datawidgets/web/_datagrid.scss +++ b/packages/modules/data-widgets/src/themesource/datawidgets/web/_datagrid.scss @@ -428,7 +428,8 @@ $root: ".widget-datagrid"; align-items: center; } - &-exporting { + &-exporting, + &-selecting-all-pages { .widget-datagrid-top-bar, .widget-datagrid-header, .widget-datagrid-content, @@ -441,6 +442,30 @@ $root: ".widget-datagrid"; } } + // Better positioning for multi-page selection modal + &-selecting-all-pages { + .widget-datagrid-modal { + &-overlay { + position: fixed; + top: 0; + right: 0; + bottom: 0; + left: 0; + } + + &-main { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + display: flex; + align-items: center; + justify-content: center; + } + } + } + &-col-select input:focus-visible { outline-offset: 0; } diff --git a/packages/pluggableWidgets/datagrid-web/CHANGELOG.md b/packages/pluggableWidgets/datagrid-web/CHANGELOG.md index fbe7db8aa7..40ffc9ad45 100644 --- a/packages/pluggableWidgets/datagrid-web/CHANGELOG.md +++ b/packages/pluggableWidgets/datagrid-web/CHANGELOG.md @@ -12,6 +12,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - We fixed an issue where missing consistency checks for the captions were causing runtime errors instead of in Studio Pro +- 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. + ## [3.6.1] - 2025-10-14 ### Fixed diff --git a/packages/shared/widget-plugin-grid/src/query/datasource-traversal.ts b/packages/shared/widget-plugin-grid/src/query/datasource-traversal.ts new file mode 100644 index 0000000000..7fbd812b5f --- /dev/null +++ b/packages/shared/widget-plugin-grid/src/query/datasource-traversal.ts @@ -0,0 +1,182 @@ +import { ListValue, ObjectItem } from "mendix"; +import { createNanoEvents, Emitter } from "nanoevents"; + +export interface TraversalOptions { + chunkSize?: number; + signal?: AbortSignal; + onProgress?: (processed: number, total?: number) => void; + onChunk?: (items: ObjectItem[], offset: number) => void | Promise; +} + +interface TraversalEvents { + statechange: (state: { offset: number; limit: number; status: string }) => void; +} + +/** + * Traverses all items in a ListValue datasource by paginating through chunks. + * Mirrors the pattern used by ExportController - snapshots state, iterates, then restores. + */ +export async function traverseAllItems(ds: ListValue, options: TraversalOptions = {}): Promise { + // Use the datasource's current limit as chunk size to respect its pagination + const naturalChunkSize = ds.limit ?? 25; + const { chunkSize = naturalChunkSize, signal, onProgress, onChunk } = options; + + // Snapshot current state + const snapshot = { + offset: ds.offset, + limit: ds.limit + }; + + // Create event emitter for tracking datasource changes + const emitter: Emitter = createNanoEvents(); + let processed = 0; + + try { + // Start from the beginning + let currentOffset = 0; + let hasMore = true; + let chunkIndex = 0; + + while (hasMore && !signal?.aborted) { + // Set pagination parameters + + ds.setOffset(currentOffset); + if (ds.limit !== chunkSize) { + ds.setLimit(chunkSize); + } + + // Trigger reload + ds.reload(); + + // Wait for reload to complete with simpler polling + await waitForReload(ds, currentOffset, chunkSize); + + // Process items + const items = ds.items ?? []; + + if (items.length === 0) { + break; // No more items + } + + // Handle the chunk + if (onChunk) { + await onChunk(items, currentOffset); + } + + // Update progress + processed += items.length; + onProgress?.(processed, ds.totalCount); + + // Check if this was the last page + // Stop if we've processed all items according to totalCount + // or if we got fewer items than expected + const totalCount = ds.totalCount; + const reachedTotal = totalCount && processed >= totalCount; + const gotPartialChunk = items.length < chunkSize; + hasMore = !reachedTotal && !gotPartialChunk; + currentOffset += chunkSize; + chunkIndex++; + + // Yield to UI + await new Promise(resolve => setTimeout(resolve, 0)); + } + + if (signal?.aborted) { + // Traversal aborted + } else { + // Traversal completed + } + } finally { + // Always restore original view state + await restoreSnapshot(ds, snapshot, emitter); + } +} + +/** + * Reloads the datasource and waits for it to stabilize + */ +async function reloadAndWait(ds: ListValue, emitter: Emitter): Promise { + const targetOffset = ds.offset; + const targetLimit = ds.limit; + + // Set up one-time listener for state change + const statePromise = new Promise(resolve => { + const checkState = (): void => { + if (ds.status !== "loading" && ds.offset === targetOffset && ds.limit === targetLimit) { + resolve(); + } + }; + + // Check immediately in case already loaded + checkState(); + + // Otherwise wait for state change + const unsubscribe = emitter.on("statechange", state => { + if (state.status !== "loading" && state.offset === targetOffset && state.limit === targetLimit) { + unsubscribe(); + resolve(); + } + }); + }); + + // Trigger reload + ds.reload(); + + // Poll for changes (fallback for when events aren't available) + const pollPromise = pollDatasource(ds, targetOffset, targetLimit); + + // Race between event-based and polling + await Promise.race([statePromise, pollPromise]); +} + +/** + * Simpler wait function that waits for datasource to be ready + */ +async function waitForReload( + ds: ListValue, + expectedOffset: number, + expectedLimit: number, + maxAttempts = 50 +): Promise { + for (let i = 0; i < maxAttempts; i++) { + if (ds.status !== "loading" && ds.offset === expectedOffset && ds.limit === expectedLimit && ds.items) { + return; + } + await new Promise(resolve => setTimeout(resolve, 100)); + } + console.warn("Datasource wait timeout - proceeding anyway"); +} + +/** + * Polls the datasource until it reflects the expected state + */ +async function pollDatasource( + ds: ListValue, + expectedOffset: number, + expectedLimit: number, + maxAttempts = 50 +): Promise { + for (let i = 0; i < maxAttempts; i++) { + if (ds.status !== "loading" && ds.offset === expectedOffset && ds.limit === expectedLimit) { + return; + } + await new Promise(resolve => setTimeout(resolve, 100)); + } + console.warn("Datasource polling timeout - proceeding anyway"); +} + +/** + * Restores the datasource to its original offset/limit + */ +async function restoreSnapshot( + ds: ListValue, + snapshot: { offset: number; limit: number }, + emitter: Emitter +): Promise { + // Restore original pagination + ds.setLimit(snapshot.limit); + ds.setOffset(snapshot.offset); + + // Wait for restore to complete + await reloadAndWait(ds, emitter); +} diff --git a/packages/shared/widget-plugin-grid/src/selection/helpers.ts b/packages/shared/widget-plugin-grid/src/selection/helpers.ts index 05738b5416..844b505be7 100644 --- a/packages/shared/widget-plugin-grid/src/selection/helpers.ts +++ b/packages/shared/widget-plugin-grid/src/selection/helpers.ts @@ -3,6 +3,7 @@ import type { ActionValue, ListValue, ObjectItem, SelectionMultiValue, Selection import { action, computed, makeObservable, observable } from "mobx"; import { useEffect, useRef, useState } from "react"; import { Direction, MoveEvent1D, MoveEvent2D, MultiSelectionStatus, ScrollKeyCode, SelectionMode, Size } from "./types"; +import { traverseAllItems } from "../query/datasource-traversal"; class SingleSelectionHelper { type = "Single" as const; @@ -42,6 +43,10 @@ export class MultiSelectionHelper { }); } + get pageItemsCount(): number { + return this.selectableItems.length; + } + isSelected(value: ObjectItem): boolean { return this.selectionValue.selection.some(obj => obj.id === value.id); } @@ -231,6 +236,93 @@ export class MultiSelectionHelper { this._resetRange(); } + /** + * Gets the currently selected item IDs + */ + getSelectedIds(): Set { + return new Set(this.selectionValue.selection.map(item => item.id)); + } + + /** + * Selects items by their IDs, preserving object references where possible + */ + selectByIds(ids: string[]): void { + const currentSelection = new Map(this.selectionValue.selection.map(item => [item.id as string, item])); + const pageItems = new Map(this.selectableItems.map(item => [item.id as string, item])); + + // Build new selection preserving object references + const newSelection: ObjectItem[] = []; + + for (const id of ids) { + // Prefer existing selection object, then current page object + const item = currentSelection.get(id) || pageItems.get(id); + if (item) { + newSelection.push(item); + } + } + + this.selectionValue.setSelection(newSelection); + this._resetRange(); + } + + /** + * Selects all items across multiple pages by loading them in batches. + * This method preserves existing selections and adds new items as pages are loaded. + * + * @param datasource - The data source to load items from + * @param bufferSize - The batch size for loading items + * @param onProgress - Optional callback to report progress (loaded, total) + * @param abortSignal - Optional AbortSignal for cancellation + * @returns Promise that resolves when selection is complete + */ + async selectAllPages( + datasource: ListValue, + bufferSize: number, + onProgress?: (loaded: number, total: number) => void, + abortSignal?: AbortSignal + ): Promise { + // If everything fits in current page, use regular selectAll + if ( + this.selectableItems.length > 0 && + datasource.items?.length === this.selectableItems.length && + (!datasource.totalCount || datasource.totalCount <= this.selectableItems.length) + ) { + this.selectAll(); + return; + } + + // Use the new traversal utility for better robustness + // Accumulate all items across pages, avoiding duplicates + const allItems: ObjectItem[] = []; + const processedIds = new Set(); + const existingSelection = new Map(this.selectionValue.selection.map(item => [item.id as string, item])); + + await traverseAllItems(datasource, { + chunkSize: bufferSize, + signal: abortSignal, + onProgress: processed => { + const total = datasource.totalCount || processed; + onProgress?.(processed, total); + }, + onChunk: async items => { + // Add new items, preserving existing selection objects and avoiding duplicates + for (const item of items) { + const itemId = item.id as string; + if (!processedIds.has(itemId)) { + processedIds.add(itemId); + allItems.push(existingSelection.get(itemId) || item); + } + } + } + }); + + // Set the final selection if not aborted + if (!abortSignal?.aborted) { + this.selectionValue.setSelection(allItems); + this._resetRange(); + } + } + /** * Deselects all currently selected items by removing them from the selection. * Resets the selection range after clearing the selection. diff --git a/packages/shared/widget-plugin-grid/src/selection/select-action-handler.ts b/packages/shared/widget-plugin-grid/src/selection/select-action-handler.ts index 5e35c57966..e0f42e0ebf 100644 --- a/packages/shared/widget-plugin-grid/src/selection/select-action-handler.ts +++ b/packages/shared/widget-plugin-grid/src/selection/select-action-handler.ts @@ -1,5 +1,5 @@ -import { ObjectItem } from "mendix"; -import { SelectionHelper } from "./helpers"; +import { ListValue, ObjectItem } from "mendix"; +import { MultiSelectionHelper, SelectionHelper } from "./helpers"; import { SelectAdjacentFx, SelectAllFx, SelectFx, SelectionType, WidgetSelectionProperty } from "./types"; export class SelectActionHandler { @@ -75,6 +75,28 @@ export class SelectActionHandler { } }; + /** + * Selects all items across multiple pages + * @param datasource The data source to load items from + * @param bufferSize The batch size for loading items + * @param onProgress Progress callback + * @param abortSignal Cancellation signal + */ + async onSelectAllPages( + datasource: ListValue, + bufferSize: number, + onProgress?: (loaded: number, total: number) => void, + abortSignal?: AbortSignal + ): Promise { + if (!this.selectionHelper || this.selectionHelper.type !== "Multi") { + console.warn("Datagrid: selectAllPages requires Multi selection mode"); + return; + } + + const multiHelper = this.selectionHelper as MultiSelectionHelper; + await multiHelper.selectAllPages(datasource, bufferSize, onProgress, abortSignal); + } + onSelectAdjacent: SelectAdjacentFx = (...params) => { if (this.selectionHelper?.type === "Multi") { this.selectionHelper.selectUpToAdjacent(...params); From d792358a273b55e8a01049b64e6315ab9fe8c0d7 Mon Sep 17 00:00:00 2001 From: Rahman Date: Mon, 22 Sep 2025 13:42:49 +0200 Subject: [PATCH 03/35] test(datagrid-web): fix tests with new SelectActionHelper arguments --- .../src/components/__tests__/Table.spec.tsx | 82 ++++++++++++++++--- 1 file changed, 72 insertions(+), 10 deletions(-) diff --git a/packages/pluggableWidgets/datagrid-web/src/components/__tests__/Table.spec.tsx b/packages/pluggableWidgets/datagrid-web/src/components/__tests__/Table.spec.tsx index 82dcd37e3f..8c169940ea 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/__tests__/Table.spec.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/__tests__/Table.spec.tsx @@ -1,7 +1,13 @@ import { ClickActionHelper } from "@mendix/widget-plugin-grid/helpers/ClickActionHelper"; import { MultiSelectionStatus, useSelectionHelper } from "@mendix/widget-plugin-grid/selection"; import { SelectionCountStore } from "@mendix/widget-plugin-grid/selection/stores/SelectionCountStore"; -import { list, listWidget, objectItems, SelectionMultiValueBuilder } from "@mendix/widget-plugin-test-utils"; +import { + list, + ListValueBuilder, + listWidget, + objectItems, + SelectionMultiValueBuilder +} from "@mendix/widget-plugin-test-utils"; import "@testing-library/jest-dom"; import { cleanup, getAllByRole, getByRole, queryByRole, render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; @@ -19,7 +25,8 @@ import { GridBasicData } from "../../helpers/state/GridBasicData"; import { GridColumn } from "../../typings/GridColumn"; import { column, mockGridColumn, mockWidgetProps } from "../../utils/test-utils"; import { Widget, WidgetProps } from "../Widget"; - +import { RootGridStore } from "src/helpers/state/RootGridStore"; +import { SelectAllProgressStore } from "src/features/multi-page-selection/SelectAllProgressStore"; // you can also pass the mock implementation // to jest.fn as an argument window.IntersectionObserver = jest.fn(() => ({ @@ -61,6 +68,8 @@ function withCtx( checkboxEventsController: widgetProps.checkboxEventsController, focusController: widgetProps.focusController, selectionCountStore: defaultSelectionCountStore as unknown as SelectionCountStore, + selectAllProgressStore: {} as unknown as SelectAllProgressStore, + rootStore: {} as unknown as RootGridStore, ...contextOverrides }; @@ -209,7 +218,15 @@ describe("Table", () => { beforeEach(() => { props = mockWidgetProps(); - props.selectActionHelper = new SelectActionHelper("Single", undefined, "checkbox", false, 5, "clear"); + props.selectActionHelper = new SelectActionHelper( + "Single", + undefined, + "checkbox", + false, + 5, + "clear", + new ListValueBuilder().build() + ); props.paging = true; props.data = objectItems(3); }); @@ -308,7 +325,15 @@ describe("Table", () => { const props = mockWidgetProps(); props.data = objectItems(5); props.paging = true; - props.selectActionHelper = new SelectActionHelper("Multi", undefined, "checkbox", false, 5, "clear"); + props.selectActionHelper = new SelectActionHelper( + "Multi", + undefined, + "checkbox", + false, + 5, + "clear", + new ListValueBuilder().build() + ); renderWithRootContext(props); const colheader = screen.getAllByRole("columnheader")[0]; @@ -320,7 +345,15 @@ describe("Table", () => { const props = mockWidgetProps(); props.data = objectItems(5); props.paging = true; - props.selectActionHelper = new SelectActionHelper("Multi", undefined, "checkbox", true, 5, "clear"); + props.selectActionHelper = new SelectActionHelper( + "Multi", + undefined, + "checkbox", + true, + 5, + "clear", + new ListValueBuilder().build() + ); const renderWithStatus = (status: MultiSelectionStatus): ReturnType => { return renderWithRootContext(props, { @@ -342,7 +375,15 @@ describe("Table", () => { it("not render header checkbox if method is rowClick", () => { const props = mockWidgetProps(); - props.selectActionHelper = new SelectActionHelper("Multi", undefined, "rowClick", false, 5, "clear"); + props.selectActionHelper = new SelectActionHelper( + "Multi", + undefined, + "rowClick", + false, + 5, + "clear", + new ListValueBuilder().build() + ); renderWithRootContext(props); @@ -352,7 +393,15 @@ describe("Table", () => { it("call onSelectAll when header checkbox is clicked", async () => { const props = mockWidgetProps(); - props.selectActionHelper = new SelectActionHelper("Multi", undefined, "checkbox", true, 5, "clear"); + props.selectActionHelper = new SelectActionHelper( + "Multi", + undefined, + "checkbox", + true, + 5, + "clear", + new ListValueBuilder().build() + ); props.selectActionHelper.onSelectAll = jest.fn(); renderWithRootContext(props, { @@ -374,7 +423,15 @@ describe("Table", () => { beforeEach(() => { props = mockWidgetProps(); - props.selectActionHelper = new SelectActionHelper("Single", undefined, "rowClick", true, 5, "clear"); + props.selectActionHelper = new SelectActionHelper( + "Single", + undefined, + "rowClick", + true, + 5, + "clear", + new ListValueBuilder().build() + ); props.paging = true; props.data = objectItems(3); }); @@ -480,7 +537,10 @@ describe("Table", () => { itemSelectionMethod: selectionMethod, itemSelectionMode: "clear", showSelectAllToggle: false, - pageSize: 5 + pageSize: 5, + datasource: ds, + selectAllPagesEnabled: false, + selectAllPagesBufferSize: 500 }, helper ); @@ -502,7 +562,9 @@ describe("Table", () => { cellEventsController, checkboxEventsController, focusController: props.focusController, - selectionCountStore: {} as unknown as SelectionCountStore + selectionCountStore: {} as unknown as SelectionCountStore, + selectAllProgressStore: {} as unknown as SelectAllProgressStore, + rootStore: {} as unknown as RootGridStore }; return ( From 0361cc9b3173dfd8849b46eeb5e8bb0b3549bc03 Mon Sep 17 00:00:00 2001 From: Rahman Date: Thu, 25 Sep 2025 20:38:12 +0200 Subject: [PATCH 04/35] feat(datagrid-web): refactor, remove duplicate logic, move new controller to helpers --- .../src/components/CheckboxColumnHeader.tsx | 19 +- .../MultiPageSelectionController.ts | 246 ------------------ .../src/helpers/state/RootGridStore.ts | 46 ++-- .../src/query/DatasourceController.ts | 26 +- 4 files changed, 61 insertions(+), 276 deletions(-) delete mode 100644 packages/pluggableWidgets/datagrid-web/src/features/multi-page-selection/MultiPageSelectionController.ts diff --git a/packages/pluggableWidgets/datagrid-web/src/components/CheckboxColumnHeader.tsx b/packages/pluggableWidgets/datagrid-web/src/components/CheckboxColumnHeader.tsx index dcfa9c432d..46569c5dd9 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/CheckboxColumnHeader.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/CheckboxColumnHeader.tsx @@ -23,18 +23,19 @@ export function CheckboxColumnHeader(): ReactElement { return; } - if (selectActionHelper.canSelectAllPages && selectionStatus !== "none") { - // Toggle off still uses normal flow - onSelectAll(); - return; - } - - if (selectActionHelper.canSelectAllPages && selectionStatus === "none") { - // Delegate to root store orchestration - await rootStore?.startMultiPageSelectAll(selectActionHelper); + // When multi-page selection is enabled, handle both select and unselect across all pages + if (selectActionHelper.canSelectAllPages) { + if (selectionStatus === "none") { + // Select all pages + await rootStore?.startMultiPageSelectAll(selectActionHelper); + } else { + // Unselect all pages (both "all" and "some" states) + await rootStore?.clearAllPages(); + } return; } + // Fallback to normal single-page toggle onSelectAll(); }; diff --git a/packages/pluggableWidgets/datagrid-web/src/features/multi-page-selection/MultiPageSelectionController.ts b/packages/pluggableWidgets/datagrid-web/src/features/multi-page-selection/MultiPageSelectionController.ts deleted file mode 100644 index 9a188a9cbe..0000000000 --- a/packages/pluggableWidgets/datagrid-web/src/features/multi-page-selection/MultiPageSelectionController.ts +++ /dev/null @@ -1,246 +0,0 @@ -import { DatasourceController } from "@mendix/widget-plugin-grid/query/DatasourceController"; -import { MultiSelectionHelper, SelectionHelper } from "@mendix/widget-plugin-grid/selection/helpers"; -import { ReactiveController, ReactiveControllerHost } from "@mendix/widget-plugin-mobx-kit/reactive-controller"; -import { ListValue, ObjectItem } from "mendix"; -import { SelectAllProgressStore } from "./SelectAllProgressStore"; - -interface MultiPageSelectionControllerSpec { - query: DatasourceController; - progressStore: SelectAllProgressStore; -} - -export class MultiPageSelectionController implements ReactiveController { - private query: DatasourceController; - private progressStore: SelectAllProgressStore; - private abortController?: AbortController; - private locked = false; - - constructor(host: ReactiveControllerHost, spec: MultiPageSelectionControllerSpec) { - host.addController(this); - this.query = spec.query; - this.progressStore = spec.progressStore; - } - - get isRunning(): boolean { - return this.locked; - } - - setup(): () => void { - // No setup needed for now - return () => { - // Cleanup - this.abort(); - }; - } - - /** - * Checks if multi-page selection is possible given the current state - */ - canSelectAllPages(enabled: boolean, selectionType: string): boolean { - return enabled && selectionType === "Multi"; - } - - /** - * Starts the multi-page selection process - */ - async selectAllPages(datasource: ListValue, selectionHelper: SelectionHelper): Promise { - if (this.locked) { - return false; - } - - if (selectionHelper.type !== "Multi") { - return false; - } - - this.locked = true; - let success = false; - - try { - // Ensure totalCount is available - const totalCount = await this.ensureTotalCount(datasource); - - if (!totalCount || totalCount <= 0) { - return false; - } - - // Check if everything fits in current page - const currentPageSize = datasource.items?.length ?? 0; - if (totalCount <= currentPageSize) { - return false; - } - - // Start progress tracking - this.progressStore.onloadstart(totalCount); - this.abortController = new AbortController(); - - // Use controller-based traversal to properly handle pagination - const naturalChunkSize = datasource.limit ?? 25; - if (selectionHelper.type !== "Multi") { - throw new Error("Expected MultiSelectionHelper"); - } - const multiHelper = selectionHelper; - await this.selectAllWithController(multiHelper, totalCount, naturalChunkSize); - - success = true; - } catch (_error) { - // Selection failed or was aborted - throw new Error("MultiPageSelectionController: Selection failed or was aborted", { cause: _error }); - } finally { - this.progressStore.onloadend(); - this.locked = false; - this.abortController = undefined; - } - - return success; - } - - /** - * Controller-based selection that properly handles datasource pagination - */ - private async selectAllWithController( - multiHelper: MultiSelectionHelper, - totalCount: number, - chunkSize: number - ): Promise { - // Snapshot current state for restoration - const originalOffset = this.query.offset; - const originalLimit = this.query.limit; - const allItems: ObjectItem[] = []; - - try { - const processedIds = new Set(); - let currentOffset = 0; - let processed = 0; - - while (processed < totalCount && !this.abortController?.signal.aborted) { - // Use controller to set pagination - this.query.setOffset(currentOffset); - - // Wait for the datasource to reflect the change - await this.waitForDatasourceUpdate(currentOffset, chunkSize); - - const items = this.query.datasource.items ?? []; - - if (items.length === 0) { - break; - } - - // Add new items, avoiding duplicates - for (const item of items) { - const itemId = item.id as string; - if (!processedIds.has(itemId)) { - processedIds.add(itemId); - allItems.push(item); - } - } - - processed += items.length; - this.progressStore.onprogress(processed); - - // Check if we got fewer items than expected (end of data) - if (items.length < chunkSize) { - break; - } - - currentOffset += chunkSize; - - // Small delay to yield to UI - await new Promise(resolve => setTimeout(resolve, 10)); - } - } finally { - // Always restore the original page, but set selection after restoration - await this.restoreOriginalStateAndSetSelection(originalOffset, originalLimit, allItems, multiHelper); - } - } - - /** - * Restores the original datasource state and then sets the selection - * This ensures the user stays on their original page while keeping all selected items - */ - private async restoreOriginalStateAndSetSelection( - originalOffset: number, - originalLimit: number, - allItems: ObjectItem[], - multiHelper: MultiSelectionHelper - ): Promise { - if (this.abortController?.signal.aborted) { - // If aborted, just restore state without setting selection - this.query.setOffset(originalOffset); - return; - } - - if (allItems.length === 0) { - // No items to select, just restore state - this.query.setOffset(originalOffset); - return; - } - - // First, set the selection while we have all the items - (multiHelper as any).selectionValue.setSelection(allItems); - (multiHelper as any)._resetRange(); - - // Small delay to ensure selection is committed - await new Promise(resolve => setTimeout(resolve, 50)); - - // Now restore the original page - this.query.setOffset(originalOffset); - - // Wait for the datasource to reflect the restored state - await this.waitForDatasourceUpdate(originalOffset, originalLimit); - } - - /** - * Waits for the datasource to reflect the controller changes - */ - private async waitForDatasourceUpdate( - expectedOffset: number, - expectedLimit: number, - maxAttempts = 20 - ): Promise { - for (let i = 0; i < maxAttempts; i++) { - const ds = this.query.datasource; - if (ds.status !== "loading" && ds.offset === expectedOffset && ds.limit === expectedLimit) { - return; - } - await new Promise(resolve => setTimeout(resolve, 50)); - } - } - - /** - * Ensures totalCount is available, requesting it if necessary - */ - private async ensureTotalCount(datasource: ListValue): Promise { - let totalCount = datasource.totalCount; - - if (typeof totalCount !== "number" || totalCount <= 0) { - // Request total count - this.query.requestTotalCount(true); - - // Wait for it to become available - const maxAttempts = 20; - for (let i = 0; i < maxAttempts; i++) { - await new Promise(resolve => setTimeout(resolve, 100)); - - totalCount = datasource.totalCount; - const status = datasource.status; - - if (typeof totalCount === "number" && totalCount > 0 && status !== "loading") { - break; - } - } - } - - return totalCount; - } - - /** - * Aborts the current selection process - */ - abort(): void { - if (this.abortController) { - this.abortController.abort(); - this.progressStore.oncancel(); - this.locked = false; - } - } -} diff --git a/packages/pluggableWidgets/datagrid-web/src/helpers/state/RootGridStore.ts b/packages/pluggableWidgets/datagrid-web/src/helpers/state/RootGridStore.ts index a39991a822..3758bdc337 100644 --- a/packages/pluggableWidgets/datagrid-web/src/helpers/state/RootGridStore.ts +++ b/packages/pluggableWidgets/datagrid-web/src/helpers/state/RootGridStore.ts @@ -4,6 +4,7 @@ import { CustomFilterHost } from "@mendix/widget-plugin-filtering/stores/generic import { DatasourceController } from "@mendix/widget-plugin-grid/query/DatasourceController"; import { QueryController } from "@mendix/widget-plugin-grid/query/query-controller"; import { RefreshController } from "@mendix/widget-plugin-grid/query/RefreshController"; +import { clearAllPages, selectAllPages } from "@mendix/widget-plugin-grid/selection/select-all-pages"; import { SelectionCountStore } from "@mendix/widget-plugin-grid/selection/stores/SelectionCountStore"; import { BaseControllerHost } from "@mendix/widget-plugin-mobx-kit/BaseControllerHost"; import { disposeBatch } from "@mendix/widget-plugin-mobx-kit/disposeBatch"; @@ -17,11 +18,10 @@ import { DerivedLoaderController } from "../../controllers/DerivedLoaderControll import { PaginationController } from "../../controllers/PaginationController"; import { ProgressStore } from "../../features/data-export/ProgressStore"; import { SelectAllProgressStore } from "../../features/multi-page-selection/SelectAllProgressStore"; -import { MultiPageSelectionController } from "../../features/multi-page-selection/MultiPageSelectionController"; import { StaticInfo } from "../../typings/static-info"; +import { SelectActionHelper } from "../SelectActionHelper"; import { ColumnGroupStore } from "./ColumnGroupStore"; import { GridPersonalizationStore } from "./GridPersonalizationStore"; -import { SelectActionHelper } from "../SelectActionHelper"; type RequiredProps = Pick< DatagridContainerProps, @@ -56,7 +56,8 @@ export class RootGridStore extends BaseControllerHost { staticInfo: StaticInfo; exportProgressCtrl: ProgressStore; selectAllProgressStore: SelectAllProgressStore; - multiPageSelectionCtrl: MultiPageSelectionController; + private selectAllAbortController?: AbortController; + private selectAllLocked = false; loaderCtrl: DerivedLoaderController; paginationCtrl: PaginationController; readonly filterAPI: FilterAPI; @@ -104,11 +105,6 @@ export class RootGridStore extends BaseControllerHost { this.selectAllProgressStore = new SelectAllProgressStore(); - this.multiPageSelectionCtrl = new MultiPageSelectionController(this, { - query, - progressStore: this.selectAllProgressStore - }); - new DatasourceParamsController(this, { query, filterHost: combinedFilter, @@ -147,33 +143,45 @@ export class RootGridStore extends BaseControllerHost { } async startMultiPageSelectAll(selectActionHelper: SelectActionHelper): Promise { - const ds = this.gate.props.datasource; - const selectionHelper = this.basicData.currentSelectionHelper; - - if (!selectionHelper) { + if (this.selectAllLocked) { return; } // Check if multi-page selection is possible - const canSelect = this.multiPageSelectionCtrl.canSelectAllPages( - selectActionHelper.canSelectAllPages, - selectActionHelper.selectionType - ); + const canSelect = selectActionHelper.canSelectAllPages; if (!canSelect) { selectActionHelper.onSelectAll("selectAll"); return; } - // Delegate to the controller - const success = await this.multiPageSelectionCtrl.selectAllPages(ds, selectionHelper); + this.selectAllLocked = true; + this.selectAllAbortController = new AbortController(); + const success = await selectAllPages({ + query: this.query as QueryController, + gate: this.gate as any, + progress: this.selectAllProgressStore, + bufferSize: selectActionHelper.selectAllPagesBufferSize, + signal: this.selectAllAbortController.signal + }); if (!success) { selectActionHelper.onSelectAll("selectAll"); } + this.selectAllLocked = false; + this.selectAllAbortController = undefined; + } + + async clearAllPages(): Promise { + clearAllPages(this.gate); } abortMultiPageSelect(): void { - this.multiPageSelectionCtrl.abort(); + if (this.selectAllAbortController) { + this.selectAllAbortController.abort(); + this.selectAllProgressStore.oncancel(); + this.selectAllLocked = false; + this.selectAllAbortController = undefined; + } } } diff --git a/packages/shared/widget-plugin-grid/src/query/DatasourceController.ts b/packages/shared/widget-plugin-grid/src/query/DatasourceController.ts index 6fe2f5a66f..954dd1b5ff 100644 --- a/packages/shared/widget-plugin-grid/src/query/DatasourceController.ts +++ b/packages/shared/widget-plugin-grid/src/query/DatasourceController.ts @@ -1,8 +1,8 @@ import { disposeBatch } from "@mendix/widget-plugin-mobx-kit/disposeBatch"; import { DerivedPropsGate } from "@mendix/widget-plugin-mobx-kit/props-gate"; import { ReactiveController, ReactiveControllerHost } from "@mendix/widget-plugin-mobx-kit/reactive-controller"; -import { ListValue, ValueStatus } from "mendix"; -import { action, autorun, computed, IComputedValue, makeAutoObservable } from "mobx"; +import { ListValue, ObjectItem, ValueStatus } from "mendix"; +import { action, autorun, computed, IComputedValue, makeAutoObservable, when } from "mobx"; import { QueryController } from "./query-controller"; type Gate = DerivedPropsGate<{ datasource: ListValue }>; @@ -164,4 +164,26 @@ export class DatasourceController implements ReactiveController, QueryController setPageSize(size: number): void { this.pageSize = size; } + + fetchPage(limit: number, offset: number, signal: AbortSignal): Promise { + return new Promise((resolve, reject) => { + if (signal.aborted) { + return reject(signal.reason); + } + + const predicate = when( + () => + this.datasource.offset === offset && + this.datasource.limit === limit && + this.datasource.status === "available" + ); + + predicate.then(() => resolve(this.datasource.items ?? [])).catch(reject); + + this.datasource.setOffset(offset); + this.datasource.setLimit(limit); + + signal.addEventListener("abort", () => predicate.cancel()); + }); + } } From affc85e16cf0524c567e7675783746e75b45caad Mon Sep 17 00:00:00 2001 From: Rahman Date: Thu, 25 Sep 2025 20:41:12 +0200 Subject: [PATCH 05/35] feat(datagrid-web): refactor shared packages --- .../src/query/DatasourceController.ts | 10 +- .../src/query/datasource-traversal.ts | 182 ------------------ .../src/query/query-controller.ts | 3 +- .../widget-plugin-grid/src/selection.ts | 1 + .../src/selection/helpers.ts | 59 ------ .../src/selection/select-action-handler.ts | 26 +-- .../src/selection/select-all-pages.ts | 97 ++++++++++ 7 files changed, 111 insertions(+), 267 deletions(-) delete mode 100644 packages/shared/widget-plugin-grid/src/query/datasource-traversal.ts create mode 100644 packages/shared/widget-plugin-grid/src/selection/select-all-pages.ts diff --git a/packages/shared/widget-plugin-grid/src/query/DatasourceController.ts b/packages/shared/widget-plugin-grid/src/query/DatasourceController.ts index 954dd1b5ff..d1d639d799 100644 --- a/packages/shared/widget-plugin-grid/src/query/DatasourceController.ts +++ b/packages/shared/widget-plugin-grid/src/query/DatasourceController.ts @@ -165,7 +165,15 @@ export class DatasourceController implements ReactiveController, QueryController this.pageSize = size; } - fetchPage(limit: number, offset: number, signal: AbortSignal): Promise { + fetchPage({ + limit, + offset, + signal + }: { + limit: number; + offset: number; + signal: AbortSignal; + }): Promise { return new Promise((resolve, reject) => { if (signal.aborted) { return reject(signal.reason); diff --git a/packages/shared/widget-plugin-grid/src/query/datasource-traversal.ts b/packages/shared/widget-plugin-grid/src/query/datasource-traversal.ts deleted file mode 100644 index 7fbd812b5f..0000000000 --- a/packages/shared/widget-plugin-grid/src/query/datasource-traversal.ts +++ /dev/null @@ -1,182 +0,0 @@ -import { ListValue, ObjectItem } from "mendix"; -import { createNanoEvents, Emitter } from "nanoevents"; - -export interface TraversalOptions { - chunkSize?: number; - signal?: AbortSignal; - onProgress?: (processed: number, total?: number) => void; - onChunk?: (items: ObjectItem[], offset: number) => void | Promise; -} - -interface TraversalEvents { - statechange: (state: { offset: number; limit: number; status: string }) => void; -} - -/** - * Traverses all items in a ListValue datasource by paginating through chunks. - * Mirrors the pattern used by ExportController - snapshots state, iterates, then restores. - */ -export async function traverseAllItems(ds: ListValue, options: TraversalOptions = {}): Promise { - // Use the datasource's current limit as chunk size to respect its pagination - const naturalChunkSize = ds.limit ?? 25; - const { chunkSize = naturalChunkSize, signal, onProgress, onChunk } = options; - - // Snapshot current state - const snapshot = { - offset: ds.offset, - limit: ds.limit - }; - - // Create event emitter for tracking datasource changes - const emitter: Emitter = createNanoEvents(); - let processed = 0; - - try { - // Start from the beginning - let currentOffset = 0; - let hasMore = true; - let chunkIndex = 0; - - while (hasMore && !signal?.aborted) { - // Set pagination parameters - - ds.setOffset(currentOffset); - if (ds.limit !== chunkSize) { - ds.setLimit(chunkSize); - } - - // Trigger reload - ds.reload(); - - // Wait for reload to complete with simpler polling - await waitForReload(ds, currentOffset, chunkSize); - - // Process items - const items = ds.items ?? []; - - if (items.length === 0) { - break; // No more items - } - - // Handle the chunk - if (onChunk) { - await onChunk(items, currentOffset); - } - - // Update progress - processed += items.length; - onProgress?.(processed, ds.totalCount); - - // Check if this was the last page - // Stop if we've processed all items according to totalCount - // or if we got fewer items than expected - const totalCount = ds.totalCount; - const reachedTotal = totalCount && processed >= totalCount; - const gotPartialChunk = items.length < chunkSize; - hasMore = !reachedTotal && !gotPartialChunk; - currentOffset += chunkSize; - chunkIndex++; - - // Yield to UI - await new Promise(resolve => setTimeout(resolve, 0)); - } - - if (signal?.aborted) { - // Traversal aborted - } else { - // Traversal completed - } - } finally { - // Always restore original view state - await restoreSnapshot(ds, snapshot, emitter); - } -} - -/** - * Reloads the datasource and waits for it to stabilize - */ -async function reloadAndWait(ds: ListValue, emitter: Emitter): Promise { - const targetOffset = ds.offset; - const targetLimit = ds.limit; - - // Set up one-time listener for state change - const statePromise = new Promise(resolve => { - const checkState = (): void => { - if (ds.status !== "loading" && ds.offset === targetOffset && ds.limit === targetLimit) { - resolve(); - } - }; - - // Check immediately in case already loaded - checkState(); - - // Otherwise wait for state change - const unsubscribe = emitter.on("statechange", state => { - if (state.status !== "loading" && state.offset === targetOffset && state.limit === targetLimit) { - unsubscribe(); - resolve(); - } - }); - }); - - // Trigger reload - ds.reload(); - - // Poll for changes (fallback for when events aren't available) - const pollPromise = pollDatasource(ds, targetOffset, targetLimit); - - // Race between event-based and polling - await Promise.race([statePromise, pollPromise]); -} - -/** - * Simpler wait function that waits for datasource to be ready - */ -async function waitForReload( - ds: ListValue, - expectedOffset: number, - expectedLimit: number, - maxAttempts = 50 -): Promise { - for (let i = 0; i < maxAttempts; i++) { - if (ds.status !== "loading" && ds.offset === expectedOffset && ds.limit === expectedLimit && ds.items) { - return; - } - await new Promise(resolve => setTimeout(resolve, 100)); - } - console.warn("Datasource wait timeout - proceeding anyway"); -} - -/** - * Polls the datasource until it reflects the expected state - */ -async function pollDatasource( - ds: ListValue, - expectedOffset: number, - expectedLimit: number, - maxAttempts = 50 -): Promise { - for (let i = 0; i < maxAttempts; i++) { - if (ds.status !== "loading" && ds.offset === expectedOffset && ds.limit === expectedLimit) { - return; - } - await new Promise(resolve => setTimeout(resolve, 100)); - } - console.warn("Datasource polling timeout - proceeding anyway"); -} - -/** - * Restores the datasource to its original offset/limit - */ -async function restoreSnapshot( - ds: ListValue, - snapshot: { offset: number; limit: number }, - emitter: Emitter -): Promise { - // Restore original pagination - ds.setLimit(snapshot.limit); - ds.setOffset(snapshot.offset); - - // Wait for restore to complete - await reloadAndWait(ds, emitter); -} diff --git a/packages/shared/widget-plugin-grid/src/query/query-controller.ts b/packages/shared/widget-plugin-grid/src/query/query-controller.ts index a5fb0421b3..5637adbd29 100644 --- a/packages/shared/widget-plugin-grid/src/query/query-controller.ts +++ b/packages/shared/widget-plugin-grid/src/query/query-controller.ts @@ -1,4 +1,4 @@ -import { ListValue } from "mendix"; +import { ListValue, ObjectItem } from "mendix"; type Members = | "setOffset" @@ -18,4 +18,5 @@ export interface QueryController extends Pick { isFirstLoad: boolean; isRefreshing: boolean; isFetchingNextBatch: boolean; + fetchPage(params: { limit: number; offset: number; signal: AbortSignal }): Promise; } diff --git a/packages/shared/widget-plugin-grid/src/selection.ts b/packages/shared/widget-plugin-grid/src/selection.ts index c7514487c6..d672bef5d5 100644 --- a/packages/shared/widget-plugin-grid/src/selection.ts +++ b/packages/shared/widget-plugin-grid/src/selection.ts @@ -6,4 +6,5 @@ export { useCreateSelectionContextValue, useSelectionContextValue } from "./selection/context.js"; +export { selectAllPages } from "./selection/select-all-pages.js"; export { SelectActionHandler } from "./selection/select-action-handler.js"; diff --git a/packages/shared/widget-plugin-grid/src/selection/helpers.ts b/packages/shared/widget-plugin-grid/src/selection/helpers.ts index 844b505be7..e26e884968 100644 --- a/packages/shared/widget-plugin-grid/src/selection/helpers.ts +++ b/packages/shared/widget-plugin-grid/src/selection/helpers.ts @@ -3,7 +3,6 @@ import type { ActionValue, ListValue, ObjectItem, SelectionMultiValue, Selection import { action, computed, makeObservable, observable } from "mobx"; import { useEffect, useRef, useState } from "react"; import { Direction, MoveEvent1D, MoveEvent2D, MultiSelectionStatus, ScrollKeyCode, SelectionMode, Size } from "./types"; -import { traverseAllItems } from "../query/datasource-traversal"; class SingleSelectionHelper { type = "Single" as const; @@ -265,64 +264,6 @@ export class MultiSelectionHelper { this._resetRange(); } - /** - * Selects all items across multiple pages by loading them in batches. - * This method preserves existing selections and adds new items as pages are loaded. - * - * @param datasource - The data source to load items from - * @param bufferSize - The batch size for loading items - * @param onProgress - Optional callback to report progress (loaded, total) - * @param abortSignal - Optional AbortSignal for cancellation - * @returns Promise that resolves when selection is complete - */ - async selectAllPages( - datasource: ListValue, - bufferSize: number, - onProgress?: (loaded: number, total: number) => void, - abortSignal?: AbortSignal - ): Promise { - // If everything fits in current page, use regular selectAll - if ( - this.selectableItems.length > 0 && - datasource.items?.length === this.selectableItems.length && - (!datasource.totalCount || datasource.totalCount <= this.selectableItems.length) - ) { - this.selectAll(); - return; - } - - // Use the new traversal utility for better robustness - // Accumulate all items across pages, avoiding duplicates - const allItems: ObjectItem[] = []; - const processedIds = new Set(); - const existingSelection = new Map(this.selectionValue.selection.map(item => [item.id as string, item])); - - await traverseAllItems(datasource, { - chunkSize: bufferSize, - signal: abortSignal, - onProgress: processed => { - const total = datasource.totalCount || processed; - onProgress?.(processed, total); - }, - onChunk: async items => { - // Add new items, preserving existing selection objects and avoiding duplicates - for (const item of items) { - const itemId = item.id as string; - if (!processedIds.has(itemId)) { - processedIds.add(itemId); - allItems.push(existingSelection.get(itemId) || item); - } - } - } - }); - - // Set the final selection if not aborted - if (!abortSignal?.aborted) { - this.selectionValue.setSelection(allItems); - this._resetRange(); - } - } - /** * Deselects all currently selected items by removing them from the selection. * Resets the selection range after clearing the selection. diff --git a/packages/shared/widget-plugin-grid/src/selection/select-action-handler.ts b/packages/shared/widget-plugin-grid/src/selection/select-action-handler.ts index e0f42e0ebf..5e35c57966 100644 --- a/packages/shared/widget-plugin-grid/src/selection/select-action-handler.ts +++ b/packages/shared/widget-plugin-grid/src/selection/select-action-handler.ts @@ -1,5 +1,5 @@ -import { ListValue, ObjectItem } from "mendix"; -import { MultiSelectionHelper, SelectionHelper } from "./helpers"; +import { ObjectItem } from "mendix"; +import { SelectionHelper } from "./helpers"; import { SelectAdjacentFx, SelectAllFx, SelectFx, SelectionType, WidgetSelectionProperty } from "./types"; export class SelectActionHandler { @@ -75,28 +75,6 @@ export class SelectActionHandler { } }; - /** - * Selects all items across multiple pages - * @param datasource The data source to load items from - * @param bufferSize The batch size for loading items - * @param onProgress Progress callback - * @param abortSignal Cancellation signal - */ - async onSelectAllPages( - datasource: ListValue, - bufferSize: number, - onProgress?: (loaded: number, total: number) => void, - abortSignal?: AbortSignal - ): Promise { - if (!this.selectionHelper || this.selectionHelper.type !== "Multi") { - console.warn("Datagrid: selectAllPages requires Multi selection mode"); - return; - } - - const multiHelper = this.selectionHelper as MultiSelectionHelper; - await multiHelper.selectAllPages(datasource, bufferSize, onProgress, abortSignal); - } - onSelectAdjacent: SelectAdjacentFx = (...params) => { if (this.selectionHelper?.type === "Multi") { this.selectionHelper.selectUpToAdjacent(...params); diff --git a/packages/shared/widget-plugin-grid/src/selection/select-all-pages.ts b/packages/shared/widget-plugin-grid/src/selection/select-all-pages.ts new file mode 100644 index 0000000000..bd2e032210 --- /dev/null +++ b/packages/shared/widget-plugin-grid/src/selection/select-all-pages.ts @@ -0,0 +1,97 @@ +import { QueryController } from "../query/query-controller"; +import { DerivedPropsGate } from "@mendix/widget-plugin-mobx-kit/props-gate"; +import { ObjectItem, SelectionMultiValue, SelectionSingleValue } from "mendix"; +type Gate = DerivedPropsGate<{ itemSelection?: SelectionMultiValue | SelectionSingleValue }>; + +export interface ISelectAllProgressStore { + selecting: boolean; + lengthComputable: boolean; + loaded: number; + total: number; + cancelled: boolean; + onloadstart: (total: number) => void; + onprogress: (loaded: number) => void; + onloadend: () => void; + oncancel: () => void; + progress: number; + displayProgress: string; +} + +export async function selectAllPages({ + query, + gate, + progress, + bufferSize, + signal +}: { + query: QueryController; + gate: Gate; + progress: ISelectAllProgressStore; + bufferSize: number; + signal: AbortSignal; +}): Promise { + if (!gate.props.itemSelection || gate.props.itemSelection.type !== "Multi") { + return false; + } + + if (!query.hasMoreItems) { + return false; + } + + const originalOffset = query.offset; + const originalLimit = query.limit; + + const totalCount = query.totalCount; + if (totalCount && totalCount <= bufferSize) { + // Direct selection without progress + const allItems = await query.fetchPage({ limit: totalCount, offset: 0, signal }); + gate.props.itemSelection?.setSelection(allItems); + return true; + } + + if (totalCount && totalCount > 0) { + progress.onloadstart(totalCount); + } else { + progress.onloadstart(0); + progress.lengthComputable = false; + } + + const allItems: ObjectItem[] = []; + const pageLimit = Math.max(1, bufferSize || query.limit || 25); + let offset = 0; + + try { + while (!signal.aborted) { + const page = await query.fetchPage({ limit: pageLimit, offset, signal }); + if (!page.length) break; + allItems.push(...page); + offset += pageLimit; + if (progress.lengthComputable && totalCount && totalCount > 0) { + progress.onprogress(Math.min(allItems.length, totalCount)); + } else { + progress.onprogress(allItems.length); + } + if (!query.hasMoreItems) break; + } + + if (signal.aborted) { + return false; + } + gate.props.itemSelection?.setSelection(allItems); + + // Restore original pagination + query.setLimit(originalLimit); + query.setOffset(originalOffset); + return true; + } finally { + progress.onloadend(); + } +} + +export function clearAllPages(gate: Gate): boolean { + if (!gate.props.itemSelection || gate.props.itemSelection.type !== "Multi") { + return false; + } + gate.props.itemSelection.setSelection([]); + return true; +} From 96253dc04aa41f62256227da35b611f7c09ec73a Mon Sep 17 00:00:00 2001 From: Rahman Date: Fri, 26 Sep 2025 10:00:46 +0200 Subject: [PATCH 06/35] chore(datagrid-web): cleanup unused prop properties --- .../datagrid-web/src/Datagrid.editorConfig.ts | 1 - .../datagrid-web/src/Datagrid.xml | 19 +++++++++++++++---- .../datagrid-web/typings/DatagridProps.d.ts | 2 -- 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/packages/pluggableWidgets/datagrid-web/src/Datagrid.editorConfig.ts b/packages/pluggableWidgets/datagrid-web/src/Datagrid.editorConfig.ts index c4b40ffd43..d26812783f 100644 --- a/packages/pluggableWidgets/datagrid-web/src/Datagrid.editorConfig.ts +++ b/packages/pluggableWidgets/datagrid-web/src/Datagrid.editorConfig.ts @@ -176,7 +176,6 @@ function hideSelectionProperties(defaultProperties: Properties, values: Datagrid if (!selectAllPagesEnabled) { hidePropertyIn(defaultProperties, values, "selectAllPagesBufferSize"); - hidePropertyIn(defaultProperties, values, "selectAllPagesLabel"); hidePropertyIn(defaultProperties, values, "selectingAllLabel"); hidePropertyIn(defaultProperties, values, "cancelSelectionLabel"); } diff --git a/packages/pluggableWidgets/datagrid-web/src/Datagrid.xml b/packages/pluggableWidgets/datagrid-web/src/Datagrid.xml index afb1d00ec4..fde7b10e7e 100644 --- a/packages/pluggableWidgets/datagrid-web/src/Datagrid.xml +++ b/packages/pluggableWidgets/datagrid-web/src/Datagrid.xml @@ -62,11 +62,22 @@ Off - - Clear selection label - Customize the label of the 'Clear section' button + + Select all buffer size + Batch size for processing select all operations. If total count is less than buffer size, selection is immediate. + + + Selecting all label + Label shown in the progress dialog when selecting all items + + Selecting all items... + + + + Cancel selection label + Label for the cancel button in the selection progress dialog - Clear selection + Cancel selection diff --git a/packages/pluggableWidgets/datagrid-web/typings/DatagridProps.d.ts b/packages/pluggableWidgets/datagrid-web/typings/DatagridProps.d.ts index 2627bb8526..88ee64833e 100644 --- a/packages/pluggableWidgets/datagrid-web/typings/DatagridProps.d.ts +++ b/packages/pluggableWidgets/datagrid-web/typings/DatagridProps.d.ts @@ -104,7 +104,6 @@ export interface DatagridContainerProps { ======= selectAllPagesEnabled: boolean; selectAllPagesBufferSize: number; - selectAllPagesLabel?: DynamicValue; selectingAllLabel?: DynamicValue; cancelSelectionLabel?: DynamicValue; >>>>>>> 2e4671e66 (feat(datagrid-web): add multipage selection to dg2) @@ -166,7 +165,6 @@ export interface DatagridPreviewProps { ======= selectAllPagesEnabled: boolean; selectAllPagesBufferSize: number | null; - selectAllPagesLabel: string; selectingAllLabel: string; cancelSelectionLabel: string; >>>>>>> 2e4671e66 (feat(datagrid-web): add multipage selection to dg2) From 9897d70bc4fcd7c9ba693a61db3d76206654c938 Mon Sep 17 00:00:00 2001 From: Rahman Date: Fri, 26 Sep 2025 10:02:30 +0200 Subject: [PATCH 07/35] chore(datagrid-web): unused class properties --- .../src/selection/helpers.ts | 33 ------------------- 1 file changed, 33 deletions(-) diff --git a/packages/shared/widget-plugin-grid/src/selection/helpers.ts b/packages/shared/widget-plugin-grid/src/selection/helpers.ts index e26e884968..05738b5416 100644 --- a/packages/shared/widget-plugin-grid/src/selection/helpers.ts +++ b/packages/shared/widget-plugin-grid/src/selection/helpers.ts @@ -42,10 +42,6 @@ export class MultiSelectionHelper { }); } - get pageItemsCount(): number { - return this.selectableItems.length; - } - isSelected(value: ObjectItem): boolean { return this.selectionValue.selection.some(obj => obj.id === value.id); } @@ -235,35 +231,6 @@ export class MultiSelectionHelper { this._resetRange(); } - /** - * Gets the currently selected item IDs - */ - getSelectedIds(): Set { - return new Set(this.selectionValue.selection.map(item => item.id)); - } - - /** - * Selects items by their IDs, preserving object references where possible - */ - selectByIds(ids: string[]): void { - const currentSelection = new Map(this.selectionValue.selection.map(item => [item.id as string, item])); - const pageItems = new Map(this.selectableItems.map(item => [item.id as string, item])); - - // Build new selection preserving object references - const newSelection: ObjectItem[] = []; - - for (const id of ids) { - // Prefer existing selection object, then current page object - const item = currentSelection.get(id) || pageItems.get(id); - if (item) { - newSelection.push(item); - } - } - - this.selectionValue.setSelection(newSelection); - this._resetRange(); - } - /** * Deselects all currently selected items by removing them from the selection. * Resets the selection range after clearing the selection. From 90981ddbf6eb24f2ff3e0369fa1eb5e0da36150d Mon Sep 17 00:00:00 2001 From: Rahman Date: Fri, 26 Sep 2025 15:43:20 +0200 Subject: [PATCH 08/35] feat(datagrid-web): wip:refactor, MultiPageSelectionController --- .../src/Datagrid.editorPreview.tsx | 1 + .../datagrid-web/src/Datagrid.tsx | 4 +- .../src/components/CheckboxColumnHeader.tsx | 11 +- .../datagrid-web/src/components/Widget.tsx | 4 +- .../src/components/__tests__/Table.spec.tsx | 7 +- .../datagrid-web/src/helpers/root-context.ts | 4 +- .../src/helpers/state/RootGridStore.ts | 56 +----- .../src/query/DatasourceController.ts | 4 + .../src/query/query-controller.ts | 1 + .../widget-plugin-grid/src/selection.ts | 2 +- .../selection/MultiPageSelectionController.ts | 165 ++++++++++++++++++ .../src/selection/select-all-pages.ts | 97 ---------- 12 files changed, 201 insertions(+), 155 deletions(-) create mode 100644 packages/shared/widget-plugin-grid/src/selection/MultiPageSelectionController.ts delete mode 100644 packages/shared/widget-plugin-grid/src/selection/select-all-pages.ts diff --git a/packages/pluggableWidgets/datagrid-web/src/Datagrid.editorPreview.tsx b/packages/pluggableWidgets/datagrid-web/src/Datagrid.editorPreview.tsx index 8d208afa33..755e9f7651 100644 --- a/packages/pluggableWidgets/datagrid-web/src/Datagrid.editorPreview.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/Datagrid.editorPreview.tsx @@ -97,6 +97,7 @@ export function preview(props: DatagridPreviewProps): ReactElement { selectActionHelper, cellEventsController: eventsController, checkboxEventsController: eventsController, + multiPageSelectionController: {} as any, // Mock for preview focusController, selectionCountStore, selectAllProgressStore: new SelectAllProgressStore(), diff --git a/packages/pluggableWidgets/datagrid-web/src/Datagrid.tsx b/packages/pluggableWidgets/datagrid-web/src/Datagrid.tsx index e19562916f..f5ecaf3d93 100644 --- a/packages/pluggableWidgets/datagrid-web/src/Datagrid.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/Datagrid.tsx @@ -74,8 +74,8 @@ const Container = observer((props: Props): ReactElement => { checkboxEventsController, focusController, selectionCountStore: rootStore.selectionCountStore, - selectAllProgressStore: rootStore.selectAllProgressStore, - rootStore + multiPageSelectionController: rootStore.multiPageSelectionController, + selectAllProgressStore: rootStore.selectAllProgressStore }; }); diff --git a/packages/pluggableWidgets/datagrid-web/src/components/CheckboxColumnHeader.tsx b/packages/pluggableWidgets/datagrid-web/src/components/CheckboxColumnHeader.tsx index 46569c5dd9..4cdd974f64 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/CheckboxColumnHeader.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/CheckboxColumnHeader.tsx @@ -3,7 +3,8 @@ import { Fragment, ReactElement } from "react"; import { useDatagridRootScope } from "../helpers/root-context"; export function CheckboxColumnHeader(): ReactElement { - const { selectActionHelper, basicData, selectAllProgressStore, rootStore } = useDatagridRootScope(); + const { selectActionHelper, basicData, selectAllProgressStore, multiPageSelectionController } = + useDatagridRootScope(); const { showCheckboxColumn, showSelectAllToggle, onSelectAll } = selectActionHelper; const { selectionStatus, selectAllRowsLabel } = basicData; @@ -27,10 +28,14 @@ export function CheckboxColumnHeader(): ReactElement { if (selectActionHelper.canSelectAllPages) { if (selectionStatus === "none") { // Select all pages - await rootStore?.startMultiPageSelectAll(selectActionHelper); + const success = await multiPageSelectionController.selectAllPages(); + if (!success) { + // Fallback to single page selection if multi-page fails + onSelectAll(); + } } else { // Unselect all pages (both "all" and "some" states) - await rootStore?.clearAllPages(); + multiPageSelectionController.clearAllPages(); } return; } diff --git a/packages/pluggableWidgets/datagrid-web/src/components/Widget.tsx b/packages/pluggableWidgets/datagrid-web/src/components/Widget.tsx index 6b2a15e307..7f433754bd 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/Widget.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/Widget.tsx @@ -84,7 +84,7 @@ export interface WidgetProps(props: WidgetProps): ReactElement => { const { className, exporting, numberOfItems, onExportCancel, selectActionHelper } = props; - const { basicData, selectAllProgressStore, rootStore } = useDatagridRootScope(); + const { basicData, selectAllProgressStore, multiPageSelectionController } = useDatagridRootScope(); const selectionEnabled = selectActionHelper.selectionType !== "None"; @@ -102,7 +102,7 @@ export const Widget = observer((props: WidgetProps): Re open={selectAllProgressStore.selecting} selectingLabel={basicData.selectingAllLabel ?? "Selecting all items..."} cancelLabel={basicData.cancelSelectionLabel ?? "Cancel selection"} - onCancel={() => rootStore.abortMultiPageSelect()} + onCancel={() => multiPageSelectionController.abort()} progress={selectAllProgressStore.loaded} total={selectAllProgressStore.total} /> diff --git a/packages/pluggableWidgets/datagrid-web/src/components/__tests__/Table.spec.tsx b/packages/pluggableWidgets/datagrid-web/src/components/__tests__/Table.spec.tsx index 8c169940ea..aa5fdc4cf9 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/__tests__/Table.spec.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/__tests__/Table.spec.tsx @@ -1,5 +1,9 @@ import { ClickActionHelper } from "@mendix/widget-plugin-grid/helpers/ClickActionHelper"; -import { MultiSelectionStatus, useSelectionHelper } from "@mendix/widget-plugin-grid/selection"; +import { + MultiSelectionStatus, + useSelectionHelper, + MultiPageSelectionController +} from "@mendix/widget-plugin-grid/selection"; import { SelectionCountStore } from "@mendix/widget-plugin-grid/selection/stores/SelectionCountStore"; import { list, @@ -66,6 +70,7 @@ function withCtx( selectActionHelper: widgetProps.selectActionHelper, cellEventsController: widgetProps.cellEventsController, checkboxEventsController: widgetProps.checkboxEventsController, + multiPageSelectionController: {} as unknown as MultiPageSelectionController, focusController: widgetProps.focusController, selectionCountStore: defaultSelectionCountStore as unknown as SelectionCountStore, selectAllProgressStore: {} as unknown as SelectAllProgressStore, diff --git a/packages/pluggableWidgets/datagrid-web/src/helpers/root-context.ts b/packages/pluggableWidgets/datagrid-web/src/helpers/root-context.ts index a0d9f4333c..e0b417436d 100644 --- a/packages/pluggableWidgets/datagrid-web/src/helpers/root-context.ts +++ b/packages/pluggableWidgets/datagrid-web/src/helpers/root-context.ts @@ -4,9 +4,9 @@ import { SelectionCountStore } from "@mendix/widget-plugin-grid/selection/stores import { createContext, useContext } from "react"; import { SelectAllProgressStore } from "../features/multi-page-selection/SelectAllProgressStore"; import { GridBasicData } from "../helpers/state/GridBasicData"; -import { RootGridStore } from "../helpers/state/RootGridStore"; import { EventsController } from "../typings/CellComponent"; import { SelectActionHelper } from "./SelectActionHelper"; +import { MultiPageSelectionController } from "@mendix/widget-plugin-grid/selection/MultiPageSelectionController"; export interface DatagridRootScope { basicData: GridBasicData; @@ -15,10 +15,10 @@ export interface DatagridRootScope { selectActionHelper: SelectActionHelper; cellEventsController: EventsController; checkboxEventsController: EventsController; + multiPageSelectionController: MultiPageSelectionController; focusController: FocusTargetController; selectionCountStore: SelectionCountStore; selectAllProgressStore: SelectAllProgressStore; - rootStore: RootGridStore; } export const DatagridContext = createContext(null); diff --git a/packages/pluggableWidgets/datagrid-web/src/helpers/state/RootGridStore.ts b/packages/pluggableWidgets/datagrid-web/src/helpers/state/RootGridStore.ts index 3758bdc337..583afe1d1c 100644 --- a/packages/pluggableWidgets/datagrid-web/src/helpers/state/RootGridStore.ts +++ b/packages/pluggableWidgets/datagrid-web/src/helpers/state/RootGridStore.ts @@ -4,7 +4,7 @@ import { CustomFilterHost } from "@mendix/widget-plugin-filtering/stores/generic import { DatasourceController } from "@mendix/widget-plugin-grid/query/DatasourceController"; import { QueryController } from "@mendix/widget-plugin-grid/query/query-controller"; import { RefreshController } from "@mendix/widget-plugin-grid/query/RefreshController"; -import { clearAllPages, selectAllPages } from "@mendix/widget-plugin-grid/selection/select-all-pages"; +import { MultiPageSelectionController } from "@mendix/widget-plugin-grid/selection"; import { SelectionCountStore } from "@mendix/widget-plugin-grid/selection/stores/SelectionCountStore"; import { BaseControllerHost } from "@mendix/widget-plugin-mobx-kit/BaseControllerHost"; import { disposeBatch } from "@mendix/widget-plugin-mobx-kit/disposeBatch"; @@ -19,7 +19,6 @@ import { PaginationController } from "../../controllers/PaginationController"; import { ProgressStore } from "../../features/data-export/ProgressStore"; import { SelectAllProgressStore } from "../../features/multi-page-selection/SelectAllProgressStore"; import { StaticInfo } from "../../typings/static-info"; -import { SelectActionHelper } from "../SelectActionHelper"; import { ColumnGroupStore } from "./ColumnGroupStore"; import { GridPersonalizationStore } from "./GridPersonalizationStore"; @@ -56,8 +55,7 @@ export class RootGridStore extends BaseControllerHost { staticInfo: StaticInfo; exportProgressCtrl: ProgressStore; selectAllProgressStore: SelectAllProgressStore; - private selectAllAbortController?: AbortController; - private selectAllLocked = false; + multiPageSelectionController: MultiPageSelectionController; loaderCtrl: DerivedLoaderController; paginationCtrl: PaginationController; readonly filterAPI: FilterAPI; @@ -105,6 +103,13 @@ export class RootGridStore extends BaseControllerHost { this.selectAllProgressStore = new SelectAllProgressStore(); + this.multiPageSelectionController = new MultiPageSelectionController(this, { + gate, + query, + progressStore: this.selectAllProgressStore, + bufferSize: props.selectAllPagesBufferSize ?? 500 + }); + new DatasourceParamsController(this, { query, filterHost: combinedFilter, @@ -141,47 +146,4 @@ export class RootGridStore extends BaseControllerHost { this.columnsStore.updateProps(props); this.settingsStore.updateProps(props); } - - async startMultiPageSelectAll(selectActionHelper: SelectActionHelper): Promise { - if (this.selectAllLocked) { - return; - } - - // Check if multi-page selection is possible - const canSelect = selectActionHelper.canSelectAllPages; - - if (!canSelect) { - selectActionHelper.onSelectAll("selectAll"); - return; - } - - this.selectAllLocked = true; - this.selectAllAbortController = new AbortController(); - const success = await selectAllPages({ - query: this.query as QueryController, - gate: this.gate as any, - progress: this.selectAllProgressStore, - bufferSize: selectActionHelper.selectAllPagesBufferSize, - signal: this.selectAllAbortController.signal - }); - - if (!success) { - selectActionHelper.onSelectAll("selectAll"); - } - this.selectAllLocked = false; - this.selectAllAbortController = undefined; - } - - async clearAllPages(): Promise { - clearAllPages(this.gate); - } - - abortMultiPageSelect(): void { - if (this.selectAllAbortController) { - this.selectAllAbortController.abort(); - this.selectAllProgressStore.oncancel(); - this.selectAllLocked = false; - this.selectAllAbortController = undefined; - } - } } diff --git a/packages/shared/widget-plugin-grid/src/query/DatasourceController.ts b/packages/shared/widget-plugin-grid/src/query/DatasourceController.ts index d1d639d799..eabfa2b66b 100644 --- a/packages/shared/widget-plugin-grid/src/query/DatasourceController.ts +++ b/packages/shared/widget-plugin-grid/src/query/DatasourceController.ts @@ -103,6 +103,10 @@ export class DatasourceController implements ReactiveController, QueryController return this.datasource.hasMoreItems ?? false; } + get items(): ObjectItem[] | undefined { + return this.datasource.items; + } + /** * Returns computed value that holds controller copy. * Recomputes the copy every time the datasource changes. diff --git a/packages/shared/widget-plugin-grid/src/query/query-controller.ts b/packages/shared/widget-plugin-grid/src/query/query-controller.ts index 5637adbd29..67ea7b4440 100644 --- a/packages/shared/widget-plugin-grid/src/query/query-controller.ts +++ b/packages/shared/widget-plugin-grid/src/query/query-controller.ts @@ -9,6 +9,7 @@ type Members = | "totalCount" | "limit" | "offset" + | "items" | "hasMoreItems"; export interface QueryController extends Pick { diff --git a/packages/shared/widget-plugin-grid/src/selection.ts b/packages/shared/widget-plugin-grid/src/selection.ts index d672bef5d5..17c350bbdb 100644 --- a/packages/shared/widget-plugin-grid/src/selection.ts +++ b/packages/shared/widget-plugin-grid/src/selection.ts @@ -6,5 +6,5 @@ export { useCreateSelectionContextValue, useSelectionContextValue } from "./selection/context.js"; -export { selectAllPages } from "./selection/select-all-pages.js"; +export { MultiPageSelectionController } from "./selection/MultiPageSelectionController.js"; export { SelectActionHandler } from "./selection/select-action-handler.js"; diff --git a/packages/shared/widget-plugin-grid/src/selection/MultiPageSelectionController.ts b/packages/shared/widget-plugin-grid/src/selection/MultiPageSelectionController.ts new file mode 100644 index 0000000000..6febe68466 --- /dev/null +++ b/packages/shared/widget-plugin-grid/src/selection/MultiPageSelectionController.ts @@ -0,0 +1,165 @@ +import { QueryController } from "../query/query-controller"; +import { DerivedPropsGate } from "@mendix/widget-plugin-mobx-kit/props-gate"; +import { ReactiveController, ReactiveControllerHost } from "@mendix/widget-plugin-mobx-kit/reactive-controller"; +import { SelectionMultiValue, SelectionSingleValue } from "mendix"; + +type Gate = DerivedPropsGate<{ itemSelection?: SelectionMultiValue | SelectionSingleValue }>; + +export interface ISelectAllProgressStore { + selecting: boolean; + lengthComputable: boolean; + loaded: number; + total: number; + cancelled: boolean; + onloadstart: (total: number) => void; + onprogress: (loaded: number) => void; + onloadend: () => void; + oncancel: () => void; + progress: number; + displayProgress: string; +} + +interface MultiPageSelectionControllerSpec { + gate: Gate; + query: QueryController; + progressStore: ISelectAllProgressStore; + bufferSize: number; +} + +export class MultiPageSelectionController implements ReactiveController { + private readonly gate: Gate; + private readonly query: QueryController; + private readonly progressStore: ISelectAllProgressStore; + private readonly bufferSize: number; + private abortController?: AbortController; + private locked = false; + + constructor(host: ReactiveControllerHost, spec: MultiPageSelectionControllerSpec) { + host.addController(this); + this.gate = spec.gate; + this.query = spec.query; + this.progressStore = spec.progressStore; + this.bufferSize = spec.bufferSize; + } + + setup(): () => void { + // No specific setup needed for now + return () => { + // Cleanup + this.abort(); + }; + } + + get isRunning(): boolean { + return this.locked; + } + + get isMultiSelection(): boolean { + return this.gate.props.itemSelection?.type === "Multi"; + } + + get hasSelection(): boolean { + const selection = this.gate.props.itemSelection?.selection; + if (!selection) return false; + return Array.isArray(selection) ? selection.length > 0 : true; + } + + get selectionCount(): number { + const selection = this.gate.props.itemSelection?.selection; + if (!selection) return 0; + return Array.isArray(selection) ? selection.length : 1; + } + + get canSelectAllPages(): boolean { + return this.isMultiSelection && this.query.hasMoreItems; + } + + async selectAllPages(): Promise { + if (this.locked) { + return false; + } + + if (!this.isMultiSelection) { + return false; + } + + if (!this.query.hasMoreItems) { + return false; + } + + this.locked = true; + let success = false; + + try { + const originalOffset = this.query.offset; + const originalLimit = this.query.limit; + + this.progressStore.onloadstart(this.query.totalCount ?? 0); + this.progressStore.lengthComputable = false; + this.abortController = new AbortController(); + + const pageLimit = Math.max(1, this.bufferSize || this.query.limit || 25); + let offset = 0; + let loaded = 0; + + try { + this.gate.props.itemSelection?.setKeepSelection(() => true); + while (!this.abortController.signal.aborted) { + await this.query.fetchPage({ + limit: pageLimit, + offset, + signal: this.abortController.signal + }); + loaded += this.query.items?.length ?? 0; + + // Accumulate selection by spreading existing and new items + const currentSelection = this.gate.props.itemSelection?.selection; + const existingItems = Array.isArray(currentSelection) ? currentSelection : []; + const newItems = this.query.items ?? []; + const combinedSelection = [...existingItems, ...newItems]; + + this.gate.props.itemSelection?.setSelection(combinedSelection as any); + offset += pageLimit; + this.progressStore.onprogress(loaded); + if (!this.query.hasMoreItems) break; + } + + if (this.abortController.signal.aborted) { + return false; + } + + // Restore original pagination + this.query.setLimit(originalLimit); + this.query.setOffset(originalOffset); + success = true; + } finally { + this.gate.props.itemSelection?.setKeepSelection(undefined); + } + } catch (error) { + // Selection failed or was aborted + console.error("MultiPageSelectionController: Selection failed", error); + } finally { + this.progressStore.onloadend(); + this.locked = false; + this.abortController = undefined; + } + + return success; + } + + clearAllPages(): boolean { + if (!this.isMultiSelection) { + return false; + } + this.gate.props.itemSelection?.setSelection([] as any); + return true; + } + + abort(): void { + if (this.abortController) { + this.abortController.abort(); + this.progressStore.oncancel(); + this.locked = false; + } + } +} diff --git a/packages/shared/widget-plugin-grid/src/selection/select-all-pages.ts b/packages/shared/widget-plugin-grid/src/selection/select-all-pages.ts deleted file mode 100644 index bd2e032210..0000000000 --- a/packages/shared/widget-plugin-grid/src/selection/select-all-pages.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { QueryController } from "../query/query-controller"; -import { DerivedPropsGate } from "@mendix/widget-plugin-mobx-kit/props-gate"; -import { ObjectItem, SelectionMultiValue, SelectionSingleValue } from "mendix"; -type Gate = DerivedPropsGate<{ itemSelection?: SelectionMultiValue | SelectionSingleValue }>; - -export interface ISelectAllProgressStore { - selecting: boolean; - lengthComputable: boolean; - loaded: number; - total: number; - cancelled: boolean; - onloadstart: (total: number) => void; - onprogress: (loaded: number) => void; - onloadend: () => void; - oncancel: () => void; - progress: number; - displayProgress: string; -} - -export async function selectAllPages({ - query, - gate, - progress, - bufferSize, - signal -}: { - query: QueryController; - gate: Gate; - progress: ISelectAllProgressStore; - bufferSize: number; - signal: AbortSignal; -}): Promise { - if (!gate.props.itemSelection || gate.props.itemSelection.type !== "Multi") { - return false; - } - - if (!query.hasMoreItems) { - return false; - } - - const originalOffset = query.offset; - const originalLimit = query.limit; - - const totalCount = query.totalCount; - if (totalCount && totalCount <= bufferSize) { - // Direct selection without progress - const allItems = await query.fetchPage({ limit: totalCount, offset: 0, signal }); - gate.props.itemSelection?.setSelection(allItems); - return true; - } - - if (totalCount && totalCount > 0) { - progress.onloadstart(totalCount); - } else { - progress.onloadstart(0); - progress.lengthComputable = false; - } - - const allItems: ObjectItem[] = []; - const pageLimit = Math.max(1, bufferSize || query.limit || 25); - let offset = 0; - - try { - while (!signal.aborted) { - const page = await query.fetchPage({ limit: pageLimit, offset, signal }); - if (!page.length) break; - allItems.push(...page); - offset += pageLimit; - if (progress.lengthComputable && totalCount && totalCount > 0) { - progress.onprogress(Math.min(allItems.length, totalCount)); - } else { - progress.onprogress(allItems.length); - } - if (!query.hasMoreItems) break; - } - - if (signal.aborted) { - return false; - } - gate.props.itemSelection?.setSelection(allItems); - - // Restore original pagination - query.setLimit(originalLimit); - query.setOffset(originalOffset); - return true; - } finally { - progress.onloadend(); - } -} - -export function clearAllPages(gate: Gate): boolean { - if (!gate.props.itemSelection || gate.props.itemSelection.type !== "Multi") { - return false; - } - gate.props.itemSelection.setSelection([]); - return true; -} From aa8c0a3b3ace6c94f5c0f50e2e64b1b72c6b9270 Mon Sep 17 00:00:00 2001 From: Illia Obukhau <8282906+iobuhov@users.noreply.github.com> Date: Thu, 2 Oct 2025 09:41:00 +0200 Subject: [PATCH 09/35] refactor: change store logic and design --- .../datagrid-web/src/Datagrid.xml | 6 +- .../src/features/data-export/ProgressStore.ts | 6 +- .../SelectAllProgressStore.ts | 47 ----- .../widget-plugin-grid/src/selection.ts | 8 +- .../selection/MultiPageSelectionController.ts | 165 ---------------- .../src/selection/SelectAllController.ts | 187 ++++++++++++++++++ .../src/stores/ProgressStore.ts | 49 +++++ .../stores/SelectionCountStore.ts | 0 8 files changed, 246 insertions(+), 222 deletions(-) delete mode 100644 packages/pluggableWidgets/datagrid-web/src/features/multi-page-selection/SelectAllProgressStore.ts delete mode 100644 packages/shared/widget-plugin-grid/src/selection/MultiPageSelectionController.ts create mode 100644 packages/shared/widget-plugin-grid/src/selection/SelectAllController.ts create mode 100644 packages/shared/widget-plugin-grid/src/stores/ProgressStore.ts rename packages/shared/widget-plugin-grid/src/{selection => }/stores/SelectionCountStore.ts (100%) diff --git a/packages/pluggableWidgets/datagrid-web/src/Datagrid.xml b/packages/pluggableWidgets/datagrid-web/src/Datagrid.xml index fde7b10e7e..c9a178e261 100644 --- a/packages/pluggableWidgets/datagrid-web/src/Datagrid.xml +++ b/packages/pluggableWidgets/datagrid-web/src/Datagrid.xml @@ -62,9 +62,9 @@ Off - - Select all buffer size - Batch size for processing select all operations. If total count is less than buffer size, selection is immediate. + + Select all page size + When selecting items from a large data source, items are selected in batches. This setting controls the size of the batches. Selecting all label diff --git a/packages/pluggableWidgets/datagrid-web/src/features/data-export/ProgressStore.ts b/packages/pluggableWidgets/datagrid-web/src/features/data-export/ProgressStore.ts index 18980bd0c8..fa0327b6c6 100644 --- a/packages/pluggableWidgets/datagrid-web/src/features/data-export/ProgressStore.ts +++ b/packages/pluggableWidgets/datagrid-web/src/features/data-export/ProgressStore.ts @@ -1,7 +1,7 @@ import { makeAutoObservable } from "mobx"; export class ProgressStore { - exporting = false; + inProgress = false; lengthComputable = false; loaded = 0; total = 0; @@ -10,7 +10,7 @@ export class ProgressStore { } onloadstart = (event: ProgressEvent): void => { - this.exporting = true; + this.inProgress = true; this.lengthComputable = event.lengthComputable; this.total = event.total; this.loaded = 0; @@ -21,7 +21,7 @@ export class ProgressStore { }; onloadend = (): void => { - this.exporting = false; + this.inProgress = false; this.lengthComputable = false; this.loaded = 0; this.total = 0; diff --git a/packages/pluggableWidgets/datagrid-web/src/features/multi-page-selection/SelectAllProgressStore.ts b/packages/pluggableWidgets/datagrid-web/src/features/multi-page-selection/SelectAllProgressStore.ts deleted file mode 100644 index da733c0545..0000000000 --- a/packages/pluggableWidgets/datagrid-web/src/features/multi-page-selection/SelectAllProgressStore.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { makeAutoObservable } from "mobx"; - -export class SelectAllProgressStore { - selecting = false; - lengthComputable = false; - loaded = 0; - total = 0; - cancelled = false; - - constructor() { - makeAutoObservable(this); - } - - onloadstart = (total: number): void => { - this.selecting = true; - this.lengthComputable = true; - this.total = total; - this.loaded = 0; - this.cancelled = false; - }; - - onprogress = (loaded: number): void => { - this.loaded = loaded; - }; - - onloadend = (): void => { - this.selecting = false; - this.lengthComputable = false; - this.loaded = 0; - this.total = 0; - this.cancelled = false; - }; - - oncancel = (): void => { - this.cancelled = true; - this.onloadend(); - }; - - get progress(): number { - return this.total > 0 ? (this.loaded / this.total) * 100 : 0; - } - - get displayProgress(): string { - if (!this.selecting) return ""; - return `${this.loaded} of ${this.total}`; - } -} diff --git a/packages/shared/widget-plugin-grid/src/selection.ts b/packages/shared/widget-plugin-grid/src/selection.ts index 17c350bbdb..d21ed80470 100644 --- a/packages/shared/widget-plugin-grid/src/selection.ts +++ b/packages/shared/widget-plugin-grid/src/selection.ts @@ -1,10 +1,10 @@ -export * from "./selection/types.js"; -export * from "./selection/helpers.js"; -export * from "./selection/keyboard.js"; export { getGlobalSelectionContext, useCreateSelectionContextValue, useSelectionContextValue } from "./selection/context.js"; -export { MultiPageSelectionController } from "./selection/MultiPageSelectionController.js"; +export * from "./selection/helpers.js"; +export * from "./selection/keyboard.js"; export { SelectActionHandler } from "./selection/select-action-handler.js"; +export { SelectAllController } from "./selection/SelectAllController.js"; +export * from "./selection/types.js"; diff --git a/packages/shared/widget-plugin-grid/src/selection/MultiPageSelectionController.ts b/packages/shared/widget-plugin-grid/src/selection/MultiPageSelectionController.ts deleted file mode 100644 index 6febe68466..0000000000 --- a/packages/shared/widget-plugin-grid/src/selection/MultiPageSelectionController.ts +++ /dev/null @@ -1,165 +0,0 @@ -import { QueryController } from "../query/query-controller"; -import { DerivedPropsGate } from "@mendix/widget-plugin-mobx-kit/props-gate"; -import { ReactiveController, ReactiveControllerHost } from "@mendix/widget-plugin-mobx-kit/reactive-controller"; -import { SelectionMultiValue, SelectionSingleValue } from "mendix"; - -type Gate = DerivedPropsGate<{ itemSelection?: SelectionMultiValue | SelectionSingleValue }>; - -export interface ISelectAllProgressStore { - selecting: boolean; - lengthComputable: boolean; - loaded: number; - total: number; - cancelled: boolean; - onloadstart: (total: number) => void; - onprogress: (loaded: number) => void; - onloadend: () => void; - oncancel: () => void; - progress: number; - displayProgress: string; -} - -interface MultiPageSelectionControllerSpec { - gate: Gate; - query: QueryController; - progressStore: ISelectAllProgressStore; - bufferSize: number; -} - -export class MultiPageSelectionController implements ReactiveController { - private readonly gate: Gate; - private readonly query: QueryController; - private readonly progressStore: ISelectAllProgressStore; - private readonly bufferSize: number; - private abortController?: AbortController; - private locked = false; - - constructor(host: ReactiveControllerHost, spec: MultiPageSelectionControllerSpec) { - host.addController(this); - this.gate = spec.gate; - this.query = spec.query; - this.progressStore = spec.progressStore; - this.bufferSize = spec.bufferSize; - } - - setup(): () => void { - // No specific setup needed for now - return () => { - // Cleanup - this.abort(); - }; - } - - get isRunning(): boolean { - return this.locked; - } - - get isMultiSelection(): boolean { - return this.gate.props.itemSelection?.type === "Multi"; - } - - get hasSelection(): boolean { - const selection = this.gate.props.itemSelection?.selection; - if (!selection) return false; - return Array.isArray(selection) ? selection.length > 0 : true; - } - - get selectionCount(): number { - const selection = this.gate.props.itemSelection?.selection; - if (!selection) return 0; - return Array.isArray(selection) ? selection.length : 1; - } - - get canSelectAllPages(): boolean { - return this.isMultiSelection && this.query.hasMoreItems; - } - - async selectAllPages(): Promise { - if (this.locked) { - return false; - } - - if (!this.isMultiSelection) { - return false; - } - - if (!this.query.hasMoreItems) { - return false; - } - - this.locked = true; - let success = false; - - try { - const originalOffset = this.query.offset; - const originalLimit = this.query.limit; - - this.progressStore.onloadstart(this.query.totalCount ?? 0); - this.progressStore.lengthComputable = false; - this.abortController = new AbortController(); - - const pageLimit = Math.max(1, this.bufferSize || this.query.limit || 25); - let offset = 0; - let loaded = 0; - - try { - this.gate.props.itemSelection?.setKeepSelection(() => true); - while (!this.abortController.signal.aborted) { - await this.query.fetchPage({ - limit: pageLimit, - offset, - signal: this.abortController.signal - }); - loaded += this.query.items?.length ?? 0; - - // Accumulate selection by spreading existing and new items - const currentSelection = this.gate.props.itemSelection?.selection; - const existingItems = Array.isArray(currentSelection) ? currentSelection : []; - const newItems = this.query.items ?? []; - const combinedSelection = [...existingItems, ...newItems]; - - this.gate.props.itemSelection?.setSelection(combinedSelection as any); - offset += pageLimit; - this.progressStore.onprogress(loaded); - if (!this.query.hasMoreItems) break; - } - - if (this.abortController.signal.aborted) { - return false; - } - - // Restore original pagination - this.query.setLimit(originalLimit); - this.query.setOffset(originalOffset); - success = true; - } finally { - this.gate.props.itemSelection?.setKeepSelection(undefined); - } - } catch (error) { - // Selection failed or was aborted - console.error("MultiPageSelectionController: Selection failed", error); - } finally { - this.progressStore.onloadend(); - this.locked = false; - this.abortController = undefined; - } - - return success; - } - - clearAllPages(): boolean { - if (!this.isMultiSelection) { - return false; - } - this.gate.props.itemSelection?.setSelection([] as any); - return true; - } - - abort(): void { - if (this.abortController) { - this.abortController.abort(); - this.progressStore.oncancel(); - this.locked = false; - } - } -} diff --git a/packages/shared/widget-plugin-grid/src/selection/SelectAllController.ts b/packages/shared/widget-plugin-grid/src/selection/SelectAllController.ts new file mode 100644 index 0000000000..e13e2a0bd2 --- /dev/null +++ b/packages/shared/widget-plugin-grid/src/selection/SelectAllController.ts @@ -0,0 +1,187 @@ +import { DerivedPropsGate } from "@mendix/widget-plugin-mobx-kit/props-gate"; +import { ReactiveController, ReactiveControllerHost } from "@mendix/widget-plugin-mobx-kit/reactive-controller"; +import { SelectionMultiValue, SelectionSingleValue } from "mendix"; +import { action, makeAutoObservable } from "mobx"; +import { QueryController } from "../query/query-controller"; + +type Gate = DerivedPropsGate<{ itemSelection?: SelectionMultiValue | SelectionSingleValue }>; + +interface SelectAllControllerSpec { + gate: Gate; + query: QueryController; + bufferSize: number; +} + +// interface SelectAllControllerEvents { +// /** Emitted once when action is started. */ +// loadstart: (pe: ProgressEvent) => void; +// /** Emitted every time new page is loaded. */ +// progress: (pe: ProgressEvent) => void; +// /** Emitted if abort method is called. */ +// abort: (pe: ProgressEvent) => void; +// /** Emitted when no more data is available. */ +// end: (pe: ProgressEvent) => void; +// /** Emitted at the end of the request. */ +// loadend: (pe: ProgressEvent) => void; +// } + +type SelectAllEventType = "loadstart" | "progress" | "abort" | "loadend"; + +export class SelectAllController extends EventTarget implements ReactiveController { + private readonly gate: Gate; + private readonly query: QueryController; + // private readonly progressStore: ProgressStore; + // private readonly bufferSize: number; + private abortController?: AbortController; + private locked = false; + readonly pageSize: number = 2; + + constructor(host: ReactiveControllerHost, spec: SelectAllControllerSpec) { + super(); + host.addController(this); + this.gate = spec.gate; + this.query = spec.query; + // this.pageSize = Math.max(this.pageSize, spec.bufferSize); + + type PrivateMembers = "setIsLocked"; + makeAutoObservable(this, { setIsLocked: action }); + } + + setup(): () => void { + // this.addEventListener("loadend", (event) => {event.target.}); + return () => this.abort(); + } + + addEventListener( + type: SelectAllEventType, + callback: (pe: ProgressEvent) => void, + options?: AddEventListenerOptions | boolean + ): void { + super.addEventListener(type, callback, options); + } + + // get isMultiSelection(): boolean { + // return this.gate.props.itemSelection?.type === "Multi"; + // } + + // get hasSelection(): boolean { + // const selection = this.gate.props.itemSelection?.selection; + // if (!selection) return false; + // return Array.isArray(selection) ? selection.length > 0 : true; + // } + + // get selectionCount(): number { + // const selection = this.gate.props.itemSelection?.selection; + // if (!selection) return 0; + // return Array.isArray(selection) ? selection.length : 1; + // } + + // get canSelectAllPages(): boolean { + // return this.isMultiSelection && this.query.hasMoreItems; + // } + + get selection(): SelectionMultiValue | undefined { + const selection = this.gate.props.itemSelection; + return selection?.type === "Multi" ? selection : undefined; + } + + get canExecute(): boolean { + return this.selection?.type === "Multi" && !this.locked; + } + + get isExecuting(): boolean { + return this.locked; + } + + private setIsLocked(value: boolean): void { + this.locked = value; + } + + private beforeRunChecks(): boolean { + const selection = this.gate.props.itemSelection; + if (selection?.type !== "Single") { + console.debug("SelectAllController: action can't be executed when selection is 'Single'."); + return false; + } + + if (selection?.type === undefined) { + console.debug("SelectAllController: selection is undefined. Check widget selection setting."); + return false; + } + + if (this.locked) { + console.debug("SelectAllController: action is already executing."); + return false; + } + return true; + } + + // private createProgressEvent(type: string, loaded: number): ProgressEvent { + // return new ProgressEvent(type, { + // lengthComputable: typeof this.query.totalCount === "number", + // loaded: this.loaded, + // total: this.totalCount + // }); + // } + + async selectAllPages(): Promise { + if (!this.beforeRunChecks()) { + return; + } + + this.setIsLocked(true); + + const { offset: initOffset, limit: initLimit } = this.query; + const hasTotal = typeof this.query.totalCount === "number"; + const totalCount = this.query.totalCount ?? 0; + let loaded = 0; + let offset = 0; + const pe = (type: SelectAllEventType): ProgressEvent => + new ProgressEvent(type, { loaded, total: totalCount, lengthComputable: hasTotal }); + + try { + this.abortController = new AbortController(); + this.dispatchEvent(pe("loadstart")); + this.selection?.setKeepSelection(() => true); + let loading = true; + while (loading) { + await this.query.fetchPage({ + limit: this.pageSize, + offset, + signal: this.abortController.signal + }); + const loadedItems = this.query.items ?? []; + const merged = (this.selection?.selection ?? []).concat(loadedItems); + + loaded += loadedItems.length; + offset += this.pageSize; + this.selection?.setSelection(merged); + this.dispatchEvent(pe("progress")); + loading = !this.abortController.signal.aborted && this.query.hasMoreItems; + } + } catch (error) { + if (error.name !== "AbortError") { + console.error("SelectAllController: error occurred while executing action.", error); + } + } finally { + this.dispatchEvent(pe("loadend")); + this.query.setOffset(initOffset); + this.query.setLimit(initLimit); + this.locked = false; + this.abortController = undefined; + } + } + + clearSelection(): void { + if (this.locked) { + console.debug("SelectAllController: can't clear selection while executing."); + return; + } + this.selection?.setSelection([]); + } + + abort(): void { + this.abortController?.abort(); + this.dispatchEvent(new ProgressEvent("abort")); + } +} diff --git a/packages/shared/widget-plugin-grid/src/stores/ProgressStore.ts b/packages/shared/widget-plugin-grid/src/stores/ProgressStore.ts new file mode 100644 index 0000000000..fc5e17b5e4 --- /dev/null +++ b/packages/shared/widget-plugin-grid/src/stores/ProgressStore.ts @@ -0,0 +1,49 @@ +import { makeAutoObservable } from "mobx"; + +export class ProgressStore { + inProgress = false; + /** + * If `false`, then `ProgressStore.total` and + * `ProgressStore.progress` has no meaningful value. + */ + lengthComputable = false; + loaded = 0; + total = 0; + constructor() { + makeAutoObservable(this); + } + + get percentage(): number { + if (!this.lengthComputable || !this.inProgress || this.total <= 0) { + return 0; + } + + const percentage = (this.loaded / this.total) * 100; + switch (true) { + case isNaN(percentage): + return 0; + case isFinite(percentage): + return percentage; + default: + return 0; + } + } + + onloadstart = (event: ProgressEvent): void => { + this.inProgress = true; + this.lengthComputable = event.lengthComputable; + this.total = event.total; + this.loaded = 0; + }; + + onprogress = (event: ProgressEvent): void => { + this.loaded = event.loaded; + }; + + onloadend = (): void => { + this.inProgress = false; + this.lengthComputable = false; + this.loaded = 0; + this.total = 0; + }; +} diff --git a/packages/shared/widget-plugin-grid/src/selection/stores/SelectionCountStore.ts b/packages/shared/widget-plugin-grid/src/stores/SelectionCountStore.ts similarity index 100% rename from packages/shared/widget-plugin-grid/src/selection/stores/SelectionCountStore.ts rename to packages/shared/widget-plugin-grid/src/stores/SelectionCountStore.ts From d409447ba2a4b9d3ff907768936808b1239dea8d Mon Sep 17 00:00:00 2001 From: Illia Obukhau <8282906+iobuhov@users.noreply.github.com> Date: Fri, 3 Oct 2025 11:52:44 +0200 Subject: [PATCH 10/35] chore: start controller integration --- .../datagrid-web/src/Datagrid.editorConfig.ts | 2 +- .../src/Datagrid.editorPreview.tsx | 22 +++++-- .../datagrid-web/src/Datagrid.tsx | 10 +-- .../controllers/DerivedLoaderController.ts | 11 +--- .../src/helpers/SelectActionHelper.ts | 12 ++-- .../datagrid-web/src/helpers/root-context.ts | 9 ++- .../src/helpers/state/RootGridStore.ts | 17 +++-- .../datagrid-web/typings/DatagridProps.d.ts | 4 +- .../src/selection/SelectAllController.ts | 64 ++++--------------- .../__tests__/SelectionCountStore.spec.ts | 2 +- 10 files changed, 58 insertions(+), 95 deletions(-) diff --git a/packages/pluggableWidgets/datagrid-web/src/Datagrid.editorConfig.ts b/packages/pluggableWidgets/datagrid-web/src/Datagrid.editorConfig.ts index d26812783f..0261cdd02c 100644 --- a/packages/pluggableWidgets/datagrid-web/src/Datagrid.editorConfig.ts +++ b/packages/pluggableWidgets/datagrid-web/src/Datagrid.editorConfig.ts @@ -175,7 +175,7 @@ function hideSelectionProperties(defaultProperties: Properties, values: Datagrid } if (!selectAllPagesEnabled) { - hidePropertyIn(defaultProperties, values, "selectAllPagesBufferSize"); + hidePropertyIn(defaultProperties, values, "selectAllPagesPageSize"); hidePropertyIn(defaultProperties, values, "selectingAllLabel"); hidePropertyIn(defaultProperties, values, "cancelSelectionLabel"); } diff --git a/packages/pluggableWidgets/datagrid-web/src/Datagrid.editorPreview.tsx b/packages/pluggableWidgets/datagrid-web/src/Datagrid.editorPreview.tsx index 755e9f7651..e858594ee0 100644 --- a/packages/pluggableWidgets/datagrid-web/src/Datagrid.editorPreview.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/Datagrid.editorPreview.tsx @@ -5,6 +5,11 @@ import { enableStaticRendering } from "mobx-react-lite"; enableStaticRendering(true); import { useFocusTargetController } from "@mendix/widget-plugin-grid/keyboard-navigation/useFocusTargetController"; +import { DatasourceController } from "@mendix/widget-plugin-grid/query/DatasourceController"; +import { SelectAllController } from "@mendix/widget-plugin-grid/selection"; +import { SelectionCountStore } from "@mendix/widget-plugin-grid/selection/stores/SelectionCountStore"; +import { ProgressStore } from "@mendix/widget-plugin-grid/stores/ProgressStore"; +import { BaseControllerHost } from "@mendix/widget-plugin-mobx-kit/BaseControllerHost"; import { GateProvider } from "@mendix/widget-plugin-mobx-kit/GateProvider"; import { useConst } from "@mendix/widget-plugin-mobx-kit/react/useConst"; import { parseStyle } from "@mendix/widget-plugin-platform/preview/parse-style"; @@ -18,9 +23,6 @@ import { ColumnPreview } from "./helpers/ColumnPreview"; import { DatagridContext } from "./helpers/root-context"; import { useSelectActionHelper } from "./helpers/SelectActionHelper"; import { GridBasicData } from "./helpers/state/GridBasicData"; -import { SelectAllProgressStore } from "./features/multi-page-selection/SelectAllProgressStore"; - -import { SelectionCountStore } from "@mendix/widget-plugin-grid/selection/stores/SelectionCountStore"; import "./ui/DatagridPreview.scss"; // Fix type definition for Selectable @@ -62,6 +64,8 @@ const initColumns: ColumnsPreviewType[] = [ const numberOfItems = 3; +class Host extends BaseControllerHost {} + export function preview(props: DatagridPreviewProps): ReactElement { const EmptyPlaceholder = props.emptyPlaceholder.renderer; const data: ObjectItem[] = Array.from({ length: numberOfItems }).map((_, index) => ({ @@ -88,9 +92,12 @@ export function preview(props: DatagridPreviewProps): ReactElement { const eventsController = { getProps: () => Object.create({}) }; const ctx = useConst(() => { - const gateProvider = new GateProvider({}); - const basicData = new GridBasicData(gateProvider.gate); - const selectionCountStore = new SelectionCountStore(gateProvider.gate); + const host = new Host(); + const gateProvider = new GateProvider({ datasource: {} as any, itemSelection: undefined }); + const basicData = new GridBasicData(gateProvider.gate as any); + const query = new DatasourceController(host, { gate: gateProvider.gate }); + const selectionCountStore = new SelectionCountStore(gateProvider.gate as any); + const selectAllController = new SelectAllController(host, { gate: gateProvider.gate, pageSize: 2, query }); return { basicData, selectionHelper: undefined, @@ -100,7 +107,8 @@ export function preview(props: DatagridPreviewProps): ReactElement { multiPageSelectionController: {} as any, // Mock for preview focusController, selectionCountStore, - selectAllProgressStore: new SelectAllProgressStore(), + selectAllProgressStore: new ProgressStore(), + selectAllController, rootStore: {} as any // Mock for preview }; }); diff --git a/packages/pluggableWidgets/datagrid-web/src/Datagrid.tsx b/packages/pluggableWidgets/datagrid-web/src/Datagrid.tsx index f5ecaf3d93..c89964c5e9 100644 --- a/packages/pluggableWidgets/datagrid-web/src/Datagrid.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/Datagrid.tsx @@ -13,7 +13,7 @@ import { ProgressStore } from "./features/data-export/ProgressStore"; import { useDataExport } from "./features/data-export/useDataExport"; import { useCellEventsController } from "./features/row-interaction/CellEventsController"; import { useCheckboxEventsController } from "./features/row-interaction/CheckboxEventsController"; -import { DatagridContext } from "./helpers/root-context"; +import { DatagridContext, DatagridRootScope } from "./helpers/root-context"; import { useSelectActionHelper } from "./helpers/SelectActionHelper"; import { IColumnGroupStore } from "./helpers/state/ColumnGroupStore"; import { RootGridStore } from "./helpers/state/RootGridStore"; @@ -66,7 +66,7 @@ const Container = observer((props: Props): ReactElement => { const ctx = useConst(() => { rootStore.basicData.setSelectionHelper(selectionHelper); - return { + const scope: DatagridRootScope = { basicData: rootStore.basicData, selectionHelper, selectActionHelper, @@ -74,9 +74,11 @@ const Container = observer((props: Props): ReactElement => { checkboxEventsController, focusController, selectionCountStore: rootStore.selectionCountStore, - multiPageSelectionController: rootStore.multiPageSelectionController, + selectAllController: rootStore.selectAllController, selectAllProgressStore: rootStore.selectAllProgressStore }; + + return scope; }); return ( @@ -126,7 +128,7 @@ const Container = observer((props: Props): ReactElement => { rowClass={useCallback((value: any) => props.rowClass?.get(value)?.value ?? "", [props.rowClass])} setPage={paginationCtrl.setPage} styles={props.style} - exporting={exportProgress.exporting} + exporting={exportProgress.inProgress} processedRows={exportProgress.loaded} visibleColumns={columnsStore.visibleColumns} availableColumns={columnsStore.availableColumns} diff --git a/packages/pluggableWidgets/datagrid-web/src/controllers/DerivedLoaderController.ts b/packages/pluggableWidgets/datagrid-web/src/controllers/DerivedLoaderController.ts index 02492e1f31..c32a876f52 100644 --- a/packages/pluggableWidgets/datagrid-web/src/controllers/DerivedLoaderController.ts +++ b/packages/pluggableWidgets/datagrid-web/src/controllers/DerivedLoaderController.ts @@ -3,7 +3,7 @@ import { computed, makeObservable } from "mobx"; type DerivedLoaderControllerSpec = { showSilentRefresh: boolean; refreshIndicator: boolean; - exp: { exporting: boolean }; + exp: { inProgress: boolean }; cols: { loaded: boolean }; query: { isFetchingNextBatch: boolean; @@ -24,14 +24,9 @@ export class DerivedLoaderController { get isFirstLoad(): boolean { const { cols, exp, query } = this.spec; - if (!cols.loaded) { - return true; - } - - if (exp.exporting) { - return false; - } + if (!cols.loaded) return true; + if (exp.inProgress) return false; return query.isFirstLoad; } diff --git a/packages/pluggableWidgets/datagrid-web/src/helpers/SelectActionHelper.ts b/packages/pluggableWidgets/datagrid-web/src/helpers/SelectActionHelper.ts index 52b93077c8..aa8a854c03 100644 --- a/packages/pluggableWidgets/datagrid-web/src/helpers/SelectActionHelper.ts +++ b/packages/pluggableWidgets/datagrid-web/src/helpers/SelectActionHelper.ts @@ -1,4 +1,3 @@ -import { useMemo } from "react"; import { SelectActionHandler, SelectionHelper, @@ -6,6 +5,7 @@ import { WidgetSelectionProperty } from "@mendix/widget-plugin-grid/selection"; import { ListValue } from "mendix"; +import { useMemo } from "react"; import { DatagridContainerProps, DatagridPreviewProps, ItemSelectionMethodEnum } from "../../typings/DatagridProps"; export type SelectionMethod = "rowClick" | "checkbox" | "none"; @@ -60,10 +60,6 @@ export class SelectActionHelper extends SelectActionHandler { get totalCount(): number | undefined { return this._datasource?.totalCount; } - - get selectAllPagesBufferSize(): number { - return this._selectAllPagesBufferSize; - } } export function useSelectActionHelper( @@ -76,7 +72,7 @@ export function useSelectActionHelper( | "itemSelectionMode" | "datasource" | "selectAllPagesEnabled" - | "selectAllPagesBufferSize" + | "selectAllPagesPageSize" >, selectionHelper?: SelectionHelper ): SelectActionHelper { @@ -90,7 +86,7 @@ export function useSelectActionHelper( props.itemSelectionMode, props.datasource as ListValue, props.selectAllPagesEnabled, - props.selectAllPagesBufferSize ?? 500 + props.selectAllPagesPageSize ?? 500 ); }, [ props.itemSelection, @@ -101,6 +97,6 @@ export function useSelectActionHelper( props.itemSelectionMode, props.datasource, props.selectAllPagesEnabled, - props.selectAllPagesBufferSize + props.selectAllPagesPageSize ]); } diff --git a/packages/pluggableWidgets/datagrid-web/src/helpers/root-context.ts b/packages/pluggableWidgets/datagrid-web/src/helpers/root-context.ts index e0b417436d..25aaba0295 100644 --- a/packages/pluggableWidgets/datagrid-web/src/helpers/root-context.ts +++ b/packages/pluggableWidgets/datagrid-web/src/helpers/root-context.ts @@ -1,12 +1,11 @@ import { FocusTargetController } from "@mendix/widget-plugin-grid/keyboard-navigation/FocusTargetController"; -import { SelectionHelper } from "@mendix/widget-plugin-grid/selection"; +import { SelectAllController, SelectionHelper } from "@mendix/widget-plugin-grid/selection"; import { SelectionCountStore } from "@mendix/widget-plugin-grid/selection/stores/SelectionCountStore"; +import { ProgressStore } from "@mendix/widget-plugin-grid/stores/ProgressStore"; import { createContext, useContext } from "react"; -import { SelectAllProgressStore } from "../features/multi-page-selection/SelectAllProgressStore"; import { GridBasicData } from "../helpers/state/GridBasicData"; import { EventsController } from "../typings/CellComponent"; import { SelectActionHelper } from "./SelectActionHelper"; -import { MultiPageSelectionController } from "@mendix/widget-plugin-grid/selection/MultiPageSelectionController"; export interface DatagridRootScope { basicData: GridBasicData; @@ -15,10 +14,10 @@ export interface DatagridRootScope { selectActionHelper: SelectActionHelper; cellEventsController: EventsController; checkboxEventsController: EventsController; - multiPageSelectionController: MultiPageSelectionController; + selectAllController: SelectAllController; focusController: FocusTargetController; selectionCountStore: SelectionCountStore; - selectAllProgressStore: SelectAllProgressStore; + selectAllProgressStore: ProgressStore; } export const DatagridContext = createContext(null); diff --git a/packages/pluggableWidgets/datagrid-web/src/helpers/state/RootGridStore.ts b/packages/pluggableWidgets/datagrid-web/src/helpers/state/RootGridStore.ts index 583afe1d1c..f6fcbbbae7 100644 --- a/packages/pluggableWidgets/datagrid-web/src/helpers/state/RootGridStore.ts +++ b/packages/pluggableWidgets/datagrid-web/src/helpers/state/RootGridStore.ts @@ -4,8 +4,9 @@ import { CustomFilterHost } from "@mendix/widget-plugin-filtering/stores/generic import { DatasourceController } from "@mendix/widget-plugin-grid/query/DatasourceController"; import { QueryController } from "@mendix/widget-plugin-grid/query/query-controller"; import { RefreshController } from "@mendix/widget-plugin-grid/query/RefreshController"; -import { MultiPageSelectionController } from "@mendix/widget-plugin-grid/selection"; +import { SelectAllController } from "@mendix/widget-plugin-grid/selection"; import { SelectionCountStore } from "@mendix/widget-plugin-grid/selection/stores/SelectionCountStore"; +import { ProgressStore } from "@mendix/widget-plugin-grid/stores/ProgressStore"; import { BaseControllerHost } from "@mendix/widget-plugin-mobx-kit/BaseControllerHost"; import { disposeBatch } from "@mendix/widget-plugin-mobx-kit/disposeBatch"; import { DerivedPropsGate } from "@mendix/widget-plugin-mobx-kit/props-gate"; @@ -16,8 +17,7 @@ import { DatagridContainerProps } from "../../../typings/DatagridProps"; import { DatasourceParamsController } from "../../controllers/DatasourceParamsController"; import { DerivedLoaderController } from "../../controllers/DerivedLoaderController"; import { PaginationController } from "../../controllers/PaginationController"; -import { ProgressStore } from "../../features/data-export/ProgressStore"; -import { SelectAllProgressStore } from "../../features/multi-page-selection/SelectAllProgressStore"; + import { StaticInfo } from "../../typings/static-info"; import { ColumnGroupStore } from "./ColumnGroupStore"; import { GridPersonalizationStore } from "./GridPersonalizationStore"; @@ -54,8 +54,8 @@ export class RootGridStore extends BaseControllerHost { basicData: GridBasicData; staticInfo: StaticInfo; exportProgressCtrl: ProgressStore; - selectAllProgressStore: SelectAllProgressStore; - multiPageSelectionController: MultiPageSelectionController; + selectAllController: SelectAllController; + selectAllProgressStore: ProgressStore; loaderCtrl: DerivedLoaderController; paginationCtrl: PaginationController; readonly filterAPI: FilterAPI; @@ -101,13 +101,12 @@ export class RootGridStore extends BaseControllerHost { this.exportProgressCtrl = exportCtrl; - this.selectAllProgressStore = new SelectAllProgressStore(); + this.selectAllProgressStore = new ProgressStore(); - this.multiPageSelectionController = new MultiPageSelectionController(this, { + this.selectAllController = new SelectAllController(this, { gate, query, - progressStore: this.selectAllProgressStore, - bufferSize: props.selectAllPagesBufferSize ?? 500 + pageSize: props.selectAllPagesPageSize }); new DatasourceParamsController(this, { diff --git a/packages/pluggableWidgets/datagrid-web/typings/DatagridProps.d.ts b/packages/pluggableWidgets/datagrid-web/typings/DatagridProps.d.ts index 88ee64833e..f6f68d4b7b 100644 --- a/packages/pluggableWidgets/datagrid-web/typings/DatagridProps.d.ts +++ b/packages/pluggableWidgets/datagrid-web/typings/DatagridProps.d.ts @@ -103,7 +103,7 @@ export interface DatagridContainerProps { clearSelectionButtonLabel?: DynamicValue; ======= selectAllPagesEnabled: boolean; - selectAllPagesBufferSize: number; + selectAllPagesPageSize: number; selectingAllLabel?: DynamicValue; cancelSelectionLabel?: DynamicValue; >>>>>>> 2e4671e66 (feat(datagrid-web): add multipage selection to dg2) @@ -164,7 +164,7 @@ export interface DatagridPreviewProps { clearSelectionButtonLabel: string; ======= selectAllPagesEnabled: boolean; - selectAllPagesBufferSize: number | null; + selectAllPagesPageSize: number | null; selectingAllLabel: string; cancelSelectionLabel: string; >>>>>>> 2e4671e66 (feat(datagrid-web): add multipage selection to dg2) diff --git a/packages/shared/widget-plugin-grid/src/selection/SelectAllController.ts b/packages/shared/widget-plugin-grid/src/selection/SelectAllController.ts index e13e2a0bd2..d4a96d78dd 100644 --- a/packages/shared/widget-plugin-grid/src/selection/SelectAllController.ts +++ b/packages/shared/widget-plugin-grid/src/selection/SelectAllController.ts @@ -1,7 +1,7 @@ import { DerivedPropsGate } from "@mendix/widget-plugin-mobx-kit/props-gate"; import { ReactiveController, ReactiveControllerHost } from "@mendix/widget-plugin-mobx-kit/reactive-controller"; import { SelectionMultiValue, SelectionSingleValue } from "mendix"; -import { action, makeAutoObservable } from "mobx"; +import { action, computed, makeObservable, observable } from "mobx"; import { QueryController } from "../query/query-controller"; type Gate = DerivedPropsGate<{ itemSelection?: SelectionMultiValue | SelectionSingleValue }>; @@ -9,29 +9,14 @@ type Gate = DerivedPropsGate<{ itemSelection?: SelectionMultiValue | SelectionSi interface SelectAllControllerSpec { gate: Gate; query: QueryController; - bufferSize: number; + pageSize: number; } -// interface SelectAllControllerEvents { -// /** Emitted once when action is started. */ -// loadstart: (pe: ProgressEvent) => void; -// /** Emitted every time new page is loaded. */ -// progress: (pe: ProgressEvent) => void; -// /** Emitted if abort method is called. */ -// abort: (pe: ProgressEvent) => void; -// /** Emitted when no more data is available. */ -// end: (pe: ProgressEvent) => void; -// /** Emitted at the end of the request. */ -// loadend: (pe: ProgressEvent) => void; -// } - type SelectAllEventType = "loadstart" | "progress" | "abort" | "loadend"; export class SelectAllController extends EventTarget implements ReactiveController { private readonly gate: Gate; private readonly query: QueryController; - // private readonly progressStore: ProgressStore; - // private readonly bufferSize: number; private abortController?: AbortController; private locked = false; readonly pageSize: number = 2; @@ -41,14 +26,21 @@ export class SelectAllController extends EventTarget implements ReactiveControll host.addController(this); this.gate = spec.gate; this.query = spec.query; - // this.pageSize = Math.max(this.pageSize, spec.bufferSize); - type PrivateMembers = "setIsLocked"; - makeAutoObservable(this, { setIsLocked: action }); + type PrivateMembers = "setIsLocked" | "locked"; + makeObservable(this, { + setIsLocked: action, + selection: computed, + canExecute: computed, + isExecuting: computed, + locked: observable, + selectAllPages: action, + clearSelection: action, + abort: action + }); } setup(): () => void { - // this.addEventListener("loadend", (event) => {event.target.}); return () => this.abort(); } @@ -60,26 +52,6 @@ export class SelectAllController extends EventTarget implements ReactiveControll super.addEventListener(type, callback, options); } - // get isMultiSelection(): boolean { - // return this.gate.props.itemSelection?.type === "Multi"; - // } - - // get hasSelection(): boolean { - // const selection = this.gate.props.itemSelection?.selection; - // if (!selection) return false; - // return Array.isArray(selection) ? selection.length > 0 : true; - // } - - // get selectionCount(): number { - // const selection = this.gate.props.itemSelection?.selection; - // if (!selection) return 0; - // return Array.isArray(selection) ? selection.length : 1; - // } - - // get canSelectAllPages(): boolean { - // return this.isMultiSelection && this.query.hasMoreItems; - // } - get selection(): SelectionMultiValue | undefined { const selection = this.gate.props.itemSelection; return selection?.type === "Multi" ? selection : undefined; @@ -116,14 +88,6 @@ export class SelectAllController extends EventTarget implements ReactiveControll return true; } - // private createProgressEvent(type: string, loaded: number): ProgressEvent { - // return new ProgressEvent(type, { - // lengthComputable: typeof this.query.totalCount === "number", - // loaded: this.loaded, - // total: this.totalCount - // }); - // } - async selectAllPages(): Promise { if (!this.beforeRunChecks()) { return; @@ -161,7 +125,7 @@ export class SelectAllController extends EventTarget implements ReactiveControll } } catch (error) { if (error.name !== "AbortError") { - console.error("SelectAllController: error occurred while executing action.", error); + throw error; } } finally { this.dispatchEvent(pe("loadend")); diff --git a/packages/shared/widget-plugin-grid/src/selection/__tests__/SelectionCountStore.spec.ts b/packages/shared/widget-plugin-grid/src/selection/__tests__/SelectionCountStore.spec.ts index e9026e01dc..4765f40138 100644 --- a/packages/shared/widget-plugin-grid/src/selection/__tests__/SelectionCountStore.spec.ts +++ b/packages/shared/widget-plugin-grid/src/selection/__tests__/SelectionCountStore.spec.ts @@ -1,7 +1,7 @@ import { GateProvider } from "@mendix/widget-plugin-mobx-kit/GateProvider"; import { objectItems, SelectionMultiValueBuilder, SelectionSingleValueBuilder } from "@mendix/widget-plugin-test-utils"; import { SelectionMultiValue, SelectionSingleValue } from "mendix"; -import { SelectionCountStore } from "../stores/SelectionCountStore"; +import { SelectionCountStore } from "../../stores/SelectionCountStore"; type Props = { itemSelection?: SelectionSingleValue | SelectionMultiValue; From f9c595150e606502ba11ac91826b7bbca326b86e Mon Sep 17 00:00:00 2001 From: Illia Obukhau <8282906+iobuhov@users.noreply.github.com> Date: Wed, 8 Oct 2025 10:48:06 +0200 Subject: [PATCH 11/35] feat: add select all host --- .../src/Datagrid.editorPreview.tsx | 1 - .../datagrid-web/src/Datagrid.tsx | 2 +- .../src/components/CheckboxColumnHeader.tsx | 62 ++++++------------- .../src/components/SelectAllBar.tsx | 20 ++++++ .../datagrid-web/src/components/Widget.tsx | 27 ++++---- .../src/helpers/state/RootGridStore.ts | 40 ++++++++---- .../src/helpers/state/useRootStore.ts | 39 +++++++++--- .../src/query/DatasourceController.ts | 7 +-- .../widget-plugin-grid/src/selection.ts | 1 + .../src/selection/SelectAllController.ts | 29 ++++----- .../src/selection/SelectAllHost.ts | 52 ++++++++++++++++ 11 files changed, 184 insertions(+), 96 deletions(-) create mode 100644 packages/pluggableWidgets/datagrid-web/src/components/SelectAllBar.tsx create mode 100644 packages/shared/widget-plugin-grid/src/selection/SelectAllHost.ts diff --git a/packages/pluggableWidgets/datagrid-web/src/Datagrid.editorPreview.tsx b/packages/pluggableWidgets/datagrid-web/src/Datagrid.editorPreview.tsx index e858594ee0..6832d1290f 100644 --- a/packages/pluggableWidgets/datagrid-web/src/Datagrid.editorPreview.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/Datagrid.editorPreview.tsx @@ -104,7 +104,6 @@ export function preview(props: DatagridPreviewProps): ReactElement { selectActionHelper, cellEventsController: eventsController, checkboxEventsController: eventsController, - multiPageSelectionController: {} as any, // Mock for preview focusController, selectionCountStore, selectAllProgressStore: new ProgressStore(), diff --git a/packages/pluggableWidgets/datagrid-web/src/Datagrid.tsx b/packages/pluggableWidgets/datagrid-web/src/Datagrid.tsx index c89964c5e9..363747f83d 100644 --- a/packages/pluggableWidgets/datagrid-web/src/Datagrid.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/Datagrid.tsx @@ -158,7 +158,7 @@ export default function Datagrid(props: DatagridContainerProps): ReactElement | {...props} rootStore={rootStore} columnsStore={rootStore.columnsStore} - progressStore={rootStore.exportProgressCtrl} + progressStore={rootStore.exportProgressStore} /> ); } diff --git a/packages/pluggableWidgets/datagrid-web/src/components/CheckboxColumnHeader.tsx b/packages/pluggableWidgets/datagrid-web/src/components/CheckboxColumnHeader.tsx index 4cdd974f64..e4039e28cf 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/CheckboxColumnHeader.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/CheckboxColumnHeader.tsx @@ -3,8 +3,7 @@ import { Fragment, ReactElement } from "react"; import { useDatagridRootScope } from "../helpers/root-context"; export function CheckboxColumnHeader(): ReactElement { - const { selectActionHelper, basicData, selectAllProgressStore, multiPageSelectionController } = - useDatagridRootScope(); + const { selectActionHelper, basicData } = useDatagridRootScope(); const { showCheckboxColumn, showSelectAllToggle, onSelectAll } = selectActionHelper; const { selectionStatus, selectAllRowsLabel } = basicData; @@ -12,50 +11,25 @@ export function CheckboxColumnHeader(): ReactElement { return ; } - let checkbox = null; - - if (showSelectAllToggle) { - if (selectionStatus === "unknown") { - throw new Error("Don't know how to render checkbox with selectionStatus=unknown"); - } - - const handleHeaderToggle = async (): Promise => { - if (selectAllProgressStore.selecting) { - return; - } - - // When multi-page selection is enabled, handle both select and unselect across all pages - if (selectActionHelper.canSelectAllPages) { - if (selectionStatus === "none") { - // Select all pages - const success = await multiPageSelectionController.selectAllPages(); - if (!success) { - // Fallback to single page selection if multi-page fails - onSelectAll(); - } - } else { - // Unselect all pages (both "all" and "some" states) - multiPageSelectionController.clearAllPages(); - } - return; - } - - // Fallback to normal single-page toggle - onSelectAll(); - }; - - checkbox = ( - - ); - } - return (
- {checkbox} + {showSelectAllToggle && ( + + )}
); } + +function Checkbox(props: { status: SelectionStatus; onChange: () => void; "aria-label"?: string }): React.ReactNode { + if (props.status === "unknown") { + console.error("Data grid 2: don't know how to render column checkbox with selectionStatus=unknown"); + return null; + } + return ( + + ); +} diff --git a/packages/pluggableWidgets/datagrid-web/src/components/SelectAllBar.tsx b/packages/pluggableWidgets/datagrid-web/src/components/SelectAllBar.tsx new file mode 100644 index 0000000000..7fc3ae81c9 --- /dev/null +++ b/packages/pluggableWidgets/datagrid-web/src/components/SelectAllBar.tsx @@ -0,0 +1,20 @@ +import { observer } from "mobx-react-lite"; +import { createElement } from "react"; +import { useDatagridRootScope } from "../helpers/root-context"; + +export const SelectAllBar = observer(function SelectAllBar(): React.ReactNode { + const { + selectAllController, + basicData: { selectionStatus } + } = useDatagridRootScope(); + + if (selectionStatus === "unknown") return null; + + if (selectionStatus === "none") return null; + + return ( +
+ +
+ ); +}); diff --git a/packages/pluggableWidgets/datagrid-web/src/components/Widget.tsx b/packages/pluggableWidgets/datagrid-web/src/components/Widget.tsx index 7f433754bd..685ba8f3ed 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/Widget.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/Widget.tsx @@ -21,13 +21,14 @@ import { Grid } from "./Grid"; import { GridBody } from "./GridBody"; import { GridHeader } from "./GridHeader"; import { RowsRenderer } from "./RowsRenderer"; +import { SelectAllBar } from "./SelectAllBar"; +import { SelectionCounter } from "./SelectionCounter"; +import { SelectionProgressDialog } from "./SelectionProgressDialog"; import { WidgetContent } from "./WidgetContent"; import { WidgetFooter } from "./WidgetFooter"; import { WidgetHeader } from "./WidgetHeader"; import { WidgetRoot } from "./WidgetRoot"; -import { SelectionProgressDialog } from "./SelectionProgressDialog"; import { WidgetTopBar } from "./WidgetTopBar"; -import { SelectionCounter } from "./SelectionCounter"; export interface WidgetProps { CellComponent: CellComponent; @@ -84,7 +85,7 @@ export interface WidgetProps(props: WidgetProps): ReactElement => { const { className, exporting, numberOfItems, onExportCancel, selectActionHelper } = props; - const { basicData, selectAllProgressStore, multiPageSelectionController } = useDatagridRootScope(); + const { basicData, selectAllProgressStore, selectAllController } = useDatagridRootScope(); const selectionEnabled = selectActionHelper.selectionType !== "None"; @@ -95,14 +96,14 @@ export const Widget = observer((props: WidgetProps): Re selection={selectionEnabled} style={props.styles} exporting={exporting} - selectingAllPages={selectAllProgressStore.selecting} + selectingAllPages={selectAllProgressStore.inProgress} >
multiPageSelectionController.abort()} + onCancel={() => selectAllController.abort()} progress={selectAllProgressStore.loaded} total={selectAllProgressStore.total} /> @@ -148,8 +149,10 @@ const Main = observer((props: WidgetProps): ReactElemen const { basicData, selectionCountStore } = useDatagridRootScope(); const showHeader = !!headerContent; - const showTopBarPagination = paging && (pagingPosition === "top" || pagingPosition === "both"); - const showFooterPagination = paging && (pagingPosition === "bottom" || pagingPosition === "both"); + const showTopBar = paging && (pagingPosition === "top" || pagingPosition === "both"); + const isSelectionEnabled = selectActionHelper.selectionType !== "None"; + const isSelectionMulti = isSelectionEnabled ? selectActionHelper.selectionType === "Multi" : undefined; + const isSelectAllBarEnabled = isSelectionMulti; const pagination = paging ? ( (props: WidgetProps): ReactElemen visibilitySelectorColumn: columnsHidable }); - const selectionEnabled = selectActionHelper.selectionType !== "None"; - return ( (props: WidgetProps): ReactElemen > {showHeader && {headerContent}} - + (props: WidgetProps): ReactElemen isLoading={props.columnsLoading} preview={props.preview} /> + {isSelectAllBarEnabled && } {showRefreshIndicator ? : null} ; type Spec = { gate: Gate; - exportCtrl: ProgressStore; + exportProgressStore: ProgressStore; + selectAllProgressStore: ProgressStore; + selectAllController: SelectAllController; }; export class RootGridStore extends BaseControllerHost { @@ -53,7 +55,7 @@ export class RootGridStore extends BaseControllerHost { selectionCountStore: SelectionCountStore; basicData: GridBasicData; staticInfo: StaticInfo; - exportProgressCtrl: ProgressStore; + exportProgressStore: ProgressStore; selectAllController: SelectAllController; selectAllProgressStore: ProgressStore; loaderCtrl: DerivedLoaderController; @@ -63,7 +65,7 @@ export class RootGridStore extends BaseControllerHost { private gate: Gate; - constructor({ gate, exportCtrl }: Spec) { + constructor({ gate, exportProgressStore, selectAllProgressStore, selectAllController }: Spec) { super(); const { props } = gate; @@ -99,15 +101,11 @@ export class RootGridStore extends BaseControllerHost { this.paginationCtrl = new PaginationController(this, { gate, query }); - this.exportProgressCtrl = exportCtrl; + this.exportProgressStore = exportProgressStore; - this.selectAllProgressStore = new ProgressStore(); + this.selectAllProgressStore = selectAllProgressStore; - this.selectAllController = new SelectAllController(this, { - gate, - query, - pageSize: props.selectAllPagesPageSize - }); + this.selectAllController = selectAllController; new DatasourceParamsController(this, { query, @@ -121,7 +119,7 @@ export class RootGridStore extends BaseControllerHost { }); this.loaderCtrl = new DerivedLoaderController({ - exp: exportCtrl, + exp: exportProgressStore, cols: this.columnsStore, showSilentRefresh: props.refreshInterval > 1, refreshIndicator: props.refreshIndicator, @@ -137,10 +135,30 @@ export class RootGridStore extends BaseControllerHost { add(this.columnsStore.setup()); add(() => this.settingsStore.dispose()); add(autorun(() => this.updateProps(this.gate.props))); + add(this.setupSelectAllProgressStore()); return disposeAll; } + private setupSelectAllProgressStore() { + const controller = this.selectAllController; + const loadstart = (e: ProgressEvent): void => this.selectAllProgressStore.onloadstart(e); + const loadend = (e: ProgressEvent): void => this.selectAllProgressStore.onloadstart(e); + const progress = (e: ProgressEvent): void => this.selectAllProgressStore.onprogress(e); + + controller.addEventListener("loadstart", loadstart); + controller.addEventListener("loadend", loadend); + controller.addEventListener("abort", loadend); + controller.addEventListener("progress", progress); + + return () => { + controller.removeEventListener("loadstart", loadstart); + controller.removeEventListener("loadend", loadend); + controller.removeEventListener("abort", loadend); + controller.removeEventListener("progress", progress); + }; + } + private updateProps(props: RequiredProps): void { this.columnsStore.updateProps(props); this.settingsStore.updateProps(props); diff --git a/packages/pluggableWidgets/datagrid-web/src/helpers/state/useRootStore.ts b/packages/pluggableWidgets/datagrid-web/src/helpers/state/useRootStore.ts index 40903de468..d17bb58539 100644 --- a/packages/pluggableWidgets/datagrid-web/src/helpers/state/useRootStore.ts +++ b/packages/pluggableWidgets/datagrid-web/src/helpers/state/useRootStore.ts @@ -1,21 +1,46 @@ +import { SelectAllHost } from "@mendix/widget-plugin-grid/selection"; +import { ProgressStore } from "@mendix/widget-plugin-grid/stores/ProgressStore"; import { ClosableGateProvider } from "@mendix/widget-plugin-mobx-kit/ClosableGateProvider"; import { useConst } from "@mendix/widget-plugin-mobx-kit/react/useConst"; import { useSetup } from "@mendix/widget-plugin-mobx-kit/react/useSetup"; import { useEffect } from "react"; import { DatagridContainerProps } from "../../../typings/DatagridProps"; -import { ProgressStore } from "../../features/data-export/ProgressStore"; import { RootGridStore } from "./RootGridStore"; export function useRootStore(props: DatagridContainerProps): RootGridStore { - const [gateProvider, exportProgressCtrl] = useConst(() => { - const epc = new ProgressStore(); - const gp = new ClosableGateProvider(props, () => epc.exporting); - return [gp, epc] as const; + const exportProgressStore = useConst(() => new ProgressStore()); + + const selectAllProgressStore = useConst(() => new ProgressStore()); + + const mainGateProvider = useConst(() => { + // Closed when exporting or selecting all + return new ClosableGateProvider(props, () => { + return exportProgressStore.inProgress || selectAllProgressStore.inProgress; + }); + }); + + const selectAllGateProvider = useConst(() => { + // Closed when not selecting all + return new ClosableGateProvider(props, () => !selectAllProgressStore.inProgress); }); - const rootStore = useSetup(() => new RootGridStore({ gate: gateProvider.gate, exportCtrl: exportProgressCtrl })); + + const selectAllHost = useSetup( + () => new SelectAllHost({ gate: selectAllGateProvider.gate, selectAllProgressStore }) + ); + + const rootStore = useSetup( + () => + new RootGridStore({ + gate: mainGateProvider.gate, + exportProgressStore, + selectAllProgressStore, + selectAllController: selectAllHost.selectAllController + }) + ); useEffect(() => { - gateProvider.setProps(props); + mainGateProvider.setProps(props); + selectAllGateProvider.setProps(props); }); return rootStore; diff --git a/packages/shared/widget-plugin-grid/src/query/DatasourceController.ts b/packages/shared/widget-plugin-grid/src/query/DatasourceController.ts index eabfa2b66b..326cef2f13 100644 --- a/packages/shared/widget-plugin-grid/src/query/DatasourceController.ts +++ b/packages/shared/widget-plugin-grid/src/query/DatasourceController.ts @@ -187,15 +187,14 @@ export class DatasourceController implements ReactiveController, QueryController () => this.datasource.offset === offset && this.datasource.limit === limit && - this.datasource.status === "available" + this.datasource.status === "available", + { signal } ); - predicate.then(() => resolve(this.datasource.items ?? [])).catch(reject); + predicate.then(() => resolve(this.datasource.items ?? []), reject); this.datasource.setOffset(offset); this.datasource.setLimit(limit); - - signal.addEventListener("abort", () => predicate.cancel()); }); } } diff --git a/packages/shared/widget-plugin-grid/src/selection.ts b/packages/shared/widget-plugin-grid/src/selection.ts index d21ed80470..ea50545b34 100644 --- a/packages/shared/widget-plugin-grid/src/selection.ts +++ b/packages/shared/widget-plugin-grid/src/selection.ts @@ -7,4 +7,5 @@ export * from "./selection/helpers.js"; export * from "./selection/keyboard.js"; export { SelectActionHandler } from "./selection/select-action-handler.js"; export { SelectAllController } from "./selection/SelectAllController.js"; +export { SelectAllHost } from "./selection/SelectAllHost.js"; export * from "./selection/types.js"; diff --git a/packages/shared/widget-plugin-grid/src/selection/SelectAllController.ts b/packages/shared/widget-plugin-grid/src/selection/SelectAllController.ts index d4a96d78dd..f51ef9b9c8 100644 --- a/packages/shared/widget-plugin-grid/src/selection/SelectAllController.ts +++ b/packages/shared/widget-plugin-grid/src/selection/SelectAllController.ts @@ -1,6 +1,6 @@ import { DerivedPropsGate } from "@mendix/widget-plugin-mobx-kit/props-gate"; import { ReactiveController, ReactiveControllerHost } from "@mendix/widget-plugin-mobx-kit/reactive-controller"; -import { SelectionMultiValue, SelectionSingleValue } from "mendix"; +import { ObjectItem, SelectionMultiValue, SelectionSingleValue } from "mendix"; import { action, computed, makeObservable, observable } from "mobx"; import { QueryController } from "../query/query-controller"; @@ -19,7 +19,7 @@ export class SelectAllController extends EventTarget implements ReactiveControll private readonly query: QueryController; private abortController?: AbortController; private locked = false; - readonly pageSize: number = 2; + readonly pageSize: number = 100; constructor(host: ReactiveControllerHost, spec: SelectAllControllerSpec) { super(); @@ -71,15 +71,15 @@ export class SelectAllController extends EventTarget implements ReactiveControll private beforeRunChecks(): boolean { const selection = this.gate.props.itemSelection; - if (selection?.type !== "Single") { - console.debug("SelectAllController: action can't be executed when selection is 'Single'."); - return false; - } - if (selection?.type === undefined) { + if (selection === undefined) { console.debug("SelectAllController: selection is undefined. Check widget selection setting."); return false; } + if (selection.type !== "Multi") { + console.debug("SelectAllController: action can't be executed when selection is 'Single'."); + return false; + } if (this.locked) { console.debug("SelectAllController: action is already executing."); @@ -102,37 +102,38 @@ export class SelectAllController extends EventTarget implements ReactiveControll let offset = 0; const pe = (type: SelectAllEventType): ProgressEvent => new ProgressEvent(type, { loaded, total: totalCount, lengthComputable: hasTotal }); + // We should avoid duplicates, so, we start with clean array. + const allItems: ObjectItem[] = []; try { this.abortController = new AbortController(); this.dispatchEvent(pe("loadstart")); - this.selection?.setKeepSelection(() => true); let loading = true; while (loading) { - await this.query.fetchPage({ + const loadedItems = await this.query.fetchPage({ limit: this.pageSize, offset, signal: this.abortController.signal }); - const loadedItems = this.query.items ?? []; - const merged = (this.selection?.selection ?? []).concat(loadedItems); + allItems.push(...loadedItems); loaded += loadedItems.length; offset += this.pageSize; - this.selection?.setSelection(merged); this.dispatchEvent(pe("progress")); loading = !this.abortController.signal.aborted && this.query.hasMoreItems; } } catch (error) { - if (error.name !== "AbortError") { + const aborted = this.abortController?.signal.aborted; + if (!aborted) { throw error; } } finally { - this.dispatchEvent(pe("loadend")); this.query.setOffset(initOffset); this.query.setLimit(initLimit); + this.selection?.setSelection(allItems); this.locked = false; this.abortController = undefined; + this.dispatchEvent(pe("loadend")); } } diff --git a/packages/shared/widget-plugin-grid/src/selection/SelectAllHost.ts b/packages/shared/widget-plugin-grid/src/selection/SelectAllHost.ts new file mode 100644 index 0000000000..eeb32db66f --- /dev/null +++ b/packages/shared/widget-plugin-grid/src/selection/SelectAllHost.ts @@ -0,0 +1,52 @@ +import { BaseControllerHost } from "@mendix/widget-plugin-mobx-kit/BaseControllerHost"; +import { disposeBatch } from "@mendix/widget-plugin-mobx-kit/disposeBatch"; +import { DerivedPropsGate } from "@mendix/widget-plugin-mobx-kit/props-gate"; +import { ListValue, SelectionMultiValue, SelectionSingleValue } from "mendix"; +import { DatasourceController } from "../query/DatasourceController"; +import { ProgressStore } from "../stores/ProgressStore"; +import { SelectAllController } from "./SelectAllController"; + +type SelectAllHostSpec = { + gate: DerivedPropsGate<{ itemSelection?: SelectionMultiValue | SelectionSingleValue; datasource: ListValue }>; + selectAllProgressStore: ProgressStore; +}; + +export class SelectAllHost extends BaseControllerHost { + readonly selectAllController: SelectAllController; + readonly selectAllProgressStore: ProgressStore; + + constructor(spec: SelectAllHostSpec) { + super(); + const query = new DatasourceController(this, { gate: spec.gate }); + this.selectAllController = new SelectAllController(this, { gate: spec.gate, query, pageSize: 30 }); + this.selectAllProgressStore = spec.selectAllProgressStore; + } + + setup(): () => void { + const [add, disposeAll] = disposeBatch(); + + add(super.setup()); + add(this.setupSelectAllProgressStore()); + + return disposeAll; + } + + private setupSelectAllProgressStore() { + const controller = this.selectAllController; + const loadstart = (e: ProgressEvent): void => this.selectAllProgressStore.onloadstart(e); + const loadend = (e: ProgressEvent): void => this.selectAllProgressStore.onloadstart(e); + const progress = (e: ProgressEvent): void => this.selectAllProgressStore.onprogress(e); + + controller.addEventListener("loadstart", loadstart); + controller.addEventListener("loadend", loadend); + controller.addEventListener("abort", loadend); + controller.addEventListener("progress", progress); + + return () => { + controller.removeEventListener("loadstart", loadstart); + controller.removeEventListener("loadend", loadend); + controller.removeEventListener("abort", loadend); + controller.removeEventListener("progress", progress); + }; + } +} From 57073a32240567eef9545c495183a18eeefd9029 Mon Sep 17 00:00:00 2001 From: Illia Obukhau <8282906+iobuhov@users.noreply.github.com> Date: Wed, 8 Oct 2025 11:44:31 +0200 Subject: [PATCH 12/35] refactor: minor fixes --- .../src/helpers/state/RootGridStore.ts | 21 ---------- .../src/helpers/state/useRootStore.ts | 6 +-- .../SelectAllController.ts | 40 ++++++++++--------- .../SelectAllHost.ts | 16 ++++---- .../widget-plugin-grid/src/selection.ts | 4 +- 5 files changed, 33 insertions(+), 54 deletions(-) rename packages/shared/widget-plugin-grid/src/{selection => select-all}/SelectAllController.ts (79%) rename packages/shared/widget-plugin-grid/src/{selection => select-all}/SelectAllHost.ts (74%) diff --git a/packages/pluggableWidgets/datagrid-web/src/helpers/state/RootGridStore.ts b/packages/pluggableWidgets/datagrid-web/src/helpers/state/RootGridStore.ts index 88d8ec88bf..06701c89d2 100644 --- a/packages/pluggableWidgets/datagrid-web/src/helpers/state/RootGridStore.ts +++ b/packages/pluggableWidgets/datagrid-web/src/helpers/state/RootGridStore.ts @@ -135,30 +135,9 @@ export class RootGridStore extends BaseControllerHost { add(this.columnsStore.setup()); add(() => this.settingsStore.dispose()); add(autorun(() => this.updateProps(this.gate.props))); - add(this.setupSelectAllProgressStore()); - return disposeAll; } - private setupSelectAllProgressStore() { - const controller = this.selectAllController; - const loadstart = (e: ProgressEvent): void => this.selectAllProgressStore.onloadstart(e); - const loadend = (e: ProgressEvent): void => this.selectAllProgressStore.onloadstart(e); - const progress = (e: ProgressEvent): void => this.selectAllProgressStore.onprogress(e); - - controller.addEventListener("loadstart", loadstart); - controller.addEventListener("loadend", loadend); - controller.addEventListener("abort", loadend); - controller.addEventListener("progress", progress); - - return () => { - controller.removeEventListener("loadstart", loadstart); - controller.removeEventListener("loadend", loadend); - controller.removeEventListener("abort", loadend); - controller.removeEventListener("progress", progress); - }; - } - private updateProps(props: RequiredProps): void { this.columnsStore.updateProps(props); this.settingsStore.updateProps(props); diff --git a/packages/pluggableWidgets/datagrid-web/src/helpers/state/useRootStore.ts b/packages/pluggableWidgets/datagrid-web/src/helpers/state/useRootStore.ts index d17bb58539..b853e8c7c3 100644 --- a/packages/pluggableWidgets/datagrid-web/src/helpers/state/useRootStore.ts +++ b/packages/pluggableWidgets/datagrid-web/src/helpers/state/useRootStore.ts @@ -1,6 +1,7 @@ import { SelectAllHost } from "@mendix/widget-plugin-grid/selection"; import { ProgressStore } from "@mendix/widget-plugin-grid/stores/ProgressStore"; import { ClosableGateProvider } from "@mendix/widget-plugin-mobx-kit/ClosableGateProvider"; +import { GateProvider } from "@mendix/widget-plugin-mobx-kit/GateProvider"; import { useConst } from "@mendix/widget-plugin-mobx-kit/react/useConst"; import { useSetup } from "@mendix/widget-plugin-mobx-kit/react/useSetup"; import { useEffect } from "react"; @@ -19,10 +20,7 @@ export function useRootStore(props: DatagridContainerProps): RootGridStore { }); }); - const selectAllGateProvider = useConst(() => { - // Closed when not selecting all - return new ClosableGateProvider(props, () => !selectAllProgressStore.inProgress); - }); + const selectAllGateProvider = useConst(() => new GateProvider(props)); const selectAllHost = useSetup( () => new SelectAllHost({ gate: selectAllGateProvider.gate, selectAllProgressStore }) diff --git a/packages/shared/widget-plugin-grid/src/selection/SelectAllController.ts b/packages/shared/widget-plugin-grid/src/select-all/SelectAllController.ts similarity index 79% rename from packages/shared/widget-plugin-grid/src/selection/SelectAllController.ts rename to packages/shared/widget-plugin-grid/src/select-all/SelectAllController.ts index f51ef9b9c8..be6dfa8d4e 100644 --- a/packages/shared/widget-plugin-grid/src/selection/SelectAllController.ts +++ b/packages/shared/widget-plugin-grid/src/select-all/SelectAllController.ts @@ -14,15 +14,15 @@ interface SelectAllControllerSpec { type SelectAllEventType = "loadstart" | "progress" | "abort" | "loadend"; -export class SelectAllController extends EventTarget implements ReactiveController { +export class SelectAllController implements ReactiveController { private readonly gate: Gate; private readonly query: QueryController; private abortController?: AbortController; private locked = false; readonly pageSize: number = 100; + private readonly emitter = new EventTarget(); constructor(host: ReactiveControllerHost, spec: SelectAllControllerSpec) { - super(); host.addController(this); this.gate = spec.gate; this.query = spec.query; @@ -44,12 +44,12 @@ export class SelectAllController extends EventTarget implements ReactiveControll return () => this.abort(); } - addEventListener( - type: SelectAllEventType, - callback: (pe: ProgressEvent) => void, - options?: AddEventListenerOptions | boolean - ): void { - super.addEventListener(type, callback, options); + on(type: SelectAllEventType, listener: (pe: ProgressEvent) => void): void { + this.emitter.addEventListener(type, listener); + } + + off(type: SelectAllEventType, listener: (pe: ProgressEvent) => void): void { + this.emitter.removeEventListener(type, listener); } get selection(): SelectionMultiValue | undefined { @@ -96,6 +96,7 @@ export class SelectAllController extends EventTarget implements ReactiveControll this.setIsLocked(true); const { offset: initOffset, limit: initLimit } = this.query; + const initSelection = this.selection?.selection; const hasTotal = typeof this.query.totalCount === "number"; const totalCount = this.query.totalCount ?? 0; let loaded = 0; @@ -104,36 +105,39 @@ export class SelectAllController extends EventTarget implements ReactiveControll new ProgressEvent(type, { loaded, total: totalCount, lengthComputable: hasTotal }); // We should avoid duplicates, so, we start with clean array. const allItems: ObjectItem[] = []; + this.abortController = new AbortController(); + const signal = this.abortController.signal; try { - this.abortController = new AbortController(); - this.dispatchEvent(pe("loadstart")); + this.emitter.dispatchEvent(pe("loadstart")); let loading = true; while (loading) { const loadedItems = await this.query.fetchPage({ limit: this.pageSize, offset, - signal: this.abortController.signal + signal }); allItems.push(...loadedItems); loaded += loadedItems.length; offset += this.pageSize; - this.dispatchEvent(pe("progress")); - loading = !this.abortController.signal.aborted && this.query.hasMoreItems; + this.emitter.dispatchEvent(pe("progress")); + loading = !signal.aborted && this.query.hasMoreItems; } + // Set allItems on success + this.selection?.setSelection(allItems); } catch (error) { - const aborted = this.abortController?.signal.aborted; - if (!aborted) { + if (!signal.aborted) { throw error; } + // Restore selection on abort + this.selection?.setSelection(initSelection ?? []); } finally { this.query.setOffset(initOffset); this.query.setLimit(initLimit); - this.selection?.setSelection(allItems); this.locked = false; + this.emitter.dispatchEvent(pe("loadend")); this.abortController = undefined; - this.dispatchEvent(pe("loadend")); } } @@ -147,6 +151,6 @@ export class SelectAllController extends EventTarget implements ReactiveControll abort(): void { this.abortController?.abort(); - this.dispatchEvent(new ProgressEvent("abort")); + this.emitter.dispatchEvent(new ProgressEvent("abort")); } } diff --git a/packages/shared/widget-plugin-grid/src/selection/SelectAllHost.ts b/packages/shared/widget-plugin-grid/src/select-all/SelectAllHost.ts similarity index 74% rename from packages/shared/widget-plugin-grid/src/selection/SelectAllHost.ts rename to packages/shared/widget-plugin-grid/src/select-all/SelectAllHost.ts index eeb32db66f..0b423bdd8c 100644 --- a/packages/shared/widget-plugin-grid/src/selection/SelectAllHost.ts +++ b/packages/shared/widget-plugin-grid/src/select-all/SelectAllHost.ts @@ -34,19 +34,17 @@ export class SelectAllHost extends BaseControllerHost { private setupSelectAllProgressStore() { const controller = this.selectAllController; const loadstart = (e: ProgressEvent): void => this.selectAllProgressStore.onloadstart(e); - const loadend = (e: ProgressEvent): void => this.selectAllProgressStore.onloadstart(e); + const loadend = (): void => this.selectAllProgressStore.onloadend(); const progress = (e: ProgressEvent): void => this.selectAllProgressStore.onprogress(e); - controller.addEventListener("loadstart", loadstart); - controller.addEventListener("loadend", loadend); - controller.addEventListener("abort", loadend); - controller.addEventListener("progress", progress); + controller.on("loadstart", loadstart); + controller.on("loadend", loadend); + controller.on("progress", progress); return () => { - controller.removeEventListener("loadstart", loadstart); - controller.removeEventListener("loadend", loadend); - controller.removeEventListener("abort", loadend); - controller.removeEventListener("progress", progress); + controller.off("loadstart", loadstart); + controller.off("loadend", loadend); + controller.off("progress", progress); }; } } diff --git a/packages/shared/widget-plugin-grid/src/selection.ts b/packages/shared/widget-plugin-grid/src/selection.ts index ea50545b34..067c28ecba 100644 --- a/packages/shared/widget-plugin-grid/src/selection.ts +++ b/packages/shared/widget-plugin-grid/src/selection.ts @@ -1,3 +1,5 @@ +export { SelectAllController } from "./select-all/SelectAllController.js"; +export { SelectAllHost } from "./select-all/SelectAllHost.js"; export { getGlobalSelectionContext, useCreateSelectionContextValue, @@ -6,6 +8,4 @@ export { export * from "./selection/helpers.js"; export * from "./selection/keyboard.js"; export { SelectActionHandler } from "./selection/select-action-handler.js"; -export { SelectAllController } from "./selection/SelectAllController.js"; -export { SelectAllHost } from "./selection/SelectAllHost.js"; export * from "./selection/types.js"; From 9097510500cd40c61e3eda475f2a76dbad697a47 Mon Sep 17 00:00:00 2001 From: Illia Obukhau <8282906+iobuhov@users.noreply.github.com> Date: Wed, 8 Oct 2025 12:15:37 +0200 Subject: [PATCH 13/35] refactor: change getter to method --- .../src/select-all/SelectAllController.ts | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/packages/shared/widget-plugin-grid/src/select-all/SelectAllController.ts b/packages/shared/widget-plugin-grid/src/select-all/SelectAllController.ts index be6dfa8d4e..7c635207bb 100644 --- a/packages/shared/widget-plugin-grid/src/select-all/SelectAllController.ts +++ b/packages/shared/widget-plugin-grid/src/select-all/SelectAllController.ts @@ -19,7 +19,7 @@ export class SelectAllController implements ReactiveController { private readonly query: QueryController; private abortController?: AbortController; private locked = false; - readonly pageSize: number = 100; + readonly pageSize: number = 500; private readonly emitter = new EventTarget(); constructor(host: ReactiveControllerHost, spec: SelectAllControllerSpec) { @@ -30,7 +30,6 @@ export class SelectAllController implements ReactiveController { type PrivateMembers = "setIsLocked" | "locked"; makeObservable(this, { setIsLocked: action, - selection: computed, canExecute: computed, isExecuting: computed, locked: observable, @@ -52,13 +51,18 @@ export class SelectAllController implements ReactiveController { this.emitter.removeEventListener(type, listener); } - get selection(): SelectionMultiValue | undefined { + /** + * @throws if selection is undefined or single + */ + selection(): SelectionMultiValue { const selection = this.gate.props.itemSelection; - return selection?.type === "Multi" ? selection : undefined; + if (selection === undefined) throw new Error("SelectAllController: selection is undefined."); + if (selection.type === "Single") throw new Error("SelectAllController: single selection is not supported."); + return selection; } get canExecute(): boolean { - return this.selection?.type === "Multi" && !this.locked; + return this.gate.props.itemSelection?.type === "Multi" && !this.locked; } get isExecuting(): boolean { @@ -96,7 +100,7 @@ export class SelectAllController implements ReactiveController { this.setIsLocked(true); const { offset: initOffset, limit: initLimit } = this.query; - const initSelection = this.selection?.selection; + const initSelection = this.selection().selection; const hasTotal = typeof this.query.totalCount === "number"; const totalCount = this.query.totalCount ?? 0; let loaded = 0; @@ -125,13 +129,13 @@ export class SelectAllController implements ReactiveController { loading = !signal.aborted && this.query.hasMoreItems; } // Set allItems on success - this.selection?.setSelection(allItems); + this.selection().setSelection(allItems); } catch (error) { if (!signal.aborted) { throw error; } // Restore selection on abort - this.selection?.setSelection(initSelection ?? []); + this.selection().setSelection(initSelection); } finally { this.query.setOffset(initOffset); this.query.setLimit(initLimit); @@ -146,7 +150,7 @@ export class SelectAllController implements ReactiveController { console.debug("SelectAllController: can't clear selection while executing."); return; } - this.selection?.setSelection([]); + this.selection().setSelection([]); } abort(): void { From f07c151c7b3e29d6757a55ce7a4eca93232f8212 Mon Sep 17 00:00:00 2001 From: Illia Obukhau <8282906+iobuhov@users.noreply.github.com> Date: Wed, 8 Oct 2025 12:57:30 +0200 Subject: [PATCH 14/35] feat: add execution time measurements --- .../datagrid-web/src/features/data-export/DSExportRequest.ts | 4 ++++ .../widget-plugin-grid/src/select-all/SelectAllController.ts | 4 ++++ 2 files changed, 8 insertions(+) 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 7898b5a76e..82fe8622e6 100644 --- a/packages/pluggableWidgets/datagrid-web/src/features/data-export/DSExportRequest.ts +++ b/packages/pluggableWidgets/datagrid-web/src/features/data-export/DSExportRequest.ts @@ -114,6 +114,7 @@ export class DSExportRequest { } send = (): Promise => { + performance.mark("DSExportRequest_send"); this.emitLoadStart(); this._status = "awaiting"; this.offset = 0; @@ -230,6 +231,9 @@ export class DSExportRequest { this.emitEnd(); this.emitLoadEnd(); this.dispose(); + performance.mark("DSExportRequest_end"); + const measure = performance.measure("DSExportRequest", "DSExportRequest_send", "DSExportRequest_end"); + console.debug(`DSExportRequest: export took ${(measure.duration / 1000).toFixed(2)} seconds`); } private dispose(): void { diff --git a/packages/shared/widget-plugin-grid/src/select-all/SelectAllController.ts b/packages/shared/widget-plugin-grid/src/select-all/SelectAllController.ts index 7c635207bb..022063c257 100644 --- a/packages/shared/widget-plugin-grid/src/select-all/SelectAllController.ts +++ b/packages/shared/widget-plugin-grid/src/select-all/SelectAllController.ts @@ -112,6 +112,7 @@ export class SelectAllController implements ReactiveController { this.abortController = new AbortController(); const signal = this.abortController.signal; + performance.mark("SelectAll_Start"); try { this.emitter.dispatchEvent(pe("loadstart")); let loading = true; @@ -142,6 +143,9 @@ export class SelectAllController implements ReactiveController { this.locked = false; this.emitter.dispatchEvent(pe("loadend")); this.abortController = undefined; + performance.mark("SelectAll_End"); + const measure1 = performance.measure("Measure1", "SelectAll_Start", "SelectAll_End"); + console.debug(`Data grid 2: select all took ${(measure1.duration / 1000).toFixed(2)} seconds`); } } From 1cdc3442b68be732f7e1e17532b57cf1c4a347ca Mon Sep 17 00:00:00 2001 From: Illia Obukhau <8282906+iobuhov@users.noreply.github.com> Date: Thu, 9 Oct 2025 14:30:21 +0200 Subject: [PATCH 15/35] refactor: implement ds reset --- .../datawidgets/web/_datagrid.scss | 50 ++++++++--------- .../datagrid-web/src/Datagrid.tsx | 2 +- .../src/components/SelectAllBar.tsx | 2 +- .../src/query/DatasourceController.ts | 10 +++- .../src/query/query-controller.ts | 3 +- .../src/select-all/SelectAllController.ts | 54 ++++++++++++------- 6 files changed, 73 insertions(+), 48 deletions(-) diff --git a/packages/modules/data-widgets/src/themesource/datawidgets/web/_datagrid.scss b/packages/modules/data-widgets/src/themesource/datawidgets/web/_datagrid.scss index bcf966eb54..61ba1fcc95 100644 --- a/packages/modules/data-widgets/src/themesource/datawidgets/web/_datagrid.scss +++ b/packages/modules/data-widgets/src/themesource/datawidgets/web/_datagrid.scss @@ -428,8 +428,7 @@ $root: ".widget-datagrid"; align-items: center; } - &-exporting, - &-selecting-all-pages { + &-exporting { .widget-datagrid-top-bar, .widget-datagrid-header, .widget-datagrid-content, @@ -443,28 +442,28 @@ $root: ".widget-datagrid"; } // Better positioning for multi-page selection modal - &-selecting-all-pages { - .widget-datagrid-modal { - &-overlay { - position: fixed; - top: 0; - right: 0; - bottom: 0; - left: 0; - } - - &-main { - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - display: flex; - align-items: center; - justify-content: center; - } - } - } + // &-selecting-all-pages { + // .widget-datagrid-modal { + // &-overlay { + // position: fixed; + // top: 0; + // right: 0; + // bottom: 0; + // left: 0; + // } + + // &-main { + // position: fixed; + // top: 0; + // left: 0; + // right: 0; + // bottom: 0; + // display: flex; + // align-items: center; + // justify-content: center; + // } + // } + // } &-col-select input:focus-visible { outline-offset: 0; @@ -617,6 +616,9 @@ $root: ".widget-datagrid"; outline-offset: 2px; } } +:where(#{$root}-select-all-bar) { + grid-column: 1 / -1; +} @keyframes skeleton-loading { 0% { diff --git a/packages/pluggableWidgets/datagrid-web/src/Datagrid.tsx b/packages/pluggableWidgets/datagrid-web/src/Datagrid.tsx index 363747f83d..e4a824a809 100644 --- a/packages/pluggableWidgets/datagrid-web/src/Datagrid.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/Datagrid.tsx @@ -30,7 +30,7 @@ const Container = observer((props: Props): ReactElement => { const { columnsStore, rootStore } = props; const { paginationCtrl } = rootStore; - const items = props.datasource.items ?? []; + const items = rootStore.query.items ?? []; const [exportProgress, abortExport] = useDataExport(props, props.columnsStore, props.progressStore); diff --git a/packages/pluggableWidgets/datagrid-web/src/components/SelectAllBar.tsx b/packages/pluggableWidgets/datagrid-web/src/components/SelectAllBar.tsx index 7fc3ae81c9..bb20d7b534 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/SelectAllBar.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/SelectAllBar.tsx @@ -13,7 +13,7 @@ export const SelectAllBar = observer(function SelectAllBar(): React.ReactNode { if (selectionStatus === "none") return null; return ( -
+
); diff --git a/packages/shared/widget-plugin-grid/src/query/DatasourceController.ts b/packages/shared/widget-plugin-grid/src/query/DatasourceController.ts index 326cef2f13..224890de45 100644 --- a/packages/shared/widget-plugin-grid/src/query/DatasourceController.ts +++ b/packages/shared/widget-plugin-grid/src/query/DatasourceController.ts @@ -169,6 +169,12 @@ export class DatasourceController implements ReactiveController, QueryController this.pageSize = size; } + reload(): Promise { + const ds = this.datasource; + this.datasource.reload(); + return when(() => this.datasource !== ds); + } + fetchPage({ limit, offset, @@ -176,10 +182,10 @@ export class DatasourceController implements ReactiveController, QueryController }: { limit: number; offset: number; - signal: AbortSignal; + signal?: AbortSignal; }): Promise { return new Promise((resolve, reject) => { - if (signal.aborted) { + if (signal && signal.aborted) { return reject(signal.reason); } diff --git a/packages/shared/widget-plugin-grid/src/query/query-controller.ts b/packages/shared/widget-plugin-grid/src/query/query-controller.ts index 67ea7b4440..306374b068 100644 --- a/packages/shared/widget-plugin-grid/src/query/query-controller.ts +++ b/packages/shared/widget-plugin-grid/src/query/query-controller.ts @@ -19,5 +19,6 @@ export interface QueryController extends Pick { isFirstLoad: boolean; isRefreshing: boolean; isFetchingNextBatch: boolean; - fetchPage(params: { limit: number; offset: number; signal: AbortSignal }): Promise; + fetchPage(params: { limit: number; offset: number; signal?: AbortSignal }): Promise; + reload(): Promise; } diff --git a/packages/shared/widget-plugin-grid/src/select-all/SelectAllController.ts b/packages/shared/widget-plugin-grid/src/select-all/SelectAllController.ts index 022063c257..3599a208e0 100644 --- a/packages/shared/widget-plugin-grid/src/select-all/SelectAllController.ts +++ b/packages/shared/widget-plugin-grid/src/select-all/SelectAllController.ts @@ -1,7 +1,7 @@ import { DerivedPropsGate } from "@mendix/widget-plugin-mobx-kit/props-gate"; import { ReactiveController, ReactiveControllerHost } from "@mendix/widget-plugin-mobx-kit/reactive-controller"; import { ObjectItem, SelectionMultiValue, SelectionSingleValue } from "mendix"; -import { action, computed, makeObservable, observable } from "mobx"; +import { action, computed, makeObservable, observable, when } from "mobx"; import { QueryController } from "../query/query-controller"; type Gate = DerivedPropsGate<{ itemSelection?: SelectionMultiValue | SelectionSingleValue }>; @@ -19,7 +19,7 @@ export class SelectAllController implements ReactiveController { private readonly query: QueryController; private abortController?: AbortController; private locked = false; - readonly pageSize: number = 500; + readonly pageSize: number = 1024; private readonly emitter = new EventTarget(); constructor(host: ReactiveControllerHost, spec: SelectAllControllerSpec) { @@ -32,6 +32,7 @@ export class SelectAllController implements ReactiveController { setIsLocked: action, canExecute: computed, isExecuting: computed, + selection: computed, locked: observable, selectAllPages: action, clearSelection: action, @@ -51,13 +52,10 @@ export class SelectAllController implements ReactiveController { this.emitter.removeEventListener(type, listener); } - /** - * @throws if selection is undefined or single - */ - selection(): SelectionMultiValue { + get selection(): SelectionMultiValue | undefined { const selection = this.gate.props.itemSelection; - if (selection === undefined) throw new Error("SelectAllController: selection is undefined."); - if (selection.type === "Single") throw new Error("SelectAllController: single selection is not supported."); + if (selection === undefined) return; + if (selection.type === "Single") return; return selection; } @@ -100,11 +98,11 @@ export class SelectAllController implements ReactiveController { this.setIsLocked(true); const { offset: initOffset, limit: initLimit } = this.query; - const initSelection = this.selection().selection; const hasTotal = typeof this.query.totalCount === "number"; const totalCount = this.query.totalCount ?? 0; let loaded = 0; let offset = 0; + let success = false; const pe = (type: SelectAllEventType): ProgressEvent => new ProgressEvent(type, { loaded, total: totalCount, lengthComputable: hasTotal }); // We should avoid duplicates, so, we start with clean array. @@ -129,32 +127,50 @@ export class SelectAllController implements ReactiveController { this.emitter.dispatchEvent(pe("progress")); loading = !signal.aborted && this.query.hasMoreItems; } - // Set allItems on success - this.selection().setSelection(allItems); + success = true; } catch (error) { if (!signal.aborted) { - throw error; + console.error("SelectAllController: an error was encountered during the 'select all' action."); + console.error(error); } - // Restore selection on abort - this.selection().setSelection(initSelection); } finally { - this.query.setOffset(initOffset); - this.query.setLimit(initLimit); - this.locked = false; + // Restore init view + // This step should be done before loadend to avoid UI flickering + await this.query.fetchPage({ + limit: initLimit, + offset: initOffset + }); + this.emitter.dispatchEvent(pe("loadend")); + + const selectionBeforeReload = this.selection?.selection ?? []; + // Reload selection to make sure setSelection is working as expected. + await this.reloadSelection(); + + this.selection?.setSelection(success ? allItems : selectionBeforeReload); + + this.locked = false; + console.info(success, initLimit, initOffset); this.abortController = undefined; performance.mark("SelectAll_End"); const measure1 = performance.measure("Measure1", "SelectAll_Start", "SelectAll_End"); - console.debug(`Data grid 2: select all took ${(measure1.duration / 1000).toFixed(2)} seconds`); + console.debug(`Data grid 2: 'select all' took ${(measure1.duration / 1000).toFixed(2)} seconds.`); } } + reloadSelection(): Promise { + const selection = this.selection; + selection?.setSelection([]); + // Resolve when selection value is updated + return when(() => this.selection !== selection); + } + clearSelection(): void { if (this.locked) { console.debug("SelectAllController: can't clear selection while executing."); return; } - this.selection().setSelection([]); + this.selection?.setSelection([]); } abort(): void { From 1340607e09d326f7f74ba8c1de6d5eb30c865207 Mon Sep 17 00:00:00 2001 From: Illia Obukhau <8282906+iobuhov@users.noreply.github.com> Date: Thu, 9 Oct 2025 16:58:36 +0200 Subject: [PATCH 16/35] refactor: use gate to avoid computations --- .../src/Datagrid.editorPreview.tsx | 2 +- .../datagrid-web/src/Datagrid.tsx | 29 +++++-------------- .../src/features/data-export/useDataExport.ts | 6 ++-- .../datagrid-web/src/helpers/root-context.ts | 2 +- .../src/helpers/state/RootGridStore.ts | 23 ++++++--------- .../src/select-all/SelectAllController.ts | 6 ++-- 6 files changed, 24 insertions(+), 44 deletions(-) diff --git a/packages/pluggableWidgets/datagrid-web/src/Datagrid.editorPreview.tsx b/packages/pluggableWidgets/datagrid-web/src/Datagrid.editorPreview.tsx index 6832d1290f..f94e8cb75f 100644 --- a/packages/pluggableWidgets/datagrid-web/src/Datagrid.editorPreview.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/Datagrid.editorPreview.tsx @@ -7,8 +7,8 @@ enableStaticRendering(true); import { useFocusTargetController } from "@mendix/widget-plugin-grid/keyboard-navigation/useFocusTargetController"; import { DatasourceController } from "@mendix/widget-plugin-grid/query/DatasourceController"; import { SelectAllController } from "@mendix/widget-plugin-grid/selection"; -import { SelectionCountStore } from "@mendix/widget-plugin-grid/selection/stores/SelectionCountStore"; import { ProgressStore } from "@mendix/widget-plugin-grid/stores/ProgressStore"; +import { SelectionCountStore } from "@mendix/widget-plugin-grid/stores/SelectionCountStore"; import { BaseControllerHost } from "@mendix/widget-plugin-mobx-kit/BaseControllerHost"; import { GateProvider } from "@mendix/widget-plugin-mobx-kit/GateProvider"; import { useConst } from "@mendix/widget-plugin-mobx-kit/react/useConst"; diff --git a/packages/pluggableWidgets/datagrid-web/src/Datagrid.tsx b/packages/pluggableWidgets/datagrid-web/src/Datagrid.tsx index e4a824a809..45e4271263 100644 --- a/packages/pluggableWidgets/datagrid-web/src/Datagrid.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/Datagrid.tsx @@ -9,35 +9,31 @@ import { DatagridContainerProps } from "../typings/DatagridProps"; import { Cell } from "./components/Cell"; import { Widget } from "./components/Widget"; import { WidgetHeaderContext } from "./components/WidgetHeaderContext"; -import { ProgressStore } from "./features/data-export/ProgressStore"; import { useDataExport } from "./features/data-export/useDataExport"; import { useCellEventsController } from "./features/row-interaction/CellEventsController"; import { useCheckboxEventsController } from "./features/row-interaction/CheckboxEventsController"; import { DatagridContext, DatagridRootScope } from "./helpers/root-context"; import { useSelectActionHelper } from "./helpers/SelectActionHelper"; -import { IColumnGroupStore } from "./helpers/state/ColumnGroupStore"; import { RootGridStore } from "./helpers/state/RootGridStore"; import { useRootStore } from "./helpers/state/useRootStore"; import { useDataGridJSActions } from "./helpers/useDataGridJSActions"; interface Props extends DatagridContainerProps { - columnsStore: IColumnGroupStore; rootStore: RootGridStore; - progressStore: ProgressStore; } const Container = observer((props: Props): ReactElement => { - const { columnsStore, rootStore } = props; - const { paginationCtrl } = rootStore; + const { rootStore } = props; + const { paginationCtrl, gate, query, columnsStore, exportProgressStore } = rootStore; - const items = rootStore.query.items ?? []; + const items = query.items ?? []; - const [exportProgress, abortExport] = useDataExport(props, props.columnsStore, props.progressStore); + const [exportProgress, abortExport] = useDataExport(props, columnsStore, exportProgressStore); const selectionHelper = useSelectionHelper( - props.itemSelection, - props.datasource, - props.onSelectionChange, + gate.props.itemSelection, + gate.props.datasource, + gate.props.onSelectionChange, props.keepSelection ? "always keep" : "always clear" ); @@ -151,14 +147,5 @@ const Container = observer((props: Props): ReactElement => { Container.displayName = "DatagridComponent"; export default function Datagrid(props: DatagridContainerProps): ReactElement | null { - const rootStore = useRootStore(props); - - return ( - - ); + return ; } 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 8f853e9611..6733b2d7b5 100644 --- a/packages/pluggableWidgets/datagrid-web/src/features/data-export/useDataExport.ts +++ b/packages/pluggableWidgets/datagrid-web/src/features/data-export/useDataExport.ts @@ -1,9 +1,9 @@ import { useCallback, useEffect, useState } from "react"; +import { DatagridContainerProps } from "../../../typings/DatagridProps"; +import { IColumnGroupStore } from "../../helpers/state/ColumnGroupStore"; import { ExportController } from "./ExportController"; import { ProgressStore } from "./ProgressStore"; import { getExportRegistry } from "./registry"; -import { DatagridContainerProps } from "../../../typings/DatagridProps"; -import { IColumnGroupStore } from "../../helpers/state/ColumnGroupStore"; type ResourceEntry = { key: string; @@ -11,7 +11,7 @@ type ResourceEntry = { }; export function useDataExport( - props: DatagridContainerProps, + props: Pick, columnsStore: IColumnGroupStore, progress: ProgressStore ): [store: ProgressStore, abort: () => void] { diff --git a/packages/pluggableWidgets/datagrid-web/src/helpers/root-context.ts b/packages/pluggableWidgets/datagrid-web/src/helpers/root-context.ts index 25aaba0295..03ee7ac3f9 100644 --- a/packages/pluggableWidgets/datagrid-web/src/helpers/root-context.ts +++ b/packages/pluggableWidgets/datagrid-web/src/helpers/root-context.ts @@ -1,7 +1,7 @@ import { FocusTargetController } from "@mendix/widget-plugin-grid/keyboard-navigation/FocusTargetController"; import { SelectAllController, SelectionHelper } from "@mendix/widget-plugin-grid/selection"; -import { SelectionCountStore } from "@mendix/widget-plugin-grid/selection/stores/SelectionCountStore"; import { ProgressStore } from "@mendix/widget-plugin-grid/stores/ProgressStore"; +import { SelectionCountStore } from "@mendix/widget-plugin-grid/stores/SelectionCountStore"; import { createContext, useContext } from "react"; import { GridBasicData } from "../helpers/state/GridBasicData"; import { EventsController } from "../typings/CellComponent"; diff --git a/packages/pluggableWidgets/datagrid-web/src/helpers/state/RootGridStore.ts b/packages/pluggableWidgets/datagrid-web/src/helpers/state/RootGridStore.ts index 06701c89d2..6053439781 100644 --- a/packages/pluggableWidgets/datagrid-web/src/helpers/state/RootGridStore.ts +++ b/packages/pluggableWidgets/datagrid-web/src/helpers/state/RootGridStore.ts @@ -5,8 +5,8 @@ import { DatasourceController } from "@mendix/widget-plugin-grid/query/Datasourc import { QueryController } from "@mendix/widget-plugin-grid/query/query-controller"; import { RefreshController } from "@mendix/widget-plugin-grid/query/RefreshController"; import { SelectAllController } from "@mendix/widget-plugin-grid/selection"; -import { SelectionCountStore } from "@mendix/widget-plugin-grid/selection/stores/SelectionCountStore"; import { ProgressStore } from "@mendix/widget-plugin-grid/stores/ProgressStore"; +import { SelectionCountStore } from "@mendix/widget-plugin-grid/stores/SelectionCountStore"; import { BaseControllerHost } from "@mendix/widget-plugin-mobx-kit/BaseControllerHost"; import { disposeBatch } from "@mendix/widget-plugin-mobx-kit/disposeBatch"; import { DerivedPropsGate } from "@mendix/widget-plugin-mobx-kit/props-gate"; @@ -17,7 +17,6 @@ import { DatagridContainerProps } from "../../../typings/DatagridProps"; import { DatasourceParamsController } from "../../controllers/DatasourceParamsController"; import { DerivedLoaderController } from "../../controllers/DerivedLoaderController"; import { PaginationController } from "../../controllers/PaginationController"; - import { StaticInfo } from "../../typings/static-info"; import { ColumnGroupStore } from "./ColumnGroupStore"; import { GridPersonalizationStore } from "./GridPersonalizationStore"; @@ -60,10 +59,9 @@ export class RootGridStore extends BaseControllerHost { selectAllProgressStore: ProgressStore; loaderCtrl: DerivedLoaderController; paginationCtrl: PaginationController; - readonly filterAPI: FilterAPI; - query!: QueryController; - - private gate: Gate; + filterAPI: FilterAPI; + query: QueryController; + gate: Gate; constructor({ gate, exportProgressStore, selectAllProgressStore, selectAllController }: Spec) { super(); @@ -78,8 +76,7 @@ export class RootGridStore extends BaseControllerHost { const filterHost = new CustomFilterHost(); - const query = new DatasourceController(this, { gate }); - this.query = query; + const query = (this.query = new DatasourceController(this, { gate })); this.filterAPI = createContextWithStub({ filterObserver: filterHost, @@ -134,12 +131,10 @@ export class RootGridStore extends BaseControllerHost { add(super.setup()); add(this.columnsStore.setup()); add(() => this.settingsStore.dispose()); - add(autorun(() => this.updateProps(this.gate.props))); + // Column store & settings store is still using old `updateProps` + // approach. So, we use autorun to sync props. + add(autorun(() => this.columnsStore.updateProps(this.gate.props))); + add(autorun(() => this.settingsStore.updateProps(this.gate.props))); return disposeAll; } - - private updateProps(props: RequiredProps): void { - this.columnsStore.updateProps(props); - this.settingsStore.updateProps(props); - } } diff --git a/packages/shared/widget-plugin-grid/src/select-all/SelectAllController.ts b/packages/shared/widget-plugin-grid/src/select-all/SelectAllController.ts index 3599a208e0..372a171e5d 100644 --- a/packages/shared/widget-plugin-grid/src/select-all/SelectAllController.ts +++ b/packages/shared/widget-plugin-grid/src/select-all/SelectAllController.ts @@ -146,12 +146,10 @@ export class SelectAllController implements ReactiveController { const selectionBeforeReload = this.selection?.selection ?? []; // Reload selection to make sure setSelection is working as expected. await this.reloadSelection(); - this.selection?.setSelection(success ? allItems : selectionBeforeReload); - this.locked = false; - console.info(success, initLimit, initOffset); this.abortController = undefined; + performance.mark("SelectAll_End"); const measure1 = performance.measure("Measure1", "SelectAll_Start", "SelectAll_End"); console.debug(`Data grid 2: 'select all' took ${(measure1.duration / 1000).toFixed(2)} seconds.`); @@ -161,7 +159,7 @@ export class SelectAllController implements ReactiveController { reloadSelection(): Promise { const selection = this.selection; selection?.setSelection([]); - // Resolve when selection value is updated + // `when` resolves when selection value is updated return when(() => this.selection !== selection); } From 11ffb0c89cc071b4671ee85cd0cf5a71cb21a28d Mon Sep 17 00:00:00 2001 From: Illia Obukhau <8282906+iobuhov@users.noreply.github.com> Date: Fri, 10 Oct 2025 11:41:40 +0200 Subject: [PATCH 17/35] feat: change xml groups --- .../datagrid-web/src/Datagrid.xml | 197 +++++++++--------- .../datagrid-web/typings/DatagridProps.d.ts | 44 ++-- 2 files changed, 118 insertions(+), 123 deletions(-) diff --git a/packages/pluggableWidgets/datagrid-web/src/Datagrid.xml b/packages/pluggableWidgets/datagrid-web/src/Datagrid.xml index c9a178e261..e0ccef0f09 100644 --- a/packages/pluggableWidgets/datagrid-web/src/Datagrid.xml +++ b/packages/pluggableWidgets/datagrid-web/src/Datagrid.xml @@ -8,10 +8,6 @@ - - Enable advanced options - - Data source @@ -20,78 +16,6 @@ Refresh time (in seconds) - - Selection - - - - - - - - - Selection method - - - Checkbox - Row click - - - - Toggle on click - Defines item selection behavior. - - Yes - No - - - - Show (un)check all toggle - Show a checkbox in the grid header to check or uncheck multiple items. - - - Keep selection - If enabled, selected items will stay selected unless cleared by the user or a Nanoflow. - - - Show selection count - - - Top - Bottom - Off - - - - Select all page size - When selecting items from a large data source, items are selected in batches. This setting controls the size of the batches. - - - Selecting all label - Label shown in the progress dialog when selecting all items - - Selecting all items... - - - - Cancel selection label - Label for the cancel button in the selection progress dialog - - Cancel selection - - - - Loading type - - - Spinner - Skeleton - - - - Show refresh indicator - Show a refresh indicator when the data is being loaded. - @@ -236,7 +160,102 @@ - + + + On click trigger + + + Single click + Double click + + + + On click action + + + + On selection change + + + + Filters placeholder + + + + + + + + Selection + + + + + + + + + Selection method + + + Checkbox + Row click + + + + Toggle on click + Defines item selection behavior. + + Yes + No + + + + Show (un)check all toggle + Show a checkbox in the grid header to check or uncheck multiple items. + + + Keep selection + If enabled, selected items will stay selected unless cleared by the user or a Nanoflow. + + + Enable select all pages + Allow select all through multiple pages (based on current filter). Only works if total count is known. + + + Select all page size + When selecting items from a large data source, items are selected in batches. This setting controls the size of the batches. + + + Selecting all label + Label shown in the progress dialog when selecting all items + + Selecting all items... + + + + Cancel selection label + Label for the cancel button in the selection progress dialog + + Cancel selection + + + + + + Loading type + + + Spinner + Skeleton + + + + Show refresh indicator + Show a refresh indicator when the data is being loaded. + + + Page size @@ -278,6 +297,8 @@ Load More + + Empty list message @@ -296,28 +317,6 @@ - - - On click trigger - - - Single click - Double click - - - - On click action - - - - On selection change - - - - Filters placeholder - - - diff --git a/packages/pluggableWidgets/datagrid-web/typings/DatagridProps.d.ts b/packages/pluggableWidgets/datagrid-web/typings/DatagridProps.d.ts index f6f68d4b7b..51d290bed9 100644 --- a/packages/pluggableWidgets/datagrid-web/typings/DatagridProps.d.ts +++ b/packages/pluggableWidgets/datagrid-web/typings/DatagridProps.d.ts @@ -7,14 +7,6 @@ import { ComponentType, CSSProperties, ReactNode } from "react"; import { ActionValue, DynamicValue, EditableValue, ListValue, ListActionValue, ListAttributeValue, ListAttributeListValue, ListExpressionValue, ListWidgetValue, SelectionSingleValue, SelectionMultiValue } from "mendix"; import { Big } from "big.js"; -export type ItemSelectionMethodEnum = "checkbox" | "rowClick"; - -export type ItemSelectionModeEnum = "toggle" | "clear"; - -export type SelectionCountPositionEnum = "top" | "bottom" | "off"; - -export type LoadingTypeEnum = "spinner" | "skeleton"; - export type ShowContentAsEnum = "attribute" | "dynamicText" | "customContent"; export type HidableEnum = "yes" | "hidden" | "no"; @@ -49,6 +41,14 @@ export interface ColumnsType { wrapText: boolean; } +export type OnClickTriggerEnum = "single" | "double"; + +export type ItemSelectionMethodEnum = "checkbox" | "rowClick"; + +export type ItemSelectionModeEnum = "toggle" | "clear"; + +export type LoadingTypeEnum = "spinner" | "skeleton"; + export type PaginationEnum = "buttons" | "virtualScrolling" | "loadMore"; export type ShowPagingButtonsEnum = "always" | "auto"; @@ -57,8 +57,6 @@ export type PagingPositionEnum = "bottom" | "top" | "both"; export type ShowEmptyPlaceholderEnum = "none" | "custom"; -export type OnClickTriggerEnum = "single" | "double"; - export type ConfigurationStorageTypeEnum = "attribute" | "localStorage"; export interface ColumnsPreviewType { @@ -90,9 +88,14 @@ export interface DatagridContainerProps { class: string; style?: CSSProperties; tabIndex?: number; - advanced: boolean; datasource: ListValue; refreshInterval: number; + columns: ColumnsType[]; + columnsFilterable: boolean; + onClickTrigger: OnClickTriggerEnum; + onClick?: ListActionValue; + onSelectionChange?: ActionValue; + filtersPlaceholder?: ReactNode; itemSelection?: SelectionSingleValue | SelectionMultiValue; itemSelectionMethod: ItemSelectionMethodEnum; itemSelectionMode: ItemSelectionModeEnum; @@ -109,8 +112,6 @@ export interface DatagridContainerProps { >>>>>>> 2e4671e66 (feat(datagrid-web): add multipage selection to dg2) loadingType: LoadingTypeEnum; refreshIndicator: boolean; - columns: ColumnsType[]; - columnsFilterable: boolean; pageSize: number; pagination: PaginationEnum; showPagingButtons: ShowPagingButtonsEnum; @@ -120,10 +121,6 @@ export interface DatagridContainerProps { showEmptyPlaceholder: ShowEmptyPlaceholderEnum; emptyPlaceholder?: ReactNode; rowClass?: ListExpressionValue; - onClickTrigger: OnClickTriggerEnum; - onClick?: ListActionValue; - onSelectionChange?: ActionValue; - filtersPlaceholder?: ReactNode; columnsSortable: boolean; columnsResizable: boolean; columnsDraggable: boolean; @@ -151,9 +148,14 @@ export interface DatagridPreviewProps { readOnly: boolean; renderMode: "design" | "xray" | "structure"; translate: (text: string) => string; - advanced: boolean; datasource: {} | { caption: string } | { type: string } | null; refreshInterval: number | null; + columns: ColumnsPreviewType[]; + columnsFilterable: boolean; + onClickTrigger: OnClickTriggerEnum; + onClick: {} | null; + onSelectionChange: {} | null; + filtersPlaceholder: { widgetCount: number; renderer: ComponentType<{ children: ReactNode; caption?: string }> }; itemSelection: "None" | "Single" | "Multi"; itemSelectionMethod: ItemSelectionMethodEnum; itemSelectionMode: ItemSelectionModeEnum; @@ -170,8 +172,6 @@ export interface DatagridPreviewProps { >>>>>>> 2e4671e66 (feat(datagrid-web): add multipage selection to dg2) loadingType: LoadingTypeEnum; refreshIndicator: boolean; - columns: ColumnsPreviewType[]; - columnsFilterable: boolean; pageSize: number | null; pagination: PaginationEnum; showPagingButtons: ShowPagingButtonsEnum; @@ -181,10 +181,6 @@ export interface DatagridPreviewProps { showEmptyPlaceholder: ShowEmptyPlaceholderEnum; emptyPlaceholder: { widgetCount: number; renderer: ComponentType<{ children: ReactNode; caption?: string }> }; rowClass: string; - onClickTrigger: OnClickTriggerEnum; - onClick: {} | null; - onSelectionChange: {} | null; - filtersPlaceholder: { widgetCount: number; renderer: ComponentType<{ children: ReactNode; caption?: string }> }; columnsSortable: boolean; columnsResizable: boolean; columnsDraggable: boolean; From 0b768d0182995678a487e10899facc0eb9589404 Mon Sep 17 00:00:00 2001 From: Illia Obukhau <8282906+iobuhov@users.noreply.github.com> Date: Mon, 13 Oct 2025 15:01:36 +0200 Subject: [PATCH 18/35] feat: add sab vm --- .../datagrid-web/src/Datagrid.xml | 63 +++++++++----- .../src/components/SelectAllBar.tsx | 24 ++++-- .../src/components/WidgetFooter.tsx | 14 +++ .../datagrid-web/src/helpers/root-context.ts | 2 + .../src/helpers/state/GridBasicData.ts | 17 ++-- .../helpers/state/SelectAllBarViewModel.ts | 85 +++++++++++++++++++ .../datagrid-web/typings/DatagridProps.d.ts | 21 ++++- .../src/stores/SelectionCountStore.ts | 27 +++--- 8 files changed, 199 insertions(+), 54 deletions(-) create mode 100644 packages/pluggableWidgets/datagrid-web/src/helpers/state/SelectAllBarViewModel.ts diff --git a/packages/pluggableWidgets/datagrid-web/src/Datagrid.xml b/packages/pluggableWidgets/datagrid-web/src/Datagrid.xml index e0ccef0f09..7e6e83fc81 100644 --- a/packages/pluggableWidgets/datagrid-web/src/Datagrid.xml +++ b/packages/pluggableWidgets/datagrid-web/src/Datagrid.xml @@ -219,27 +219,13 @@ If enabled, selected items will stay selected unless cleared by the user or a Nanoflow. - Enable select all pages - Allow select all through multiple pages (based on current filter). Only works if total count is known. + Enable select all + Allow select all through multiple pages (based on current filter). - + Select all page size When selecting items from a large data source, items are selected in batches. This setting controls the size of the batches. - - Selecting all label - Label shown in the progress dialog when selecting all items - - Selecting all items... - - - - Cancel selection label - Label for the cancel button in the selection progress dialog - - Cancel selection - - @@ -363,7 +349,7 @@ - + Filter section @@ -384,19 +370,35 @@ - Select row + Select row label If selection is enabled, assistive technology will read this upon reaching a checkbox. Select row - Select all row + Select all label If selection is enabled, assistive technology will read this upon reaching 'Select all' checkbox. Select all rows + + Selecting all label + ARIA label for the progress dialog when selecting all items + + Selecting all items... + + + + Cancel selection label + ARIA label for the cancel button in the selection progress dialog + + Cancel selection + + + + Row count singular Must include '%d' to denote number position ('%d row selected') @@ -405,6 +407,27 @@ Row count plural Must include '%d' to denote number position ('%d rows selected') + + Select all + This caption used when total count is available. + + Select all %d items in the data source. + + + + 2 + + + Select remaining items in the data source. + + + + Clear selection caption + + + Clear selection + + diff --git a/packages/pluggableWidgets/datagrid-web/src/components/SelectAllBar.tsx b/packages/pluggableWidgets/datagrid-web/src/components/SelectAllBar.tsx index bb20d7b534..3681e5190e 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/SelectAllBar.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/SelectAllBar.tsx @@ -1,20 +1,28 @@ +import { If } from "@mendix/widget-plugin-component-kit/If"; import { observer } from "mobx-react-lite"; import { createElement } from "react"; import { useDatagridRootScope } from "../helpers/root-context"; export const SelectAllBar = observer(function SelectAllBar(): React.ReactNode { - const { - selectAllController, - basicData: { selectionStatus } - } = useDatagridRootScope(); + const { selectAllBarViewModel } = useDatagridRootScope(); + const { barVisible, selectionCountText, clearVisible, clearSelectionLabel, selectAllVisible, selectAllLabel } = + selectAllBarViewModel; - if (selectionStatus === "unknown") return null; - - if (selectionStatus === "none") return null; + if (!barVisible) return null; return (
- + {selectionCountText}  + + + + + +
); }); diff --git a/packages/pluggableWidgets/datagrid-web/src/components/WidgetFooter.tsx b/packages/pluggableWidgets/datagrid-web/src/components/WidgetFooter.tsx index 654345fa1d..9e9c0d1bf5 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/WidgetFooter.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/WidgetFooter.tsx @@ -33,3 +33,17 @@ export function WidgetFooter(props: WidgetFooterProps): ReactElement | null {
); } + +const SelectionCounter = observer(function SelectionCounter() { + const { selectionCountStore, selectActionHelper } = useDatagridRootScope(); + + return ( + + {selectionCountStore.selectedCountText} +  |  + + + ); +}); diff --git a/packages/pluggableWidgets/datagrid-web/src/helpers/root-context.ts b/packages/pluggableWidgets/datagrid-web/src/helpers/root-context.ts index 03ee7ac3f9..3b4fb6247a 100644 --- a/packages/pluggableWidgets/datagrid-web/src/helpers/root-context.ts +++ b/packages/pluggableWidgets/datagrid-web/src/helpers/root-context.ts @@ -6,6 +6,7 @@ import { createContext, useContext } from "react"; import { GridBasicData } from "../helpers/state/GridBasicData"; import { EventsController } from "../typings/CellComponent"; import { SelectActionHelper } from "./SelectActionHelper"; +import { SelectAllBarViewModel } from "./state/SelectAllBarViewModel"; export interface DatagridRootScope { basicData: GridBasicData; @@ -18,6 +19,7 @@ export interface DatagridRootScope { focusController: FocusTargetController; selectionCountStore: SelectionCountStore; selectAllProgressStore: ProgressStore; + selectAllBarViewModel: SelectAllBarViewModel; } export const DatagridContext = createContext(null); diff --git a/packages/pluggableWidgets/datagrid-web/src/helpers/state/GridBasicData.ts b/packages/pluggableWidgets/datagrid-web/src/helpers/state/GridBasicData.ts index b67c9726ee..52dbd9d3d5 100644 --- a/packages/pluggableWidgets/datagrid-web/src/helpers/state/GridBasicData.ts +++ b/packages/pluggableWidgets/datagrid-web/src/helpers/state/GridBasicData.ts @@ -13,11 +13,18 @@ type Props = Pick< | "onClick" | "selectingAllLabel" | "cancelSelectionLabel" + | "selectAllTemplate" + | "selectRemainingTemplate" + | "clearSelectionCaption" >; type Gate = DerivedPropsGate; -/** This is basic data class, just a props mapper. Don't add any state or complex logic. */ +/** + * This is basic data class, just a props mapper. + * Don't add any state or complex logic. + * Don't use this class to share instances. Use context. + */ export class GridBasicData { private gate: Gate; private selectionHelper: SelectionHelper | null = null; @@ -58,12 +65,4 @@ export class GridBasicData { get selectionStatus(): SelectionStatus { return this.selectionHelper?.type === "Multi" ? this.selectionHelper.selectionStatus : "none"; } - - get currentSelectionHelper(): SelectionHelper | null { - return this.selectionHelper; - } - - setSelectionHelper(selectionHelper: SelectionHelper | undefined): void { - this.selectionHelper = selectionHelper ?? null; - } } diff --git a/packages/pluggableWidgets/datagrid-web/src/helpers/state/SelectAllBarViewModel.ts b/packages/pluggableWidgets/datagrid-web/src/helpers/state/SelectAllBarViewModel.ts new file mode 100644 index 0000000000..d7ab1705cd --- /dev/null +++ b/packages/pluggableWidgets/datagrid-web/src/helpers/state/SelectAllBarViewModel.ts @@ -0,0 +1,85 @@ +import { SelectAllController } from "@mendix/widget-plugin-grid/selection"; +import { SelectionCountStore } from "@mendix/widget-plugin-grid/stores/SelectionCountStore"; +import { DerivedPropsGate } from "@mendix/widget-plugin-mobx-kit/props-gate"; +import { makeAutoObservable } from "mobx"; +import { DatagridContainerProps } from "../../../typings/DatagridProps"; + +type Props = Pick< + DatagridContainerProps, + | "cancelSelectionLabel" + | "selectAllTemplate" + | "selectRemainingTemplate" + | "clearSelectionCaption" + | "itemSelection" + | "selectedCountTemplatePlural" + | "selectedCountTemplateSingular" + | "datasource" +>; + +type Gate = DerivedPropsGate; + +export class SelectAllBarViewModel { + showClear = false; + + constructor( + private gate: Gate, + private selectAllController: SelectAllController, + private count = new SelectionCountStore(gate) + ) { + makeAutoObservable(this); + } + + private setShowClear(value: boolean): void { + this.showClear = value; + } + + private get total(): number { + return this.gate.props.datasource.totalCount ?? 0; + } + + private get selectAllFormat(): string { + return this.gate.props.selectAllTemplate?.value ?? "select.all.items"; + } + + private get selectRemainingText(): string { + return this.gate.props.selectRemainingTemplate?.value ?? "select.remaining.items"; + } + + get selectAllLabel(): string { + if (this.total > 0) return this.selectAllFormat.replace("%d", `${this.total}`); + return this.selectRemainingText; + } + + get clearSelectionLabel(): string { + return this.gate.props.clearSelectionCaption?.value ?? "clear.selection.caption"; + } + + get selectionCountText(): string { + return this.count.selectedCountText; + } + + get barVisible(): boolean { + return this.count.selectedCountText !== ""; + } + + get clearVisible(): boolean { + if (this.total > 0) return this.total === this.count.selectedCount; + return this.showClear; + } + + get selectAllVisible(): boolean { + if (this.clearVisible) return false; + if (this.total > 0) return this.total > this.count.selectedCount; + return this.gate.props.datasource.hasMoreItems ?? false; + } + + onClear(): void { + this.selectAllController.clearSelection(); + this.setShowClear(false); + } + + async onSelectAll(): Promise { + await this.selectAllController.selectAllPages(); + this.setShowClear(true); + } +} diff --git a/packages/pluggableWidgets/datagrid-web/typings/DatagridProps.d.ts b/packages/pluggableWidgets/datagrid-web/typings/DatagridProps.d.ts index 51d290bed9..4e2fffa239 100644 --- a/packages/pluggableWidgets/datagrid-web/typings/DatagridProps.d.ts +++ b/packages/pluggableWidgets/datagrid-web/typings/DatagridProps.d.ts @@ -107,9 +107,12 @@ export interface DatagridContainerProps { ======= selectAllPagesEnabled: boolean; selectAllPagesPageSize: number; +<<<<<<< HEAD selectingAllLabel?: DynamicValue; cancelSelectionLabel?: DynamicValue; >>>>>>> 2e4671e66 (feat(datagrid-web): add multipage selection to dg2) +======= +>>>>>>> 448ce2ee2 (feat: add sab vm) loadingType: LoadingTypeEnum; refreshIndicator: boolean; pageSize: number; @@ -133,8 +136,13 @@ export interface DatagridContainerProps { cancelExportLabel?: DynamicValue; selectRowLabel?: DynamicValue; selectAllRowsLabel?: DynamicValue; + selectingAllLabel?: DynamicValue; + cancelSelectionLabel?: DynamicValue; selectedCountTemplateSingular?: DynamicValue; selectedCountTemplatePlural?: DynamicValue; + selectAllTemplate: DynamicValue; + selectRemainingTemplate: DynamicValue; + clearSelectionCaption: DynamicValue; } export interface DatagridPreviewProps { @@ -161,15 +169,17 @@ export interface DatagridPreviewProps { itemSelectionMode: ItemSelectionModeEnum; showSelectAllToggle: boolean; keepSelection: boolean; -<<<<<<< HEAD + selectionCountPosition: SelectionCountPositionEnum; clearSelectionButtonLabel: string; -======= + selectAllPagesEnabled: boolean; selectAllPagesPageSize: number | null; + selectingAllLabel: string; cancelSelectionLabel: string; ->>>>>>> 2e4671e66 (feat(datagrid-web): add multipage selection to dg2) + + loadingType: LoadingTypeEnum; refreshIndicator: boolean; pageSize: number | null; @@ -194,6 +204,11 @@ export interface DatagridPreviewProps { cancelExportLabel: string; selectRowLabel: string; selectAllRowsLabel: string; + selectingAllLabel: string; + cancelSelectionLabel: string; selectedCountTemplateSingular: string; selectedCountTemplatePlural: string; + selectAllTemplate: string; + selectRemainingTemplate: string; + clearSelectionCaption: string; } diff --git a/packages/shared/widget-plugin-grid/src/stores/SelectionCountStore.ts b/packages/shared/widget-plugin-grid/src/stores/SelectionCountStore.ts index 73b2a6a93a..b406260bad 100644 --- a/packages/shared/widget-plugin-grid/src/stores/SelectionCountStore.ts +++ b/packages/shared/widget-plugin-grid/src/stores/SelectionCountStore.ts @@ -6,7 +6,7 @@ type Gate = DerivedPropsGate<{ itemSelection?: SelectionSingleValue | SelectionMultiValue; selectedCountTemplateSingular?: DynamicValue; selectedCountTemplatePlural?: DynamicValue; - clearSelectionButtonLabel?: DynamicValue; + clearSelectionCaption?: DynamicValue; }>; export class SelectionCountStore { @@ -22,23 +22,18 @@ export class SelectionCountStore { this.gate = gate; makeObservable(this, { - displayCount: computed, + selectedCountText: computed, selectedCount: computed, - fmtSingular: computed, - fmtPlural: computed, - clearButtonLabel: computed + formatSingular: computed, + formatPlural: computed }); } - get clearButtonLabel(): string { - return this.gate.props.clearSelectionButtonLabel?.value || this.defaultClearLabel; - } - - get fmtSingular(): string { + get formatSingular(): string { return this.gate.props.selectedCountTemplateSingular?.value || this.singular; } - get fmtPlural(): string { + get formatPlural(): string { return this.gate.props.selectedCountTemplatePlural?.value || this.plural; } @@ -57,10 +52,14 @@ export class SelectionCountStore { return itemSelection.selection?.length ?? 0; } - get displayCount(): string { + get selectedCountText(): string { const count = this.selectedCount; if (count === 0) return ""; - if (count === 1) return this.fmtSingular.replace("%d", "1"); - return this.fmtPlural.replace("%d", `${count}`); + if (count === 1) return this.formatSingular.replace("%d", "1"); + return this.formatPlural.replace("%d", `${count}`); + } + + get clearSelectionLabel(): string { + return this.gate.props.clearSelectionCaption?.value ?? "clear.selection.caption"; } } From d529b027b260b232cfe97edd05896d45939e4549 Mon Sep 17 00:00:00 2001 From: Illia Obukhau <8282906+iobuhov@users.noreply.github.com> Date: Mon, 13 Oct 2025 15:37:22 +0200 Subject: [PATCH 19/35] feat: add spd vm --- .../src/components/SelectAllBar.tsx | 20 ++++----- .../components/SelectionProgressDialog.tsx | 34 +++++--------- .../datagrid-web/src/components/Widget.tsx | 11 +---- .../datagrid-web/src/helpers/root-context.ts | 5 ++- .../src/helpers/state/GridBasicData.ts | 20 +-------- .../state/SelectionProgressDialogViewModel.ts | 44 +++++++++++++++++++ .../datagrid-web/typings/DatagridProps.d.ts | 20 +++++++-- 7 files changed, 86 insertions(+), 68 deletions(-) create mode 100644 packages/pluggableWidgets/datagrid-web/src/helpers/state/SelectionProgressDialogViewModel.ts diff --git a/packages/pluggableWidgets/datagrid-web/src/components/SelectAllBar.tsx b/packages/pluggableWidgets/datagrid-web/src/components/SelectAllBar.tsx index 3681e5190e..1aeaaa52b6 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/SelectAllBar.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/SelectAllBar.tsx @@ -4,23 +4,21 @@ import { createElement } from "react"; import { useDatagridRootScope } from "../helpers/root-context"; export const SelectAllBar = observer(function SelectAllBar(): React.ReactNode { - const { selectAllBarViewModel } = useDatagridRootScope(); - const { barVisible, selectionCountText, clearVisible, clearSelectionLabel, selectAllVisible, selectAllLabel } = - selectAllBarViewModel; + const { selectAllBarViewModel: vm } = useDatagridRootScope(); - if (!barVisible) return null; + if (!vm.barVisible) return null; return (
- {selectionCountText}  - - - -
diff --git a/packages/pluggableWidgets/datagrid-web/src/components/SelectionProgressDialog.tsx b/packages/pluggableWidgets/datagrid-web/src/components/SelectionProgressDialog.tsx index 6babcc9b7b..5acad87b09 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/SelectionProgressDialog.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/SelectionProgressDialog.tsx @@ -1,34 +1,20 @@ import { createElement, ReactElement } from "react"; -import { PseudoModal } from "./PseudoModal"; +import { useDatagridRootScope } from "../helpers/root-context"; import { ExportAlert } from "./ExportAlert"; +import { PseudoModal } from "./PseudoModal"; -export type SelectionProgressDialogProps = { - open: boolean; - selectingLabel: string; - cancelLabel: string; - onCancel: () => void; - progress: number; - total: number; -}; - -export function SelectionProgressDialog({ - open, - selectingLabel, - cancelLabel, - onCancel, - progress, - total -}: SelectionProgressDialogProps): ReactElement | null { - if (!open) return null; +export function SelectionProgressDialog(): ReactElement | null { + const { selectionProgressDialogViewModel: vm } = useDatagridRootScope(); + if (!vm.open) return null; return ( vm.onCancel()} + progress={vm.progress} + total={vm.total} /> ); diff --git a/packages/pluggableWidgets/datagrid-web/src/components/Widget.tsx b/packages/pluggableWidgets/datagrid-web/src/components/Widget.tsx index 685ba8f3ed..49713464ae 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/Widget.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/Widget.tsx @@ -85,7 +85,7 @@ export interface WidgetProps(props: WidgetProps): ReactElement => { const { className, exporting, numberOfItems, onExportCancel, selectActionHelper } = props; - const { basicData, selectAllProgressStore, selectAllController } = useDatagridRootScope(); + const { basicData, selectAllProgressStore } = useDatagridRootScope(); const selectionEnabled = selectActionHelper.selectionType !== "None"; @@ -99,14 +99,7 @@ export const Widget = observer((props: WidgetProps): Re selectingAllPages={selectAllProgressStore.inProgress} >
- selectAllController.abort()} - progress={selectAllProgressStore.loaded} - total={selectAllProgressStore.total} - /> + {exporting && ( (null); diff --git a/packages/pluggableWidgets/datagrid-web/src/helpers/state/GridBasicData.ts b/packages/pluggableWidgets/datagrid-web/src/helpers/state/GridBasicData.ts index 52dbd9d3d5..0d5d226728 100644 --- a/packages/pluggableWidgets/datagrid-web/src/helpers/state/GridBasicData.ts +++ b/packages/pluggableWidgets/datagrid-web/src/helpers/state/GridBasicData.ts @@ -5,17 +5,7 @@ import { DatagridContainerProps } from "../../../typings/DatagridProps"; type Props = Pick< DatagridContainerProps, - | "exportDialogLabel" - | "cancelExportLabel" - | "selectRowLabel" - | "selectAllRowsLabel" - | "itemSelection" - | "onClick" - | "selectingAllLabel" - | "cancelSelectionLabel" - | "selectAllTemplate" - | "selectRemainingTemplate" - | "clearSelectionCaption" + "exportDialogLabel" | "cancelExportLabel" | "selectRowLabel" | "selectAllRowsLabel" | "itemSelection" | "onClick" >; type Gate = DerivedPropsGate; @@ -50,14 +40,6 @@ export class GridBasicData { return this.gate.props.selectAllRowsLabel?.value; } - get selectingAllLabel(): string | undefined { - return this.gate.props.selectingAllLabel?.value; - } - - get cancelSelectionLabel(): string | undefined { - return this.gate.props.cancelSelectionLabel?.value; - } - get gridInteractive(): boolean { return !!(this.gate.props.itemSelection || this.gate.props.onClick); } diff --git a/packages/pluggableWidgets/datagrid-web/src/helpers/state/SelectionProgressDialogViewModel.ts b/packages/pluggableWidgets/datagrid-web/src/helpers/state/SelectionProgressDialogViewModel.ts new file mode 100644 index 0000000000..c140f0ef6d --- /dev/null +++ b/packages/pluggableWidgets/datagrid-web/src/helpers/state/SelectionProgressDialogViewModel.ts @@ -0,0 +1,44 @@ +import { SelectAllController } from "@mendix/widget-plugin-grid/selection"; +import { ProgressStore } from "@mendix/widget-plugin-grid/stores/ProgressStore"; +import { DerivedPropsGate } from "@mendix/widget-plugin-mobx-kit/props-gate"; +import { DynamicValue } from "mendix"; +import { makeAutoObservable } from "mobx"; + +type Gate = DerivedPropsGate<{ + selectingAllLabel?: DynamicValue; + cancelSelectionLabel?: DynamicValue; +}>; + +export class SelectionProgressDialogViewModel { + constructor( + private gate: Gate, + private progressStore: ProgressStore, + private selectAllController: SelectAllController + ) { + makeAutoObservable(this); + } + + get open(): boolean { + return this.progressStore.inProgress; + } + + get progress(): number { + return this.progressStore.loaded; + } + + get total(): number { + return this.progressStore.total; + } + + get selectingAllLabel(): string { + return this.gate.props.selectingAllLabel?.value ?? "Selecting all items..."; + } + + get cancelSelectionLabel(): string { + return this.gate.props.cancelSelectionLabel?.value ?? "Cancel selection"; + } + + onCancel(): void { + this.selectAllController.abort(); + } +} diff --git a/packages/pluggableWidgets/datagrid-web/typings/DatagridProps.d.ts b/packages/pluggableWidgets/datagrid-web/typings/DatagridProps.d.ts index 4e2fffa239..1d4c5d9061 100644 --- a/packages/pluggableWidgets/datagrid-web/typings/DatagridProps.d.ts +++ b/packages/pluggableWidgets/datagrid-web/typings/DatagridProps.d.ts @@ -3,9 +3,21 @@ * WARNING: All changes made to this file will be overwritten * @author Mendix Widgets Framework Team */ -import { ComponentType, CSSProperties, ReactNode } from "react"; -import { ActionValue, DynamicValue, EditableValue, ListValue, ListActionValue, ListAttributeValue, ListAttributeListValue, ListExpressionValue, ListWidgetValue, SelectionSingleValue, SelectionMultiValue } from "mendix"; import { Big } from "big.js"; +import { + ActionValue, + DynamicValue, + EditableValue, + ListActionValue, + ListAttributeListValue, + ListAttributeValue, + ListExpressionValue, + ListValue, + ListWidgetValue, + SelectionMultiValue, + SelectionSingleValue +} from "mendix"; +import { ComponentType, CSSProperties, ReactNode } from "react"; export type ShowContentAsEnum = "attribute" | "dynamicText" | "customContent"; @@ -19,7 +31,9 @@ export type AlignmentEnum = "left" | "center" | "right"; export interface ColumnsType { showContentAs: ShowContentAsEnum; - attribute?: ListAttributeValue | ListAttributeListValue; + attribute?: + | ListAttributeValue + | ListAttributeListValue; content?: ListWidgetValue; dynamicText?: ListExpressionValue; exportValue?: ListExpressionValue; From e570e448ada90dfdb791718cfe0ac442c0e1857d Mon Sep 17 00:00:00 2001 From: Illia Obukhau <8282906+iobuhov@users.noreply.github.com> Date: Mon, 13 Oct 2025 18:42:10 +0200 Subject: [PATCH 20/35] feat: select all v1 --- .../datawidgets/web/_datagrid.scss | 47 +++++-------------- .../datagrid-web/src/Datagrid.editorConfig.ts | 36 +------------- .../src/Datagrid.editorPreview.tsx | 22 +++++++-- .../datagrid-web/src/Datagrid.tsx | 6 +-- .../datagrid-web/src/Datagrid.xml | 4 +- .../src/components/CheckboxColumnHeader.tsx | 10 ++-- .../src/components/SelectAllBar.tsx | 4 +- .../src/components/WidgetFooter.tsx | 2 +- .../src/helpers/SelectActionHelper.ts | 32 ++----------- .../datagrid-web/src/helpers/root-context.ts | 1 - .../src/helpers/state/GridBasicData.ts | 6 --- .../src/helpers/state/RootGridStore.ts | 26 +++++++++- .../helpers/state/SelectAllBarViewModel.ts | 29 +++++++++--- .../datagrid-web/src/utils/test-utils.tsx | 14 +----- .../datagrid-web/typings/DatagridProps.d.ts | 20 ++------ .../src/select-all/SelectAllController.ts | 32 +++++++++---- 16 files changed, 126 insertions(+), 165 deletions(-) diff --git a/packages/modules/data-widgets/src/themesource/datawidgets/web/_datagrid.scss b/packages/modules/data-widgets/src/themesource/datawidgets/web/_datagrid.scss index 61ba1fcc95..c82ef57bf7 100644 --- a/packages/modules/data-widgets/src/themesource/datawidgets/web/_datagrid.scss +++ b/packages/modules/data-widgets/src/themesource/datawidgets/web/_datagrid.scss @@ -428,7 +428,8 @@ $root: ".widget-datagrid"; align-items: center; } - &-exporting { + &-exporting, + &-selecting-all-pages { .widget-datagrid-top-bar, .widget-datagrid-header, .widget-datagrid-content, @@ -441,30 +442,6 @@ $root: ".widget-datagrid"; } } - // Better positioning for multi-page selection modal - // &-selecting-all-pages { - // .widget-datagrid-modal { - // &-overlay { - // position: fixed; - // top: 0; - // right: 0; - // bottom: 0; - // left: 0; - // } - - // &-main { - // position: fixed; - // top: 0; - // left: 0; - // right: 0; - // bottom: 0; - // display: flex; - // align-items: center; - // justify-content: center; - // } - // } - // } - &-col-select input:focus-visible { outline-offset: 0; } @@ -598,26 +575,28 @@ $root: ".widget-datagrid"; align-items: center; } -#{$root}-clear-selection { +#{$root}-btn-invisible { cursor: pointer; background: transparent; border: none; - text-decoration: underline; color: var(--link-color); - padding: 0; + padding: 0.3em; + border-radius: 6px; display: inline-block; - &:focus:not(:focus-visible) { - outline: none; - } - + &:hover, &:focus-visible { - outline: 1px solid var(--brand-primary, $brand-primary); - outline-offset: 2px; + background-color: #e6e7f2; } } + :where(#{$root}-select-all-bar) { grid-column: 1 / -1; + background-color: #f0f1f2; + display: flex; + flex-flow: row nowrap; + align-items: center; + padding: var(--spacing-smaller, 8px) var(--spacing-medium, 16px); } @keyframes skeleton-loading { diff --git a/packages/pluggableWidgets/datagrid-web/src/Datagrid.editorConfig.ts b/packages/pluggableWidgets/datagrid-web/src/Datagrid.editorConfig.ts index 0261cdd02c..9919d9fbbb 100644 --- a/packages/pluggableWidgets/datagrid-web/src/Datagrid.editorConfig.ts +++ b/packages/pluggableWidgets/datagrid-web/src/Datagrid.editorConfig.ts @@ -3,8 +3,7 @@ import { hideNestedPropertiesIn, hidePropertiesIn, hidePropertyIn, - Properties, - transformGroupsIntoTabs + Properties } from "@mendix/pluggable-widgets-tools"; import { container, @@ -22,7 +21,7 @@ import { ColumnsPreviewType, DatagridPreviewProps } from "../typings/DatagridPro export function getProperties( values: DatagridPreviewProps, defaultProperties: Properties, - platform: "web" | "desktop" + _: "web" | "desktop" ): Properties { values.columns.forEach((column, index) => { if (column.showContentAs !== "attribute" && !column.sortable && !values.columnsFilterable) { @@ -65,15 +64,6 @@ export function getProperties( if (column.minWidth !== "manual") { hidePropertyIn(defaultProperties, values, "columns", index, "minWidthLimit"); } - if (!values.advanced && platform === "web") { - hideNestedPropertiesIn(defaultProperties, values, "columns", index, [ - "columnClass", - "sortable", - "resizable", - "draggable", - "hidable" - ]); - } }); if (values.pagination === "buttons") { @@ -125,28 +115,6 @@ export function getProperties( "columns" ); - if (platform === "web") { - if (!values.advanced) { - hidePropertiesIn(defaultProperties, values, [ - "pagination", - "pagingPosition", - "showEmptyPlaceholder", - "rowClass", - "columnsSortable", - "columnsDraggable", - "columnsResizable", - "columnsHidable", - "configurationAttribute", - "onConfigurationChange", - "filterSectionTitle" - ]); - } - - transformGroupsIntoTabs(defaultProperties); - } else { - hidePropertyIn(defaultProperties, values, "advanced"); - } - if (values.configurationStorageType === "localStorage") { hidePropertiesIn(defaultProperties, values, ["configurationAttribute", "onConfigurationChange"]); } diff --git a/packages/pluggableWidgets/datagrid-web/src/Datagrid.editorPreview.tsx b/packages/pluggableWidgets/datagrid-web/src/Datagrid.editorPreview.tsx index f94e8cb75f..bafcef9e28 100644 --- a/packages/pluggableWidgets/datagrid-web/src/Datagrid.editorPreview.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/Datagrid.editorPreview.tsx @@ -20,9 +20,11 @@ import { ColumnsPreviewType, DatagridPreviewProps } from "typings/DatagridProps" import { Cell } from "./components/Cell"; import { Widget } from "./components/Widget"; import { ColumnPreview } from "./helpers/ColumnPreview"; -import { DatagridContext } from "./helpers/root-context"; +import { DatagridContext, DatagridRootScope } from "./helpers/root-context"; import { useSelectActionHelper } from "./helpers/SelectActionHelper"; import { GridBasicData } from "./helpers/state/GridBasicData"; +import { SelectAllBarViewModel } from "./helpers/state/SelectAllBarViewModel"; +import { SelectionProgressDialogViewModel } from "./helpers/state/SelectionProgressDialogViewModel"; import "./ui/DatagridPreview.scss"; // Fix type definition for Selectable @@ -98,6 +100,7 @@ export function preview(props: DatagridPreviewProps): ReactElement { const query = new DatasourceController(host, { gate: gateProvider.gate }); const selectionCountStore = new SelectionCountStore(gateProvider.gate as any); const selectAllController = new SelectAllController(host, { gate: gateProvider.gate, pageSize: 2, query }); + const selectAllProgressStore = new ProgressStore(); return { basicData, selectionHelper: undefined, @@ -106,10 +109,19 @@ export function preview(props: DatagridPreviewProps): ReactElement { checkboxEventsController: eventsController, focusController, selectionCountStore, - selectAllProgressStore: new ProgressStore(), - selectAllController, - rootStore: {} as any // Mock for preview - }; + selectAllProgressStore, + selectAllBarViewModel: new SelectAllBarViewModel( + host, + gateProvider.gate as any, + selectAllController, + selectionCountStore + ), + selectionProgressDialogViewModel: new SelectionProgressDialogViewModel( + gateProvider.gate as any, + selectAllProgressStore, + selectAllController + ) + } satisfies DatagridRootScope; }); return ( diff --git a/packages/pluggableWidgets/datagrid-web/src/Datagrid.tsx b/packages/pluggableWidgets/datagrid-web/src/Datagrid.tsx index 45e4271263..5a27137363 100644 --- a/packages/pluggableWidgets/datagrid-web/src/Datagrid.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/Datagrid.tsx @@ -61,7 +61,6 @@ const Container = observer((props: Props): ReactElement => { const checkboxEventsController = useCheckboxEventsController(selectActionHelper, focusController); const ctx = useConst(() => { - rootStore.basicData.setSelectionHelper(selectionHelper); const scope: DatagridRootScope = { basicData: rootStore.basicData, selectionHelper, @@ -70,8 +69,9 @@ const Container = observer((props: Props): ReactElement => { checkboxEventsController, focusController, selectionCountStore: rootStore.selectionCountStore, - selectAllController: rootStore.selectAllController, - selectAllProgressStore: rootStore.selectAllProgressStore + selectAllProgressStore: rootStore.selectAllProgressStore, + selectAllBarViewModel: rootStore.selectAllBarViewModel, + selectionProgressDialogViewModel: rootStore.selectionProgressDialogViewModel }; return scope; diff --git a/packages/pluggableWidgets/datagrid-web/src/Datagrid.xml b/packages/pluggableWidgets/datagrid-web/src/Datagrid.xml index 7e6e83fc81..a2921010de 100644 --- a/packages/pluggableWidgets/datagrid-web/src/Datagrid.xml +++ b/packages/pluggableWidgets/datagrid-web/src/Datagrid.xml @@ -411,14 +411,14 @@ Select all This caption used when total count is available. - Select all %d items in the data source. + Select all %d rows in the data source 2 - Select remaining items in the data source. + Select remaining rows in the data source diff --git a/packages/pluggableWidgets/datagrid-web/src/components/CheckboxColumnHeader.tsx b/packages/pluggableWidgets/datagrid-web/src/components/CheckboxColumnHeader.tsx index e4039e28cf..a6efc23506 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/CheckboxColumnHeader.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/CheckboxColumnHeader.tsx @@ -3,9 +3,9 @@ import { Fragment, ReactElement } from "react"; import { useDatagridRootScope } from "../helpers/root-context"; export function CheckboxColumnHeader(): ReactElement { - const { selectActionHelper, basicData } = useDatagridRootScope(); + const { selectActionHelper, basicData, selectionHelper } = useDatagridRootScope(); const { showCheckboxColumn, showSelectAllToggle, onSelectAll } = selectActionHelper; - const { selectionStatus, selectAllRowsLabel } = basicData; + const { selectAllRowsLabel } = basicData; if (showCheckboxColumn === false) { return ; @@ -14,7 +14,11 @@ export function CheckboxColumnHeader(): ReactElement { return (
{showSelectAllToggle && ( - + )}
); diff --git a/packages/pluggableWidgets/datagrid-web/src/components/SelectAllBar.tsx b/packages/pluggableWidgets/datagrid-web/src/components/SelectAllBar.tsx index 1aeaaa52b6..c0adc2a8e6 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/SelectAllBar.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/SelectAllBar.tsx @@ -12,12 +12,12 @@ export const SelectAllBar = observer(function SelectAllBar(): React.ReactNode {
{vm.selectionCountText}  - - diff --git a/packages/pluggableWidgets/datagrid-web/src/components/WidgetFooter.tsx b/packages/pluggableWidgets/datagrid-web/src/components/WidgetFooter.tsx index 9e9c0d1bf5..ff615ac56d 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/WidgetFooter.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/WidgetFooter.tsx @@ -41,7 +41,7 @@ const SelectionCounter = observer(function SelectionCounter() { {selectionCountStore.selectedCountText}  |  - diff --git a/packages/pluggableWidgets/datagrid-web/src/helpers/SelectActionHelper.ts b/packages/pluggableWidgets/datagrid-web/src/helpers/SelectActionHelper.ts index aa8a854c03..b0fbc3f6b9 100644 --- a/packages/pluggableWidgets/datagrid-web/src/helpers/SelectActionHelper.ts +++ b/packages/pluggableWidgets/datagrid-web/src/helpers/SelectActionHelper.ts @@ -4,7 +4,6 @@ import { SelectionMode, WidgetSelectionProperty } from "@mendix/widget-plugin-grid/selection"; -import { ListValue } from "mendix"; import { useMemo } from "react"; import { DatagridContainerProps, DatagridPreviewProps, ItemSelectionMethodEnum } from "../../typings/DatagridProps"; export type SelectionMethod = "rowClick" | "checkbox" | "none"; @@ -13,9 +12,6 @@ export class SelectActionHelper extends SelectActionHandler { pageSize: number; private _selectionMethod: ItemSelectionMethodEnum; private _showSelectAllToggle: boolean; - private _datasource: ListValue; - private _selectAllPagesEnabled: boolean; - private _selectAllPagesBufferSize: number; constructor( selection: WidgetSelectionProperty, @@ -23,18 +19,12 @@ export class SelectActionHelper extends SelectActionHandler { _selectionMethod: ItemSelectionMethodEnum, _showSelectAllToggle: boolean, pageSize: number, - private _selectionMode: SelectionMode, - datasource: ListValue, - selectAllPagesEnabled?: boolean, - selectAllPagesBufferSize?: number + private _selectionMode: SelectionMode ) { super(selection, selectionHelper); this._selectionMethod = _selectionMethod; this._showSelectAllToggle = _showSelectAllToggle; this.pageSize = pageSize; - this._datasource = datasource; - this._selectAllPagesEnabled = selectAllPagesEnabled ?? false; - this._selectAllPagesBufferSize = selectAllPagesBufferSize ?? 500; } get selectionMethod(): SelectionMethod { @@ -52,14 +42,6 @@ export class SelectActionHelper extends SelectActionHandler { get selectionMode(): SelectionMode { return this.selectionMethod === "checkbox" ? "toggle" : this._selectionMode; } - - get canSelectAllPages(): boolean { - return this._selectAllPagesEnabled && this.selectionType === "Multi"; - } - - get totalCount(): number | undefined { - return this._datasource?.totalCount; - } } export function useSelectActionHelper( @@ -71,8 +53,6 @@ export function useSelectActionHelper( | "pageSize" | "itemSelectionMode" | "datasource" - | "selectAllPagesEnabled" - | "selectAllPagesPageSize" >, selectionHelper?: SelectionHelper ): SelectActionHelper { @@ -83,10 +63,7 @@ export function useSelectActionHelper( props.itemSelectionMethod, props.showSelectAllToggle, props.pageSize ?? 5, - props.itemSelectionMode, - props.datasource as ListValue, - props.selectAllPagesEnabled, - props.selectAllPagesPageSize ?? 500 + props.itemSelectionMode ); }, [ props.itemSelection, @@ -94,9 +71,6 @@ export function useSelectActionHelper( props.itemSelectionMethod, props.showSelectAllToggle, props.pageSize, - props.itemSelectionMode, - props.datasource, - props.selectAllPagesEnabled, - props.selectAllPagesPageSize + props.itemSelectionMode ]); } diff --git a/packages/pluggableWidgets/datagrid-web/src/helpers/root-context.ts b/packages/pluggableWidgets/datagrid-web/src/helpers/root-context.ts index cae44ce111..edda0a554c 100644 --- a/packages/pluggableWidgets/datagrid-web/src/helpers/root-context.ts +++ b/packages/pluggableWidgets/datagrid-web/src/helpers/root-context.ts @@ -11,7 +11,6 @@ import { SelectionProgressDialogViewModel } from "./state/SelectionProgressDialo export interface DatagridRootScope { basicData: GridBasicData; - // Controllers selectionHelper: SelectionHelper | undefined; selectActionHelper: SelectActionHelper; cellEventsController: EventsController; diff --git a/packages/pluggableWidgets/datagrid-web/src/helpers/state/GridBasicData.ts b/packages/pluggableWidgets/datagrid-web/src/helpers/state/GridBasicData.ts index 0d5d226728..ed52846020 100644 --- a/packages/pluggableWidgets/datagrid-web/src/helpers/state/GridBasicData.ts +++ b/packages/pluggableWidgets/datagrid-web/src/helpers/state/GridBasicData.ts @@ -1,4 +1,3 @@ -import { SelectionHelper, SelectionStatus } from "@mendix/widget-plugin-grid/selection"; import { DerivedPropsGate } from "@mendix/widget-plugin-mobx-kit/props-gate"; import { makeAutoObservable } from "mobx"; import { DatagridContainerProps } from "../../../typings/DatagridProps"; @@ -17,7 +16,6 @@ type Gate = DerivedPropsGate; */ export class GridBasicData { private gate: Gate; - private selectionHelper: SelectionHelper | null = null; constructor(gate: Gate) { this.gate = gate; @@ -43,8 +41,4 @@ export class GridBasicData { get gridInteractive(): boolean { return !!(this.gate.props.itemSelection || this.gate.props.onClick); } - - get selectionStatus(): SelectionStatus { - return this.selectionHelper?.type === "Multi" ? this.selectionHelper.selectionStatus : "none"; - } } diff --git a/packages/pluggableWidgets/datagrid-web/src/helpers/state/RootGridStore.ts b/packages/pluggableWidgets/datagrid-web/src/helpers/state/RootGridStore.ts index 6053439781..4855c9a48a 100644 --- a/packages/pluggableWidgets/datagrid-web/src/helpers/state/RootGridStore.ts +++ b/packages/pluggableWidgets/datagrid-web/src/helpers/state/RootGridStore.ts @@ -20,6 +20,8 @@ import { PaginationController } from "../../controllers/PaginationController"; import { StaticInfo } from "../../typings/static-info"; import { ColumnGroupStore } from "./ColumnGroupStore"; import { GridPersonalizationStore } from "./GridPersonalizationStore"; +import { SelectAllBarViewModel } from "./SelectAllBarViewModel"; +import { SelectionProgressDialogViewModel } from "./SelectionProgressDialogViewModel"; type RequiredProps = Pick< DatagridContainerProps, @@ -36,7 +38,14 @@ type RequiredProps = Pick< | "pagination" | "showPagingButtons" | "showNumberOfRows" - | "clearSelectionButtonLabel" + | "selectAllPagesEnabled" + | "selectAllPagesPageSize" + | "onSelectionChange" + | "selectAllTemplate" + | "selectRemainingTemplate" + | "clearSelectionCaption" + | "selectingAllLabel" + | "cancelSelectionLabel" >; type Gate = DerivedPropsGate; @@ -62,6 +71,8 @@ export class RootGridStore extends BaseControllerHost { filterAPI: FilterAPI; query: QueryController; gate: Gate; + selectAllBarViewModel: SelectAllBarViewModel; + selectionProgressDialogViewModel: SelectionProgressDialogViewModel; constructor({ gate, exportProgressStore, selectAllProgressStore, selectAllController }: Spec) { super(); @@ -123,6 +134,19 @@ export class RootGridStore extends BaseControllerHost { query }); + this.selectAllBarViewModel = new SelectAllBarViewModel( + this, + gate, + this.selectAllController, + this.selectionCountStore + ); + + this.selectionProgressDialogViewModel = new SelectionProgressDialogViewModel( + gate, + selectAllProgressStore, + selectAllController + ); + combinedFilter.hydrate(props.datasource.filter); } diff --git a/packages/pluggableWidgets/datagrid-web/src/helpers/state/SelectAllBarViewModel.ts b/packages/pluggableWidgets/datagrid-web/src/helpers/state/SelectAllBarViewModel.ts index d7ab1705cd..6425dfdcc8 100644 --- a/packages/pluggableWidgets/datagrid-web/src/helpers/state/SelectAllBarViewModel.ts +++ b/packages/pluggableWidgets/datagrid-web/src/helpers/state/SelectAllBarViewModel.ts @@ -1,7 +1,8 @@ import { SelectAllController } from "@mendix/widget-plugin-grid/selection"; import { SelectionCountStore } from "@mendix/widget-plugin-grid/stores/SelectionCountStore"; import { DerivedPropsGate } from "@mendix/widget-plugin-mobx-kit/props-gate"; -import { makeAutoObservable } from "mobx"; +import { ReactiveController, ReactiveControllerHost } from "@mendix/widget-plugin-mobx-kit/reactive-controller"; +import { autorun, makeAutoObservable } from "mobx"; import { DatagridContainerProps } from "../../../typings/DatagridProps"; type Props = Pick< @@ -18,14 +19,16 @@ type Props = Pick< type Gate = DerivedPropsGate; -export class SelectAllBarViewModel { +export class SelectAllBarViewModel implements ReactiveController { showClear = false; constructor( + host: ReactiveControllerHost, private gate: Gate, private selectAllController: SelectAllController, private count = new SelectionCountStore(gate) ) { + host.addController(this); makeAutoObservable(this); } @@ -45,6 +48,10 @@ export class SelectAllBarViewModel { return this.gate.props.selectRemainingTemplate?.value ?? "select.remaining.items"; } + private get isSelectionEmpty(): boolean { + return this.count.selectedCount === 0; + } + get selectAllLabel(): string { if (this.total > 0) return this.selectAllFormat.replace("%d", `${this.total}`); return this.selectRemainingText; @@ -63,23 +70,33 @@ export class SelectAllBarViewModel { } get clearVisible(): boolean { + if (this.showClear) return true; if (this.total > 0) return this.total === this.count.selectedCount; - return this.showClear; + return false; } get selectAllVisible(): boolean { - if (this.clearVisible) return false; + // Note: order of checks matter. + if (this.showClear) return false; if (this.total > 0) return this.total > this.count.selectedCount; return this.gate.props.datasource.hasMoreItems ?? false; } + setup(): () => void { + return autorun(() => { + if (this.isSelectionEmpty) { + this.setShowClear(false); + } + }); + } + onClear(): void { this.selectAllController.clearSelection(); this.setShowClear(false); } async onSelectAll(): Promise { - await this.selectAllController.selectAllPages(); - this.setShowClear(true); + const { success } = await this.selectAllController.selectAllPages(); + this.setShowClear(success); } } diff --git a/packages/pluggableWidgets/datagrid-web/src/utils/test-utils.tsx b/packages/pluggableWidgets/datagrid-web/src/utils/test-utils.tsx index 2df9bff98a..bd16125514 100644 --- a/packages/pluggableWidgets/datagrid-web/src/utils/test-utils.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/utils/test-utils.tsx @@ -1,7 +1,7 @@ import { FocusTargetController } from "@mendix/widget-plugin-grid/keyboard-navigation/FocusTargetController"; import { PositionController } from "@mendix/widget-plugin-grid/keyboard-navigation/PositionController"; import { VirtualGridLayout } from "@mendix/widget-plugin-grid/keyboard-navigation/VirtualGridLayout"; -import { dynamicValue, listAttr, listExp, ListValueBuilder } from "@mendix/widget-plugin-test-utils"; +import { dynamicValue, listAttr, listExp } from "@mendix/widget-plugin-test-utils"; import { GUID, ObjectItem } from "mendix"; import { ColumnsType } from "../../typings/DatagridProps"; import { Cell } from "../components/Cell"; @@ -39,17 +39,7 @@ export const column = (header = "Test", patch?: (col: ColumnsType) => void): Col }; export function mockSelectionProps(patch?: (props: SelectActionHelper) => SelectActionHelper): SelectActionHelper { - const props = new SelectActionHelper( - "None", - undefined, - "checkbox", - false, - 5, - "clear", - new ListValueBuilder().build(), - false, - 500 - ); + const props = new SelectActionHelper("None", undefined, "checkbox", false, 5, "clear"); if (patch) { patch(props); diff --git a/packages/pluggableWidgets/datagrid-web/typings/DatagridProps.d.ts b/packages/pluggableWidgets/datagrid-web/typings/DatagridProps.d.ts index 1d4c5d9061..4e2fffa239 100644 --- a/packages/pluggableWidgets/datagrid-web/typings/DatagridProps.d.ts +++ b/packages/pluggableWidgets/datagrid-web/typings/DatagridProps.d.ts @@ -3,21 +3,9 @@ * WARNING: All changes made to this file will be overwritten * @author Mendix Widgets Framework Team */ -import { Big } from "big.js"; -import { - ActionValue, - DynamicValue, - EditableValue, - ListActionValue, - ListAttributeListValue, - ListAttributeValue, - ListExpressionValue, - ListValue, - ListWidgetValue, - SelectionMultiValue, - SelectionSingleValue -} from "mendix"; import { ComponentType, CSSProperties, ReactNode } from "react"; +import { ActionValue, DynamicValue, EditableValue, ListValue, ListActionValue, ListAttributeValue, ListAttributeListValue, ListExpressionValue, ListWidgetValue, SelectionSingleValue, SelectionMultiValue } from "mendix"; +import { Big } from "big.js"; export type ShowContentAsEnum = "attribute" | "dynamicText" | "customContent"; @@ -31,9 +19,7 @@ export type AlignmentEnum = "left" | "center" | "right"; export interface ColumnsType { showContentAs: ShowContentAsEnum; - attribute?: - | ListAttributeValue - | ListAttributeListValue; + attribute?: ListAttributeValue | ListAttributeListValue; content?: ListWidgetValue; dynamicText?: ListExpressionValue; exportValue?: ListExpressionValue; diff --git a/packages/shared/widget-plugin-grid/src/select-all/SelectAllController.ts b/packages/shared/widget-plugin-grid/src/select-all/SelectAllController.ts index 372a171e5d..a63e796ce4 100644 --- a/packages/shared/widget-plugin-grid/src/select-all/SelectAllController.ts +++ b/packages/shared/widget-plugin-grid/src/select-all/SelectAllController.ts @@ -32,6 +32,8 @@ export class SelectAllController implements ReactiveController { setIsLocked: action, canExecute: computed, isExecuting: computed, + // Here we use keepAlive to make sure selection is never outdated. + // selection: computed({ keepAlive: true }), selection: computed, locked: observable, selectAllPages: action, @@ -90,14 +92,15 @@ export class SelectAllController implements ReactiveController { return true; } - async selectAllPages(): Promise { + async selectAllPages(): Promise<{ success: boolean }> { if (!this.beforeRunChecks()) { - return; + return { success: false }; } this.setIsLocked(true); const { offset: initOffset, limit: initLimit } = this.query; + const initSelection = this.selection?.selection ?? []; const hasTotal = typeof this.query.totalCount === "number"; const totalCount = this.query.totalCount ?? 0; let loaded = 0; @@ -140,27 +143,38 @@ export class SelectAllController implements ReactiveController { limit: initLimit, offset: initOffset }); - + await this.reloadSelection(); this.emitter.dispatchEvent(pe("loadend")); - const selectionBeforeReload = this.selection?.selection ?? []; + // const selectionBeforeReload = this.selection?.selection ?? []; // Reload selection to make sure setSelection is working as expected. - await this.reloadSelection(); - this.selection?.setSelection(success ? allItems : selectionBeforeReload); + this.selection?.setSelection(success ? allItems : initSelection); this.locked = false; this.abortController = undefined; performance.mark("SelectAll_End"); const measure1 = performance.measure("Measure1", "SelectAll_Start", "SelectAll_End"); console.debug(`Data grid 2: 'select all' took ${(measure1.duration / 1000).toFixed(2)} seconds.`); + // eslint-disable-next-line no-unsafe-finally + return { success }; } } + /** + * This method is a hack to reload selection. To work it requires at leas one object. + * The problem is that if we setting value equal to current selection, then prop is + * not reloaded. We solve this by setting ether empty array or array with one object. + * @returns + */ reloadSelection(): Promise { - const selection = this.selection; - selection?.setSelection([]); + const prevSelection = this.selection; + const items = this.query.items ?? []; + const currentSelection = this.selection?.selection ?? []; + const newSelection = currentSelection.length > 0 ? [] : items; + this.selection?.setSelection(newSelection); // `when` resolves when selection value is updated - return when(() => this.selection !== selection); + const ok = when(() => this.selection !== prevSelection); + return ok; } clearSelection(): void { From 67f8a5f091dc0115f959769156f734ad00e2c0d8 Mon Sep 17 00:00:00 2001 From: Illia Obukhau <8282906+iobuhov@users.noreply.github.com> Date: Wed, 15 Oct 2025 12:32:42 +0200 Subject: [PATCH 21/35] refactor: change texts & etc --- .../datawidgets/web/_datagrid.scss | 11 +- .../src/Datagrid.editorPreview.tsx | 1 + .../datagrid-web/src/Datagrid.xml | 21 +-- .../src/components/SelectAllBar.tsx | 16 +- .../components/SelectionProgressDialog.tsx | 2 +- .../datagrid-web/src/components/Widget.tsx | 4 +- .../src/components/WidgetFooter.tsx | 2 +- .../src/components/loader/SpinnerLoader.tsx | 12 +- .../src/helpers/state/RootGridStore.ts | 1 + .../helpers/state/SelectAllBarViewModel.ts | 138 +++++++++++++----- .../state/SelectionProgressDialogViewModel.ts | 66 +++++++-- .../datagrid-web/typings/DatagridProps.d.ts | 14 +- .../src/select-all/SelectAllController.ts | 59 ++++---- .../src/stores/SelectionCountStore.ts | 5 +- 14 files changed, 243 insertions(+), 109 deletions(-) diff --git a/packages/modules/data-widgets/src/themesource/datawidgets/web/_datagrid.scss b/packages/modules/data-widgets/src/themesource/datawidgets/web/_datagrid.scss index c82ef57bf7..d2c152aaa7 100644 --- a/packages/modules/data-widgets/src/themesource/datawidgets/web/_datagrid.scss +++ b/packages/modules/data-widgets/src/themesource/datawidgets/web/_datagrid.scss @@ -489,7 +489,10 @@ $root: ".widget-datagrid"; &-spinner { justify-content: center; - width: 100%; + + &-full-width { + width: 100%; + } &-margin { margin: 52px 0; @@ -580,7 +583,7 @@ $root: ".widget-datagrid"; background: transparent; border: none; color: var(--link-color); - padding: 0.3em; + padding: 0.3em 0.5em; border-radius: 6px; display: inline-block; @@ -597,6 +600,10 @@ $root: ".widget-datagrid"; flex-flow: row nowrap; align-items: center; padding: var(--spacing-smaller, 8px) var(--spacing-medium, 16px); + + #{$root}-spinner { + padding: 6.2px; + } } @keyframes skeleton-loading { diff --git a/packages/pluggableWidgets/datagrid-web/src/Datagrid.editorPreview.tsx b/packages/pluggableWidgets/datagrid-web/src/Datagrid.editorPreview.tsx index bafcef9e28..db56ae6e4e 100644 --- a/packages/pluggableWidgets/datagrid-web/src/Datagrid.editorPreview.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/Datagrid.editorPreview.tsx @@ -117,6 +117,7 @@ export function preview(props: DatagridPreviewProps): ReactElement { selectionCountStore ), selectionProgressDialogViewModel: new SelectionProgressDialogViewModel( + host, gateProvider.gate as any, selectAllProgressStore, selectAllController diff --git a/packages/pluggableWidgets/datagrid-web/src/Datagrid.xml b/packages/pluggableWidgets/datagrid-web/src/Datagrid.xml index a2921010de..95ed8ec323 100644 --- a/packages/pluggableWidgets/datagrid-web/src/Datagrid.xml +++ b/packages/pluggableWidgets/datagrid-web/src/Datagrid.xml @@ -218,14 +218,10 @@ Keep selection If enabled, selected items will stay selected unless cleared by the user or a Nanoflow. - + Enable select all Allow select all through multiple pages (based on current filter). - - Select all page size - When selecting items from a large data source, items are selected in batches. This setting controls the size of the batches. - @@ -407,18 +403,25 @@ Row count plural Must include '%d' to denote number position ('%d rows selected') + + Select all text + + + Select all rows in the data source + + - Select all + Select all template This caption used when total count is available. Select all %d rows in the data source - - 2 + + Select status template - Select remaining rows in the data source + All %d rows selected. diff --git a/packages/pluggableWidgets/datagrid-web/src/components/SelectAllBar.tsx b/packages/pluggableWidgets/datagrid-web/src/components/SelectAllBar.tsx index c0adc2a8e6..75085abe1a 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/SelectAllBar.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/SelectAllBar.tsx @@ -6,18 +6,22 @@ import { useDatagridRootScope } from "../helpers/root-context"; export const SelectAllBar = observer(function SelectAllBar(): React.ReactNode { const { selectAllBarViewModel: vm } = useDatagridRootScope(); - if (!vm.barVisible) return null; + if (!vm.isBarVisible) return null; return (
- {vm.selectionCountText}  - - - - diff --git a/packages/pluggableWidgets/datagrid-web/src/components/SelectionProgressDialog.tsx b/packages/pluggableWidgets/datagrid-web/src/components/SelectionProgressDialog.tsx index 5acad87b09..0f8d96604f 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/SelectionProgressDialog.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/SelectionProgressDialog.tsx @@ -5,7 +5,7 @@ import { PseudoModal } from "./PseudoModal"; export function SelectionProgressDialog(): ReactElement | null { const { selectionProgressDialogViewModel: vm } = useDatagridRootScope(); - if (!vm.open) return null; + if (!vm.dialogOpen) return null; return ( (props: WidgetProps): ReactElement => { const { className, exporting, numberOfItems, onExportCancel, selectActionHelper } = props; - const { basicData, selectAllProgressStore } = useDatagridRootScope(); + const { basicData, selectionProgressDialogViewModel } = useDatagridRootScope(); const selectionEnabled = selectActionHelper.selectionType !== "None"; @@ -96,7 +96,7 @@ export const Widget = observer((props: WidgetProps): Re selection={selectionEnabled} style={props.styles} exporting={exporting} - selectingAllPages={selectAllProgressStore.inProgress} + selectingAllPages={selectionProgressDialogViewModel.isOpen} >
diff --git a/packages/pluggableWidgets/datagrid-web/src/components/WidgetFooter.tsx b/packages/pluggableWidgets/datagrid-web/src/components/WidgetFooter.tsx index ff615ac56d..ea0c891eaf 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/WidgetFooter.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/WidgetFooter.tsx @@ -40,7 +40,7 @@ const SelectionCounter = observer(function SelectionCounter() { return ( {selectionCountStore.selectedCountText} -  |  +   diff --git a/packages/pluggableWidgets/datagrid-web/src/components/loader/SpinnerLoader.tsx b/packages/pluggableWidgets/datagrid-web/src/components/loader/SpinnerLoader.tsx index 6c54034cac..550ba8662c 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/loader/SpinnerLoader.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/loader/SpinnerLoader.tsx @@ -4,12 +4,20 @@ import { ReactElement } from "react"; type SpinnerLoaderProps = { size?: "small" | "medium" | "large"; withMargins?: boolean; + fullWidth?: boolean; }; -export function SpinnerLoader({ size = "medium", withMargins = false }: SpinnerLoaderProps): ReactElement { +export function SpinnerLoader({ + size = "medium", + withMargins = false, + fullWidth = true +}: SpinnerLoaderProps): ReactElement { return (
diff --git a/packages/pluggableWidgets/datagrid-web/src/helpers/state/RootGridStore.ts b/packages/pluggableWidgets/datagrid-web/src/helpers/state/RootGridStore.ts index 4855c9a48a..64c8e93c2d 100644 --- a/packages/pluggableWidgets/datagrid-web/src/helpers/state/RootGridStore.ts +++ b/packages/pluggableWidgets/datagrid-web/src/helpers/state/RootGridStore.ts @@ -142,6 +142,7 @@ export class RootGridStore extends BaseControllerHost { ); this.selectionProgressDialogViewModel = new SelectionProgressDialogViewModel( + this, gate, selectAllProgressStore, selectAllController diff --git a/packages/pluggableWidgets/datagrid-web/src/helpers/state/SelectAllBarViewModel.ts b/packages/pluggableWidgets/datagrid-web/src/helpers/state/SelectAllBarViewModel.ts index 6425dfdcc8..54f3ea5fac 100644 --- a/packages/pluggableWidgets/datagrid-web/src/helpers/state/SelectAllBarViewModel.ts +++ b/packages/pluggableWidgets/datagrid-web/src/helpers/state/SelectAllBarViewModel.ts @@ -2,101 +2,163 @@ import { SelectAllController } from "@mendix/widget-plugin-grid/selection"; import { SelectionCountStore } from "@mendix/widget-plugin-grid/stores/SelectionCountStore"; import { DerivedPropsGate } from "@mendix/widget-plugin-mobx-kit/props-gate"; import { ReactiveController, ReactiveControllerHost } from "@mendix/widget-plugin-mobx-kit/reactive-controller"; -import { autorun, makeAutoObservable } from "mobx"; +import { action, makeAutoObservable, reaction } from "mobx"; import { DatagridContainerProps } from "../../../typings/DatagridProps"; type Props = Pick< DatagridContainerProps, | "cancelSelectionLabel" | "selectAllTemplate" - | "selectRemainingTemplate" + | "selectAllText" | "clearSelectionCaption" | "itemSelection" | "selectedCountTemplatePlural" | "selectedCountTemplateSingular" | "datasource" + | "allSelectedText" >; type Gate = DerivedPropsGate; export class SelectAllBarViewModel implements ReactiveController { - showClear = false; + private barVisible = false; + private clearVisible = false; + pending = false; + + #gate: Gate; + #selectAllController: SelectAllController; + #count: SelectionCountStore; constructor( host: ReactiveControllerHost, - private gate: Gate, - private selectAllController: SelectAllController, - private count = new SelectionCountStore(gate) + gate: Gate, + selectAllController: SelectAllController, + count = new SelectionCountStore(gate) ) { host.addController(this); - makeAutoObservable(this); + type PrivateMembers = "setClearVisible" | "setPending" | "hideBar" | "showBar"; + makeAutoObservable(this, { + setClearVisible: action, + setPending: action, + hideBar: action, + showBar: action + }); + this.#gate = gate; + this.#selectAllController = selectAllController; + this.#count = count; + } + + private setClearVisible(value: boolean): void { + this.clearVisible = value; + } + + private setPending(value: boolean): void { + this.pending = value; + } + + private hideBar(): void { + this.barVisible = false; + this.clearVisible = false; } - private setShowClear(value: boolean): void { - this.showClear = value; + private showBar(): void { + this.barVisible = true; } private get total(): number { - return this.gate.props.datasource.totalCount ?? 0; + return this.#gate.props.datasource.totalCount ?? 0; } private get selectAllFormat(): string { - return this.gate.props.selectAllTemplate?.value ?? "select.all.items"; + return this.#gate.props.selectAllTemplate?.value ?? "select.all.n.items"; + } + + private get selectAllText(): string { + return this.#gate.props.selectAllText?.value ?? "select.all.items"; } - private get selectRemainingText(): string { - return this.gate.props.selectRemainingTemplate?.value ?? "select.remaining.items"; + private get allSelectedText(): string { + const str = this.#gate.props.allSelectedText?.value ?? "all.selected"; + return str.replace("%d", `${this.#count.selectedCount}`); } - private get isSelectionEmpty(): boolean { - return this.count.selectedCount === 0; + private get selectedSet(): Set { + const selection = this.#gate.props.itemSelection; + if (!selection) return new Set(); + if (selection.type === "Single") return new Set(); + return new Set([...selection.selection.map(it => it.id)]); + } + + private get isCurrentPageSelected(): boolean { + const items = this.#gate.props.datasource.items ?? []; + if (items.length === 0) return false; + return items.every(items => this.selectedSet.has(items.id)); + } + + private get isAllItemsSelected(): boolean { + if (this.total > 0) return this.total === this.#count.selectedCount; + + const { offset, limit, items = [], hasMoreItems } = this.#gate.props.datasource; + const noMoreItems = typeof hasMoreItems === "boolean" && hasMoreItems === false; + const fullyLoaded = offset === 0 && limit >= items.length; + + return fullyLoaded && noMoreItems && items.length === this.#count.selectedCount; } get selectAllLabel(): string { if (this.total > 0) return this.selectAllFormat.replace("%d", `${this.total}`); - return this.selectRemainingText; + return this.selectAllText; } get clearSelectionLabel(): string { - return this.gate.props.clearSelectionCaption?.value ?? "clear.selection.caption"; + return this.#gate.props.clearSelectionCaption?.value ?? "clear.selection.caption"; + } + + get selectionStatus(): string { + if (this.isAllItemsSelected) return this.allSelectedText; + return this.#count.selectedCountText; } - get selectionCountText(): string { - return this.count.selectedCountText; + get isBarVisible(): boolean { + return this.barVisible; } - get barVisible(): boolean { - return this.count.selectedCountText !== ""; + get isClearVisible(): boolean { + return this.clearVisible; } - get clearVisible(): boolean { - if (this.showClear) return true; - if (this.total > 0) return this.total === this.count.selectedCount; - return false; + get isSelectAllVisible(): boolean { + return !this.clearVisible; } - get selectAllVisible(): boolean { - // Note: order of checks matter. - if (this.showClear) return false; - if (this.total > 0) return this.total > this.count.selectedCount; - return this.gate.props.datasource.hasMoreItems ?? false; + get isSelectAllDisabled(): boolean { + return this.pending; } setup(): () => void { - return autorun(() => { - if (this.isSelectionEmpty) { - this.setShowClear(false); + return reaction( + () => this.isCurrentPageSelected, + isCurrentPageSelected => { + if (isCurrentPageSelected === false) { + this.hideBar(); + } else if (this.isAllItemsSelected === false) { + this.showBar(); + } } - }); + ); } onClear(): void { - this.selectAllController.clearSelection(); - this.setShowClear(false); + this.#selectAllController.clearSelection(); } async onSelectAll(): Promise { - const { success } = await this.selectAllController.selectAllPages(); - this.setShowClear(success); + this.setPending(true); + try { + const { success } = await this.#selectAllController.selectAllPages(); + this.setClearVisible(success); + } finally { + this.setPending(false); + } } } diff --git a/packages/pluggableWidgets/datagrid-web/src/helpers/state/SelectionProgressDialogViewModel.ts b/packages/pluggableWidgets/datagrid-web/src/helpers/state/SelectionProgressDialogViewModel.ts index c140f0ef6d..8c077fa8de 100644 --- a/packages/pluggableWidgets/datagrid-web/src/helpers/state/SelectionProgressDialogViewModel.ts +++ b/packages/pluggableWidgets/datagrid-web/src/helpers/state/SelectionProgressDialogViewModel.ts @@ -1,44 +1,84 @@ import { SelectAllController } from "@mendix/widget-plugin-grid/selection"; import { ProgressStore } from "@mendix/widget-plugin-grid/stores/ProgressStore"; import { DerivedPropsGate } from "@mendix/widget-plugin-mobx-kit/props-gate"; +import { ReactiveController, ReactiveControllerHost } from "@mendix/widget-plugin-mobx-kit/reactive-controller"; import { DynamicValue } from "mendix"; -import { makeAutoObservable } from "mobx"; +import { action, makeAutoObservable, reaction } from "mobx"; type Gate = DerivedPropsGate<{ selectingAllLabel?: DynamicValue; cancelSelectionLabel?: DynamicValue; }>; -export class SelectionProgressDialogViewModel { +export class SelectionProgressDialogViewModel implements ReactiveController { + /** + * This state is synced with progressStore, but with short delay to + * avoid UI flickering. + */ + private dialogOpen = false; + + #gate: Gate; + #progressStore: ProgressStore; + #selectAllController: SelectAllController; + #timerId: ReturnType | undefined; + constructor( - private gate: Gate, - private progressStore: ProgressStore, - private selectAllController: SelectAllController + host: ReactiveControllerHost, + gate: Gate, + progressStore: ProgressStore, + selectAllController: SelectAllController ) { - makeAutoObservable(this); + host.addController(this); + type PrivateMembers = "setDialogOpen"; + makeAutoObservable(this, { setDialogOpen: action }); + this.#gate = gate; + this.#progressStore = progressStore; + this.#selectAllController = selectAllController; + } + + private setDialogOpen(value: boolean): void { + this.dialogOpen = value; } - get open(): boolean { - return this.progressStore.inProgress; + get isOpen(): boolean { + return this.dialogOpen; } get progress(): number { - return this.progressStore.loaded; + return this.#progressStore.loaded; } get total(): number { - return this.progressStore.total; + return this.#progressStore.total; } get selectingAllLabel(): string { - return this.gate.props.selectingAllLabel?.value ?? "Selecting all items..."; + return this.#gate.props.selectingAllLabel?.value ?? "Selecting all items..."; } get cancelSelectionLabel(): string { - return this.gate.props.cancelSelectionLabel?.value ?? "Cancel selection"; + return this.#gate.props.cancelSelectionLabel?.value ?? "Cancel selection"; + } + + setup(): () => void { + return reaction( + () => this.#progressStore.inProgress, + inProgress => { + if (inProgress) { + // Delay showing dialog for 2 second + this.#timerId = setTimeout(() => { + this.setDialogOpen(true); + this.#timerId = undefined; + }, 2000); + } else { + this.setDialogOpen(false); + clearTimeout(this.#timerId); + } + } + ); } onCancel(): void { - this.selectAllController.abort(); + this.#selectAllController.abort(); } } diff --git a/packages/pluggableWidgets/datagrid-web/typings/DatagridProps.d.ts b/packages/pluggableWidgets/datagrid-web/typings/DatagridProps.d.ts index 4e2fffa239..db6013793b 100644 --- a/packages/pluggableWidgets/datagrid-web/typings/DatagridProps.d.ts +++ b/packages/pluggableWidgets/datagrid-web/typings/DatagridProps.d.ts @@ -101,6 +101,7 @@ export interface DatagridContainerProps { itemSelectionMode: ItemSelectionModeEnum; showSelectAllToggle: boolean; keepSelection: boolean; +<<<<<<< HEAD <<<<<<< HEAD selectionCountPosition: SelectionCountPositionEnum; clearSelectionButtonLabel?: DynamicValue; @@ -113,6 +114,9 @@ export interface DatagridContainerProps { >>>>>>> 2e4671e66 (feat(datagrid-web): add multipage selection to dg2) ======= >>>>>>> 448ce2ee2 (feat: add sab vm) +======= + enableSelectAll: boolean; +>>>>>>> 298cb49db (refactor: change texts & etc) loadingType: LoadingTypeEnum; refreshIndicator: boolean; pageSize: number; @@ -140,8 +144,9 @@ export interface DatagridContainerProps { cancelSelectionLabel?: DynamicValue; selectedCountTemplateSingular?: DynamicValue; selectedCountTemplatePlural?: DynamicValue; + selectAllText: DynamicValue; selectAllTemplate: DynamicValue; - selectRemainingTemplate: DynamicValue; + allSelectedText: DynamicValue; clearSelectionCaption: DynamicValue; } @@ -169,6 +174,7 @@ export interface DatagridPreviewProps { itemSelectionMode: ItemSelectionModeEnum; showSelectAllToggle: boolean; keepSelection: boolean; +<<<<<<< HEAD selectionCountPosition: SelectionCountPositionEnum; clearSelectionButtonLabel: string; @@ -180,6 +186,9 @@ export interface DatagridPreviewProps { cancelSelectionLabel: string; +======= + enableSelectAll: boolean; +>>>>>>> 298cb49db (refactor: change texts & etc) loadingType: LoadingTypeEnum; refreshIndicator: boolean; pageSize: number | null; @@ -208,7 +217,8 @@ export interface DatagridPreviewProps { cancelSelectionLabel: string; selectedCountTemplateSingular: string; selectedCountTemplatePlural: string; + selectAllText: string; selectAllTemplate: string; - selectRemainingTemplate: string; + allSelectedText: string; clearSelectionCaption: string; } diff --git a/packages/shared/widget-plugin-grid/src/select-all/SelectAllController.ts b/packages/shared/widget-plugin-grid/src/select-all/SelectAllController.ts index a63e796ce4..9b6b3fb649 100644 --- a/packages/shared/widget-plugin-grid/src/select-all/SelectAllController.ts +++ b/packages/shared/widget-plugin-grid/src/select-all/SelectAllController.ts @@ -9,23 +9,22 @@ type Gate = DerivedPropsGate<{ itemSelection?: SelectionMultiValue | SelectionSi interface SelectAllControllerSpec { gate: Gate; query: QueryController; - pageSize: number; } type SelectAllEventType = "loadstart" | "progress" | "abort" | "loadend"; export class SelectAllController implements ReactiveController { - private readonly gate: Gate; - private readonly query: QueryController; - private abortController?: AbortController; + readonly #gate: Gate; + readonly #query: QueryController; + readonly #emitter = new EventTarget(); + readonly #pageSize = 1024; + #abortController?: AbortController; private locked = false; - readonly pageSize: number = 1024; - private readonly emitter = new EventTarget(); constructor(host: ReactiveControllerHost, spec: SelectAllControllerSpec) { host.addController(this); - this.gate = spec.gate; - this.query = spec.query; + this.#gate = spec.gate; + this.#query = spec.query; type PrivateMembers = "setIsLocked" | "locked"; makeObservable(this, { @@ -47,22 +46,22 @@ export class SelectAllController implements ReactiveController { } on(type: SelectAllEventType, listener: (pe: ProgressEvent) => void): void { - this.emitter.addEventListener(type, listener); + this.#emitter.addEventListener(type, listener); } off(type: SelectAllEventType, listener: (pe: ProgressEvent) => void): void { - this.emitter.removeEventListener(type, listener); + this.#emitter.removeEventListener(type, listener); } get selection(): SelectionMultiValue | undefined { - const selection = this.gate.props.itemSelection; + const selection = this.#gate.props.itemSelection; if (selection === undefined) return; if (selection.type === "Single") return; return selection; } get canExecute(): boolean { - return this.gate.props.itemSelection?.type === "Multi" && !this.locked; + return this.#gate.props.itemSelection?.type === "Multi" && !this.locked; } get isExecuting(): boolean { @@ -74,7 +73,7 @@ export class SelectAllController implements ReactiveController { } private beforeRunChecks(): boolean { - const selection = this.gate.props.itemSelection; + const selection = this.#gate.props.itemSelection; if (selection === undefined) { console.debug("SelectAllController: selection is undefined. Check widget selection setting."); @@ -99,10 +98,10 @@ export class SelectAllController implements ReactiveController { this.setIsLocked(true); - const { offset: initOffset, limit: initLimit } = this.query; + const { offset: initOffset, limit: initLimit } = this.#query; const initSelection = this.selection?.selection ?? []; - const hasTotal = typeof this.query.totalCount === "number"; - const totalCount = this.query.totalCount ?? 0; + const hasTotal = typeof this.#query.totalCount === "number"; + const totalCount = this.#query.totalCount ?? 0; let loaded = 0; let offset = 0; let success = false; @@ -110,25 +109,25 @@ export class SelectAllController implements ReactiveController { new ProgressEvent(type, { loaded, total: totalCount, lengthComputable: hasTotal }); // We should avoid duplicates, so, we start with clean array. const allItems: ObjectItem[] = []; - this.abortController = new AbortController(); - const signal = this.abortController.signal; + this.#abortController = new AbortController(); + const signal = this.#abortController.signal; performance.mark("SelectAll_Start"); try { - this.emitter.dispatchEvent(pe("loadstart")); + this.#emitter.dispatchEvent(pe("loadstart")); let loading = true; while (loading) { - const loadedItems = await this.query.fetchPage({ - limit: this.pageSize, + const loadedItems = await this.#query.fetchPage({ + limit: this.#pageSize, offset, signal }); allItems.push(...loadedItems); loaded += loadedItems.length; - offset += this.pageSize; - this.emitter.dispatchEvent(pe("progress")); - loading = !signal.aborted && this.query.hasMoreItems; + offset += this.#pageSize; + this.#emitter.dispatchEvent(pe("progress")); + loading = !signal.aborted && this.#query.hasMoreItems; } success = true; } catch (error) { @@ -139,18 +138,18 @@ export class SelectAllController implements ReactiveController { } finally { // Restore init view // This step should be done before loadend to avoid UI flickering - await this.query.fetchPage({ + await this.#query.fetchPage({ limit: initLimit, offset: initOffset }); await this.reloadSelection(); - this.emitter.dispatchEvent(pe("loadend")); + this.#emitter.dispatchEvent(pe("loadend")); // const selectionBeforeReload = this.selection?.selection ?? []; // Reload selection to make sure setSelection is working as expected. this.selection?.setSelection(success ? allItems : initSelection); this.locked = false; - this.abortController = undefined; + this.#abortController = undefined; performance.mark("SelectAll_End"); const measure1 = performance.measure("Measure1", "SelectAll_Start", "SelectAll_End"); @@ -168,7 +167,7 @@ export class SelectAllController implements ReactiveController { */ reloadSelection(): Promise { const prevSelection = this.selection; - const items = this.query.items ?? []; + const items = this.#query.items ?? []; const currentSelection = this.selection?.selection ?? []; const newSelection = currentSelection.length > 0 ? [] : items; this.selection?.setSelection(newSelection); @@ -186,7 +185,7 @@ export class SelectAllController implements ReactiveController { } abort(): void { - this.abortController?.abort(); - this.emitter.dispatchEvent(new ProgressEvent("abort")); + this.#abortController?.abort(); + this.#emitter.dispatchEvent(new ProgressEvent("abort")); } } diff --git a/packages/shared/widget-plugin-grid/src/stores/SelectionCountStore.ts b/packages/shared/widget-plugin-grid/src/stores/SelectionCountStore.ts index b406260bad..6227e7f0ee 100644 --- a/packages/shared/widget-plugin-grid/src/stores/SelectionCountStore.ts +++ b/packages/shared/widget-plugin-grid/src/stores/SelectionCountStore.ts @@ -11,9 +11,8 @@ type Gate = DerivedPropsGate<{ export class SelectionCountStore { private gate: Gate; - private singular: string = "%d row selected"; - private plural: string = "%d rows selected"; - private defaultClearLabel: string = "Clear selection"; + private singular: string = "%d row selected."; + private plural: string = "%d rows selected."; constructor(gate: Gate, spec: { singular?: string; plural?: string; clearLabel?: string } = {}) { this.singular = spec.singular ?? this.singular; From 41baeb91c7e56af175a1fe594f931203490d750c Mon Sep 17 00:00:00 2001 From: Illia Obukhau <8282906+iobuhov@users.noreply.github.com> Date: Wed, 15 Oct 2025 14:19:02 +0200 Subject: [PATCH 22/35] fix: pass dg settings flag --- .../src/helpers/state/RootGridStore.ts | 6 +++--- .../src/helpers/state/SelectAllBarViewModel.ts | 15 ++++++++++----- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/packages/pluggableWidgets/datagrid-web/src/helpers/state/RootGridStore.ts b/packages/pluggableWidgets/datagrid-web/src/helpers/state/RootGridStore.ts index 64c8e93c2d..00cb55e553 100644 --- a/packages/pluggableWidgets/datagrid-web/src/helpers/state/RootGridStore.ts +++ b/packages/pluggableWidgets/datagrid-web/src/helpers/state/RootGridStore.ts @@ -38,11 +38,11 @@ type RequiredProps = Pick< | "pagination" | "showPagingButtons" | "showNumberOfRows" - | "selectAllPagesEnabled" - | "selectAllPagesPageSize" + | "enableSelectAll" | "onSelectionChange" | "selectAllTemplate" - | "selectRemainingTemplate" + | "selectAllText" + | "allSelectedText" | "clearSelectionCaption" | "selectingAllLabel" | "cancelSelectionLabel" diff --git a/packages/pluggableWidgets/datagrid-web/src/helpers/state/SelectAllBarViewModel.ts b/packages/pluggableWidgets/datagrid-web/src/helpers/state/SelectAllBarViewModel.ts index 54f3ea5fac..11abee64b8 100644 --- a/packages/pluggableWidgets/datagrid-web/src/helpers/state/SelectAllBarViewModel.ts +++ b/packages/pluggableWidgets/datagrid-web/src/helpers/state/SelectAllBarViewModel.ts @@ -16,6 +16,7 @@ type Props = Pick< | "selectedCountTemplateSingular" | "datasource" | "allSelectedText" + | "enableSelectAll" >; type Gate = DerivedPropsGate; @@ -25,9 +26,10 @@ export class SelectAllBarViewModel implements ReactiveController { private clearVisible = false; pending = false; - #gate: Gate; - #selectAllController: SelectAllController; - #count: SelectionCountStore; + readonly #gate: Gate; + readonly #selectAllController: SelectAllController; + readonly #count: SelectionCountStore; + readonly #enableSelectAll: boolean; constructor( host: ReactiveControllerHost, @@ -46,6 +48,7 @@ export class SelectAllBarViewModel implements ReactiveController { this.#gate = gate; this.#selectAllController = selectAllController; this.#count = count; + this.#enableSelectAll = gate.props.enableSelectAll; } private setClearVisible(value: boolean): void { @@ -120,7 +123,7 @@ export class SelectAllBarViewModel implements ReactiveController { } get isBarVisible(): boolean { - return this.barVisible; + return this.#enableSelectAll && this.barVisible; } get isClearVisible(): boolean { @@ -135,7 +138,9 @@ export class SelectAllBarViewModel implements ReactiveController { return this.pending; } - setup(): () => void { + setup(): (() => void) | void { + if (!this.#enableSelectAll) return; + return reaction( () => this.isCurrentPageSelected, isCurrentPageSelected => { From 3e7fc1766502fe7a84cc40a937dbfacdbc8d1268 Mon Sep 17 00:00:00 2001 From: Illia Obukhau <8282906+iobuhov@users.noreply.github.com> Date: Wed, 15 Oct 2025 14:29:55 +0200 Subject: [PATCH 23/35] feat: use css variable --- .../data-widgets/src/themesource/datawidgets/web/_datagrid.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/modules/data-widgets/src/themesource/datawidgets/web/_datagrid.scss b/packages/modules/data-widgets/src/themesource/datawidgets/web/_datagrid.scss index d2c152aaa7..3b52e89f9d 100644 --- a/packages/modules/data-widgets/src/themesource/datawidgets/web/_datagrid.scss +++ b/packages/modules/data-widgets/src/themesource/datawidgets/web/_datagrid.scss @@ -589,7 +589,7 @@ $root: ".widget-datagrid"; &:hover, &:focus-visible { - background-color: #e6e7f2; + background-color: var(--brand-primary-50, #e6e7f2); } } From 2b1a0989c6020b6933b4ab0eefe5fe39d58493a7 Mon Sep 17 00:00:00 2001 From: Illia Obukhau <8282906+iobuhov@users.noreply.github.com> Date: Wed, 15 Oct 2025 17:27:57 +0200 Subject: [PATCH 24/35] feat: fix ts issues --- .../datagrid-web/src/Datagrid.editorConfig.ts | 10 +- .../src/Datagrid.editorPreview.tsx | 2 +- .../components/SelectionProgressDialog.tsx | 3 +- .../src/components/WidgetFooter.tsx | 4 +- .../src/components/__tests__/Table.spec.tsx | 155 ++++++------ .../__snapshots__/Table.spec.tsx.snap | 224 ++++++++++++++++++ .../features/data-export/ExportController.ts | 2 +- .../src/features/data-export/ProgressStore.ts | 29 --- .../src/features/data-export/useDataExport.ts | 2 +- .../gallery-web/src/Gallery.xml | 7 + .../src/components/SelectionCounter.tsx | 20 +- .../gallery-web/src/helpers/root-context.ts | 2 +- .../gallery-web/src/stores/GalleryStore.ts | 2 +- .../gallery-web/src/utils/test-utils.tsx | 4 +- .../gallery-web/typings/GalleryProps.d.ts | 2 + .../src/select-all/SelectAllController.ts | 23 +- .../src/select-all/SelectAllHost.ts | 2 +- .../src/stores/SelectionCountStore.ts | 2 +- 18 files changed, 328 insertions(+), 167 deletions(-) delete mode 100644 packages/pluggableWidgets/datagrid-web/src/features/data-export/ProgressStore.ts diff --git a/packages/pluggableWidgets/datagrid-web/src/Datagrid.editorConfig.ts b/packages/pluggableWidgets/datagrid-web/src/Datagrid.editorConfig.ts index 9919d9fbbb..3f996ec75f 100644 --- a/packages/pluggableWidgets/datagrid-web/src/Datagrid.editorConfig.ts +++ b/packages/pluggableWidgets/datagrid-web/src/Datagrid.editorConfig.ts @@ -123,7 +123,7 @@ export function getProperties( } function hideSelectionProperties(defaultProperties: Properties, values: DatagridPreviewProps): void { - const { itemSelection, itemSelectionMethod, selectAllPagesEnabled } = values; + const { itemSelection, itemSelectionMethod } = values; if (itemSelection === "None") { hidePropertiesIn(defaultProperties, values, ["itemSelectionMethod", "itemSelectionMode", "onSelectionChange"]); @@ -139,13 +139,7 @@ function hideSelectionProperties(defaultProperties: Properties, values: Datagrid if (itemSelection !== "Multi") { hidePropertyIn(defaultProperties, values, "keepSelection"); - hidePropertyIn(defaultProperties, values, "selectAllPagesEnabled"); - } - - if (!selectAllPagesEnabled) { - hidePropertyIn(defaultProperties, values, "selectAllPagesPageSize"); - hidePropertyIn(defaultProperties, values, "selectingAllLabel"); - hidePropertyIn(defaultProperties, values, "cancelSelectionLabel"); + hidePropertyIn(defaultProperties, values, "enableSelectAll"); } } diff --git a/packages/pluggableWidgets/datagrid-web/src/Datagrid.editorPreview.tsx b/packages/pluggableWidgets/datagrid-web/src/Datagrid.editorPreview.tsx index db56ae6e4e..5f1c32d625 100644 --- a/packages/pluggableWidgets/datagrid-web/src/Datagrid.editorPreview.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/Datagrid.editorPreview.tsx @@ -99,7 +99,7 @@ export function preview(props: DatagridPreviewProps): ReactElement { const basicData = new GridBasicData(gateProvider.gate as any); const query = new DatasourceController(host, { gate: gateProvider.gate }); const selectionCountStore = new SelectionCountStore(gateProvider.gate as any); - const selectAllController = new SelectAllController(host, { gate: gateProvider.gate, pageSize: 2, query }); + const selectAllController = new SelectAllController(host, gateProvider.gate, query); const selectAllProgressStore = new ProgressStore(); return { basicData, diff --git a/packages/pluggableWidgets/datagrid-web/src/components/SelectionProgressDialog.tsx b/packages/pluggableWidgets/datagrid-web/src/components/SelectionProgressDialog.tsx index 0f8d96604f..66091e2640 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/SelectionProgressDialog.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/SelectionProgressDialog.tsx @@ -5,7 +5,8 @@ import { PseudoModal } from "./PseudoModal"; export function SelectionProgressDialog(): ReactElement | null { const { selectionProgressDialogViewModel: vm } = useDatagridRootScope(); - if (!vm.dialogOpen) return null; + if (!vm.isOpen) return null; + return ( {selectionCountStore.selectedCountText}   - ); diff --git a/packages/pluggableWidgets/datagrid-web/src/components/__tests__/Table.spec.tsx b/packages/pluggableWidgets/datagrid-web/src/components/__tests__/Table.spec.tsx index aa5fdc4cf9..76379200c5 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/__tests__/Table.spec.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/__tests__/Table.spec.tsx @@ -1,23 +1,18 @@ import { ClickActionHelper } from "@mendix/widget-plugin-grid/helpers/ClickActionHelper"; -import { - MultiSelectionStatus, - useSelectionHelper, - MultiPageSelectionController -} from "@mendix/widget-plugin-grid/selection"; -import { SelectionCountStore } from "@mendix/widget-plugin-grid/selection/stores/SelectionCountStore"; -import { - list, - ListValueBuilder, - listWidget, - objectItems, - SelectionMultiValueBuilder -} from "@mendix/widget-plugin-test-utils"; +import { DatasourceController } from "@mendix/widget-plugin-grid/query/DatasourceController"; +import { MultiSelectionHelper, SelectAllController, useSelectionHelper } from "@mendix/widget-plugin-grid/selection"; +import { ProgressStore } from "@mendix/widget-plugin-grid/stores/ProgressStore"; +import { SelectionCountStore } from "@mendix/widget-plugin-grid/stores/SelectionCountStore"; +import { BaseControllerHost } from "@mendix/widget-plugin-mobx-kit/BaseControllerHost"; +import { GateProvider } from "@mendix/widget-plugin-mobx-kit/GateProvider"; +import { list, listWidget, objectItems, SelectionMultiValueBuilder } from "@mendix/widget-plugin-test-utils"; import "@testing-library/jest-dom"; import { cleanup, getAllByRole, getByRole, queryByRole, render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { ListValue, ObjectItem, SelectionMultiValue } from "mendix"; import { ReactElement } from "react"; -import { ItemSelectionMethodEnum } from "typings/DatagridProps"; +import { RootGridStore } from "src/helpers/state/RootGridStore"; +import { DatagridContainerProps, ItemSelectionMethodEnum } from "typings/DatagridProps"; import { CellEventsController, useCellEventsController } from "../../features/row-interaction/CellEventsController"; import { CheckboxEventsController, @@ -26,11 +21,11 @@ import { import { SelectActionHelper, useSelectActionHelper } from "../../helpers/SelectActionHelper"; import { DatagridContext, DatagridRootScope } from "../../helpers/root-context"; import { GridBasicData } from "../../helpers/state/GridBasicData"; +import { SelectAllBarViewModel } from "../../helpers/state/SelectAllBarViewModel"; +import { SelectionProgressDialogViewModel } from "../../helpers/state/SelectionProgressDialogViewModel"; import { GridColumn } from "../../typings/GridColumn"; import { column, mockGridColumn, mockWidgetProps } from "../../utils/test-utils"; import { Widget, WidgetProps } from "../Widget"; -import { RootGridStore } from "src/helpers/state/RootGridStore"; -import { SelectAllProgressStore } from "src/features/multi-page-selection/SelectAllProgressStore"; // you can also pass the mock implementation // to jest.fn as an argument window.IntersectionObserver = jest.fn(() => ({ @@ -43,6 +38,8 @@ window.IntersectionObserver = jest.fn(() => ({ takeRecords: jest.fn() })); +class Host extends BaseControllerHost {} + function withCtx( widgetProps: WidgetProps, contextOverrides: Partial = {} @@ -64,17 +61,29 @@ function withCtx( fmtPlural: "%d rows selected" }; + const host = new Host(); + const { gate } = new GateProvider({ datasource: list(4) } as DatagridContainerProps); + const query = new DatasourceController(host, { gate }); + const selectAllProgressStore = new ProgressStore(); + const selectAllController = new SelectAllController(host, gate, query); + const mockContext = { basicData: defaultBasicData as unknown as GridBasicData, selectionHelper: undefined, selectActionHelper: widgetProps.selectActionHelper, cellEventsController: widgetProps.cellEventsController, checkboxEventsController: widgetProps.checkboxEventsController, - multiPageSelectionController: {} as unknown as MultiPageSelectionController, focusController: widgetProps.focusController, selectionCountStore: defaultSelectionCountStore as unknown as SelectionCountStore, - selectAllProgressStore: {} as unknown as SelectAllProgressStore, + selectAllProgressStore, rootStore: {} as unknown as RootGridStore, + selectAllBarViewModel: new SelectAllBarViewModel(host, gate, selectAllController), + selectionProgressDialogViewModel: new SelectionProgressDialogViewModel( + host, + gate, + selectAllProgressStore, + selectAllController + ), ...contextOverrides }; @@ -223,15 +232,7 @@ describe("Table", () => { beforeEach(() => { props = mockWidgetProps(); - props.selectActionHelper = new SelectActionHelper( - "Single", - undefined, - "checkbox", - false, - 5, - "clear", - new ListValueBuilder().build() - ); + props.selectActionHelper = new SelectActionHelper("Single", undefined, "checkbox", false, 5, "clear"); props.paging = true; props.data = objectItems(3); }); @@ -330,15 +331,7 @@ describe("Table", () => { const props = mockWidgetProps(); props.data = objectItems(5); props.paging = true; - props.selectActionHelper = new SelectActionHelper( - "Multi", - undefined, - "checkbox", - false, - 5, - "clear", - new ListValueBuilder().build() - ); + props.selectActionHelper = new SelectActionHelper("Multi", undefined, "checkbox", false, 5, "clear"); renderWithRootContext(props); const colheader = screen.getAllByRole("columnheader")[0]; @@ -348,47 +341,47 @@ describe("Table", () => { describe("with multi selection helper", () => { it("render header checkbox if helper is given and checkbox state depends on the helper status", () => { const props = mockWidgetProps(); - props.data = objectItems(5); + const items = list(5).items!; + props.data = items; props.paging = true; - props.selectActionHelper = new SelectActionHelper( - "Multi", - undefined, - "checkbox", - true, - 5, - "clear", - new ListValueBuilder().build() - ); + let selectionHelper; + let actionHelper; - const renderWithStatus = (status: MultiSelectionStatus): ReturnType => { - return renderWithRootContext(props, { - basicData: { selectionStatus: status } as unknown as GridBasicData - }); - }; + // none + selectionHelper = new MultiSelectionHelper( + { selection: [] as ObjectItem[], type: "Multi" } as SelectionMultiValue, + items + ); + actionHelper = new SelectActionHelper("Multi", selectionHelper, "checkbox", true, 5, "clear"); - renderWithStatus("none"); + renderWithRootContext(props, { selectActionHelper: actionHelper, selectionHelper }); expect(queryByRole(screen.getAllByRole("columnheader")[0], "checkbox")).not.toBeChecked(); - cleanup(); - renderWithStatus("some"); - expect(queryByRole(screen.getAllByRole("columnheader")[0], "checkbox")).toBeChecked(); + // some + selectionHelper = new MultiSelectionHelper( + { selection: [items[0]] as ObjectItem[], type: "Multi" } as SelectionMultiValue, + items + ); + actionHelper = new SelectActionHelper("Multi", selectionHelper, "checkbox", true, 5, "clear"); + renderWithRootContext(props, { selectActionHelper: actionHelper, selectionHelper }); + expect(queryByRole(screen.getAllByRole("columnheader")[0], "checkbox")).toBeChecked(); cleanup(); - renderWithStatus("all"); + + // all + selectionHelper = new MultiSelectionHelper( + { selection: items as ObjectItem[], type: "Multi" } as SelectionMultiValue, + items + ); + actionHelper = new SelectActionHelper("Multi", selectionHelper, "checkbox", true, 5, "clear"); + renderWithRootContext(props, { selectActionHelper: actionHelper, selectionHelper }); expect(queryByRole(screen.getAllByRole("columnheader")[0], "checkbox")).toBeChecked(); + cleanup(); }); it("not render header checkbox if method is rowClick", () => { const props = mockWidgetProps(); - props.selectActionHelper = new SelectActionHelper( - "Multi", - undefined, - "rowClick", - false, - 5, - "clear", - new ListValueBuilder().build() - ); + props.selectActionHelper = new SelectActionHelper("Multi", undefined, "rowClick", false, 5, "clear"); renderWithRootContext(props); @@ -398,19 +391,11 @@ describe("Table", () => { it("call onSelectAll when header checkbox is clicked", async () => { const props = mockWidgetProps(); - props.selectActionHelper = new SelectActionHelper( - "Multi", - undefined, - "checkbox", - true, - 5, - "clear", - new ListValueBuilder().build() - ); + props.selectActionHelper = new SelectActionHelper("Multi", undefined, "checkbox", true, 5, "clear"); props.selectActionHelper.onSelectAll = jest.fn(); renderWithRootContext(props, { - basicData: { selectionStatus: "none" } as unknown as GridBasicData + selectionHelper: { selectionStatus: status, type: "Multi" } as MultiSelectionHelper }); const checkbox = screen.getAllByRole("checkbox")[0]; @@ -428,15 +413,7 @@ describe("Table", () => { beforeEach(() => { props = mockWidgetProps(); - props.selectActionHelper = new SelectActionHelper( - "Single", - undefined, - "rowClick", - true, - 5, - "clear", - new ListValueBuilder().build() - ); + props.selectActionHelper = new SelectActionHelper("Single", undefined, "rowClick", true, 5, "clear"); props.paging = true; props.data = objectItems(3); }); @@ -543,9 +520,7 @@ describe("Table", () => { itemSelectionMode: "clear", showSelectAllToggle: false, pageSize: 5, - datasource: ds, - selectAllPagesEnabled: false, - selectAllPagesBufferSize: 500 + datasource: ds }, helper ); @@ -568,8 +543,10 @@ describe("Table", () => { checkboxEventsController, focusController: props.focusController, selectionCountStore: {} as unknown as SelectionCountStore, - selectAllProgressStore: {} as unknown as SelectAllProgressStore, - rootStore: {} as unknown as RootGridStore + selectAllProgressStore: {} as unknown as ProgressStore, + rootStore: {} as unknown as RootGridStore, + selectAllBarViewModel: {} as unknown as SelectAllBarViewModel, + selectionProgressDialogViewModel: {} as unknown as SelectionProgressDialogViewModel }; return ( diff --git a/packages/pluggableWidgets/datagrid-web/src/components/__tests__/__snapshots__/Table.spec.tsx.snap b/packages/pluggableWidgets/datagrid-web/src/components/__tests__/__snapshots__/Table.spec.tsx.snap index 9b26eb9fe5..8ddc5c61e4 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/__tests__/__snapshots__/Table.spec.tsx.snap +++ b/packages/pluggableWidgets/datagrid-web/src/components/__tests__/__snapshots__/Table.spec.tsx.snap @@ -82,6 +82,20 @@ exports[`Table renders the structure correctly 1`] = ` class="widget-datagrid-paging-bottom" >
+ +   +
+
>>>>>> 776cb753c (feat: fix ts issues) class="widget-datagrid-pb-end" />
@@ -172,6 +186,20 @@ exports[`Table renders the structure correctly for preview when no header is pro class="widget-datagrid-paging-bottom" >
+ +   +
+
>>>>>> 776cb753c (feat: fix ts issues) class="widget-datagrid-pb-end" />
@@ -295,6 +323,20 @@ exports[`Table renders the structure correctly with column alignments 1`] = ` class="widget-datagrid-paging-bottom" >
+ +   +
+
>>>>>> 776cb753c (feat: fix ts issues) class="widget-datagrid-pb-end" />
@@ -389,6 +431,20 @@ exports[`Table renders the structure correctly with custom filtering 1`] = ` class="widget-datagrid-paging-bottom" >
+ +   +
+
>>>>>> 776cb753c (feat: fix ts issues) class="widget-datagrid-pb-end" />
@@ -479,6 +535,20 @@ exports[`Table renders the structure correctly with dragging 1`] = ` class="widget-datagrid-paging-bottom" >
+ +   +
+
>>>>>> 776cb753c (feat: fix ts issues) class="widget-datagrid-pb-end" />
@@ -569,6 +639,20 @@ exports[`Table renders the structure correctly with dynamic row class 1`] = ` class="widget-datagrid-paging-bottom" >
+ +   +
+
>>>>>> 776cb753c (feat: fix ts issues) class="widget-datagrid-pb-end" />
@@ -659,6 +743,20 @@ exports[`Table renders the structure correctly with empty placeholder 1`] = ` class="widget-datagrid-paging-bottom" >
+ +   +
+
>>>>>> 776cb753c (feat: fix ts issues) class="widget-datagrid-pb-end" />
@@ -753,6 +851,20 @@ exports[`Table renders the structure correctly with filtering 1`] = ` class="widget-datagrid-paging-bottom" >
+ +   +
+
>>>>>> 776cb753c (feat: fix ts issues) class="widget-datagrid-pb-end" />
@@ -853,6 +965,20 @@ exports[`Table renders the structure correctly with header filters and a11y 1`] class="widget-datagrid-paging-bottom" >
+ +   +
+
>>>>>> 776cb753c (feat: fix ts issues) class="widget-datagrid-pb-end" />
@@ -947,6 +1073,20 @@ exports[`Table renders the structure correctly with header wrapper 1`] = ` class="widget-datagrid-paging-bottom" >
+ +   +
+
>>>>>> 776cb753c (feat: fix ts issues) class="widget-datagrid-pb-end" />
@@ -1078,6 +1218,20 @@ exports[`Table renders the structure correctly with hiding 1`] = ` class="widget-datagrid-paging-bottom" >
+ +   +
+
>>>>>> 776cb753c (feat: fix ts issues) class="widget-datagrid-pb-end" />
@@ -1168,6 +1322,20 @@ exports[`Table renders the structure correctly with paging 1`] = ` class="widget-datagrid-paging-bottom" >
+ +   +
+
>>>>>> 776cb753c (feat: fix ts issues) class="widget-datagrid-pb-end" >
+ +   +
+
>>>>>> 776cb753c (feat: fix ts issues) class="widget-datagrid-pb-end" />
@@ -1438,6 +1620,20 @@ exports[`Table renders the structure correctly with sorting 1`] = ` class="widget-datagrid-paging-bottom" >
+ +   +
+
>>>>>> 776cb753c (feat: fix ts issues) class="widget-datagrid-pb-end" />
@@ -1609,6 +1805,20 @@ exports[`Table with selection method checkbox render an extra column and add cla class="widget-datagrid-paging-bottom" >
+ +   +
+
>>>>>> 776cb753c (feat: fix ts issues) class="widget-datagrid-pb-end" >
+ +   +
+
>>>>>> 776cb753c (feat: fix ts issues) class="widget-datagrid-pb-end" >
void; diff --git a/packages/pluggableWidgets/datagrid-web/src/features/data-export/ProgressStore.ts b/packages/pluggableWidgets/datagrid-web/src/features/data-export/ProgressStore.ts deleted file mode 100644 index fa0327b6c6..0000000000 --- a/packages/pluggableWidgets/datagrid-web/src/features/data-export/ProgressStore.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { makeAutoObservable } from "mobx"; - -export class ProgressStore { - inProgress = false; - lengthComputable = false; - loaded = 0; - total = 0; - constructor() { - makeAutoObservable(this); - } - - onloadstart = (event: ProgressEvent): void => { - this.inProgress = true; - this.lengthComputable = event.lengthComputable; - this.total = event.total; - this.loaded = 0; - }; - - onprogress = (event: ProgressEvent): void => { - this.loaded = event.loaded; - }; - - onloadend = (): void => { - this.inProgress = false; - this.lengthComputable = false; - this.loaded = 0; - this.total = 0; - }; -} 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 6733b2d7b5..e04cfceca1 100644 --- a/packages/pluggableWidgets/datagrid-web/src/features/data-export/useDataExport.ts +++ b/packages/pluggableWidgets/datagrid-web/src/features/data-export/useDataExport.ts @@ -1,8 +1,8 @@ +import { ProgressStore } from "@mendix/widget-plugin-grid/stores/ProgressStore"; import { useCallback, useEffect, useState } from "react"; import { DatagridContainerProps } from "../../../typings/DatagridProps"; import { IColumnGroupStore } from "../../helpers/state/ColumnGroupStore"; import { ExportController } from "./ExportController"; -import { ProgressStore } from "./ProgressStore"; import { getExportRegistry } from "./registry"; type ResourceEntry = { diff --git a/packages/pluggableWidgets/gallery-web/src/Gallery.xml b/packages/pluggableWidgets/gallery-web/src/Gallery.xml index 72e82eb550..2da99615bc 100644 --- a/packages/pluggableWidgets/gallery-web/src/Gallery.xml +++ b/packages/pluggableWidgets/gallery-web/src/Gallery.xml @@ -214,6 +214,13 @@ Item count plural Must include '%d' to denote number position ('%d items selected') + + Clear selection caption + + + Clear selection + + diff --git a/packages/pluggableWidgets/gallery-web/src/components/SelectionCounter.tsx b/packages/pluggableWidgets/gallery-web/src/components/SelectionCounter.tsx index e311e5b834..072ffced93 100644 --- a/packages/pluggableWidgets/gallery-web/src/components/SelectionCounter.tsx +++ b/packages/pluggableWidgets/gallery-web/src/components/SelectionCounter.tsx @@ -16,20 +16,12 @@ export const SelectionCounter = observer(function SelectionCounter({ const clearButtonAriaLabel = `${selectionCountStore.clearButtonLabel} (${selectionCountStore.selectedCount} selected)`; return ( - -
- - {selectionCountStore.displayCount} - -  |  - -
+ + {selectionCountStore.selectedCountText} +   + ); }); diff --git a/packages/pluggableWidgets/gallery-web/src/helpers/root-context.ts b/packages/pluggableWidgets/gallery-web/src/helpers/root-context.ts index 6a200bb941..e428cb74d7 100644 --- a/packages/pluggableWidgets/gallery-web/src/helpers/root-context.ts +++ b/packages/pluggableWidgets/gallery-web/src/helpers/root-context.ts @@ -1,5 +1,5 @@ import { SelectActionHandler, SelectionHelper } from "@mendix/widget-plugin-grid/selection"; -import { SelectionCountStore } from "@mendix/widget-plugin-grid/selection/stores/SelectionCountStore"; +import { SelectionCountStore } from "@mendix/widget-plugin-grid/stores/SelectionCountStore"; import { createContext, useContext } from "react"; import { GalleryStore } from "../stores/GalleryStore"; diff --git a/packages/pluggableWidgets/gallery-web/src/stores/GalleryStore.ts b/packages/pluggableWidgets/gallery-web/src/stores/GalleryStore.ts index 4c45d00ca0..f7cbb78bfd 100644 --- a/packages/pluggableWidgets/gallery-web/src/stores/GalleryStore.ts +++ b/packages/pluggableWidgets/gallery-web/src/stores/GalleryStore.ts @@ -4,7 +4,7 @@ import { CustomFilterHost } from "@mendix/widget-plugin-filtering/stores/generic import { DatasourceController } from "@mendix/widget-plugin-grid/query/DatasourceController"; import { PaginationController } from "@mendix/widget-plugin-grid/query/PaginationController"; import { RefreshController } from "@mendix/widget-plugin-grid/query/RefreshController"; -import { SelectionCountStore } from "@mendix/widget-plugin-grid/selection/stores/SelectionCountStore"; +import { SelectionCountStore } from "@mendix/widget-plugin-grid/stores/SelectionCountStore"; import { BaseControllerHost } from "@mendix/widget-plugin-mobx-kit/BaseControllerHost"; import { DerivedPropsGate } from "@mendix/widget-plugin-mobx-kit/props-gate"; import { generateUUID } from "@mendix/widget-plugin-platform/framework/generate-uuid"; diff --git a/packages/pluggableWidgets/gallery-web/src/utils/test-utils.tsx b/packages/pluggableWidgets/gallery-web/src/utils/test-utils.tsx index 4fc8a0b41b..f7ef20fa72 100644 --- a/packages/pluggableWidgets/gallery-web/src/utils/test-utils.tsx +++ b/packages/pluggableWidgets/gallery-web/src/utils/test-utils.tsx @@ -4,7 +4,7 @@ import { PositionController } from "@mendix/widget-plugin-grid/keyboard-navigati import { VirtualGridLayout } from "@mendix/widget-plugin-grid/keyboard-navigation/VirtualGridLayout"; import { getColumnAndRowBasedOnIndex, SelectActionHandler } from "@mendix/widget-plugin-grid/selection"; import { GateProvider } from "@mendix/widget-plugin-mobx-kit/GateProvider"; -import { list, listAction, objectItems } from "@mendix/widget-plugin-test-utils"; +import { dynamic, list, listAction, objectItems } from "@mendix/widget-plugin-test-utils"; import { render, RenderResult } from "@testing-library/react"; import userEvent, { UserEvent } from "@testing-library/user-event"; import { ObjectItem } from "mendix"; @@ -58,7 +58,7 @@ export function createMockGalleryContext(): GalleryRootScope { storeSort: false, refreshIndicator: false, keepSelection: false, - selectionCountPosition: "bottom" + clearSelectionCaption: dynamic("Clear selection") }; // Create a proper gate provider and gate diff --git a/packages/pluggableWidgets/gallery-web/typings/GalleryProps.d.ts b/packages/pluggableWidgets/gallery-web/typings/GalleryProps.d.ts index b0cfa98317..660cc1e278 100644 --- a/packages/pluggableWidgets/gallery-web/typings/GalleryProps.d.ts +++ b/packages/pluggableWidgets/gallery-web/typings/GalleryProps.d.ts @@ -61,6 +61,7 @@ export interface GalleryContainerProps { ariaLabelItem?: ListExpressionValue; selectedCountTemplateSingular?: DynamicValue; selectedCountTemplatePlural?: DynamicValue; + clearSelectionCaption: DynamicValue; } export interface GalleryPreviewProps { @@ -109,4 +110,5 @@ export interface GalleryPreviewProps { ariaLabelItem: string; selectedCountTemplateSingular: string; selectedCountTemplatePlural: string; + clearSelectionCaption: string; } diff --git a/packages/shared/widget-plugin-grid/src/select-all/SelectAllController.ts b/packages/shared/widget-plugin-grid/src/select-all/SelectAllController.ts index 9b6b3fb649..cd348cf15a 100644 --- a/packages/shared/widget-plugin-grid/src/select-all/SelectAllController.ts +++ b/packages/shared/widget-plugin-grid/src/select-all/SelectAllController.ts @@ -5,27 +5,20 @@ import { action, computed, makeObservable, observable, when } from "mobx"; import { QueryController } from "../query/query-controller"; type Gate = DerivedPropsGate<{ itemSelection?: SelectionMultiValue | SelectionSingleValue }>; - -interface SelectAllControllerSpec { - gate: Gate; - query: QueryController; -} - type SelectAllEventType = "loadstart" | "progress" | "abort" | "loadend"; export class SelectAllController implements ReactiveController { + private locked = false; + readonly #gate: Gate; readonly #query: QueryController; readonly #emitter = new EventTarget(); readonly #pageSize = 1024; + #abortController?: AbortController; - private locked = false; - constructor(host: ReactiveControllerHost, spec: SelectAllControllerSpec) { + constructor(host: ReactiveControllerHost, gate: Gate, query: QueryController) { host.addController(this); - this.#gate = spec.gate; - this.#query = spec.query; - type PrivateMembers = "setIsLocked" | "locked"; makeObservable(this, { setIsLocked: action, @@ -39,6 +32,9 @@ export class SelectAllController implements ReactiveController { clearSelection: action, abort: action }); + + this.#gate = gate; + this.#query = query; } setup(): () => void { @@ -163,7 +159,6 @@ export class SelectAllController implements ReactiveController { * This method is a hack to reload selection. To work it requires at leas one object. * The problem is that if we setting value equal to current selection, then prop is * not reloaded. We solve this by setting ether empty array or array with one object. - * @returns */ reloadSelection(): Promise { const prevSelection = this.selection; @@ -171,9 +166,7 @@ export class SelectAllController implements ReactiveController { const currentSelection = this.selection?.selection ?? []; const newSelection = currentSelection.length > 0 ? [] : items; this.selection?.setSelection(newSelection); - // `when` resolves when selection value is updated - const ok = when(() => this.selection !== prevSelection); - return ok; + return when(() => this.selection !== prevSelection); } clearSelection(): void { diff --git a/packages/shared/widget-plugin-grid/src/select-all/SelectAllHost.ts b/packages/shared/widget-plugin-grid/src/select-all/SelectAllHost.ts index 0b423bdd8c..481bf7e152 100644 --- a/packages/shared/widget-plugin-grid/src/select-all/SelectAllHost.ts +++ b/packages/shared/widget-plugin-grid/src/select-all/SelectAllHost.ts @@ -18,7 +18,7 @@ export class SelectAllHost extends BaseControllerHost { constructor(spec: SelectAllHostSpec) { super(); const query = new DatasourceController(this, { gate: spec.gate }); - this.selectAllController = new SelectAllController(this, { gate: spec.gate, query, pageSize: 30 }); + this.selectAllController = new SelectAllController(this, spec.gate, query); this.selectAllProgressStore = spec.selectAllProgressStore; } diff --git a/packages/shared/widget-plugin-grid/src/stores/SelectionCountStore.ts b/packages/shared/widget-plugin-grid/src/stores/SelectionCountStore.ts index 6227e7f0ee..af45d54a14 100644 --- a/packages/shared/widget-plugin-grid/src/stores/SelectionCountStore.ts +++ b/packages/shared/widget-plugin-grid/src/stores/SelectionCountStore.ts @@ -58,7 +58,7 @@ export class SelectionCountStore { return this.formatPlural.replace("%d", `${count}`); } - get clearSelectionLabel(): string { + get clearSelectionText(): string { return this.gate.props.clearSelectionCaption?.value ?? "clear.selection.caption"; } } From 21e73d62235c4ca1ef11697ca8c54043f44aca22 Mon Sep 17 00:00:00 2001 From: Illia Obukhau <8282906+iobuhov@users.noreply.github.com> Date: Wed, 15 Oct 2025 17:38:20 +0200 Subject: [PATCH 25/35] feat: set delay for 1.5s --- .../src/helpers/state/SelectionProgressDialogViewModel.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/pluggableWidgets/datagrid-web/src/helpers/state/SelectionProgressDialogViewModel.ts b/packages/pluggableWidgets/datagrid-web/src/helpers/state/SelectionProgressDialogViewModel.ts index 8c077fa8de..3ec73ed715 100644 --- a/packages/pluggableWidgets/datagrid-web/src/helpers/state/SelectionProgressDialogViewModel.ts +++ b/packages/pluggableWidgets/datagrid-web/src/helpers/state/SelectionProgressDialogViewModel.ts @@ -69,7 +69,7 @@ export class SelectionProgressDialogViewModel implements ReactiveController { this.#timerId = setTimeout(() => { this.setDialogOpen(true); this.#timerId = undefined; - }, 2000); + }, 1500); } else { this.setDialogOpen(false); clearTimeout(this.#timerId); From 0162c7c79afa5af750b02c2d7284ce3b9906028e Mon Sep 17 00:00:00 2001 From: Illia Obukhau <8282906+iobuhov@users.noreply.github.com> Date: Fri, 17 Oct 2025 13:39:58 +0200 Subject: [PATCH 26/35] refactor: reduce used memory for checks --- .../src/helpers/state/SelectAllBarViewModel.ts | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/packages/pluggableWidgets/datagrid-web/src/helpers/state/SelectAllBarViewModel.ts b/packages/pluggableWidgets/datagrid-web/src/helpers/state/SelectAllBarViewModel.ts index 11abee64b8..c52dd36d55 100644 --- a/packages/pluggableWidgets/datagrid-web/src/helpers/state/SelectAllBarViewModel.ts +++ b/packages/pluggableWidgets/datagrid-web/src/helpers/state/SelectAllBarViewModel.ts @@ -85,17 +85,12 @@ export class SelectAllBarViewModel implements ReactiveController { return str.replace("%d", `${this.#count.selectedCount}`); } - private get selectedSet(): Set { - const selection = this.#gate.props.itemSelection; - if (!selection) return new Set(); - if (selection.type === "Single") return new Set(); - return new Set([...selection.selection.map(it => it.id)]); - } - private get isCurrentPageSelected(): boolean { - const items = this.#gate.props.datasource.items ?? []; - if (items.length === 0) return false; - return items.every(items => this.selectedSet.has(items.id)); + const selection = this.#gate.props.itemSelection; + if (!selection || selection.type === "Single") return false; + const pageIds = new Set(this.#gate.props.datasource.items?.map(item => item.id) ?? []); + const selectionSubArray = selection.selection.filter(item => pageIds.has(item.id)); + return selectionSubArray.length === pageIds.size && pageIds.size > 0; } private get isAllItemsSelected(): boolean { From 46e32d37d86ac6c416b6e47d19842068721c553e Mon Sep 17 00:00:00 2001 From: Illia Obukhau <8282906+iobuhov@users.noreply.github.com> Date: Fri, 17 Oct 2025 13:49:37 +0200 Subject: [PATCH 27/35] refactor: apply feedback --- .../datawidgets/web/_datagrid.scss | 7 ++-- .../src/components/SelectAllBar.tsx | 8 ++--- .../src/components/WidgetFooter.tsx | 2 +- .../__snapshots__/Table.spec.tsx.snap | 32 +++++++++---------- .../src/components/loader/SpinnerLoader.tsx | 10 ++---- 5 files changed, 25 insertions(+), 34 deletions(-) diff --git a/packages/modules/data-widgets/src/themesource/datawidgets/web/_datagrid.scss b/packages/modules/data-widgets/src/themesource/datawidgets/web/_datagrid.scss index 3b52e89f9d..4c5d41466f 100644 --- a/packages/modules/data-widgets/src/themesource/datawidgets/web/_datagrid.scss +++ b/packages/modules/data-widgets/src/themesource/datawidgets/web/_datagrid.scss @@ -489,10 +489,7 @@ $root: ".widget-datagrid"; &-spinner { justify-content: center; - - &-full-width { - width: 100%; - } + width: 100%; &-margin { margin: 52px 0; @@ -578,7 +575,7 @@ $root: ".widget-datagrid"; align-items: center; } -#{$root}-btn-invisible { +#{$root}-btn-link { cursor: pointer; background: transparent; border: none; diff --git a/packages/pluggableWidgets/datagrid-web/src/components/SelectAllBar.tsx b/packages/pluggableWidgets/datagrid-web/src/components/SelectAllBar.tsx index 75085abe1a..61ed8967a1 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/SelectAllBar.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/SelectAllBar.tsx @@ -1,9 +1,9 @@ import { If } from "@mendix/widget-plugin-component-kit/If"; import { observer } from "mobx-react-lite"; -import { createElement } from "react"; +import { ReactNode } from "react"; import { useDatagridRootScope } from "../helpers/root-context"; -export const SelectAllBar = observer(function SelectAllBar(): React.ReactNode { +export const SelectAllBar = observer(function SelectAllBar(): ReactNode { const { selectAllBarViewModel: vm } = useDatagridRootScope(); if (!vm.isBarVisible) return null; @@ -14,14 +14,14 @@ export const SelectAllBar = observer(function SelectAllBar(): React.ReactNode { - diff --git a/packages/pluggableWidgets/datagrid-web/src/components/WidgetFooter.tsx b/packages/pluggableWidgets/datagrid-web/src/components/WidgetFooter.tsx index 12ca2fa71d..68b83d7fb0 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/WidgetFooter.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/WidgetFooter.tsx @@ -41,7 +41,7 @@ const SelectionCounter = observer(function SelectionCounter() { {selectionCountStore.selectedCountText}   - diff --git a/packages/pluggableWidgets/datagrid-web/src/components/__tests__/__snapshots__/Table.spec.tsx.snap b/packages/pluggableWidgets/datagrid-web/src/components/__tests__/__snapshots__/Table.spec.tsx.snap index 8ddc5c61e4..6bc49c2003 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/__tests__/__snapshots__/Table.spec.tsx.snap +++ b/packages/pluggableWidgets/datagrid-web/src/components/__tests__/__snapshots__/Table.spec.tsx.snap @@ -91,7 +91,7 @@ exports[`Table renders the structure correctly 1`] = ` />  
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
Date: Mon, 20 Oct 2025 15:16:13 +0200 Subject: [PATCH 28/35] fix: resolve type issues --- .../datagrid-web/src/components/CheckboxColumnHeader.tsx | 5 +++-- .../datagrid-web/src/components/SelectionProgressDialog.tsx | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/pluggableWidgets/datagrid-web/src/components/CheckboxColumnHeader.tsx b/packages/pluggableWidgets/datagrid-web/src/components/CheckboxColumnHeader.tsx index a6efc23506..6b25f29347 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/CheckboxColumnHeader.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/CheckboxColumnHeader.tsx @@ -1,5 +1,6 @@ import { ThreeStateCheckBox } from "@mendix/widget-plugin-component-kit/ThreeStateCheckBox"; -import { Fragment, ReactElement } from "react"; +import { SelectionStatus } from "@mendix/widget-plugin-grid/selection"; +import { Fragment, ReactElement, ReactNode } from "react"; import { useDatagridRootScope } from "../helpers/root-context"; export function CheckboxColumnHeader(): ReactElement { @@ -24,7 +25,7 @@ export function CheckboxColumnHeader(): ReactElement { ); } -function Checkbox(props: { status: SelectionStatus; onChange: () => void; "aria-label"?: string }): React.ReactNode { +function Checkbox(props: { status: SelectionStatus; onChange: () => void; "aria-label"?: string }): ReactNode { if (props.status === "unknown") { console.error("Data grid 2: don't know how to render column checkbox with selectionStatus=unknown"); return null; diff --git a/packages/pluggableWidgets/datagrid-web/src/components/SelectionProgressDialog.tsx b/packages/pluggableWidgets/datagrid-web/src/components/SelectionProgressDialog.tsx index 66091e2640..edd64b7558 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/SelectionProgressDialog.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/SelectionProgressDialog.tsx @@ -1,4 +1,4 @@ -import { createElement, ReactElement } from "react"; +import { ReactElement } from "react"; import { useDatagridRootScope } from "../helpers/root-context"; import { ExportAlert } from "./ExportAlert"; import { PseudoModal } from "./PseudoModal"; From 819c4ca3d0306b7c4a9bf17aaa6613b873b81397 Mon Sep 17 00:00:00 2001 From: Illia Obukhau <8282906+iobuhov@users.noreply.github.com> Date: Tue, 21 Oct 2025 10:15:48 +0200 Subject: [PATCH 29/35] chore: bump gallery version --- packages/pluggableWidgets/gallery-web/CHANGELOG.md | 4 ++++ packages/pluggableWidgets/gallery-web/package.json | 2 +- packages/pluggableWidgets/gallery-web/src/package.xml | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/pluggableWidgets/gallery-web/CHANGELOG.md b/packages/pluggableWidgets/gallery-web/CHANGELOG.md index 6c1b7c3507..dcd62d05e9 100644 --- a/packages/pluggableWidgets/gallery-web/CHANGELOG.md +++ b/packages/pluggableWidgets/gallery-web/CHANGELOG.md @@ -14,6 +14,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - We fixed an issue where setting the gallery gap to 0 caused an offset, which made the bottom border of items to dissapear. +### Added + +- We introduced a new caption setting for the clear selection button. + ## [3.6.1] - 2025-10-14 ### Fixed diff --git a/packages/pluggableWidgets/gallery-web/package.json b/packages/pluggableWidgets/gallery-web/package.json index 61a95142a8..de7e720fd1 100644 --- a/packages/pluggableWidgets/gallery-web/package.json +++ b/packages/pluggableWidgets/gallery-web/package.json @@ -1,7 +1,7 @@ { "name": "@mendix/gallery-web", "widgetName": "Gallery", - "version": "3.6.1", + "version": "3.7.0", "description": "A flexible gallery widget that renders columns, rows and layouts.", "copyright": "© Mendix Technology BV 2025. All rights reserved.", "license": "Apache-2.0", diff --git a/packages/pluggableWidgets/gallery-web/src/package.xml b/packages/pluggableWidgets/gallery-web/src/package.xml index c62921623f..cc89a26e97 100644 --- a/packages/pluggableWidgets/gallery-web/src/package.xml +++ b/packages/pluggableWidgets/gallery-web/src/package.xml @@ -1,6 +1,6 @@ - + From 2f670ad597f48b204449346ea53c74b83732cc05 Mon Sep 17 00:00:00 2001 From: Illia Obukhau <8282906+iobuhov@users.noreply.github.com> Date: Tue, 21 Oct 2025 10:18:13 +0200 Subject: [PATCH 30/35] chore: change default strings --- .../widget-plugin-grid/src/stores/SelectionCountStore.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/shared/widget-plugin-grid/src/stores/SelectionCountStore.ts b/packages/shared/widget-plugin-grid/src/stores/SelectionCountStore.ts index af45d54a14..83dea0d1c9 100644 --- a/packages/shared/widget-plugin-grid/src/stores/SelectionCountStore.ts +++ b/packages/shared/widget-plugin-grid/src/stores/SelectionCountStore.ts @@ -11,8 +11,8 @@ type Gate = DerivedPropsGate<{ export class SelectionCountStore { private gate: Gate; - private singular: string = "%d row selected."; - private plural: string = "%d rows selected."; + private singular: string = "row.count.singular"; + private plural: string = "row.count.plural"; constructor(gate: Gate, spec: { singular?: string; plural?: string; clearLabel?: string } = {}) { this.singular = spec.singular ?? this.singular; From 7fda8ece9fb79e9281f95dba34bc959b4bbe4292 Mon Sep 17 00:00:00 2001 From: Illia Obukhau <8282906+iobuhov@users.noreply.github.com> Date: Tue, 21 Oct 2025 10:22:51 +0200 Subject: [PATCH 31/35] chore: bump versions for other widgets --- packages/modules/data-widgets/package.json | 2 +- packages/pluggableWidgets/datagrid-date-filter-web/package.json | 2 +- .../pluggableWidgets/datagrid-date-filter-web/src/package.xml | 2 +- .../pluggableWidgets/datagrid-dropdown-filter-web/package.json | 2 +- .../datagrid-dropdown-filter-web/src/package.xml | 2 +- .../pluggableWidgets/datagrid-number-filter-web/package.json | 2 +- .../pluggableWidgets/datagrid-number-filter-web/src/package.xml | 2 +- packages/pluggableWidgets/datagrid-text-filter-web/package.json | 2 +- .../pluggableWidgets/datagrid-text-filter-web/src/package.xml | 2 +- packages/pluggableWidgets/datagrid-web/package.json | 2 +- packages/pluggableWidgets/datagrid-web/src/package.xml | 2 +- packages/pluggableWidgets/dropdown-sort-web/package.json | 2 +- packages/pluggableWidgets/dropdown-sort-web/src/package.xml | 2 +- packages/pluggableWidgets/selection-helper-web/package.json | 2 +- packages/pluggableWidgets/selection-helper-web/src/package.xml | 2 +- packages/pluggableWidgets/tree-node-web/package.json | 2 +- packages/pluggableWidgets/tree-node-web/src/package.xml | 2 +- 17 files changed, 17 insertions(+), 17 deletions(-) diff --git a/packages/modules/data-widgets/package.json b/packages/modules/data-widgets/package.json index 7ce4e4e780..d90306c6f4 100644 --- a/packages/modules/data-widgets/package.json +++ b/packages/modules/data-widgets/package.json @@ -1,7 +1,7 @@ { "name": "@mendix/data-widgets", "moduleName": "Data Widgets", - "version": "3.6.1", + "version": "3.7.0", "description": "Data Widgets module containing a set of widgets to display data in various ways.", "copyright": "© Mendix Technology BV 2025. All rights reserved.", "license": "Apache-2.0", diff --git a/packages/pluggableWidgets/datagrid-date-filter-web/package.json b/packages/pluggableWidgets/datagrid-date-filter-web/package.json index f7be2cad7a..9926277555 100644 --- a/packages/pluggableWidgets/datagrid-date-filter-web/package.json +++ b/packages/pluggableWidgets/datagrid-date-filter-web/package.json @@ -1,7 +1,7 @@ { "name": "@mendix/datagrid-date-filter-web", "widgetName": "DatagridDateFilter", - "version": "3.6.0", + "version": "3.7.0", "description": "Filter Data Grid 2 rows by date or date range, using a calendar picker.", "copyright": "© Mendix Technology BV 2025. All rights reserved.", "license": "Apache-2.0", diff --git a/packages/pluggableWidgets/datagrid-date-filter-web/src/package.xml b/packages/pluggableWidgets/datagrid-date-filter-web/src/package.xml index c7d5c584b3..5701349eaa 100644 --- a/packages/pluggableWidgets/datagrid-date-filter-web/src/package.xml +++ b/packages/pluggableWidgets/datagrid-date-filter-web/src/package.xml @@ -1,6 +1,6 @@ - + diff --git a/packages/pluggableWidgets/datagrid-dropdown-filter-web/package.json b/packages/pluggableWidgets/datagrid-dropdown-filter-web/package.json index 6621ddb8e0..ad2ed1018a 100644 --- a/packages/pluggableWidgets/datagrid-dropdown-filter-web/package.json +++ b/packages/pluggableWidgets/datagrid-dropdown-filter-web/package.json @@ -1,7 +1,7 @@ { "name": "@mendix/datagrid-dropdown-filter-web", "widgetName": "DatagridDropdownFilter", - "version": "3.6.0", + "version": "3.7.0", "description": "Filter Data Grid 2 rows by selecting values from a drop-down list.", "copyright": "© Mendix Technology BV 2025. All rights reserved.", "license": "Apache-2.0", diff --git a/packages/pluggableWidgets/datagrid-dropdown-filter-web/src/package.xml b/packages/pluggableWidgets/datagrid-dropdown-filter-web/src/package.xml index 287e696bb0..37cff42f12 100644 --- a/packages/pluggableWidgets/datagrid-dropdown-filter-web/src/package.xml +++ b/packages/pluggableWidgets/datagrid-dropdown-filter-web/src/package.xml @@ -1,6 +1,6 @@ - + diff --git a/packages/pluggableWidgets/datagrid-number-filter-web/package.json b/packages/pluggableWidgets/datagrid-number-filter-web/package.json index 9b117a781e..bbb26a4432 100644 --- a/packages/pluggableWidgets/datagrid-number-filter-web/package.json +++ b/packages/pluggableWidgets/datagrid-number-filter-web/package.json @@ -1,7 +1,7 @@ { "name": "@mendix/datagrid-number-filter-web", "widgetName": "DatagridNumberFilter", - "version": "3.6.0", + "version": "3.7.0", "description": "Filter Data Grid 2 rows by numeric values, supporting equals, greater than, and less than operations.", "copyright": "© Mendix Technology BV 2025. All rights reserved.", "license": "Apache-2.0", diff --git a/packages/pluggableWidgets/datagrid-number-filter-web/src/package.xml b/packages/pluggableWidgets/datagrid-number-filter-web/src/package.xml index 43f93a6d08..664e178572 100644 --- a/packages/pluggableWidgets/datagrid-number-filter-web/src/package.xml +++ b/packages/pluggableWidgets/datagrid-number-filter-web/src/package.xml @@ -1,6 +1,6 @@ - + diff --git a/packages/pluggableWidgets/datagrid-text-filter-web/package.json b/packages/pluggableWidgets/datagrid-text-filter-web/package.json index 60dce72c99..b83661ad84 100644 --- a/packages/pluggableWidgets/datagrid-text-filter-web/package.json +++ b/packages/pluggableWidgets/datagrid-text-filter-web/package.json @@ -1,7 +1,7 @@ { "name": "@mendix/datagrid-text-filter-web", "widgetName": "DatagridTextFilter", - "version": "3.6.0", + "version": "3.7.0", "description": "Filter Data Grid 2 rows by text input, supporting contains, starts with, and equals operations.", "copyright": "© Mendix Technology BV 2025. All rights reserved.", "license": "Apache-2.0", diff --git a/packages/pluggableWidgets/datagrid-text-filter-web/src/package.xml b/packages/pluggableWidgets/datagrid-text-filter-web/src/package.xml index c1acba7476..d5f27ea33f 100644 --- a/packages/pluggableWidgets/datagrid-text-filter-web/src/package.xml +++ b/packages/pluggableWidgets/datagrid-text-filter-web/src/package.xml @@ -1,6 +1,6 @@ - + diff --git a/packages/pluggableWidgets/datagrid-web/package.json b/packages/pluggableWidgets/datagrid-web/package.json index 1887d29d8b..ceb4c54d5a 100644 --- a/packages/pluggableWidgets/datagrid-web/package.json +++ b/packages/pluggableWidgets/datagrid-web/package.json @@ -1,7 +1,7 @@ { "name": "@mendix/datagrid-web", "widgetName": "Datagrid", - "version": "3.6.1", + "version": "3.7.0", "description": "A powerful, flexible grid for displaying, sorting, and editing data collections in Mendix web apps.", "copyright": "© Mendix Technology BV 2025. All rights reserved.", "license": "Apache-2.0", diff --git a/packages/pluggableWidgets/datagrid-web/src/package.xml b/packages/pluggableWidgets/datagrid-web/src/package.xml index b28b67d632..e8d4925383 100644 --- a/packages/pluggableWidgets/datagrid-web/src/package.xml +++ b/packages/pluggableWidgets/datagrid-web/src/package.xml @@ -1,6 +1,6 @@ - + diff --git a/packages/pluggableWidgets/dropdown-sort-web/package.json b/packages/pluggableWidgets/dropdown-sort-web/package.json index 5a5c97f0c9..7f1f72acf1 100644 --- a/packages/pluggableWidgets/dropdown-sort-web/package.json +++ b/packages/pluggableWidgets/dropdown-sort-web/package.json @@ -1,7 +1,7 @@ { "name": "@mendix/dropdown-sort-web", "widgetName": "DropdownSort", - "version": "3.4.0", + "version": "3.7.0", "description": "Adds sorting functionality to Gallery widget.", "copyright": "© Mendix Technology BV 2025. All rights reserved.", "license": "Apache-2.0", diff --git a/packages/pluggableWidgets/dropdown-sort-web/src/package.xml b/packages/pluggableWidgets/dropdown-sort-web/src/package.xml index 7db3ce3bf9..8950f2c68f 100644 --- a/packages/pluggableWidgets/dropdown-sort-web/src/package.xml +++ b/packages/pluggableWidgets/dropdown-sort-web/src/package.xml @@ -1,6 +1,6 @@ - + diff --git a/packages/pluggableWidgets/selection-helper-web/package.json b/packages/pluggableWidgets/selection-helper-web/package.json index 07fb627ba8..fe338b1af2 100644 --- a/packages/pluggableWidgets/selection-helper-web/package.json +++ b/packages/pluggableWidgets/selection-helper-web/package.json @@ -1,7 +1,7 @@ { "name": "@mendix/selection-helper-web", "widgetName": "SelectionHelper", - "version": "3.6.1", + "version": "3.7.0", "description": "Makes it easier for users to select multiple items in Gallery widget.", "copyright": "© Mendix Technology BV 2025. All rights reserved.", "license": "Apache-2.0", diff --git a/packages/pluggableWidgets/selection-helper-web/src/package.xml b/packages/pluggableWidgets/selection-helper-web/src/package.xml index db5475fe2c..21d09064cf 100644 --- a/packages/pluggableWidgets/selection-helper-web/src/package.xml +++ b/packages/pluggableWidgets/selection-helper-web/src/package.xml @@ -1,6 +1,6 @@ - + diff --git a/packages/pluggableWidgets/tree-node-web/package.json b/packages/pluggableWidgets/tree-node-web/package.json index 78c19dcddb..ee5851b81f 100644 --- a/packages/pluggableWidgets/tree-node-web/package.json +++ b/packages/pluggableWidgets/tree-node-web/package.json @@ -1,7 +1,7 @@ { "name": "@mendix/tree-node-web", "widgetName": "TreeNode", - "version": "3.6.0", + "version": "3.7.0", "description": "A Mendix pluggable widget to display a tree view structure.", "copyright": "© Mendix Technology BV 2025. All rights reserved.", "license": "Apache-2.0", diff --git a/packages/pluggableWidgets/tree-node-web/src/package.xml b/packages/pluggableWidgets/tree-node-web/src/package.xml index 945c112ee4..852eae20d8 100644 --- a/packages/pluggableWidgets/tree-node-web/src/package.xml +++ b/packages/pluggableWidgets/tree-node-web/src/package.xml @@ -1,6 +1,6 @@ - + From c89705c86fa8abdb57ef4c7a071b4f3bfd7289c6 Mon Sep 17 00:00:00 2001 From: Illia Obukhau <8282906+iobuhov@users.noreply.github.com> Date: Tue, 21 Oct 2025 12:04:42 +0200 Subject: [PATCH 32/35] chore: apply feedback --- .../widget-plugin-grid/src/stores/SelectionCountStore.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/shared/widget-plugin-grid/src/stores/SelectionCountStore.ts b/packages/shared/widget-plugin-grid/src/stores/SelectionCountStore.ts index 83dea0d1c9..43d3d89567 100644 --- a/packages/shared/widget-plugin-grid/src/stores/SelectionCountStore.ts +++ b/packages/shared/widget-plugin-grid/src/stores/SelectionCountStore.ts @@ -11,8 +11,8 @@ type Gate = DerivedPropsGate<{ export class SelectionCountStore { private gate: Gate; - private singular: string = "row.count.singular"; - private plural: string = "row.count.plural"; + private singular: string = "%d.row.count"; + private plural: string = "%d.rows.count"; constructor(gate: Gate, spec: { singular?: string; plural?: string; clearLabel?: string } = {}) { this.singular = spec.singular ?? this.singular; From 76155867f464dcf92f53bae6824e0a389d559ad9 Mon Sep 17 00:00:00 2001 From: Illia Obukhau <8282906+iobuhov@users.noreply.github.com> Date: Fri, 24 Oct 2025 10:31:55 +0200 Subject: [PATCH 33/35] fix: add default translations --- .../pluggableWidgets/datagrid-web/src/Datagrid.xml | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/pluggableWidgets/datagrid-web/src/Datagrid.xml b/packages/pluggableWidgets/datagrid-web/src/Datagrid.xml index 95ed8ec323..99e49e7b50 100644 --- a/packages/pluggableWidgets/datagrid-web/src/Datagrid.xml +++ b/packages/pluggableWidgets/datagrid-web/src/Datagrid.xml @@ -397,11 +397,17 @@ Row count singular - Must include '%d' to denote number position ('%d row selected') + Must include '%d' to denote number position + + %d row selected + Row count plural - Must include '%d' to denote number position ('%d rows selected') + Must include '%d' to denote number position + + %d rows selected + Select all text From d2966347a17950f9a090821640a060dea9a7d56e Mon Sep 17 00:00:00 2001 From: Illia Obukhau <8282906+iobuhov@users.noreply.github.com> Date: Fri, 24 Oct 2025 11:38:44 +0200 Subject: [PATCH 34/35] refactor: get rid off private elements (fields) --- .../helpers/state/SelectAllBarViewModel.ts | 56 +++++++-------- .../state/SelectionProgressDialogViewModel.ts | 39 +++++----- .../src/helpers/state/useRootStore.ts | 4 +- .../src/select-all/SelectAllController.ts | 72 +++++++++---------- .../src/select-all/SelectAllHost.ts | 19 ++--- 5 files changed, 87 insertions(+), 103 deletions(-) diff --git a/packages/pluggableWidgets/datagrid-web/src/helpers/state/SelectAllBarViewModel.ts b/packages/pluggableWidgets/datagrid-web/src/helpers/state/SelectAllBarViewModel.ts index c52dd36d55..2c0f1da75c 100644 --- a/packages/pluggableWidgets/datagrid-web/src/helpers/state/SelectAllBarViewModel.ts +++ b/packages/pluggableWidgets/datagrid-web/src/helpers/state/SelectAllBarViewModel.ts @@ -5,7 +5,7 @@ import { ReactiveController, ReactiveControllerHost } from "@mendix/widget-plugi import { action, makeAutoObservable, reaction } from "mobx"; import { DatagridContainerProps } from "../../../typings/DatagridProps"; -type Props = Pick< +type DynamicProps = Pick< DatagridContainerProps, | "cancelSelectionLabel" | "selectAllTemplate" @@ -19,23 +19,18 @@ type Props = Pick< | "enableSelectAll" >; -type Gate = DerivedPropsGate; - export class SelectAllBarViewModel implements ReactiveController { private barVisible = false; private clearVisible = false; - pending = false; + private readonly enableSelectAll: boolean; - readonly #gate: Gate; - readonly #selectAllController: SelectAllController; - readonly #count: SelectionCountStore; - readonly #enableSelectAll: boolean; + pending = false; constructor( host: ReactiveControllerHost, - gate: Gate, - selectAllController: SelectAllController, - count = new SelectionCountStore(gate) + private readonly gate: DerivedPropsGate, + private readonly selectAllController: SelectAllController, + private readonly count = new SelectionCountStore(gate) ) { host.addController(this); type PrivateMembers = "setClearVisible" | "setPending" | "hideBar" | "showBar"; @@ -45,10 +40,7 @@ export class SelectAllBarViewModel implements ReactiveController { hideBar: action, showBar: action }); - this.#gate = gate; - this.#selectAllController = selectAllController; - this.#count = count; - this.#enableSelectAll = gate.props.enableSelectAll; + this.enableSelectAll = gate.props.enableSelectAll; } private setClearVisible(value: boolean): void { @@ -69,38 +61,40 @@ export class SelectAllBarViewModel implements ReactiveController { } private get total(): number { - return this.#gate.props.datasource.totalCount ?? 0; + return this.gate.props.datasource.totalCount ?? 0; } private get selectAllFormat(): string { - return this.#gate.props.selectAllTemplate?.value ?? "select.all.n.items"; + return this.gate.props.selectAllTemplate?.value ?? "select.all.n.items"; } private get selectAllText(): string { - return this.#gate.props.selectAllText?.value ?? "select.all.items"; + return this.gate.props.selectAllText?.value ?? "select.all.items"; } private get allSelectedText(): string { - const str = this.#gate.props.allSelectedText?.value ?? "all.selected"; - return str.replace("%d", `${this.#count.selectedCount}`); + const str = this.gate.props.allSelectedText?.value ?? "all.selected"; + return str.replace("%d", `${this.count.selectedCount}`); } private get isCurrentPageSelected(): boolean { - const selection = this.#gate.props.itemSelection; + const selection = this.gate.props.itemSelection; + if (!selection || selection.type === "Single") return false; - const pageIds = new Set(this.#gate.props.datasource.items?.map(item => item.id) ?? []); + + const pageIds = new Set(this.gate.props.datasource.items?.map(item => item.id) ?? []); const selectionSubArray = selection.selection.filter(item => pageIds.has(item.id)); return selectionSubArray.length === pageIds.size && pageIds.size > 0; } private get isAllItemsSelected(): boolean { - if (this.total > 0) return this.total === this.#count.selectedCount; + if (this.total > 0) return this.total === this.count.selectedCount; - const { offset, limit, items = [], hasMoreItems } = this.#gate.props.datasource; + const { offset, limit, items = [], hasMoreItems } = this.gate.props.datasource; const noMoreItems = typeof hasMoreItems === "boolean" && hasMoreItems === false; const fullyLoaded = offset === 0 && limit >= items.length; - return fullyLoaded && noMoreItems && items.length === this.#count.selectedCount; + return fullyLoaded && noMoreItems && items.length === this.count.selectedCount; } get selectAllLabel(): string { @@ -109,16 +103,16 @@ export class SelectAllBarViewModel implements ReactiveController { } get clearSelectionLabel(): string { - return this.#gate.props.clearSelectionCaption?.value ?? "clear.selection.caption"; + return this.gate.props.clearSelectionCaption?.value ?? "clear.selection.caption"; } get selectionStatus(): string { if (this.isAllItemsSelected) return this.allSelectedText; - return this.#count.selectedCountText; + return this.count.selectedCountText; } get isBarVisible(): boolean { - return this.#enableSelectAll && this.barVisible; + return this.enableSelectAll && this.barVisible; } get isClearVisible(): boolean { @@ -134,7 +128,7 @@ export class SelectAllBarViewModel implements ReactiveController { } setup(): (() => void) | void { - if (!this.#enableSelectAll) return; + if (!this.enableSelectAll) return; return reaction( () => this.isCurrentPageSelected, @@ -149,13 +143,13 @@ export class SelectAllBarViewModel implements ReactiveController { } onClear(): void { - this.#selectAllController.clearSelection(); + this.selectAllController.clearSelection(); } async onSelectAll(): Promise { this.setPending(true); try { - const { success } = await this.#selectAllController.selectAllPages(); + const { success } = await this.selectAllController.selectAllPages(); this.setClearVisible(success); } finally { this.setPending(false); diff --git a/packages/pluggableWidgets/datagrid-web/src/helpers/state/SelectionProgressDialogViewModel.ts b/packages/pluggableWidgets/datagrid-web/src/helpers/state/SelectionProgressDialogViewModel.ts index 3ec73ed715..58dc3cc6d8 100644 --- a/packages/pluggableWidgets/datagrid-web/src/helpers/state/SelectionProgressDialogViewModel.ts +++ b/packages/pluggableWidgets/datagrid-web/src/helpers/state/SelectionProgressDialogViewModel.ts @@ -5,10 +5,10 @@ import { ReactiveController, ReactiveControllerHost } from "@mendix/widget-plugi import { DynamicValue } from "mendix"; import { action, makeAutoObservable, reaction } from "mobx"; -type Gate = DerivedPropsGate<{ +interface DynamicProps { selectingAllLabel?: DynamicValue; cancelSelectionLabel?: DynamicValue; -}>; +} export class SelectionProgressDialogViewModel implements ReactiveController { /** @@ -16,24 +16,17 @@ export class SelectionProgressDialogViewModel implements ReactiveController { * avoid UI flickering. */ private dialogOpen = false; - - #gate: Gate; - #progressStore: ProgressStore; - #selectAllController: SelectAllController; - #timerId: ReturnType | undefined; + private timerId: ReturnType | undefined; constructor( host: ReactiveControllerHost, - gate: Gate, - progressStore: ProgressStore, - selectAllController: SelectAllController + private readonly gate: DerivedPropsGate, + private readonly progressStore: ProgressStore, + private readonly selectAllController: SelectAllController ) { host.addController(this); type PrivateMembers = "setDialogOpen"; makeAutoObservable(this, { setDialogOpen: action }); - this.#gate = gate; - this.#progressStore = progressStore; - this.#selectAllController = selectAllController; } private setDialogOpen(value: boolean): void { @@ -45,40 +38,40 @@ export class SelectionProgressDialogViewModel implements ReactiveController { } get progress(): number { - return this.#progressStore.loaded; + return this.progressStore.loaded; } get total(): number { - return this.#progressStore.total; + return this.progressStore.total; } get selectingAllLabel(): string { - return this.#gate.props.selectingAllLabel?.value ?? "Selecting all items..."; + return this.gate.props.selectingAllLabel?.value ?? "Selecting all items..."; } get cancelSelectionLabel(): string { - return this.#gate.props.cancelSelectionLabel?.value ?? "Cancel selection"; + return this.gate.props.cancelSelectionLabel?.value ?? "Cancel selection"; } setup(): () => void { return reaction( - () => this.#progressStore.inProgress, + () => this.progressStore.inProgress, inProgress => { if (inProgress) { - // Delay showing dialog for 2 second - this.#timerId = setTimeout(() => { + // Delay showing dialog to avoid flickering for fast operations + this.timerId = setTimeout(() => { this.setDialogOpen(true); - this.#timerId = undefined; + this.timerId = undefined; }, 1500); } else { this.setDialogOpen(false); - clearTimeout(this.#timerId); + clearTimeout(this.timerId); } } ); } onCancel(): void { - this.#selectAllController.abort(); + this.selectAllController.abort(); } } diff --git a/packages/pluggableWidgets/datagrid-web/src/helpers/state/useRootStore.ts b/packages/pluggableWidgets/datagrid-web/src/helpers/state/useRootStore.ts index b853e8c7c3..6f2d6fc1de 100644 --- a/packages/pluggableWidgets/datagrid-web/src/helpers/state/useRootStore.ts +++ b/packages/pluggableWidgets/datagrid-web/src/helpers/state/useRootStore.ts @@ -22,9 +22,7 @@ export function useRootStore(props: DatagridContainerProps): RootGridStore { const selectAllGateProvider = useConst(() => new GateProvider(props)); - const selectAllHost = useSetup( - () => new SelectAllHost({ gate: selectAllGateProvider.gate, selectAllProgressStore }) - ); + const selectAllHost = useSetup(() => new SelectAllHost(selectAllGateProvider.gate, selectAllProgressStore)); const rootStore = useSetup( () => diff --git a/packages/shared/widget-plugin-grid/src/select-all/SelectAllController.ts b/packages/shared/widget-plugin-grid/src/select-all/SelectAllController.ts index cd348cf15a..10bad641e7 100644 --- a/packages/shared/widget-plugin-grid/src/select-all/SelectAllController.ts +++ b/packages/shared/widget-plugin-grid/src/select-all/SelectAllController.ts @@ -4,37 +4,35 @@ import { ObjectItem, SelectionMultiValue, SelectionSingleValue } from "mendix"; import { action, computed, makeObservable, observable, when } from "mobx"; import { QueryController } from "../query/query-controller"; -type Gate = DerivedPropsGate<{ itemSelection?: SelectionMultiValue | SelectionSingleValue }>; +interface DynamicProps { + itemSelection?: SelectionMultiValue | SelectionSingleValue; +} + type SelectAllEventType = "loadstart" | "progress" | "abort" | "loadend"; export class SelectAllController implements ReactiveController { private locked = false; - - readonly #gate: Gate; - readonly #query: QueryController; - readonly #emitter = new EventTarget(); - readonly #pageSize = 1024; - - #abortController?: AbortController; - - constructor(host: ReactiveControllerHost, gate: Gate, query: QueryController) { + private abortController?: AbortController; + private readonly emitter = new EventTarget(); + private readonly pageSize = 1024; + + constructor( + host: ReactiveControllerHost, + private readonly gate: DerivedPropsGate, + private readonly query: QueryController + ) { host.addController(this); type PrivateMembers = "setIsLocked" | "locked"; makeObservable(this, { setIsLocked: action, canExecute: computed, isExecuting: computed, - // Here we use keepAlive to make sure selection is never outdated. - // selection: computed({ keepAlive: true }), selection: computed, locked: observable, selectAllPages: action, clearSelection: action, abort: action }); - - this.#gate = gate; - this.#query = query; } setup(): () => void { @@ -42,22 +40,22 @@ export class SelectAllController implements ReactiveController { } on(type: SelectAllEventType, listener: (pe: ProgressEvent) => void): void { - this.#emitter.addEventListener(type, listener); + this.emitter.addEventListener(type, listener); } off(type: SelectAllEventType, listener: (pe: ProgressEvent) => void): void { - this.#emitter.removeEventListener(type, listener); + this.emitter.removeEventListener(type, listener); } get selection(): SelectionMultiValue | undefined { - const selection = this.#gate.props.itemSelection; + const selection = this.gate.props.itemSelection; if (selection === undefined) return; if (selection.type === "Single") return; return selection; } get canExecute(): boolean { - return this.#gate.props.itemSelection?.type === "Multi" && !this.locked; + return this.gate.props.itemSelection?.type === "Multi" && !this.locked; } get isExecuting(): boolean { @@ -69,7 +67,7 @@ export class SelectAllController implements ReactiveController { } private beforeRunChecks(): boolean { - const selection = this.#gate.props.itemSelection; + const selection = this.gate.props.itemSelection; if (selection === undefined) { console.debug("SelectAllController: selection is undefined. Check widget selection setting."); @@ -94,10 +92,10 @@ export class SelectAllController implements ReactiveController { this.setIsLocked(true); - const { offset: initOffset, limit: initLimit } = this.#query; + const { offset: initOffset, limit: initLimit } = this.query; const initSelection = this.selection?.selection ?? []; - const hasTotal = typeof this.#query.totalCount === "number"; - const totalCount = this.#query.totalCount ?? 0; + const hasTotal = typeof this.query.totalCount === "number"; + const totalCount = this.query.totalCount ?? 0; let loaded = 0; let offset = 0; let success = false; @@ -105,25 +103,25 @@ export class SelectAllController implements ReactiveController { new ProgressEvent(type, { loaded, total: totalCount, lengthComputable: hasTotal }); // We should avoid duplicates, so, we start with clean array. const allItems: ObjectItem[] = []; - this.#abortController = new AbortController(); - const signal = this.#abortController.signal; + this.abortController = new AbortController(); + const signal = this.abortController.signal; performance.mark("SelectAll_Start"); try { - this.#emitter.dispatchEvent(pe("loadstart")); + this.emitter.dispatchEvent(pe("loadstart")); let loading = true; while (loading) { - const loadedItems = await this.#query.fetchPage({ - limit: this.#pageSize, + const loadedItems = await this.query.fetchPage({ + limit: this.pageSize, offset, signal }); allItems.push(...loadedItems); loaded += loadedItems.length; - offset += this.#pageSize; - this.#emitter.dispatchEvent(pe("progress")); - loading = !signal.aborted && this.#query.hasMoreItems; + offset += this.pageSize; + this.emitter.dispatchEvent(pe("progress")); + loading = !signal.aborted && this.query.hasMoreItems; } success = true; } catch (error) { @@ -134,18 +132,18 @@ export class SelectAllController implements ReactiveController { } finally { // Restore init view // This step should be done before loadend to avoid UI flickering - await this.#query.fetchPage({ + await this.query.fetchPage({ limit: initLimit, offset: initOffset }); await this.reloadSelection(); - this.#emitter.dispatchEvent(pe("loadend")); + this.emitter.dispatchEvent(pe("loadend")); // const selectionBeforeReload = this.selection?.selection ?? []; // Reload selection to make sure setSelection is working as expected. this.selection?.setSelection(success ? allItems : initSelection); this.locked = false; - this.#abortController = undefined; + this.abortController = undefined; performance.mark("SelectAll_End"); const measure1 = performance.measure("Measure1", "SelectAll_Start", "SelectAll_End"); @@ -162,7 +160,7 @@ export class SelectAllController implements ReactiveController { */ reloadSelection(): Promise { const prevSelection = this.selection; - const items = this.#query.items ?? []; + const items = this.query.items ?? []; const currentSelection = this.selection?.selection ?? []; const newSelection = currentSelection.length > 0 ? [] : items; this.selection?.setSelection(newSelection); @@ -178,7 +176,7 @@ export class SelectAllController implements ReactiveController { } abort(): void { - this.#abortController?.abort(); - this.#emitter.dispatchEvent(new ProgressEvent("abort")); + this.abortController?.abort(); + this.emitter.dispatchEvent(new ProgressEvent("abort")); } } diff --git a/packages/shared/widget-plugin-grid/src/select-all/SelectAllHost.ts b/packages/shared/widget-plugin-grid/src/select-all/SelectAllHost.ts index 481bf7e152..15cce5d440 100644 --- a/packages/shared/widget-plugin-grid/src/select-all/SelectAllHost.ts +++ b/packages/shared/widget-plugin-grid/src/select-all/SelectAllHost.ts @@ -6,20 +6,21 @@ import { DatasourceController } from "../query/DatasourceController"; import { ProgressStore } from "../stores/ProgressStore"; import { SelectAllController } from "./SelectAllController"; -type SelectAllHostSpec = { - gate: DerivedPropsGate<{ itemSelection?: SelectionMultiValue | SelectionSingleValue; datasource: ListValue }>; - selectAllProgressStore: ProgressStore; -}; +interface DynamicProps { + itemSelection?: SelectionMultiValue | SelectionSingleValue; + datasource: ListValue; +} export class SelectAllHost extends BaseControllerHost { readonly selectAllController: SelectAllController; - readonly selectAllProgressStore: ProgressStore; - constructor(spec: SelectAllHostSpec) { + constructor( + gate: DerivedPropsGate, + private readonly selectAllProgressStore: ProgressStore + ) { super(); - const query = new DatasourceController(this, { gate: spec.gate }); - this.selectAllController = new SelectAllController(this, spec.gate, query); - this.selectAllProgressStore = spec.selectAllProgressStore; + const query = new DatasourceController(this, { gate }); + this.selectAllController = new SelectAllController(this, gate, query); } setup(): () => void { From 60754f74238be699bc5f7dee21f39196c281740a Mon Sep 17 00:00:00 2001 From: Illia Obukhau <8282906+iobuhov@users.noreply.github.com> Date: Sun, 26 Oct 2025 15:13:01 +0100 Subject: [PATCH 35/35] WIP --- .../datagrid-web/src/Datagrid.xml | 9 +++ .../src/components/ExportAlert.tsx | 6 +- .../datagrid-web/src/components/GridBody.tsx | 30 ++++----- .../src/components/SelectAllBar.tsx | 3 +- .../src/components/SelectionCounter.tsx | 33 ++++------ .../components/SelectionProgressDialog.tsx | 5 +- .../src/components/WidgetFooter.tsx | 30 ++++----- .../datagrid-web/src/helpers/root-context.ts | 4 +- .../datagrid-web/typings/DatagridProps.d.ts | 53 ++++++--------- .../gallery-web/src/Gallery.tsx | 9 ++- .../gallery-web/src/stores/GalleryStore.ts | 17 +++-- .../SelectionCounterViewModel.spec.ts} | 40 ++++++------ .../src/stores/SelectionCountStore.ts | 64 ------------------- .../view-models/SelectionCounterViewModel.ts | 59 +++++++++++++++++ 14 files changed, 173 insertions(+), 189 deletions(-) rename packages/shared/widget-plugin-grid/src/{selection/__tests__/SelectionCountStore.spec.ts => __tests__/SelectionCounterViewModel.spec.ts} (72%) delete mode 100644 packages/shared/widget-plugin-grid/src/stores/SelectionCountStore.ts create mode 100644 packages/shared/widget-plugin-grid/src/view-models/SelectionCounterViewModel.ts diff --git a/packages/pluggableWidgets/datagrid-web/src/Datagrid.xml b/packages/pluggableWidgets/datagrid-web/src/Datagrid.xml index 99e49e7b50..f0880ae25a 100644 --- a/packages/pluggableWidgets/datagrid-web/src/Datagrid.xml +++ b/packages/pluggableWidgets/datagrid-web/src/Datagrid.xml @@ -222,6 +222,15 @@ Enable select all Allow select all through multiple pages (based on current filter). + + Show selection count + + + Top + Bottom + Off + + diff --git a/packages/pluggableWidgets/datagrid-web/src/components/ExportAlert.tsx b/packages/pluggableWidgets/datagrid-web/src/components/ExportAlert.tsx index d54037d39a..64ede0b94c 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/ExportAlert.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/ExportAlert.tsx @@ -55,7 +55,11 @@ export function ExportAlert({ function ExportProgress({ progress, total }: { progress: number; total: number | undefined }): ReactElement { const validTotal = isValidTotal(total) && total; return ( - + { - if (props.isFirstLoad) { - return 0 ? props.rowsSize : props.pageSize} />; - } - return ( - - {children} - {props.isFetchingNextBatch && } - - ); - }; - return (
- {content()} + {((): ReactElement => { + if (props.isFirstLoad) { + return 0 ? props.rowsSize : props.pageSize} />; + } + return ( + + {children} + {props.isFetchingNextBatch && ( + + )} + + ); + })()}
); } diff --git a/packages/pluggableWidgets/datagrid-web/src/components/SelectAllBar.tsx b/packages/pluggableWidgets/datagrid-web/src/components/SelectAllBar.tsx index 61ed8967a1..ccab41a4b0 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/SelectAllBar.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/SelectAllBar.tsx @@ -1,9 +1,8 @@ import { If } from "@mendix/widget-plugin-component-kit/If"; import { observer } from "mobx-react-lite"; -import { ReactNode } from "react"; import { useDatagridRootScope } from "../helpers/root-context"; -export const SelectAllBar = observer(function SelectAllBar(): ReactNode { +export const SelectAllBar = observer(function SelectAllBar() { const { selectAllBarViewModel: vm } = useDatagridRootScope(); if (!vm.isBarVisible) return null; diff --git a/packages/pluggableWidgets/datagrid-web/src/components/SelectionCounter.tsx b/packages/pluggableWidgets/datagrid-web/src/components/SelectionCounter.tsx index 99bee0db88..825ab51b6e 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/SelectionCounter.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/SelectionCounter.tsx @@ -1,29 +1,18 @@ -import { If } from "@mendix/widget-plugin-component-kit/If"; import { observer } from "mobx-react-lite"; +import { Fragment } from "react"; import { useDatagridRootScope } from "../helpers/root-context"; -type SelectionCounterLocation = "top" | "bottom" | undefined; - -export const SelectionCounter = observer(function SelectionCounter({ - location -}: { - location?: SelectionCounterLocation; -}) { - const { selectionCountStore, selectActionHelper } = useDatagridRootScope(); - - const containerClass = location === "top" ? "widget-datagrid-tb-start" : "widget-datagrid-pb-start"; +export const SelectionCounter = observer(function SelectionCounter() { + const { selectionCounterViewModel: vm, selectActionHelper } = useDatagridRootScope(); return ( - -
- - {selectionCountStore.displayCount} - -  |  - -
-
+ + + {vm.selectedCountText} + + + ); }); diff --git a/packages/pluggableWidgets/datagrid-web/src/components/SelectionProgressDialog.tsx b/packages/pluggableWidgets/datagrid-web/src/components/SelectionProgressDialog.tsx index edd64b7558..54b8726bb3 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/SelectionProgressDialog.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/SelectionProgressDialog.tsx @@ -1,10 +1,11 @@ -import { ReactElement } from "react"; +import { ReactNode } from "react"; import { useDatagridRootScope } from "../helpers/root-context"; import { ExportAlert } from "./ExportAlert"; import { PseudoModal } from "./PseudoModal"; -export function SelectionProgressDialog(): ReactElement | null { +export function SelectionProgressDialog(): ReactNode { const { selectionProgressDialogViewModel: vm } = useDatagridRootScope(); + if (!vm.isOpen) return null; return ( diff --git a/packages/pluggableWidgets/datagrid-web/src/components/WidgetFooter.tsx b/packages/pluggableWidgets/datagrid-web/src/components/WidgetFooter.tsx index 68b83d7fb0..bf11585ec7 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/WidgetFooter.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/WidgetFooter.tsx @@ -1,22 +1,30 @@ +import { If } from "@mendix/widget-plugin-component-kit/If"; +import { observer } from "mobx-react-lite"; import { ComponentPropsWithoutRef, ReactElement, ReactNode } from "react"; import { PaginationEnum } from "../../typings/DatagridProps"; +import { useDatagridRootScope } from "../helpers/root-context"; +import { SelectionCounter } from "./SelectionCounter"; type WidgetFooterProps = { pagination: ReactNode; - selectionCount: ReactNode; paginationType: PaginationEnum; loadMoreButtonCaption?: string; hasMoreItems: boolean; setPage?: (computePage: (prevPage: number) => number) => void; } & ComponentPropsWithoutRef<"div">; -export function WidgetFooter(props: WidgetFooterProps): ReactElement | null { - const { pagination, selectionCount, paginationType, loadMoreButtonCaption, hasMoreItems, setPage, ...rest } = props; +export const WidgetFooter = observer(function WidgetFooter(props: WidgetFooterProps): ReactElement | null { + const { selectionCounterViewModel: counter } = useDatagridRootScope(); + const { pagination, paginationType, loadMoreButtonCaption, hasMoreItems, setPage, ...rest } = props; return (
- {selectionCount} + +
+ +
+
{pagination} {hasMoreItems && paginationType === "loadMore" && ( @@ -32,18 +40,4 @@ export function WidgetFooter(props: WidgetFooterProps): ReactElement | null {
); -} - -const SelectionCounter = observer(function SelectionCounter() { - const { selectionCountStore, selectActionHelper } = useDatagridRootScope(); - - return ( - - {selectionCountStore.selectedCountText} -   - - - ); }); diff --git a/packages/pluggableWidgets/datagrid-web/src/helpers/root-context.ts b/packages/pluggableWidgets/datagrid-web/src/helpers/root-context.ts index edda0a554c..a65c7203fd 100644 --- a/packages/pluggableWidgets/datagrid-web/src/helpers/root-context.ts +++ b/packages/pluggableWidgets/datagrid-web/src/helpers/root-context.ts @@ -1,7 +1,7 @@ import { FocusTargetController } from "@mendix/widget-plugin-grid/keyboard-navigation/FocusTargetController"; import { SelectionHelper } from "@mendix/widget-plugin-grid/selection"; import { ProgressStore } from "@mendix/widget-plugin-grid/stores/ProgressStore"; -import { SelectionCountStore } from "@mendix/widget-plugin-grid/stores/SelectionCountStore"; +import { SelectionCounterViewModel } from "@mendix/widget-plugin-grid/view-models/SelectionCounterViewModel"; import { createContext, useContext } from "react"; import { GridBasicData } from "../helpers/state/GridBasicData"; import { EventsController } from "../typings/CellComponent"; @@ -16,7 +16,7 @@ export interface DatagridRootScope { cellEventsController: EventsController; checkboxEventsController: EventsController; focusController: FocusTargetController; - selectionCountStore: SelectionCountStore; + selectionCounterViewModel: SelectionCounterViewModel; selectAllProgressStore: ProgressStore; selectAllBarViewModel: SelectAllBarViewModel; selectionProgressDialogViewModel: SelectionProgressDialogViewModel; diff --git a/packages/pluggableWidgets/datagrid-web/typings/DatagridProps.d.ts b/packages/pluggableWidgets/datagrid-web/typings/DatagridProps.d.ts index db6013793b..c4f14a6d7b 100644 --- a/packages/pluggableWidgets/datagrid-web/typings/DatagridProps.d.ts +++ b/packages/pluggableWidgets/datagrid-web/typings/DatagridProps.d.ts @@ -3,9 +3,21 @@ * WARNING: All changes made to this file will be overwritten * @author Mendix Widgets Framework Team */ -import { ComponentType, CSSProperties, ReactNode } from "react"; -import { ActionValue, DynamicValue, EditableValue, ListValue, ListActionValue, ListAttributeValue, ListAttributeListValue, ListExpressionValue, ListWidgetValue, SelectionSingleValue, SelectionMultiValue } from "mendix"; import { Big } from "big.js"; +import { + ActionValue, + DynamicValue, + EditableValue, + ListActionValue, + ListAttributeListValue, + ListAttributeValue, + ListExpressionValue, + ListValue, + ListWidgetValue, + SelectionMultiValue, + SelectionSingleValue +} from "mendix"; +import { ComponentType, CSSProperties, ReactNode } from "react"; export type ShowContentAsEnum = "attribute" | "dynamicText" | "customContent"; @@ -19,7 +31,9 @@ export type AlignmentEnum = "left" | "center" | "right"; export interface ColumnsType { showContentAs: ShowContentAsEnum; - attribute?: ListAttributeValue | ListAttributeListValue; + attribute?: + | ListAttributeValue + | ListAttributeListValue; content?: ListWidgetValue; dynamicText?: ListExpressionValue; exportValue?: ListExpressionValue; @@ -47,6 +61,8 @@ export type ItemSelectionMethodEnum = "checkbox" | "rowClick"; export type ItemSelectionModeEnum = "toggle" | "clear"; +export type SelectionCountPositionEnum = "top" | "bottom" | "off"; + export type LoadingTypeEnum = "spinner" | "skeleton"; export type PaginationEnum = "buttons" | "virtualScrolling" | "loadMore"; @@ -101,22 +117,8 @@ export interface DatagridContainerProps { itemSelectionMode: ItemSelectionModeEnum; showSelectAllToggle: boolean; keepSelection: boolean; -<<<<<<< HEAD -<<<<<<< HEAD - selectionCountPosition: SelectionCountPositionEnum; - clearSelectionButtonLabel?: DynamicValue; -======= - selectAllPagesEnabled: boolean; - selectAllPagesPageSize: number; -<<<<<<< HEAD - selectingAllLabel?: DynamicValue; - cancelSelectionLabel?: DynamicValue; ->>>>>>> 2e4671e66 (feat(datagrid-web): add multipage selection to dg2) -======= ->>>>>>> 448ce2ee2 (feat: add sab vm) -======= enableSelectAll: boolean; ->>>>>>> 298cb49db (refactor: change texts & etc) + selectionCountPosition: SelectionCountPositionEnum; loadingType: LoadingTypeEnum; refreshIndicator: boolean; pageSize: number; @@ -174,21 +176,8 @@ export interface DatagridPreviewProps { itemSelectionMode: ItemSelectionModeEnum; showSelectAllToggle: boolean; keepSelection: boolean; -<<<<<<< HEAD - - selectionCountPosition: SelectionCountPositionEnum; - clearSelectionButtonLabel: string; - - selectAllPagesEnabled: boolean; - selectAllPagesPageSize: number | null; - - selectingAllLabel: string; - cancelSelectionLabel: string; - - -======= enableSelectAll: boolean; ->>>>>>> 298cb49db (refactor: change texts & etc) + selectionCountPosition: SelectionCountPositionEnum; loadingType: LoadingTypeEnum; refreshIndicator: boolean; pageSize: number | null; diff --git a/packages/pluggableWidgets/gallery-web/src/Gallery.tsx b/packages/pluggableWidgets/gallery-web/src/Gallery.tsx index 4e2262c42a..35df28e373 100644 --- a/packages/pluggableWidgets/gallery-web/src/Gallery.tsx +++ b/packages/pluggableWidgets/gallery-web/src/Gallery.tsx @@ -1,4 +1,6 @@ -import { useClickActionHelper } from "@mendix/widget-plugin-grid/helpers/ClickActionHelper"; +import { ReactElement } from "react"; + +/* import { useClickActionHelper } from "@mendix/widget-plugin-grid/helpers/ClickActionHelper"; import { useFocusTargetController } from "@mendix/widget-plugin-grid/keyboard-navigation/useFocusTargetController"; import { getColumnAndRowBasedOnIndex, useSelectionHelper } from "@mendix/widget-plugin-grid/selection"; import { useConst } from "@mendix/widget-plugin-mobx-kit/react/useConst"; @@ -124,3 +126,8 @@ export function Gallery(props: GalleryContainerProps): ReactElement { ); } + */ export function Gallery(): ReactElement { + // const scope = useCreateGalleryScope(props); + + return
; +} diff --git a/packages/pluggableWidgets/gallery-web/src/stores/GalleryStore.ts b/packages/pluggableWidgets/gallery-web/src/stores/GalleryStore.ts index f7cbb78bfd..cf888674cd 100644 --- a/packages/pluggableWidgets/gallery-web/src/stores/GalleryStore.ts +++ b/packages/pluggableWidgets/gallery-web/src/stores/GalleryStore.ts @@ -4,14 +4,14 @@ import { CustomFilterHost } from "@mendix/widget-plugin-filtering/stores/generic import { DatasourceController } from "@mendix/widget-plugin-grid/query/DatasourceController"; import { PaginationController } from "@mendix/widget-plugin-grid/query/PaginationController"; import { RefreshController } from "@mendix/widget-plugin-grid/query/RefreshController"; -import { SelectionCountStore } from "@mendix/widget-plugin-grid/stores/SelectionCountStore"; +import { SelectionCounterViewModel } from "@mendix/widget-plugin-grid/view-models/SelectionCounterViewModel"; import { BaseControllerHost } from "@mendix/widget-plugin-mobx-kit/BaseControllerHost"; import { DerivedPropsGate } from "@mendix/widget-plugin-mobx-kit/props-gate"; import { generateUUID } from "@mendix/widget-plugin-platform/framework/generate-uuid"; import { SortAPI } from "@mendix/widget-plugin-sorting/react/context"; import { SortStoreHost } from "@mendix/widget-plugin-sorting/stores/SortStoreHost"; import { DynamicValue, EditableValue, ListValue, SelectionMultiValue, SelectionSingleValue } from "mendix"; -import { PaginationEnum, StateStorageTypeEnum } from "../../typings/GalleryProps"; +import { PaginationEnum, SelectionCountPositionEnum, StateStorageTypeEnum } from "../../typings/GalleryProps"; import { DerivedLoaderController } from "../controllers/DerivedLoaderController"; import { QueryParamsController } from "../controllers/QueryParamsController"; import { ObservableStorage } from "../typings/storage"; @@ -23,8 +23,10 @@ interface DynamicProps { datasource: ListValue; stateStorageAttr?: EditableValue; itemSelection?: SelectionSingleValue | SelectionMultiValue; - sCountFmtSingular?: DynamicValue; - sCountFmtPlural?: DynamicValue; + selectedCountTemplateSingular?: DynamicValue; + selectedCountTemplatePlural?: DynamicValue; + // This is static prop, but because it's required in SelectionCounterViewModel, we keep it here + selectionCountPosition: SelectionCountPositionEnum; } interface StaticProps { @@ -57,7 +59,7 @@ export class GalleryStore extends BaseControllerHost { readonly filterAPI: FilterAPI; readonly sortAPI: SortAPI; loaderCtrl: DerivedLoaderController; - selectionCountStore: SelectionCountStore; + selectionCounterViewModel: SelectionCounterViewModel; constructor(spec: GalleryStoreSpec) { super(); @@ -74,10 +76,7 @@ export class GalleryStore extends BaseControllerHost { showTotalCount: spec.showTotalCount }); - this.selectionCountStore = new SelectionCountStore(spec.gate, { - singular: "%d item selected", - plural: "%d items selected" - }); + this.selectionCounterViewModel = new SelectionCounterViewModel(spec.gate); this._filtersHost = new CustomFilterHost(); diff --git a/packages/shared/widget-plugin-grid/src/selection/__tests__/SelectionCountStore.spec.ts b/packages/shared/widget-plugin-grid/src/__tests__/SelectionCounterViewModel.spec.ts similarity index 72% rename from packages/shared/widget-plugin-grid/src/selection/__tests__/SelectionCountStore.spec.ts rename to packages/shared/widget-plugin-grid/src/__tests__/SelectionCounterViewModel.spec.ts index 4765f40138..87fde82e8c 100644 --- a/packages/shared/widget-plugin-grid/src/selection/__tests__/SelectionCountStore.spec.ts +++ b/packages/shared/widget-plugin-grid/src/__tests__/SelectionCounterViewModel.spec.ts @@ -1,22 +1,20 @@ import { GateProvider } from "@mendix/widget-plugin-mobx-kit/GateProvider"; import { objectItems, SelectionMultiValueBuilder, SelectionSingleValueBuilder } from "@mendix/widget-plugin-test-utils"; -import { SelectionMultiValue, SelectionSingleValue } from "mendix"; -import { SelectionCountStore } from "../../stores/SelectionCountStore"; +import { Props, SelectionCounterViewModel } from "../view-models/SelectionCounterViewModel"; -type Props = { - itemSelection?: SelectionSingleValue | SelectionMultiValue; -}; - -const createMinimalMockProps = (overrides: Props = {}): Props => ({ ...overrides }); +const createMinimalMockProps = (overrides: Partial = {}): Props => ({ + selectionCountPosition: "bottom", + ...overrides +}); -describe("SelectionCountStore", () => { +describe("SelectionCounterViewModel", () => { let gateProvider: GateProvider; - let selectionCountStore: SelectionCountStore; + let selectionCounterVM: SelectionCounterViewModel; beforeEach(() => { const mockProps = createMinimalMockProps(); gateProvider = new GateProvider(mockProps); - selectionCountStore = new SelectionCountStore(gateProvider.gate); + selectionCounterVM = new SelectionCounterViewModel(gateProvider.gate); }); describe("when itemSelection is undefined", () => { @@ -24,7 +22,7 @@ describe("SelectionCountStore", () => { const props = createMinimalMockProps({ itemSelection: undefined }); gateProvider.setProps(props); - expect(selectionCountStore.selectedCount).toBe(0); + expect(selectionCounterVM.selectedCount).toBe(0); }); }); @@ -34,16 +32,16 @@ describe("SelectionCountStore", () => { const props = createMinimalMockProps({ itemSelection: singleSelection }); gateProvider.setProps(props); - expect(selectionCountStore.selectedCount).toBe(0); + expect(selectionCounterVM.selectedCount).toBe(0); }); - it("should return 1 when one item is selected", () => { + it("should return 0 even when one item is selected (single selection mode)", () => { const items = objectItems(3); const singleSelection = new SelectionSingleValueBuilder().withSelected(items[0]).build(); const props = createMinimalMockProps({ itemSelection: singleSelection }); gateProvider.setProps(props); - expect(selectionCountStore.selectedCount).toBe(1); + expect(selectionCounterVM.selectedCount).toBe(0); }); }); @@ -53,7 +51,7 @@ describe("SelectionCountStore", () => { const props = createMinimalMockProps({ itemSelection: multiSelection }); gateProvider.setProps(props); - expect(selectionCountStore.selectedCount).toBe(0); + expect(selectionCounterVM.selectedCount).toBe(0); }); it("should return correct count when multiple items are selected", () => { @@ -63,7 +61,7 @@ describe("SelectionCountStore", () => { const props = createMinimalMockProps({ itemSelection: multiSelection }); gateProvider.setProps(props); - expect(selectionCountStore.selectedCount).toBe(3); + expect(selectionCounterVM.selectedCount).toBe(3); }); it("should return correct count when all items are selected", () => { @@ -72,7 +70,7 @@ describe("SelectionCountStore", () => { const props = createMinimalMockProps({ itemSelection: multiSelection }); gateProvider.setProps(props); - expect(selectionCountStore.selectedCount).toBe(4); + expect(selectionCounterVM.selectedCount).toBe(4); }); it("should reactively update when selection changes", () => { @@ -82,19 +80,19 @@ describe("SelectionCountStore", () => { gateProvider.setProps(props); // Initially no items selected - expect(selectionCountStore.selectedCount).toBe(0); + expect(selectionCounterVM.selectedCount).toBe(0); // Select one item multiSelection.setSelection([items[0]]); - expect(selectionCountStore.selectedCount).toBe(1); + expect(selectionCounterVM.selectedCount).toBe(1); // Select two more items multiSelection.setSelection([items[0], items[1], items[2]]); - expect(selectionCountStore.selectedCount).toBe(3); + expect(selectionCounterVM.selectedCount).toBe(3); // Clear selection multiSelection.setSelection([]); - expect(selectionCountStore.selectedCount).toBe(0); + expect(selectionCounterVM.selectedCount).toBe(0); }); }); }); diff --git a/packages/shared/widget-plugin-grid/src/stores/SelectionCountStore.ts b/packages/shared/widget-plugin-grid/src/stores/SelectionCountStore.ts deleted file mode 100644 index 43d3d89567..0000000000 --- a/packages/shared/widget-plugin-grid/src/stores/SelectionCountStore.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { DerivedPropsGate } from "@mendix/widget-plugin-mobx-kit/props-gate"; -import { DynamicValue, SelectionMultiValue, SelectionSingleValue } from "mendix"; -import { computed, makeObservable } from "mobx"; - -type Gate = DerivedPropsGate<{ - itemSelection?: SelectionSingleValue | SelectionMultiValue; - selectedCountTemplateSingular?: DynamicValue; - selectedCountTemplatePlural?: DynamicValue; - clearSelectionCaption?: DynamicValue; -}>; - -export class SelectionCountStore { - private gate: Gate; - private singular: string = "%d.row.count"; - private plural: string = "%d.rows.count"; - - constructor(gate: Gate, spec: { singular?: string; plural?: string; clearLabel?: string } = {}) { - this.singular = spec.singular ?? this.singular; - this.plural = spec.plural ?? this.plural; - this.defaultClearLabel = spec.clearLabel ?? this.defaultClearLabel; - this.gate = gate; - - makeObservable(this, { - selectedCountText: computed, - selectedCount: computed, - formatSingular: computed, - formatPlural: computed - }); - } - - get formatSingular(): string { - return this.gate.props.selectedCountTemplateSingular?.value || this.singular; - } - - get formatPlural(): string { - return this.gate.props.selectedCountTemplatePlural?.value || this.plural; - } - - get selectedCount(): number { - const { itemSelection } = this.gate.props; - - if (!itemSelection) { - return 0; - } - - // For single selection - if (itemSelection.type === "Single") { - return itemSelection.selection ? 1 : 0; - } - - return itemSelection.selection?.length ?? 0; - } - - get selectedCountText(): string { - const count = this.selectedCount; - if (count === 0) return ""; - if (count === 1) return this.formatSingular.replace("%d", "1"); - return this.formatPlural.replace("%d", `${count}`); - } - - get clearSelectionText(): string { - return this.gate.props.clearSelectionCaption?.value ?? "clear.selection.caption"; - } -} diff --git a/packages/shared/widget-plugin-grid/src/view-models/SelectionCounterViewModel.ts b/packages/shared/widget-plugin-grid/src/view-models/SelectionCounterViewModel.ts new file mode 100644 index 0000000000..e1ec18ace5 --- /dev/null +++ b/packages/shared/widget-plugin-grid/src/view-models/SelectionCounterViewModel.ts @@ -0,0 +1,59 @@ +import { DerivedPropsGate } from "@mendix/widget-plugin-mobx-kit/props-gate"; +import { DynamicValue, SelectionMultiValue, SelectionSingleValue } from "mendix"; +import { makeAutoObservable } from "mobx"; + +interface DynamicProps { + itemSelection?: SelectionSingleValue | SelectionMultiValue; + selectedCountTemplateSingular?: DynamicValue; + selectedCountTemplatePlural?: DynamicValue; + clearSelectionCaption?: DynamicValue; +} + +interface StaticProps { + selectionCountPosition: "top" | "bottom" | "off"; +} + +export type Props = DynamicProps & StaticProps; + +export class SelectionCounterViewModel { + private readonly position: "top" | "bottom" | "off"; + + constructor(private gate: DerivedPropsGate) { + makeAutoObservable(this); + this.position = gate.props.selectionCountPosition; + } + + get formatSingular(): string { + return this.gate.props.selectedCountTemplateSingular?.value || "%d.row.count"; + } + + get formatPlural(): string { + return this.gate.props.selectedCountTemplatePlural?.value || "%d.rows.count"; + } + + get selectedCount(): number { + const { itemSelection } = this.gate.props; + + if (itemSelection === undefined) return 0; + if (itemSelection.type === "Single") return 0; + return itemSelection.selection.length; + } + + get selectedCountText(): string { + if (this.selectedCount === 0) return ""; + if (this.selectedCount === 1) return this.formatSingular.replace("%d", "1"); + return this.formatPlural.replace("%d", `${this.selectedCount}`); + } + + get clearSelectionText(): string { + return this.gate.props.clearSelectionCaption?.value ?? "clear.selection.caption"; + } + + get isTopCounterVisible(): boolean { + return this.position === "top" && this.selectedCount > 0; + } + + get isBottomCounterVisible(): boolean { + return this.position === "bottom" && this.selectedCount > 0; + } +}