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..31a75b7c13 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, @@ -480,7 +481,10 @@ $root: ".widget-datagrid"; &-spinner { justify-content: center; - width: 100%; + + &-full-width { + width: 100%; + } &-margin { margin: 52px 0; @@ -555,16 +559,36 @@ $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 0.5em; + border-radius: 6px; display: inline-block; + + &:hover, + &:focus-visible { + background-color: var(--brand-primary-50, #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); + + #{$root}-spinner { + padding: 6.2px; + } } @keyframes skeleton-loading { diff --git a/packages/pluggableWidgets/datagrid-web/CHANGELOG.md b/packages/pluggableWidgets/datagrid-web/CHANGELOG.md index a859f8820c..47757a4e55 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.1] - 2025-10-14 ### Fixed diff --git a/packages/pluggableWidgets/datagrid-web/src/Datagrid.editorConfig.ts b/packages/pluggableWidgets/datagrid-web/src/Datagrid.editorConfig.ts index 2139f6870d..f60e764482 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"]); } @@ -170,6 +138,7 @@ function hideSelectionProperties(defaultProperties: Properties, values: Datagrid if (itemSelection !== "Multi") { hidePropertyIn(defaultProperties, values, "keepSelection"); + hidePropertyIn(defaultProperties, values, "enableSelectAll"); } } diff --git a/packages/pluggableWidgets/datagrid-web/src/Datagrid.editorPreview.tsx b/packages/pluggableWidgets/datagrid-web/src/Datagrid.editorPreview.tsx index 65b72439ee..b4248655ae 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, gateProvider.gate, query); + const selectAllProgressStore = new ProgressStore(); return { basicData, selectionHelper: undefined, @@ -97,8 +108,21 @@ 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( + host, + 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..95ed8ec323 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,84 @@ - + + + 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). + + + + + Loading type + + + Spinner + Skeleton + + + + Show refresh indicator + Show a refresh indicator when the data is being loaded. + + + Page size @@ -251,6 +279,8 @@ Load More + + Empty list message @@ -269,28 +299,6 @@ - - - On click trigger - - - Single click - Double click - - - - On click action - - - - On selection change - - - - Filters placeholder - - - @@ -337,7 +345,7 @@ - + Filter section @@ -358,19 +366,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 +403,34 @@ Row count plural Must include '%d' to denote number position ('%d rows selected') + + Select all text + + + Select all rows in the data source + + + + Select all template + This caption used when total count is available. + + Select all %d rows in the data source + + + + Select status template + + + All %d rows selected. + + + + 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..75085abe1a --- /dev/null +++ b/packages/pluggableWidgets/datagrid-web/src/components/SelectAllBar.tsx @@ -0,0 +1,30 @@ +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.isBarVisible) return null; + + return ( +
+ {vm.selectionStatus}  + + + + + + +
+ ); +}); 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..66091e2640 --- /dev/null +++ b/packages/pluggableWidgets/datagrid-web/src/components/SelectionProgressDialog.tsx @@ -0,0 +1,22 @@ +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.isOpen) 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 b6bf499fa2..251d9f3494 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, selectionProgressDialogViewModel } = useDatagridRootScope(); const selectionEnabled = selectActionHelper.selectionType !== "None"; @@ -91,8 +93,10 @@ export const Widget = observer((props: WidgetProps): Re selection={selectionEnabled} style={props.styles} exporting={exporting} + selectingAllPages={selectionProgressDialogViewModel.isOpen} >
+ {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 (
({ @@ -32,6 +38,8 @@ window.IntersectionObserver = jest.fn(() => ({ takeRecords: jest.fn() })); +class Host extends BaseControllerHost {} + function withCtx( widgetProps: WidgetProps, contextOverrides: Partial = {} @@ -53,6 +61,12 @@ function withCtx( fmtPlural: "%d rows selected" }; + const host = new Host(); + const { gate } = new GateProvider({ datasource: list(4) } as DatagridContainerProps); + const query = new DatasourceController(host, { gate }); + const selectAllProgressStore = new ProgressStore(); + const selectAllController = new SelectAllController(host, gate, query); + const mockContext = { basicData: defaultBasicData as unknown as GridBasicData, selectionHelper: undefined, @@ -61,6 +75,15 @@ function withCtx( checkboxEventsController: widgetProps.checkboxEventsController, focusController: widgetProps.focusController, selectionCountStore: defaultSelectionCountStore as unknown as SelectionCountStore, + selectAllProgressStore, + rootStore: {} as unknown as RootGridStore, + selectAllBarViewModel: new SelectAllBarViewModel(host, gate, selectAllController), + selectionProgressDialogViewModel: new SelectionProgressDialogViewModel( + host, + gate, + selectAllProgressStore, + selectAllController + ), ...contextOverrides }; @@ -318,26 +341,42 @@ describe("Table", () => { describe("with multi selection helper", () => { it("render header checkbox if helper is given and checkbox state depends on the helper status", () => { const props = mockWidgetProps(); - props.data = objectItems(5); + const items = list(5).items!; + props.data = items; props.paging = true; - props.selectActionHelper = new SelectActionHelper("Multi", undefined, "checkbox", true, 5, "clear"); + let selectionHelper; + let actionHelper; - const renderWithStatus = (status: MultiSelectionStatus): ReturnType => { - return renderWithRootContext(props, { - basicData: { selectionStatus: status } as unknown as GridBasicData - }); - }; + // none + selectionHelper = new MultiSelectionHelper( + { selection: [] as ObjectItem[], type: "Multi" } as SelectionMultiValue, + items + ); + actionHelper = new SelectActionHelper("Multi", selectionHelper, "checkbox", true, 5, "clear"); - renderWithStatus("none"); + renderWithRootContext(props, { selectActionHelper: actionHelper, selectionHelper }); expect(queryByRole(screen.getAllByRole("columnheader")[0], "checkbox")).not.toBeChecked(); - cleanup(); - renderWithStatus("some"); - expect(queryByRole(screen.getAllByRole("columnheader")[0], "checkbox")).toBeChecked(); + // some + selectionHelper = new MultiSelectionHelper( + { selection: [items[0]] as ObjectItem[], type: "Multi" } as SelectionMultiValue, + items + ); + actionHelper = new SelectActionHelper("Multi", selectionHelper, "checkbox", true, 5, "clear"); + renderWithRootContext(props, { selectActionHelper: actionHelper, selectionHelper }); + expect(queryByRole(screen.getAllByRole("columnheader")[0], "checkbox")).toBeChecked(); cleanup(); - renderWithStatus("all"); + + // all + selectionHelper = new MultiSelectionHelper( + { selection: items as ObjectItem[], type: "Multi" } as SelectionMultiValue, + items + ); + actionHelper = new SelectActionHelper("Multi", selectionHelper, "checkbox", true, 5, "clear"); + renderWithRootContext(props, { selectActionHelper: actionHelper, selectionHelper }); expect(queryByRole(screen.getAllByRole("columnheader")[0], "checkbox")).toBeChecked(); + cleanup(); }); it("not render header checkbox if method is rowClick", () => { @@ -356,7 +395,7 @@ describe("Table", () => { props.selectActionHelper.onSelectAll = jest.fn(); renderWithRootContext(props, { - basicData: { selectionStatus: "none" } as unknown as GridBasicData + selectionHelper: { selectionStatus: status, type: "Multi" } as MultiSelectionHelper }); const checkbox = screen.getAllByRole("checkbox")[0]; @@ -480,7 +519,8 @@ describe("Table", () => { itemSelectionMethod: selectionMethod, itemSelectionMode: "clear", showSelectAllToggle: false, - pageSize: 5 + pageSize: 5, + datasource: ds }, helper ); @@ -502,7 +542,11 @@ describe("Table", () => { cellEventsController, checkboxEventsController, focusController: props.focusController, - selectionCountStore: {} as unknown as SelectionCountStore + selectionCountStore: {} as unknown as SelectionCountStore, + selectAllProgressStore: {} as unknown as ProgressStore, + rootStore: {} as unknown as RootGridStore, + selectAllBarViewModel: {} as unknown as SelectAllBarViewModel, + selectionProgressDialogViewModel: {} as unknown as SelectionProgressDialogViewModel }; return ( diff --git a/packages/pluggableWidgets/datagrid-web/src/components/__tests__/__snapshots__/Table.spec.tsx.snap b/packages/pluggableWidgets/datagrid-web/src/components/__tests__/__snapshots__/Table.spec.tsx.snap index cdc8ba6713..452206b1fc 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/__tests__/__snapshots__/Table.spec.tsx.snap +++ b/packages/pluggableWidgets/datagrid-web/src/components/__tests__/__snapshots__/Table.spec.tsx.snap @@ -76,7 +76,15 @@ exports[`Table renders the structure correctly 1`] = ` >
+ > + +   +
@@ -162,7 +170,15 @@ exports[`Table renders the structure correctly for preview when no header is pro >
+ > + +   +
@@ -281,7 +297,15 @@ exports[`Table renders the structure correctly with column alignments 1`] = ` >
+ > + +   +
@@ -371,7 +395,15 @@ exports[`Table renders the structure correctly with custom filtering 1`] = ` >
+ > + +   +
@@ -457,7 +489,15 @@ exports[`Table renders the structure correctly with dragging 1`] = ` >
+ > + +   +
@@ -543,7 +583,15 @@ exports[`Table renders the structure correctly with dynamic row class 1`] = ` >
+ > + +   +
@@ -629,7 +677,15 @@ exports[`Table renders the structure correctly with empty placeholder 1`] = ` >
+ > + +   +
@@ -719,7 +775,15 @@ exports[`Table renders the structure correctly with filtering 1`] = ` >
+ > + +   +
@@ -815,7 +879,15 @@ exports[`Table renders the structure correctly with header filters and a11y 1`] >
+ > + +   +
@@ -905,7 +977,15 @@ exports[`Table renders the structure correctly with header wrapper 1`] = ` >
+ > + +   +
@@ -1032,7 +1112,15 @@ exports[`Table renders the structure correctly with hiding 1`] = ` >
+ > + +   +
@@ -1118,7 +1206,15 @@ exports[`Table renders the structure correctly with paging 1`] = ` >
+ > + +   +
@@ -1294,7 +1390,15 @@ exports[`Table renders the structure correctly with resizing 1`] = ` >
+ > + +   +
@@ -1380,7 +1484,15 @@ exports[`Table renders the structure correctly with sorting 1`] = ` >
+ > + +   +
@@ -1547,7 +1659,15 @@ exports[`Table with selection method checkbox render an extra column and add cla >
+ > + +   +
@@ -1761,7 +1881,15 @@ exports[`Table with selection method rowClick add class to each selected cell 1` >
+ > + +   +
diff --git a/packages/pluggableWidgets/datagrid-web/src/components/loader/SpinnerLoader.tsx b/packages/pluggableWidgets/datagrid-web/src/components/loader/SpinnerLoader.tsx index 9d17f93e4f..3a067ccfbd 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/loader/SpinnerLoader.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/loader/SpinnerLoader.tsx @@ -4,12 +4,20 @@ import { createElement, ReactElement } from "react"; type SpinnerLoaderProps = { size?: "small" | "medium" | "large"; withMargins?: boolean; + fullWidth?: boolean; }; -export function SpinnerLoader({ size = "medium", withMargins = false }: SpinnerLoaderProps): ReactElement { +export function SpinnerLoader({ + size = "medium", + withMargins = false, + fullWidth = true +}: SpinnerLoaderProps): ReactElement { return (
diff --git a/packages/pluggableWidgets/datagrid-web/src/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/ExportController.ts b/packages/pluggableWidgets/datagrid-web/src/features/data-export/ExportController.ts index 458a342548..3700429497 100644 --- a/packages/pluggableWidgets/datagrid-web/src/features/data-export/ExportController.ts +++ b/packages/pluggableWidgets/datagrid-web/src/features/data-export/ExportController.ts @@ -1,8 +1,8 @@ +import { ProgressStore } from "@mendix/widget-plugin-grid/stores/ProgressStore"; import { ListValue } from "mendix"; import { createNanoEvents, Emitter } from "nanoevents"; import { ColumnsType } from "../../../typings/DatagridProps"; import { DSExportRequest } from "./DSExportRequest"; -import { ProgressStore } from "./ProgressStore"; interface ControllerEvents { sourcechange: (ds: ListValue) => void; diff --git a/packages/pluggableWidgets/datagrid-web/src/features/data-export/ProgressStore.ts b/packages/pluggableWidgets/datagrid-web/src/features/data-export/ProgressStore.ts deleted file mode 100644 index 18980bd0c8..0000000000 --- a/packages/pluggableWidgets/datagrid-web/src/features/data-export/ProgressStore.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { makeAutoObservable } from "mobx"; - -export class ProgressStore { - exporting = false; - lengthComputable = false; - loaded = 0; - total = 0; - constructor() { - makeAutoObservable(this); - } - - onloadstart = (event: ProgressEvent): void => { - this.exporting = true; - this.lengthComputable = event.lengthComputable; - this.total = event.total; - this.loaded = 0; - }; - - onprogress = (event: ProgressEvent): void => { - this.loaded = event.loaded; - }; - - onloadend = (): void => { - this.exporting = 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..e04cfceca1 100644 --- a/packages/pluggableWidgets/datagrid-web/src/features/data-export/useDataExport.ts +++ b/packages/pluggableWidgets/datagrid-web/src/features/data-export/useDataExport.ts @@ -1,9 +1,9 @@ +import { ProgressStore } from "@mendix/widget-plugin-grid/stores/ProgressStore"; import { useCallback, useEffect, useState } from "react"; -import { ExportController } from "./ExportController"; -import { ProgressStore } from "./ProgressStore"; -import { getExportRegistry } from "./registry"; import { DatagridContainerProps } from "../../../typings/DatagridProps"; import { IColumnGroupStore } from "../../helpers/state/ColumnGroupStore"; +import { ExportController } from "./ExportController"; +import { getExportRegistry } from "./registry"; 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..00cb55e553 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" + | "enableSelectAll" + | "onSelectionChange" + | "selectAllTemplate" + | "selectAllText" + | "allSelectedText" + | "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,27 @@ 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( + this, + gate, + selectAllProgressStore, + selectAllController + ); + combinedFilter.hydrate(props.datasource.filter); } @@ -120,13 +156,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..11abee64b8 --- /dev/null +++ b/packages/pluggableWidgets/datagrid-web/src/helpers/state/SelectAllBarViewModel.ts @@ -0,0 +1,169 @@ +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 { action, makeAutoObservable, reaction } from "mobx"; +import { DatagridContainerProps } from "../../../typings/DatagridProps"; + +type Props = Pick< + DatagridContainerProps, + | "cancelSelectionLabel" + | "selectAllTemplate" + | "selectAllText" + | "clearSelectionCaption" + | "itemSelection" + | "selectedCountTemplatePlural" + | "selectedCountTemplateSingular" + | "datasource" + | "allSelectedText" + | "enableSelectAll" +>; + +type Gate = DerivedPropsGate; + +export class SelectAllBarViewModel implements ReactiveController { + private barVisible = false; + private clearVisible = false; + pending = false; + + readonly #gate: Gate; + readonly #selectAllController: SelectAllController; + readonly #count: SelectionCountStore; + readonly #enableSelectAll: boolean; + + constructor( + host: ReactiveControllerHost, + gate: Gate, + selectAllController: SelectAllController, + count = new SelectionCountStore(gate) + ) { + host.addController(this); + type PrivateMembers = "setClearVisible" | "setPending" | "hideBar" | "showBar"; + makeAutoObservable(this, { + setClearVisible: action, + setPending: action, + hideBar: action, + showBar: action + }); + this.#gate = gate; + this.#selectAllController = selectAllController; + this.#count = count; + this.#enableSelectAll = gate.props.enableSelectAll; + } + + private setClearVisible(value: boolean): void { + this.clearVisible = value; + } + + private setPending(value: boolean): void { + this.pending = value; + } + + private hideBar(): void { + this.barVisible = false; + this.clearVisible = false; + } + + private showBar(): void { + this.barVisible = true; + } + + private get total(): number { + return this.#gate.props.datasource.totalCount ?? 0; + } + + private get selectAllFormat(): string { + return this.#gate.props.selectAllTemplate?.value ?? "select.all.n.items"; + } + + private get selectAllText(): string { + return this.#gate.props.selectAllText?.value ?? "select.all.items"; + } + + private get allSelectedText(): string { + const str = this.#gate.props.allSelectedText?.value ?? "all.selected"; + return str.replace("%d", `${this.#count.selectedCount}`); + } + + private get selectedSet(): Set { + const selection = this.#gate.props.itemSelection; + if (!selection) return new Set(); + if (selection.type === "Single") return new Set(); + return new Set([...selection.selection.map(it => it.id)]); + } + + private get isCurrentPageSelected(): boolean { + const items = this.#gate.props.datasource.items ?? []; + if (items.length === 0) return false; + return items.every(items => this.selectedSet.has(items.id)); + } + + private get isAllItemsSelected(): boolean { + if (this.total > 0) return this.total === this.#count.selectedCount; + + const { offset, limit, items = [], hasMoreItems } = this.#gate.props.datasource; + const noMoreItems = typeof hasMoreItems === "boolean" && hasMoreItems === false; + const fullyLoaded = offset === 0 && limit >= items.length; + + return fullyLoaded && noMoreItems && items.length === this.#count.selectedCount; + } + + get selectAllLabel(): string { + if (this.total > 0) return this.selectAllFormat.replace("%d", `${this.total}`); + return this.selectAllText; + } + + get clearSelectionLabel(): string { + return this.#gate.props.clearSelectionCaption?.value ?? "clear.selection.caption"; + } + + get selectionStatus(): string { + if (this.isAllItemsSelected) return this.allSelectedText; + return this.#count.selectedCountText; + } + + get isBarVisible(): boolean { + return this.#enableSelectAll && this.barVisible; + } + + get isClearVisible(): boolean { + return this.clearVisible; + } + + get isSelectAllVisible(): boolean { + return !this.clearVisible; + } + + get isSelectAllDisabled(): boolean { + return this.pending; + } + + setup(): (() => void) | void { + if (!this.#enableSelectAll) return; + + return reaction( + () => this.isCurrentPageSelected, + isCurrentPageSelected => { + if (isCurrentPageSelected === false) { + this.hideBar(); + } else if (this.isAllItemsSelected === false) { + this.showBar(); + } + } + ); + } + + onClear(): void { + this.#selectAllController.clearSelection(); + } + + async onSelectAll(): Promise { + this.setPending(true); + try { + const { success } = await this.#selectAllController.selectAllPages(); + this.setClearVisible(success); + } finally { + this.setPending(false); + } + } +} diff --git a/packages/pluggableWidgets/datagrid-web/src/helpers/state/SelectionProgressDialogViewModel.ts b/packages/pluggableWidgets/datagrid-web/src/helpers/state/SelectionProgressDialogViewModel.ts new file mode 100644 index 0000000000..3ec73ed715 --- /dev/null +++ b/packages/pluggableWidgets/datagrid-web/src/helpers/state/SelectionProgressDialogViewModel.ts @@ -0,0 +1,84 @@ +import { SelectAllController } from "@mendix/widget-plugin-grid/selection"; +import { ProgressStore } from "@mendix/widget-plugin-grid/stores/ProgressStore"; +import { DerivedPropsGate } from "@mendix/widget-plugin-mobx-kit/props-gate"; +import { ReactiveController, ReactiveControllerHost } from "@mendix/widget-plugin-mobx-kit/reactive-controller"; +import { DynamicValue } from "mendix"; +import { action, makeAutoObservable, reaction } from "mobx"; + +type Gate = DerivedPropsGate<{ + selectingAllLabel?: DynamicValue; + cancelSelectionLabel?: DynamicValue; +}>; + +export class SelectionProgressDialogViewModel implements ReactiveController { + /** + * This state is synced with progressStore, but with short delay to + * avoid UI flickering. + */ + private dialogOpen = false; + + #gate: Gate; + #progressStore: ProgressStore; + #selectAllController: SelectAllController; + #timerId: ReturnType | undefined; + + constructor( + host: ReactiveControllerHost, + gate: Gate, + progressStore: ProgressStore, + selectAllController: SelectAllController + ) { + host.addController(this); + type PrivateMembers = "setDialogOpen"; + makeAutoObservable(this, { setDialogOpen: action }); + this.#gate = gate; + this.#progressStore = progressStore; + this.#selectAllController = selectAllController; + } + + private setDialogOpen(value: boolean): void { + this.dialogOpen = value; + } + + get isOpen(): boolean { + return this.dialogOpen; + } + + 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"; + } + + setup(): () => void { + return reaction( + () => this.#progressStore.inProgress, + inProgress => { + if (inProgress) { + // Delay showing dialog for 2 second + this.#timerId = setTimeout(() => { + this.setDialogOpen(true); + this.#timerId = undefined; + }, 1500); + } else { + this.setDialogOpen(false); + clearTimeout(this.#timerId); + } + } + ); + } + + 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..d75e24905a 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,22 @@ 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; + enableSelectAll: boolean; loadingType: LoadingTypeEnum; refreshIndicator: boolean; - columns: ColumnsType[]; - columnsFilterable: boolean; pageSize: number; pagination: PaginationEnum; showPagingButtons: ShowPagingButtonsEnum; @@ -109,10 +113,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 +125,14 @@ export interface DatagridContainerProps { cancelExportLabel?: DynamicValue; selectRowLabel?: DynamicValue; selectAllRowsLabel?: DynamicValue; + selectingAllLabel?: DynamicValue; + cancelSelectionLabel?: DynamicValue; selectedCountTemplateSingular?: DynamicValue; selectedCountTemplatePlural?: DynamicValue; + selectAllText: DynamicValue; + selectAllTemplate: DynamicValue; + allSelectedText: DynamicValue; + clearSelectionCaption: DynamicValue; } export interface DatagridPreviewProps { @@ -140,18 +146,22 @@ 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; + enableSelectAll: boolean; loadingType: LoadingTypeEnum; refreshIndicator: boolean; - columns: ColumnsPreviewType[]; - columnsFilterable: boolean; pageSize: number | null; pagination: PaginationEnum; showPagingButtons: ShowPagingButtonsEnum; @@ -161,10 +171,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 +184,12 @@ export interface DatagridPreviewProps { cancelExportLabel: string; selectRowLabel: string; selectAllRowsLabel: string; + selectingAllLabel: string; + cancelSelectionLabel: string; selectedCountTemplateSingular: string; selectedCountTemplatePlural: string; + selectAllText: string; + selectAllTemplate: string; + allSelectedText: string; + clearSelectionCaption: string; } diff --git a/packages/pluggableWidgets/gallery-web/src/Gallery.xml b/packages/pluggableWidgets/gallery-web/src/Gallery.xml index 7958540ecb..cf39193ff3 100644 --- a/packages/pluggableWidgets/gallery-web/src/Gallery.xml +++ b/packages/pluggableWidgets/gallery-web/src/Gallery.xml @@ -198,6 +198,13 @@ Item count plural Must include '%d' to denote number position ('%d items selected') + + Clear selection caption + + + Clear selection + + diff --git a/packages/pluggableWidgets/gallery-web/src/components/SelectionCounter.tsx b/packages/pluggableWidgets/gallery-web/src/components/SelectionCounter.tsx index 7e81ba9d40..e72893444f 100644 --- a/packages/pluggableWidgets/gallery-web/src/components/SelectionCounter.tsx +++ b/packages/pluggableWidgets/gallery-web/src/components/SelectionCounter.tsx @@ -7,10 +7,11 @@ export const SelectionCounter = observer(function SelectionCounter() { const { selectionCountStore, itemSelectHelper } = useGalleryRootScope(); return ( - - {selectionCountStore.displayCount} |  + + {selectionCountStore.selectedCountText} +   ); diff --git a/packages/pluggableWidgets/gallery-web/src/helpers/root-context.ts b/packages/pluggableWidgets/gallery-web/src/helpers/root-context.ts index 6a200bb941..e428cb74d7 100644 --- a/packages/pluggableWidgets/gallery-web/src/helpers/root-context.ts +++ b/packages/pluggableWidgets/gallery-web/src/helpers/root-context.ts @@ -1,5 +1,5 @@ import { SelectActionHandler, SelectionHelper } from "@mendix/widget-plugin-grid/selection"; -import { SelectionCountStore } from "@mendix/widget-plugin-grid/selection/stores/SelectionCountStore"; +import { SelectionCountStore } from "@mendix/widget-plugin-grid/stores/SelectionCountStore"; import { createContext, useContext } from "react"; import { GalleryStore } from "../stores/GalleryStore"; diff --git a/packages/pluggableWidgets/gallery-web/src/stores/GalleryStore.ts b/packages/pluggableWidgets/gallery-web/src/stores/GalleryStore.ts index 4c45d00ca0..f7cbb78bfd 100644 --- a/packages/pluggableWidgets/gallery-web/src/stores/GalleryStore.ts +++ b/packages/pluggableWidgets/gallery-web/src/stores/GalleryStore.ts @@ -4,7 +4,7 @@ import { CustomFilterHost } from "@mendix/widget-plugin-filtering/stores/generic import { DatasourceController } from "@mendix/widget-plugin-grid/query/DatasourceController"; import { PaginationController } from "@mendix/widget-plugin-grid/query/PaginationController"; import { RefreshController } from "@mendix/widget-plugin-grid/query/RefreshController"; -import { SelectionCountStore } from "@mendix/widget-plugin-grid/selection/stores/SelectionCountStore"; +import { SelectionCountStore } from "@mendix/widget-plugin-grid/stores/SelectionCountStore"; import { BaseControllerHost } from "@mendix/widget-plugin-mobx-kit/BaseControllerHost"; import { DerivedPropsGate } from "@mendix/widget-plugin-mobx-kit/props-gate"; import { generateUUID } from "@mendix/widget-plugin-platform/framework/generate-uuid"; diff --git a/packages/pluggableWidgets/gallery-web/src/utils/test-utils.tsx b/packages/pluggableWidgets/gallery-web/src/utils/test-utils.tsx index 9b45fb84da..e0724bf8ca 100644 --- a/packages/pluggableWidgets/gallery-web/src/utils/test-utils.tsx +++ b/packages/pluggableWidgets/gallery-web/src/utils/test-utils.tsx @@ -4,7 +4,7 @@ import { PositionController } from "@mendix/widget-plugin-grid/keyboard-navigati import { VirtualGridLayout } from "@mendix/widget-plugin-grid/keyboard-navigation/VirtualGridLayout"; import { getColumnAndRowBasedOnIndex, SelectActionHandler } from "@mendix/widget-plugin-grid/selection"; import { GateProvider } from "@mendix/widget-plugin-mobx-kit/GateProvider"; -import { list, listAction, objectItems } from "@mendix/widget-plugin-test-utils"; +import { dynamic, list, listAction, objectItems } from "@mendix/widget-plugin-test-utils"; import { render, RenderResult } from "@testing-library/react"; import userEvent, { UserEvent } from "@testing-library/user-event"; import { ObjectItem } from "mendix"; @@ -57,7 +57,8 @@ export function createMockGalleryContext(): GalleryRootScope { storeFilters: false, storeSort: false, refreshIndicator: false, - keepSelection: false + keepSelection: false, + clearSelectionCaption: dynamic("Clear selection") }; // Create a proper gate provider and gate diff --git a/packages/pluggableWidgets/gallery-web/typings/GalleryProps.d.ts b/packages/pluggableWidgets/gallery-web/typings/GalleryProps.d.ts index 1244aa8b18..a328ce3b6b 100644 --- a/packages/pluggableWidgets/gallery-web/typings/GalleryProps.d.ts +++ b/packages/pluggableWidgets/gallery-web/typings/GalleryProps.d.ts @@ -57,6 +57,7 @@ export interface GalleryContainerProps { ariaLabelItem?: ListExpressionValue; selectedCountTemplateSingular?: DynamicValue; selectedCountTemplatePlural?: DynamicValue; + clearSelectionCaption: DynamicValue; } export interface GalleryPreviewProps { @@ -103,4 +104,5 @@ export interface GalleryPreviewProps { ariaLabelItem: string; selectedCountTemplateSingular: string; selectedCountTemplatePlural: 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..cd348cf15a --- /dev/null +++ b/packages/shared/widget-plugin-grid/src/select-all/SelectAllController.ts @@ -0,0 +1,184 @@ +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 }>; +type SelectAllEventType = "loadstart" | "progress" | "abort" | "loadend"; + +export class SelectAllController implements ReactiveController { + private locked = false; + + readonly #gate: Gate; + readonly #query: QueryController; + readonly #emitter = new EventTarget(); + readonly #pageSize = 1024; + + #abortController?: AbortController; + + constructor(host: ReactiveControllerHost, gate: Gate, query: QueryController) { + host.addController(this); + type PrivateMembers = "setIsLocked" | "locked"; + makeObservable(this, { + setIsLocked: action, + canExecute: computed, + isExecuting: computed, + // Here we use keepAlive to make sure selection is never outdated. + // selection: computed({ keepAlive: true }), + selection: computed, + locked: observable, + selectAllPages: action, + clearSelection: action, + abort: action + }); + + this.#gate = gate; + this.#query = query; + } + + setup(): () => void { + 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. + */ + 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); + return when(() => this.selection !== prevSelection); + } + + 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..481bf7e152 --- /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, spec.gate, query); + 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 68% 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..90d8ccda4f 100644 --- a/packages/shared/widget-plugin-grid/src/selection/stores/SelectionCountStore.ts +++ b/packages/shared/widget-plugin-grid/src/stores/SelectionCountStore.ts @@ -6,12 +6,13 @@ type Gate = DerivedPropsGate<{ itemSelection?: SelectionSingleValue | SelectionMultiValue; selectedCountTemplateSingular?: DynamicValue; selectedCountTemplatePlural?: DynamicValue; + clearSelectionCaption?: DynamicValue; }>; export class SelectionCountStore { private gate: Gate; - private singular: string = "%d row selected"; - private plural: string = "%d rows selected"; + private singular: string = "%d row selected."; + private plural: string = "%d rows selected."; constructor(gate: Gate, spec: { singular?: string; plural?: string } = {}) { this.singular = spec.singular ?? this.singular; @@ -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 clearSelectionText(): string { + return this.gate.props.clearSelectionCaption?.value ?? "clear.selection.caption"; } }