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 e6ecaea017..6d9f439eca 100644 --- a/packages/modules/data-widgets/src/themesource/datawidgets/web/_datagrid.scss +++ b/packages/modules/data-widgets/src/themesource/datawidgets/web/_datagrid.scss @@ -433,6 +433,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; } @@ -566,6 +590,9 @@ $root: ".widget-datagrid"; padding: 0; display: inline-block; } +:where(#{$root}-select-all-bar) { + grid-column: 1 / -1; +} @keyframes skeleton-loading { 0% { diff --git a/packages/pluggableWidgets/datagrid-web/CHANGELOG.md b/packages/pluggableWidgets/datagrid-web/CHANGELOG.md index 37c9cb6828..6f731c5e52 100644 --- a/packages/pluggableWidgets/datagrid-web/CHANGELOG.md +++ b/packages/pluggableWidgets/datagrid-web/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] +### Added + +- We added 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.0] - 2025-10-01 ### Fixed diff --git a/packages/pluggableWidgets/datagrid-web/src/Datagrid.editorConfig.ts b/packages/pluggableWidgets/datagrid-web/src/Datagrid.editorConfig.ts index 2139f6870d..849cfa2805 100644 --- a/packages/pluggableWidgets/datagrid-web/src/Datagrid.editorConfig.ts +++ b/packages/pluggableWidgets/datagrid-web/src/Datagrid.editorConfig.ts @@ -154,7 +154,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,6 +170,13 @@ 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"); } } diff --git a/packages/pluggableWidgets/datagrid-web/src/Datagrid.editorPreview.tsx b/packages/pluggableWidgets/datagrid-web/src/Datagrid.editorPreview.tsx index 65b72439ee..25a8319d6a 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 { 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"; import { parseStyle } from "@mendix/widget-plugin-platform/preview/parse-style"; @@ -18,8 +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 { SelectionCountStore } from "@mendix/widget-plugin-grid/selection/stores/SelectionCountStore"; import "./ui/DatagridPreview.scss"; // Fix type definition for Selectable @@ -61,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) => ({ @@ -87,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, @@ -97,7 +105,10 @@ export function preview(props: DatagridPreviewProps): ReactElement { cellEventsController: eventsController, checkboxEventsController: eventsController, focusController, - selectionCountStore + selectionCountStore, + 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 9ff9f6bb04..43bdfcc4e5 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 } 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"; 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 = props.datasource.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" ); @@ -66,15 +62,19 @@ const Container = observer((props: Props): ReactElement => { const ctx = useConst(() => { rootStore.basicData.setSelectionHelper(selectionHelper); - return { + const scope: DatagridRootScope = { basicData: rootStore.basicData, selectionHelper, selectActionHelper, cellEventsController, checkboxEventsController, focusController, - selectionCountStore: rootStore.selectionCountStore + selectionCountStore: rootStore.selectionCountStore, + selectAllController: rootStore.selectAllController, + selectAllProgressStore: rootStore.selectAllProgressStore }; + + return scope; }); return ( @@ -123,7 +123,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} @@ -146,14 +146,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/Datagrid.xml b/packages/pluggableWidgets/datagrid-web/src/Datagrid.xml index c19285b1cd..19fbbe7254 100644 --- a/packages/pluggableWidgets/datagrid-web/src/Datagrid.xml +++ b/packages/pluggableWidgets/datagrid-web/src/Datagrid.xml @@ -53,6 +53,28 @@ 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 diff --git a/packages/pluggableWidgets/datagrid-web/src/components/CheckboxColumnHeader.tsx b/packages/pluggableWidgets/datagrid-web/src/components/CheckboxColumnHeader.tsx index 34ab1c9e4d..21d211a717 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 { createElement, Fragment, ReactElement, useCallback } from "react"; +import { SelectionStatus } from "@mendix/widget-plugin-grid/selection"; +import { createElement, Fragment, ReactElement } from "react"; import { useDatagridRootScope } from "../helpers/root-context"; export function CheckboxColumnHeader(): ReactElement { @@ -7,31 +8,29 @@ export function CheckboxColumnHeader(): ReactElement { const { showCheckboxColumn, showSelectAllToggle, onSelectAll } = selectActionHelper; const { selectionStatus, selectAllRowsLabel } = basicData; - const onChange = useCallback(() => onSelectAll(), [onSelectAll]); - if (showCheckboxColumn === false) { return ; } - let checkbox = null; - - if (showSelectAllToggle) { - if (selectionStatus === "unknown") { - throw new Error("Don't know how to render checkbox with selectionStatus=unknown"); - } - - 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..bb20d7b534 --- /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/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 dfb7831e90..15cd9d1351 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/Widget.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/Widget.tsx @@ -20,6 +20,8 @@ import { Grid } from "./Grid"; import { GridBody } from "./GridBody"; import { GridHeader } from "./GridHeader"; import { RowsRenderer } from "./RowsRenderer"; +import { SelectAllBar } from "./SelectAllBar"; +import { SelectionProgressDialog } from "./SelectionProgressDialog"; import { WidgetContent } from "./WidgetContent"; import { WidgetFooter } from "./WidgetFooter"; import { WidgetHeader } from "./WidgetHeader"; @@ -80,7 +82,7 @@ export interface WidgetProps(props: WidgetProps): ReactElement => { const { className, exporting, numberOfItems, onExportCancel, selectActionHelper } = props; - const { basicData } = useDatagridRootScope(); + const { basicData, selectAllProgressStore, selectAllController } = useDatagridRootScope(); const selectionEnabled = selectActionHelper.selectionType !== "None"; @@ -91,8 +93,17 @@ export const Widget = observer((props: WidgetProps): Re selection={selectionEnabled} style={{}} exporting={exporting} + selectingAllPages={selectAllProgressStore.inProgress} >
+ selectAllController.abort()} + progress={selectAllProgressStore.loaded} + total={selectAllProgressStore.total} + /> {exporting && ( (props: WidgetProps): ReactElemen const showHeader = !!headerContent; 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 ( {showTopBar && {pagination}} {showHeader && {headerContent}} - + (props: WidgetProps): ReactElemen isLoading={props.columnsLoading} preview={props.preview} /> + {isSelectAllBarEnabled && } {showRefreshIndicator ? : null} (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 (
({ @@ -59,8 +70,11 @@ 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, + rootStore: {} as unknown as RootGridStore, ...contextOverrides }; @@ -209,7 +223,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 +330,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 +350,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 +380,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 +398,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 +428,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 +542,10 @@ describe("Table", () => { itemSelectionMethod: selectionMethod, itemSelectionMode: "clear", showSelectAllToggle: false, - pageSize: 5 + pageSize: 5, + datasource: ds, + selectAllPagesEnabled: false, + selectAllPagesBufferSize: 500 }, helper ); @@ -502,7 +567,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 ( 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/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/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/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/SelectActionHelper.ts b/packages/pluggableWidgets/datagrid-web/src/helpers/SelectActionHelper.ts index 9b4b28a056..aa8a854c03 100644 --- a/packages/pluggableWidgets/datagrid-web/src/helpers/SelectActionHelper.ts +++ b/packages/pluggableWidgets/datagrid-web/src/helpers/SelectActionHelper.ts @@ -1,10 +1,11 @@ -import { useMemo } from "react"; import { SelectActionHandler, SelectionHelper, 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"; @@ -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,51 @@ 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( props: Pick< DatagridContainerProps | DatagridPreviewProps, - "itemSelection" | "itemSelectionMethod" | "showSelectAllToggle" | "pageSize" | "itemSelectionMode" + | "itemSelection" + | "itemSelectionMethod" + | "showSelectAllToggle" + | "pageSize" + | "itemSelectionMode" + | "datasource" + | "selectAllPagesEnabled" + | "selectAllPagesPageSize" >, 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.selectAllPagesPageSize ?? 500 + ); + }, [ + props.itemSelection, + selectionHelper, + props.itemSelectionMethod, + props.showSelectAllToggle, + props.pageSize, + props.itemSelectionMode, + props.datasource, + props.selectAllPagesEnabled, + 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 51386f8d90..03ee7ac3f9 100644 --- a/packages/pluggableWidgets/datagrid-web/src/helpers/root-context.ts +++ b/packages/pluggableWidgets/datagrid-web/src/helpers/root-context.ts @@ -1,6 +1,7 @@ import { FocusTargetController } from "@mendix/widget-plugin-grid/keyboard-navigation/FocusTargetController"; -import { SelectionHelper } from "@mendix/widget-plugin-grid/selection"; -import { SelectionCountStore } from "@mendix/widget-plugin-grid/selection/stores/SelectionCountStore"; +import { SelectAllController, 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 { createContext, useContext } from "react"; import { GridBasicData } from "../helpers/state/GridBasicData"; import { EventsController } from "../typings/CellComponent"; @@ -13,8 +14,10 @@ export interface DatagridRootScope { selectActionHelper: SelectActionHelper; cellEventsController: EventsController; checkboxEventsController: EventsController; + selectAllController: SelectAllController; focusController: FocusTargetController; selectionCountStore: SelectionCountStore; + selectAllProgressStore: ProgressStore; } 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 7e64c08ec0..5d48ebe815 100644 --- a/packages/pluggableWidgets/datagrid-web/src/helpers/state/RootGridStore.ts +++ b/packages/pluggableWidgets/datagrid-web/src/helpers/state/RootGridStore.ts @@ -2,8 +2,11 @@ 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 { SelectAllController } 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 { disposeBatch } from "@mendix/widget-plugin-mobx-kit/disposeBatch"; import { DerivedPropsGate } from "@mendix/widget-plugin-mobx-kit/props-gate"; @@ -14,7 +17,6 @@ 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 { StaticInfo } from "../../typings/static-info"; import { ColumnGroupStore } from "./ColumnGroupStore"; import { GridPersonalizationStore } from "./GridPersonalizationStore"; @@ -34,13 +36,18 @@ type RequiredProps = Pick< | "pagination" | "showPagingButtons" | "showNumberOfRows" + | "selectAllPagesEnabled" + | "selectAllPagesPageSize" + | "onSelectionChange" >; type Gate = DerivedPropsGate; type Spec = { gate: Gate; - exportCtrl: ProgressStore; + exportProgressStore: ProgressStore; + selectAllProgressStore: ProgressStore; + selectAllController: SelectAllController; }; export class RootGridStore extends BaseControllerHost { @@ -49,14 +56,16 @@ export class RootGridStore extends BaseControllerHost { selectionCountStore: SelectionCountStore; basicData: GridBasicData; staticInfo: StaticInfo; - exportProgressCtrl: ProgressStore; + exportProgressStore: ProgressStore; + selectAllController: SelectAllController; + selectAllProgressStore: ProgressStore; loaderCtrl: DerivedLoaderController; paginationCtrl: PaginationController; - readonly filterAPI: FilterAPI; - - private gate: Gate; + filterAPI: FilterAPI; + query: QueryController; + gate: Gate; - constructor({ gate, exportCtrl }: Spec) { + constructor({ gate, exportProgressStore, selectAllProgressStore, selectAllController }: Spec) { super(); const { props } = gate; @@ -69,7 +78,7 @@ export class RootGridStore extends BaseControllerHost { const filterHost = new CustomFilterHost(); - const query = new DatasourceController(this, { gate }); + const query = (this.query = new DatasourceController(this, { gate })); this.filterAPI = createContextWithStub({ filterObserver: filterHost, @@ -91,7 +100,11 @@ export class RootGridStore extends BaseControllerHost { this.paginationCtrl = new PaginationController(this, { gate, query }); - this.exportProgressCtrl = exportCtrl; + this.exportProgressStore = exportProgressStore; + + this.selectAllProgressStore = selectAllProgressStore; + + this.selectAllController = selectAllController; new DatasourceParamsController(this, { query, @@ -105,7 +118,7 @@ export class RootGridStore extends BaseControllerHost { }); this.loaderCtrl = new DerivedLoaderController({ - exp: exportCtrl, + exp: exportProgressStore, cols: this.columnsStore, showSilentRefresh: props.refreshInterval > 1, refreshIndicator: props.refreshIndicator, @@ -120,13 +133,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/pluggableWidgets/datagrid-web/src/helpers/state/useRootStore.ts b/packages/pluggableWidgets/datagrid-web/src/helpers/state/useRootStore.ts index 40903de468..b853e8c7c3 100644 --- a/packages/pluggableWidgets/datagrid-web/src/helpers/state/useRootStore.ts +++ b/packages/pluggableWidgets/datagrid-web/src/helpers/state/useRootStore.ts @@ -1,21 +1,44 @@ +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"; 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 rootStore = useSetup(() => new RootGridStore({ gate: gateProvider.gate, exportCtrl: exportProgressCtrl })); + + const selectAllGateProvider = useConst(() => new GateProvider(props)); + + 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/pluggableWidgets/datagrid-web/src/utils/test-utils.tsx b/packages/pluggableWidgets/datagrid-web/src/utils/test-utils.tsx index bc9e5953d4..c9826d0c70 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 { createElement } from "react"; import { ColumnsType } from "../../typings/DatagridProps"; @@ -40,7 +40,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 a3e01d2e2d..0508cfa3aa 100644 --- a/packages/pluggableWidgets/datagrid-web/typings/DatagridProps.d.ts +++ b/packages/pluggableWidgets/datagrid-web/typings/DatagridProps.d.ts @@ -96,6 +96,10 @@ export interface DatagridContainerProps { itemSelectionMode: ItemSelectionModeEnum; showSelectAllToggle: boolean; keepSelection: boolean; + selectAllPagesEnabled: boolean; + selectAllPagesPageSize: number; + selectingAllLabel?: DynamicValue; + cancelSelectionLabel?: DynamicValue; loadingType: LoadingTypeEnum; refreshIndicator: boolean; columns: ColumnsType[]; @@ -148,6 +152,10 @@ export interface DatagridPreviewProps { itemSelectionMode: ItemSelectionModeEnum; showSelectAllToggle: boolean; keepSelection: boolean; + selectAllPagesEnabled: boolean; + selectAllPagesPageSize: number | null; + selectingAllLabel: string; + cancelSelectionLabel: string; loadingType: LoadingTypeEnum; refreshIndicator: boolean; columns: ColumnsPreviewType[]; diff --git a/packages/shared/widget-plugin-grid/src/query/DatasourceController.ts b/packages/shared/widget-plugin-grid/src/query/DatasourceController.ts index 6fe2f5a66f..224890de45 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 }>; @@ -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. @@ -164,4 +168,39 @@ export class DatasourceController implements ReactiveController, QueryController setPageSize(size: number): void { this.pageSize = size; } + + reload(): Promise { + const ds = this.datasource; + this.datasource.reload(); + return when(() => this.datasource !== ds); + } + + fetchPage({ + limit, + offset, + signal + }: { + limit: number; + offset: number; + signal?: AbortSignal; + }): Promise { + return new Promise((resolve, reject) => { + if (signal && signal.aborted) { + return reject(signal.reason); + } + + const predicate = when( + () => + this.datasource.offset === offset && + this.datasource.limit === limit && + this.datasource.status === "available", + { signal } + ); + + predicate.then(() => resolve(this.datasource.items ?? []), reject); + + this.datasource.setOffset(offset); + this.datasource.setLimit(limit); + }); + } } 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..306374b068 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" @@ -9,6 +9,7 @@ type Members = | "totalCount" | "limit" | "offset" + | "items" | "hasMoreItems"; export interface QueryController extends Pick { @@ -18,4 +19,6 @@ export interface QueryController extends Pick { isFirstLoad: boolean; isRefreshing: boolean; isFetchingNextBatch: boolean; + 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 new file mode 100644 index 0000000000..372a171e5d --- /dev/null +++ b/packages/shared/widget-plugin-grid/src/select-all/SelectAllController.ts @@ -0,0 +1,178 @@ +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, when } from "mobx"; +import { QueryController } from "../query/query-controller"; + +type Gate = DerivedPropsGate<{ itemSelection?: SelectionMultiValue | SelectionSingleValue }>; + +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; + 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; + + type PrivateMembers = "setIsLocked" | "locked"; + makeObservable(this, { + setIsLocked: action, + canExecute: computed, + isExecuting: computed, + selection: computed, + locked: observable, + selectAllPages: action, + clearSelection: action, + abort: action + }); + } + + setup(): () => void { + return () => this.abort(); + } + + 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 { + 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; + } + + 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 === 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."); + return false; + } + return true; + } + + 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; + 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. + const allItems: ObjectItem[] = []; + this.abortController = new AbortController(); + const signal = this.abortController.signal; + + performance.mark("SelectAll_Start"); + try { + this.emitter.dispatchEvent(pe("loadstart")); + let loading = true; + while (loading) { + 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; + } + success = true; + } catch (error) { + if (!signal.aborted) { + console.error("SelectAllController: an error was encountered during the 'select all' action."); + console.error(error); + } + } finally { + // 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; + 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.`); + } + } + + reloadSelection(): Promise { + const selection = this.selection; + selection?.setSelection([]); + // `when` resolves 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([]); + } + + abort(): void { + 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 new file mode 100644 index 0000000000..0b423bdd8c --- /dev/null +++ b/packages/shared/widget-plugin-grid/src/select-all/SelectAllHost.ts @@ -0,0 +1,50 @@ +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 = (): void => this.selectAllProgressStore.onloadend(); + const progress = (e: ProgressEvent): void => this.selectAllProgressStore.onprogress(e); + + controller.on("loadstart", loadstart); + controller.on("loadend", loadend); + controller.on("progress", progress); + + return () => { + 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 c7514487c6..067c28ecba 100644 --- a/packages/shared/widget-plugin-grid/src/selection.ts +++ b/packages/shared/widget-plugin-grid/src/selection.ts @@ -1,9 +1,11 @@ -export * from "./selection/types.js"; -export * from "./selection/helpers.js"; -export * from "./selection/keyboard.js"; +export { SelectAllController } from "./select-all/SelectAllController.js"; +export { SelectAllHost } from "./select-all/SelectAllHost.js"; export { getGlobalSelectionContext, useCreateSelectionContextValue, useSelectionContextValue } from "./selection/context.js"; +export * from "./selection/helpers.js"; +export * from "./selection/keyboard.js"; export { SelectActionHandler } from "./selection/select-action-handler.js"; +export * from "./selection/types.js"; 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; 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