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..7f862fb259 100644 --- a/packages/modules/data-widgets/src/themesource/datawidgets/web/_datagrid.scss +++ b/packages/modules/data-widgets/src/themesource/datawidgets/web/_datagrid.scss @@ -420,7 +420,8 @@ $root: ".widget-datagrid"; align-items: center; } - &-exporting { + &-exporting, + &-selecting-all-pages { .widget-datagrid-top-bar, .widget-datagrid-header, .widget-datagrid-content, @@ -555,16 +556,32 @@ $root: ".widget-datagrid"; :where(#{$root}-pb-start) { margin-block: var(--spacing-medium); padding-inline: var(--spacing-medium); + display: flex; + 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; + + &:hover, + &:focus-visible { + 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/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..8db692871e 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") { hidePropertyIn(defaultProperties, values, "showNumberOfRows"); @@ -124,28 +114,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"]); } @@ -154,7 +122,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 +138,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..c4ac43340d 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"; @@ -15,11 +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 { SelectionCountStore } from "@mendix/widget-plugin-grid/selection/stores/SelectionCountStore"; +import { SelectAllBarViewModel } from "./helpers/state/SelectAllBarViewModel"; +import { SelectionProgressDialogViewModel } from "./helpers/state/SelectionProgressDialogViewModel"; import "./ui/DatagridPreview.scss"; // Fix type definition for Selectable @@ -61,6 +66,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 +94,13 @@ 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 }); + const selectAllProgressStore = new ProgressStore(); return { basicData, selectionHelper: undefined, @@ -97,8 +108,20 @@ export function preview(props: DatagridPreviewProps): ReactElement { cellEventsController: eventsController, checkboxEventsController: eventsController, focusController, - selectionCountStore - }; + selectionCountStore, + 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 9ff9f6bb04..5f623e3ca5 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" ); @@ -65,16 +61,20 @@ const Container = observer((props: Props): ReactElement => { const checkboxEventsController = useCheckboxEventsController(selectActionHelper, focusController); 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, + selectAllProgressStore: rootStore.selectAllProgressStore, + selectAllBarViewModel: rootStore.selectAllBarViewModel, + selectionProgressDialogViewModel: rootStore.selectionProgressDialogViewModel }; + + 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..a2921010de 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,51 +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. - - - Loading type - - - Spinner - Skeleton - - - - Show refresh indicator - Show a refresh indicator when the data is being loaded. - @@ -209,7 +160,88 @@ - + + + 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 + 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. + + + + + Loading type + + + Spinner + Skeleton + + + + Show refresh indicator + Show a refresh indicator when the data is being loaded. + + + Page size @@ -251,6 +283,8 @@ Load More + + Empty list message @@ -269,28 +303,6 @@ - - - On click trigger - - - Single click - Double click - - - - On click action - - - - On selection change - - - - Filters placeholder - - - @@ -337,7 +349,7 @@ - + Filter section @@ -358,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') @@ -379,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 rows in the data source + + + + 2 + + + Select remaining rows in the data source + + + + Clear selection caption + + + Clear selection + + diff --git a/packages/pluggableWidgets/datagrid-web/src/components/CheckboxColumnHeader.tsx b/packages/pluggableWidgets/datagrid-web/src/components/CheckboxColumnHeader.tsx index 34ab1c9e4d..e6d0efe69f 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/CheckboxColumnHeader.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/CheckboxColumnHeader.tsx @@ -1,37 +1,40 @@ 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 { - const { selectActionHelper, basicData } = useDatagridRootScope(); + const { selectActionHelper, basicData, selectionHelper } = useDatagridRootScope(); const { showCheckboxColumn, showSelectAllToggle, onSelectAll } = selectActionHelper; - const { selectionStatus, selectAllRowsLabel } = basicData; - - const onChange = useCallback(() => onSelectAll(), [onSelectAll]); + const { selectAllRowsLabel } = basicData; 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..c0adc2a8e6 --- /dev/null +++ b/packages/pluggableWidgets/datagrid-web/src/components/SelectAllBar.tsx @@ -0,0 +1,26 @@ +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 { selectAllBarViewModel: vm } = useDatagridRootScope(); + + if (!vm.barVisible) 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 new file mode 100644 index 0000000000..5acad87b09 --- /dev/null +++ b/packages/pluggableWidgets/datagrid-web/src/components/SelectionProgressDialog.tsx @@ -0,0 +1,21 @@ +import { createElement, ReactElement } from "react"; +import { useDatagridRootScope } from "../helpers/root-context"; +import { ExportAlert } from "./ExportAlert"; +import { PseudoModal } from "./PseudoModal"; + +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 dfb7831e90..533bafefec 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 } = useDatagridRootScope(); const selectionEnabled = selectActionHelper.selectionType !== "None"; @@ -91,8 +93,10 @@ export const Widget = observer((props: WidgetProps): Re selection={selectionEnabled} style={{}} exporting={exporting} + selectingAllPages={selectAllProgressStore.inProgress} >
+ {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} - {selectionCountStore.displayCount} |  - diff --git a/packages/pluggableWidgets/datagrid-web/src/components/WidgetRoot.tsx b/packages/pluggableWidgets/datagrid-web/src/components/WidgetRoot.tsx index b4ade333f8..2625e2dfc9 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/WidgetRoot.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/WidgetRoot.tsx @@ -9,18 +9,19 @@ export interface WidgetRootProps extends P { selection?: boolean; selectionMethod: SelectionMethod; exporting?: boolean; + selectingAllPages?: boolean; } export function WidgetRoot(props: WidgetRootProps): ReactElement { const ref = useRef(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..b0fbc3f6b9 100644 --- a/packages/pluggableWidgets/datagrid-web/src/helpers/SelectActionHelper.ts +++ b/packages/pluggableWidgets/datagrid-web/src/helpers/SelectActionHelper.ts @@ -1,10 +1,10 @@ -import { useMemo } from "react"; import { SelectActionHandler, SelectionHelper, SelectionMode, WidgetSelectionProperty } from "@mendix/widget-plugin-grid/selection"; +import { useMemo } from "react"; import { DatagridContainerProps, DatagridPreviewProps, ItemSelectionMethodEnum } from "../../typings/DatagridProps"; export type SelectionMethod = "rowClick" | "checkbox" | "none"; @@ -47,21 +47,30 @@ export class SelectActionHelper extends SelectActionHandler { export function useSelectActionHelper( props: Pick< DatagridContainerProps | DatagridPreviewProps, - "itemSelection" | "itemSelectionMethod" | "showSelectAllToggle" | "pageSize" | "itemSelectionMode" + | "itemSelection" + | "itemSelectionMethod" + | "showSelectAllToggle" + | "pageSize" + | "itemSelectionMode" + | "datasource" >, 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.itemSelection, + selectionHelper, + props.itemSelectionMethod, + props.showSelectAllToggle, + props.pageSize, + 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 51386f8d90..edda0a554c 100644 --- a/packages/pluggableWidgets/datagrid-web/src/helpers/root-context.ts +++ b/packages/pluggableWidgets/datagrid-web/src/helpers/root-context.ts @@ -1,20 +1,25 @@ 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 { 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"; import { SelectActionHelper } from "./SelectActionHelper"; +import { SelectAllBarViewModel } from "./state/SelectAllBarViewModel"; +import { SelectionProgressDialogViewModel } from "./state/SelectionProgressDialogViewModel"; export interface DatagridRootScope { basicData: GridBasicData; - // Controllers selectionHelper: SelectionHelper | undefined; selectActionHelper: SelectActionHelper; cellEventsController: EventsController; checkboxEventsController: EventsController; focusController: FocusTargetController; selectionCountStore: SelectionCountStore; + selectAllProgressStore: ProgressStore; + selectAllBarViewModel: SelectAllBarViewModel; + selectionProgressDialogViewModel: SelectionProgressDialogViewModel; } 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..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"; @@ -10,10 +9,13 @@ type Props = Pick< 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; constructor(gate: Gate) { this.gate = gate; @@ -39,12 +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"; - } - - 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..4855c9a48a 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,10 +17,11 @@ 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"; +import { SelectAllBarViewModel } from "./SelectAllBarViewModel"; +import { SelectionProgressDialogViewModel } from "./SelectionProgressDialogViewModel"; type RequiredProps = Pick< DatagridContainerProps, @@ -34,13 +38,23 @@ type RequiredProps = Pick< | "pagination" | "showPagingButtons" | "showNumberOfRows" + | "selectAllPagesEnabled" + | "selectAllPagesPageSize" + | "onSelectionChange" + | "selectAllTemplate" + | "selectRemainingTemplate" + | "clearSelectionCaption" + | "selectingAllLabel" + | "cancelSelectionLabel" >; type Gate = DerivedPropsGate; type Spec = { gate: Gate; - exportCtrl: ProgressStore; + exportProgressStore: ProgressStore; + selectAllProgressStore: ProgressStore; + selectAllController: SelectAllController; }; export class RootGridStore extends BaseControllerHost { @@ -49,14 +63,18 @@ 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; + selectAllBarViewModel: SelectAllBarViewModel; + selectionProgressDialogViewModel: SelectionProgressDialogViewModel; - constructor({ gate, exportCtrl }: Spec) { + constructor({ gate, exportProgressStore, selectAllProgressStore, selectAllController }: Spec) { super(); const { props } = gate; @@ -69,7 +87,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 +109,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,13 +127,26 @@ export class RootGridStore extends BaseControllerHost { }); this.loaderCtrl = new DerivedLoaderController({ - exp: exportCtrl, + exp: exportProgressStore, cols: this.columnsStore, showSilentRefresh: props.refreshInterval > 1, refreshIndicator: props.refreshIndicator, query }); + this.selectAllBarViewModel = new SelectAllBarViewModel( + this, + gate, + this.selectAllController, + this.selectionCountStore + ); + + this.selectionProgressDialogViewModel = new SelectionProgressDialogViewModel( + gate, + selectAllProgressStore, + selectAllController + ); + combinedFilter.hydrate(props.datasource.filter); } @@ -120,13 +155,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/SelectAllBarViewModel.ts b/packages/pluggableWidgets/datagrid-web/src/helpers/state/SelectAllBarViewModel.ts new file mode 100644 index 0000000000..6425dfdcc8 --- /dev/null +++ b/packages/pluggableWidgets/datagrid-web/src/helpers/state/SelectAllBarViewModel.ts @@ -0,0 +1,102 @@ +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 { DatagridContainerProps } from "../../../typings/DatagridProps"; + +type Props = Pick< + DatagridContainerProps, + | "cancelSelectionLabel" + | "selectAllTemplate" + | "selectRemainingTemplate" + | "clearSelectionCaption" + | "itemSelection" + | "selectedCountTemplatePlural" + | "selectedCountTemplateSingular" + | "datasource" +>; + +type Gate = DerivedPropsGate; + +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); + } + + 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"; + } + + 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; + } + + 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.showClear) return true; + if (this.total > 0) return this.total === this.count.selectedCount; + return false; + } + + 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; + } + + setup(): () => void { + return autorun(() => { + if (this.isSelectionEmpty) { + this.setShowClear(false); + } + }); + } + + onClear(): void { + this.selectAllController.clearSelection(); + this.setShowClear(false); + } + + async onSelectAll(): Promise { + const { success } = await this.selectAllController.selectAllPages(); + this.setShowClear(success); + } +} 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/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/typings/DatagridProps.d.ts b/packages/pluggableWidgets/datagrid-web/typings/DatagridProps.d.ts index a3e01d2e2d..b42225b936 100644 --- a/packages/pluggableWidgets/datagrid-web/typings/DatagridProps.d.ts +++ b/packages/pluggableWidgets/datagrid-web/typings/DatagridProps.d.ts @@ -7,12 +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 LoadingTypeEnum = "spinner" | "skeleton"; - export type ShowContentAsEnum = "attribute" | "dynamicText" | "customContent"; export type HidableEnum = "yes" | "hidden" | "no"; @@ -47,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"; @@ -55,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 { @@ -88,18 +88,23 @@ 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; showSelectAllToggle: boolean; keepSelection: boolean; + selectAllPagesEnabled: boolean; + selectAllPagesPageSize: number; loadingType: LoadingTypeEnum; refreshIndicator: boolean; - columns: ColumnsType[]; - columnsFilterable: boolean; pageSize: number; pagination: PaginationEnum; showPagingButtons: ShowPagingButtonsEnum; @@ -109,10 +114,6 @@ export interface DatagridContainerProps { showEmptyPlaceholder: ShowEmptyPlaceholderEnum; emptyPlaceholder?: ReactNode; rowClass?: ListExpressionValue; - onClickTrigger: OnClickTriggerEnum; - onClick?: ListActionValue; - onSelectionChange?: ActionValue; - filtersPlaceholder?: ReactNode; columnsSortable: boolean; columnsResizable: boolean; columnsDraggable: boolean; @@ -125,8 +126,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 { @@ -140,18 +146,23 @@ 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; showSelectAllToggle: boolean; keepSelection: boolean; + selectAllPagesEnabled: boolean; + selectAllPagesPageSize: number | null; loadingType: LoadingTypeEnum; refreshIndicator: boolean; - columns: ColumnsPreviewType[]; - columnsFilterable: boolean; pageSize: number | null; pagination: PaginationEnum; showPagingButtons: ShowPagingButtonsEnum; @@ -161,10 +172,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; @@ -178,6 +185,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/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..a63e796ce4 --- /dev/null +++ b/packages/shared/widget-plugin-grid/src/select-all/SelectAllController.ts @@ -0,0 +1,192 @@ +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, + // 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 + }); + } + + 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<{ success: boolean }> { + if (!this.beforeRunChecks()) { + 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; + 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 + }); + await this.reloadSelection(); + 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; + + 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 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 + const ok = when(() => this.selection !== prevSelection); + return ok; + } + + 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 73% rename from packages/shared/widget-plugin-grid/src/selection/stores/SelectionCountStore.ts rename to packages/shared/widget-plugin-grid/src/stores/SelectionCountStore.ts index 96227850a2..a8f980a004 100644 --- a/packages/shared/widget-plugin-grid/src/selection/stores/SelectionCountStore.ts +++ b/packages/shared/widget-plugin-grid/src/stores/SelectionCountStore.ts @@ -6,6 +6,7 @@ type Gate = DerivedPropsGate<{ itemSelection?: SelectionSingleValue | SelectionMultiValue; selectedCountTemplateSingular?: DynamicValue; selectedCountTemplatePlural?: DynamicValue; + clearSelectionCaption?: DynamicValue; }>; export class SelectionCountStore { @@ -19,18 +20,18 @@ export class SelectionCountStore { this.gate = gate; makeObservable(this, { - displayCount: computed, + selectedCountText: computed, selectedCount: computed, - fmtSingular: computed, - fmtPlural: computed + formatSingular: computed, + formatPlural: computed }); } - 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; } @@ -49,10 +50,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"; } }