diff --git a/packages/modules/data-widgets/package.json b/packages/modules/data-widgets/package.json index 7ce4e4e780..d90306c6f4 100644 --- a/packages/modules/data-widgets/package.json +++ b/packages/modules/data-widgets/package.json @@ -1,7 +1,7 @@ { "name": "@mendix/data-widgets", "moduleName": "Data Widgets", - "version": "3.6.1", + "version": "3.7.0", "description": "Data Widgets module containing a set of widgets to display data in various ways.", "copyright": "© Mendix Technology BV 2025. All rights reserved.", "license": "Apache-2.0", diff --git a/packages/modules/data-widgets/src/themesource/datawidgets/web/_datagrid.scss b/packages/modules/data-widgets/src/themesource/datawidgets/web/_datagrid.scss index 48aac22092..4c5d41466f 100644 --- a/packages/modules/data-widgets/src/themesource/datawidgets/web/_datagrid.scss +++ b/packages/modules/data-widgets/src/themesource/datawidgets/web/_datagrid.scss @@ -428,7 +428,8 @@ $root: ".widget-datagrid"; align-items: center; } - &-exporting { + &-exporting, + &-selecting-all-pages { .widget-datagrid-top-bar, .widget-datagrid-header, .widget-datagrid-content, @@ -574,22 +575,31 @@ $root: ".widget-datagrid"; align-items: center; } -#{$root}-clear-selection { +#{$root}-btn-link { 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; - &:focus:not(:focus-visible) { - outline: none; + &:hover, + &:focus-visible { + background-color: var(--brand-primary-50, #e6e7f2); } +} - &:focus-visible { - outline: 1px solid var(--brand-primary, $brand-primary); - outline-offset: 2px; +: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; } } diff --git a/packages/pluggableWidgets/datagrid-date-filter-web/package.json b/packages/pluggableWidgets/datagrid-date-filter-web/package.json index f7be2cad7a..9926277555 100644 --- a/packages/pluggableWidgets/datagrid-date-filter-web/package.json +++ b/packages/pluggableWidgets/datagrid-date-filter-web/package.json @@ -1,7 +1,7 @@ { "name": "@mendix/datagrid-date-filter-web", "widgetName": "DatagridDateFilter", - "version": "3.6.0", + "version": "3.7.0", "description": "Filter Data Grid 2 rows by date or date range, using a calendar picker.", "copyright": "© Mendix Technology BV 2025. All rights reserved.", "license": "Apache-2.0", diff --git a/packages/pluggableWidgets/datagrid-date-filter-web/src/package.xml b/packages/pluggableWidgets/datagrid-date-filter-web/src/package.xml index c7d5c584b3..5701349eaa 100644 --- a/packages/pluggableWidgets/datagrid-date-filter-web/src/package.xml +++ b/packages/pluggableWidgets/datagrid-date-filter-web/src/package.xml @@ -1,6 +1,6 @@ - + diff --git a/packages/pluggableWidgets/datagrid-dropdown-filter-web/package.json b/packages/pluggableWidgets/datagrid-dropdown-filter-web/package.json index 6621ddb8e0..ad2ed1018a 100644 --- a/packages/pluggableWidgets/datagrid-dropdown-filter-web/package.json +++ b/packages/pluggableWidgets/datagrid-dropdown-filter-web/package.json @@ -1,7 +1,7 @@ { "name": "@mendix/datagrid-dropdown-filter-web", "widgetName": "DatagridDropdownFilter", - "version": "3.6.0", + "version": "3.7.0", "description": "Filter Data Grid 2 rows by selecting values from a drop-down list.", "copyright": "© Mendix Technology BV 2025. All rights reserved.", "license": "Apache-2.0", diff --git a/packages/pluggableWidgets/datagrid-dropdown-filter-web/src/package.xml b/packages/pluggableWidgets/datagrid-dropdown-filter-web/src/package.xml index 287e696bb0..37cff42f12 100644 --- a/packages/pluggableWidgets/datagrid-dropdown-filter-web/src/package.xml +++ b/packages/pluggableWidgets/datagrid-dropdown-filter-web/src/package.xml @@ -1,6 +1,6 @@ - + diff --git a/packages/pluggableWidgets/datagrid-number-filter-web/package.json b/packages/pluggableWidgets/datagrid-number-filter-web/package.json index 9b117a781e..bbb26a4432 100644 --- a/packages/pluggableWidgets/datagrid-number-filter-web/package.json +++ b/packages/pluggableWidgets/datagrid-number-filter-web/package.json @@ -1,7 +1,7 @@ { "name": "@mendix/datagrid-number-filter-web", "widgetName": "DatagridNumberFilter", - "version": "3.6.0", + "version": "3.7.0", "description": "Filter Data Grid 2 rows by numeric values, supporting equals, greater than, and less than operations.", "copyright": "© Mendix Technology BV 2025. All rights reserved.", "license": "Apache-2.0", diff --git a/packages/pluggableWidgets/datagrid-number-filter-web/src/package.xml b/packages/pluggableWidgets/datagrid-number-filter-web/src/package.xml index 43f93a6d08..664e178572 100644 --- a/packages/pluggableWidgets/datagrid-number-filter-web/src/package.xml +++ b/packages/pluggableWidgets/datagrid-number-filter-web/src/package.xml @@ -1,6 +1,6 @@ - + diff --git a/packages/pluggableWidgets/datagrid-text-filter-web/package.json b/packages/pluggableWidgets/datagrid-text-filter-web/package.json index 60dce72c99..b83661ad84 100644 --- a/packages/pluggableWidgets/datagrid-text-filter-web/package.json +++ b/packages/pluggableWidgets/datagrid-text-filter-web/package.json @@ -1,7 +1,7 @@ { "name": "@mendix/datagrid-text-filter-web", "widgetName": "DatagridTextFilter", - "version": "3.6.0", + "version": "3.7.0", "description": "Filter Data Grid 2 rows by text input, supporting contains, starts with, and equals operations.", "copyright": "© Mendix Technology BV 2025. All rights reserved.", "license": "Apache-2.0", diff --git a/packages/pluggableWidgets/datagrid-text-filter-web/src/package.xml b/packages/pluggableWidgets/datagrid-text-filter-web/src/package.xml index c1acba7476..d5f27ea33f 100644 --- a/packages/pluggableWidgets/datagrid-text-filter-web/src/package.xml +++ b/packages/pluggableWidgets/datagrid-text-filter-web/src/package.xml @@ -1,6 +1,6 @@ - + diff --git a/packages/pluggableWidgets/datagrid-web/CHANGELOG.md b/packages/pluggableWidgets/datagrid-web/CHANGELOG.md index fbe7db8aa7..40ffc9ad45 100644 --- a/packages/pluggableWidgets/datagrid-web/CHANGELOG.md +++ b/packages/pluggableWidgets/datagrid-web/CHANGELOG.md @@ -12,6 +12,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - We fixed an issue where missing consistency checks for the captions were causing runtime errors instead of in Studio Pro +- We added multi-page select all functionality for Datagrid widget with configurable batch processing, progress tracking, and page restoration to allow users to select all items across multiple pages with a single click. + ## [3.6.1] - 2025-10-14 ### Fixed diff --git a/packages/pluggableWidgets/datagrid-web/package.json b/packages/pluggableWidgets/datagrid-web/package.json index 1887d29d8b..ceb4c54d5a 100644 --- a/packages/pluggableWidgets/datagrid-web/package.json +++ b/packages/pluggableWidgets/datagrid-web/package.json @@ -1,7 +1,7 @@ { "name": "@mendix/datagrid-web", "widgetName": "Datagrid", - "version": "3.6.1", + "version": "3.7.0", "description": "A powerful, flexible grid for displaying, sorting, and editing data collections in Mendix web apps.", "copyright": "© Mendix Technology BV 2025. All rights reserved.", "license": "Apache-2.0", diff --git a/packages/pluggableWidgets/datagrid-web/src/Datagrid.editorConfig.ts b/packages/pluggableWidgets/datagrid-web/src/Datagrid.editorConfig.ts index abd7f1cfb2..3f996ec75f 100644 --- a/packages/pluggableWidgets/datagrid-web/src/Datagrid.editorConfig.ts +++ b/packages/pluggableWidgets/datagrid-web/src/Datagrid.editorConfig.ts @@ -3,8 +3,7 @@ import { hideNestedPropertiesIn, hidePropertiesIn, hidePropertyIn, - Properties, - transformGroupsIntoTabs + Properties } from "@mendix/pluggable-widgets-tools"; import { container, @@ -22,7 +21,7 @@ import { ColumnsPreviewType, DatagridPreviewProps } from "../typings/DatagridPro export function getProperties( values: DatagridPreviewProps, defaultProperties: Properties, - platform: "web" | "desktop" + _: "web" | "desktop" ): Properties { values.columns.forEach((column, index) => { if (column.showContentAs !== "attribute" && !column.sortable && !values.columnsFilterable) { @@ -65,15 +64,6 @@ export function getProperties( if (column.minWidth !== "manual") { hidePropertyIn(defaultProperties, values, "columns", index, "minWidthLimit"); } - if (!values.advanced && platform === "web") { - hideNestedPropertiesIn(defaultProperties, values, "columns", index, [ - "columnClass", - "sortable", - "resizable", - "draggable", - "hidable" - ]); - } }); if (values.pagination === "buttons") { @@ -125,28 +115,6 @@ export function getProperties( "columns" ); - if (platform === "web") { - if (!values.advanced) { - hidePropertiesIn(defaultProperties, values, [ - "pagination", - "pagingPosition", - "showEmptyPlaceholder", - "rowClass", - "columnsSortable", - "columnsDraggable", - "columnsResizable", - "columnsHidable", - "configurationAttribute", - "onConfigurationChange", - "filterSectionTitle" - ]); - } - - transformGroupsIntoTabs(defaultProperties); - } else { - hidePropertyIn(defaultProperties, values, "advanced"); - } - if (values.configurationStorageType === "localStorage") { hidePropertiesIn(defaultProperties, values, ["configurationAttribute", "onConfigurationChange"]); } @@ -170,11 +138,8 @@ function hideSelectionProperties(defaultProperties: Properties, values: Datagrid } if (itemSelection !== "Multi") { - hidePropertiesIn(defaultProperties, values, [ - "keepSelection", - "selectionCountPosition", - "clearSelectionButtonLabel" - ]); + 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 6086784e80..5f1c32d625 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 82dcc6a041..5a27137363 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 ( @@ -124,7 +124,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} @@ -147,14 +147,5 @@ const Container = observer((props: Props): ReactElement => { Container.displayName = "DatagridComponent"; export default function Datagrid(props: DatagridContainerProps): ReactElement | null { - const rootStore = useRootStore(props); - - return ( - - ); + return ; } diff --git a/packages/pluggableWidgets/datagrid-web/src/Datagrid.xml b/packages/pluggableWidgets/datagrid-web/src/Datagrid.xml index afb1d00ec4..f0880ae25a 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,67 +16,6 @@ Refresh time (in seconds) - - Selection - - - - - - - - - Selection method - - - Checkbox - Row click - - - - Toggle on click - Defines item selection behavior. - - Yes - No - - - - Show (un)check all toggle - Show a checkbox in the grid header to check or uncheck multiple items. - - - Keep selection - If enabled, selected items will stay selected unless cleared by the user or a Nanoflow. - - - Show selection count - - - Top - Bottom - Off - - - - Clear selection label - Customize the label of the 'Clear section' button - - Clear selection - - - - Loading type - - - Spinner - Skeleton - - - - Show refresh indicator - Show a refresh indicator when the data is being loaded. - @@ -225,7 +160,93 @@ - + + + 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). + + + Show selection count + + + Top + Bottom + Off + + + + + + Loading type + + + Spinner + Skeleton + + + + Show refresh indicator + Show a refresh indicator when the data is being loaded. + + + Page size @@ -267,6 +288,8 @@ Load More + + Empty list message @@ -285,28 +308,6 @@ - - - On click trigger - - - Single click - Double click - - - - On click action - - - - On selection change - - - - Filters placeholder - - - @@ -353,7 +354,7 @@ - + Filter section @@ -374,26 +375,76 @@ - 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') + Must include '%d' to denote number position + + %d row selected + Row count plural - Must include '%d' to denote number position ('%d rows selected') + Must include '%d' to denote number position + + %d rows selected + + + + Select all text + + + 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 130b4dce86..6b25f29347 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 { Fragment, ReactElement, useCallback } from "react"; +import { SelectionStatus } from "@mendix/widget-plugin-grid/selection"; +import { Fragment, ReactElement, ReactNode } from "react"; import { useDatagridRootScope } from "../helpers/root-context"; export function CheckboxColumnHeader(): ReactElement { - 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 }): 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/ExportAlert.tsx b/packages/pluggableWidgets/datagrid-web/src/components/ExportAlert.tsx index d54037d39a..64ede0b94c 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/ExportAlert.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/ExportAlert.tsx @@ -55,7 +55,11 @@ export function ExportAlert({ function ExportProgress({ progress, total }: { progress: number; total: number | undefined }): ReactElement { const validTotal = isValidTotal(total) && total; return ( - + { - if (props.isFirstLoad) { - return 0 ? props.rowsSize : props.pageSize} />; - } - return ( - - {children} - {props.isFetchingNextBatch && } - - ); - }; - return (
- {content()} + {((): ReactElement => { + if (props.isFirstLoad) { + return 0 ? props.rowsSize : props.pageSize} />; + } + return ( + + {children} + {props.isFetchingNextBatch && ( + + )} + + ); + })()}
); } diff --git a/packages/pluggableWidgets/datagrid-web/src/components/SelectAllBar.tsx b/packages/pluggableWidgets/datagrid-web/src/components/SelectAllBar.tsx new file mode 100644 index 0000000000..ccab41a4b0 --- /dev/null +++ b/packages/pluggableWidgets/datagrid-web/src/components/SelectAllBar.tsx @@ -0,0 +1,29 @@ +import { If } from "@mendix/widget-plugin-component-kit/If"; +import { observer } from "mobx-react-lite"; +import { useDatagridRootScope } from "../helpers/root-context"; + +export const SelectAllBar = observer(function SelectAllBar() { + const { selectAllBarViewModel: vm } = useDatagridRootScope(); + + if (!vm.isBarVisible) return null; + + return ( +
+ {vm.selectionStatus}  + + + + + + +
+ ); +}); diff --git a/packages/pluggableWidgets/datagrid-web/src/components/SelectionCounter.tsx b/packages/pluggableWidgets/datagrid-web/src/components/SelectionCounter.tsx index 99bee0db88..825ab51b6e 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/SelectionCounter.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/SelectionCounter.tsx @@ -1,29 +1,18 @@ -import { If } from "@mendix/widget-plugin-component-kit/If"; import { observer } from "mobx-react-lite"; +import { Fragment } from "react"; import { useDatagridRootScope } from "../helpers/root-context"; -type SelectionCounterLocation = "top" | "bottom" | undefined; - -export const SelectionCounter = observer(function SelectionCounter({ - location -}: { - location?: SelectionCounterLocation; -}) { - const { selectionCountStore, selectActionHelper } = useDatagridRootScope(); - - const containerClass = location === "top" ? "widget-datagrid-tb-start" : "widget-datagrid-pb-start"; +export const SelectionCounter = observer(function SelectionCounter() { + const { selectionCounterViewModel: vm, selectActionHelper } = useDatagridRootScope(); return ( - -
- - {selectionCountStore.displayCount} - -  |  - -
-
+ + + {vm.selectedCountText} + + + ); }); diff --git a/packages/pluggableWidgets/datagrid-web/src/components/SelectionProgressDialog.tsx b/packages/pluggableWidgets/datagrid-web/src/components/SelectionProgressDialog.tsx new file mode 100644 index 0000000000..54b8726bb3 --- /dev/null +++ b/packages/pluggableWidgets/datagrid-web/src/components/SelectionProgressDialog.tsx @@ -0,0 +1,23 @@ +import { ReactNode } from "react"; +import { useDatagridRootScope } from "../helpers/root-context"; +import { ExportAlert } from "./ExportAlert"; +import { PseudoModal } from "./PseudoModal"; + +export function SelectionProgressDialog(): ReactNode { + 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 35ad6ac5a2..8297cbd280 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/Widget.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/Widget.tsx @@ -21,12 +21,14 @@ import { Grid } from "./Grid"; import { GridBody } from "./GridBody"; import { GridHeader } from "./GridHeader"; import { RowsRenderer } from "./RowsRenderer"; +import { SelectAllBar } from "./SelectAllBar"; +import { SelectionCounter } from "./SelectionCounter"; +import { SelectionProgressDialog } from "./SelectionProgressDialog"; import { WidgetContent } from "./WidgetContent"; import { WidgetFooter } from "./WidgetFooter"; import { WidgetHeader } from "./WidgetHeader"; import { WidgetRoot } from "./WidgetRoot"; import { WidgetTopBar } from "./WidgetTopBar"; -import { SelectionCounter } from "./SelectionCounter"; export interface WidgetProps { CellComponent: CellComponent; @@ -83,7 +85,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"; @@ -94,8 +96,10 @@ export const Widget = observer((props: WidgetProps): Re selection={selectionEnabled} style={props.styles} exporting={exporting} + selectingAllPages={selectionProgressDialogViewModel.isOpen} >
+ {exporting && ( (props: WidgetProps): ReactElemen const { basicData, selectionCountStore } = useDatagridRootScope(); const showHeader = !!headerContent; - const showTopBarPagination = paging && (pagingPosition === "top" || pagingPosition === "both"); - const showFooterPagination = paging && (pagingPosition === "bottom" || pagingPosition === "both"); + const showTopBar = paging && (pagingPosition === "top" || pagingPosition === "both"); + const isSelectionEnabled = selectActionHelper.selectionType !== "None"; + const isSelectionMulti = isSelectionEnabled ? selectActionHelper.selectionType === "Multi" : undefined; + const isSelectAllBarEnabled = isSelectionMulti; const pagination = paging ? ( (props: WidgetProps): ReactElemen visibilitySelectorColumn: columnsHidable }); - const selectionEnabled = selectActionHelper.selectionType !== "None"; - return ( (props: WidgetProps): ReactElemen > {showHeader && {headerContent}} - + (props: WidgetProps): ReactElemen isLoading={props.columnsLoading} preview={props.preview} /> + {isSelectAllBarEnabled && } {showRefreshIndicator ? : null} number) => void; } & ComponentPropsWithoutRef<"div">; -export function WidgetFooter(props: WidgetFooterProps): ReactElement | null { - const { pagination, selectionCount, paginationType, loadMoreButtonCaption, hasMoreItems, setPage, ...rest } = props; +export const WidgetFooter = observer(function WidgetFooter(props: WidgetFooterProps): ReactElement | null { + const { selectionCounterViewModel: counter } = useDatagridRootScope(); + const { pagination, paginationType, loadMoreButtonCaption, hasMoreItems, setPage, ...rest } = props; return (
- {selectionCount} + +
+ +
+
{pagination} {hasMoreItems && paginationType === "loadMore" && ( @@ -32,4 +40,4 @@ export function WidgetFooter(props: WidgetFooterProps): ReactElement | null {
); -} +}); diff --git a/packages/pluggableWidgets/datagrid-web/src/components/WidgetRoot.tsx b/packages/pluggableWidgets/datagrid-web/src/components/WidgetRoot.tsx index 5f993efc2e..a09c016967 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 9b26eb9fe5..6bc49c2003 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/__tests__/__snapshots__/Table.spec.tsx.snap +++ b/packages/pluggableWidgets/datagrid-web/src/components/__tests__/__snapshots__/Table.spec.tsx.snap @@ -82,6 +82,20 @@ exports[`Table renders the structure correctly 1`] = ` class="widget-datagrid-paging-bottom" >
+ +   +
+
>>>>>> 776cb753c (feat: fix ts issues) class="widget-datagrid-pb-end" />
@@ -172,6 +186,20 @@ exports[`Table renders the structure correctly for preview when no header is pro class="widget-datagrid-paging-bottom" >
+ +   +
+
>>>>>> 776cb753c (feat: fix ts issues) class="widget-datagrid-pb-end" />
@@ -295,6 +323,20 @@ exports[`Table renders the structure correctly with column alignments 1`] = ` class="widget-datagrid-paging-bottom" >
+ +   +
+
>>>>>> 776cb753c (feat: fix ts issues) class="widget-datagrid-pb-end" />
@@ -389,6 +431,20 @@ exports[`Table renders the structure correctly with custom filtering 1`] = ` class="widget-datagrid-paging-bottom" >
+ +   +
+
>>>>>> 776cb753c (feat: fix ts issues) class="widget-datagrid-pb-end" />
@@ -479,6 +535,20 @@ exports[`Table renders the structure correctly with dragging 1`] = ` class="widget-datagrid-paging-bottom" >
+ +   +
+
>>>>>> 776cb753c (feat: fix ts issues) class="widget-datagrid-pb-end" />
@@ -569,6 +639,20 @@ exports[`Table renders the structure correctly with dynamic row class 1`] = ` class="widget-datagrid-paging-bottom" >
+ +   +
+
>>>>>> 776cb753c (feat: fix ts issues) class="widget-datagrid-pb-end" />
@@ -659,6 +743,20 @@ exports[`Table renders the structure correctly with empty placeholder 1`] = ` class="widget-datagrid-paging-bottom" >
+ +   +
+
>>>>>> 776cb753c (feat: fix ts issues) class="widget-datagrid-pb-end" />
@@ -753,6 +851,20 @@ exports[`Table renders the structure correctly with filtering 1`] = ` class="widget-datagrid-paging-bottom" >
+ +   +
+
>>>>>> 776cb753c (feat: fix ts issues) class="widget-datagrid-pb-end" />
@@ -853,6 +965,20 @@ exports[`Table renders the structure correctly with header filters and a11y 1`] class="widget-datagrid-paging-bottom" >
+ +   +
+
>>>>>> 776cb753c (feat: fix ts issues) class="widget-datagrid-pb-end" />
@@ -947,6 +1073,20 @@ exports[`Table renders the structure correctly with header wrapper 1`] = ` class="widget-datagrid-paging-bottom" >
+ +   +
+
>>>>>> 776cb753c (feat: fix ts issues) class="widget-datagrid-pb-end" />
@@ -1078,6 +1218,20 @@ exports[`Table renders the structure correctly with hiding 1`] = ` class="widget-datagrid-paging-bottom" >
+ +   +
+
>>>>>> 776cb753c (feat: fix ts issues) class="widget-datagrid-pb-end" />
@@ -1168,6 +1322,20 @@ exports[`Table renders the structure correctly with paging 1`] = ` class="widget-datagrid-paging-bottom" >
+ +   +
+
>>>>>> 776cb753c (feat: fix ts issues) class="widget-datagrid-pb-end" >
+ +   +
+
>>>>>> 776cb753c (feat: fix ts issues) class="widget-datagrid-pb-end" />
@@ -1438,6 +1620,20 @@ exports[`Table renders the structure correctly with sorting 1`] = ` class="widget-datagrid-paging-bottom" >
+ +   +
+
>>>>>> 776cb753c (feat: fix ts issues) class="widget-datagrid-pb-end" />
@@ -1609,6 +1805,20 @@ exports[`Table with selection method checkbox render an extra column and add cla class="widget-datagrid-paging-bottom" >
+ +   +
+
>>>>>> 776cb753c (feat: fix ts issues) class="widget-datagrid-pb-end" >
+ +   +
+
>>>>>> 776cb753c (feat: fix ts issues) class="widget-datagrid-pb-end" >
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..a65c7203fd 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 { SelectionCounterViewModel } from "@mendix/widget-plugin-grid/view-models/SelectionCounterViewModel"; import { createContext, useContext } from "react"; import { GridBasicData } from "../helpers/state/GridBasicData"; import { EventsController } from "../typings/CellComponent"; 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; + selectionCounterViewModel: SelectionCounterViewModel; + 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 c1644d2ea0..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,14 +38,23 @@ type RequiredProps = Pick< | "pagination" | "showPagingButtons" | "showNumberOfRows" - | "clearSelectionButtonLabel" + | "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 { @@ -50,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; @@ -70,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, @@ -92,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, @@ -106,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); } @@ -121,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..2c0f1da75c --- /dev/null +++ b/packages/pluggableWidgets/datagrid-web/src/helpers/state/SelectAllBarViewModel.ts @@ -0,0 +1,158 @@ +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 DynamicProps = Pick< + DatagridContainerProps, + | "cancelSelectionLabel" + | "selectAllTemplate" + | "selectAllText" + | "clearSelectionCaption" + | "itemSelection" + | "selectedCountTemplatePlural" + | "selectedCountTemplateSingular" + | "datasource" + | "allSelectedText" + | "enableSelectAll" +>; + +export class SelectAllBarViewModel implements ReactiveController { + private barVisible = false; + private clearVisible = false; + private readonly enableSelectAll: boolean; + + pending = false; + + constructor( + host: ReactiveControllerHost, + private readonly gate: DerivedPropsGate, + private readonly selectAllController: SelectAllController, + private readonly count = new SelectionCountStore(gate) + ) { + host.addController(this); + type PrivateMembers = "setClearVisible" | "setPending" | "hideBar" | "showBar"; + makeAutoObservable(this, { + setClearVisible: action, + setPending: action, + hideBar: action, + showBar: action + }); + 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 isCurrentPageSelected(): boolean { + const selection = this.gate.props.itemSelection; + + if (!selection || selection.type === "Single") return false; + + const pageIds = new Set(this.gate.props.datasource.items?.map(item => item.id) ?? []); + const selectionSubArray = selection.selection.filter(item => pageIds.has(item.id)); + return selectionSubArray.length === pageIds.size && pageIds.size > 0; + } + + private get isAllItemsSelected(): boolean { + 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..58dc3cc6d8 --- /dev/null +++ b/packages/pluggableWidgets/datagrid-web/src/helpers/state/SelectionProgressDialogViewModel.ts @@ -0,0 +1,77 @@ +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"; + +interface DynamicProps { + 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; + private timerId: ReturnType | undefined; + + constructor( + host: ReactiveControllerHost, + private readonly gate: DerivedPropsGate, + private readonly progressStore: ProgressStore, + private readonly selectAllController: SelectAllController + ) { + host.addController(this); + type PrivateMembers = "setDialogOpen"; + makeAutoObservable(this, { setDialogOpen: action }); + } + + 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 to avoid flickering for fast operations + 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..6f2d6fc1de 100644 --- a/packages/pluggableWidgets/datagrid-web/src/helpers/state/useRootStore.ts +++ b/packages/pluggableWidgets/datagrid-web/src/helpers/state/useRootStore.ts @@ -1,21 +1,42 @@ +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(selectAllGateProvider.gate, selectAllProgressStore)); + + const rootStore = useSetup( + () => + new RootGridStore({ + gate: mainGateProvider.gate, + exportProgressStore, + selectAllProgressStore, + selectAllController: selectAllHost.selectAllController + }) + ); useEffect(() => { - gateProvider.setProps(props); + mainGateProvider.setProps(props); + selectAllGateProvider.setProps(props); }); return rootStore; diff --git a/packages/pluggableWidgets/datagrid-web/src/package.xml b/packages/pluggableWidgets/datagrid-web/src/package.xml index b28b67d632..e8d4925383 100644 --- a/packages/pluggableWidgets/datagrid-web/src/package.xml +++ b/packages/pluggableWidgets/datagrid-web/src/package.xml @@ -1,6 +1,6 @@ - + diff --git a/packages/pluggableWidgets/datagrid-web/typings/DatagridProps.d.ts b/packages/pluggableWidgets/datagrid-web/typings/DatagridProps.d.ts index 513d2d6282..c4f14a6d7b 100644 --- a/packages/pluggableWidgets/datagrid-web/typings/DatagridProps.d.ts +++ b/packages/pluggableWidgets/datagrid-web/typings/DatagridProps.d.ts @@ -3,17 +3,21 @@ * WARNING: All changes made to this file will be overwritten * @author Mendix Widgets Framework Team */ -import { ComponentType, CSSProperties, ReactNode } from "react"; -import { ActionValue, DynamicValue, EditableValue, ListValue, ListActionValue, ListAttributeValue, ListAttributeListValue, ListExpressionValue, ListWidgetValue, SelectionSingleValue, SelectionMultiValue } from "mendix"; import { Big } from "big.js"; - -export type ItemSelectionMethodEnum = "checkbox" | "rowClick"; - -export type ItemSelectionModeEnum = "toggle" | "clear"; - -export type SelectionCountPositionEnum = "top" | "bottom" | "off"; - -export type LoadingTypeEnum = "spinner" | "skeleton"; +import { + ActionValue, + DynamicValue, + EditableValue, + ListActionValue, + ListAttributeListValue, + ListAttributeValue, + ListExpressionValue, + ListValue, + ListWidgetValue, + SelectionMultiValue, + SelectionSingleValue +} from "mendix"; +import { ComponentType, CSSProperties, ReactNode } from "react"; export type ShowContentAsEnum = "attribute" | "dynamicText" | "customContent"; @@ -27,7 +31,9 @@ export type AlignmentEnum = "left" | "center" | "right"; export interface ColumnsType { showContentAs: ShowContentAsEnum; - attribute?: ListAttributeValue | ListAttributeListValue; + attribute?: + | ListAttributeValue + | ListAttributeListValue; content?: ListWidgetValue; dynamicText?: ListExpressionValue; exportValue?: ListExpressionValue; @@ -49,6 +55,16 @@ export interface ColumnsType { wrapText: boolean; } +export type OnClickTriggerEnum = "single" | "double"; + +export type ItemSelectionMethodEnum = "checkbox" | "rowClick"; + +export type ItemSelectionModeEnum = "toggle" | "clear"; + +export type SelectionCountPositionEnum = "top" | "bottom" | "off"; + +export type LoadingTypeEnum = "spinner" | "skeleton"; + export type PaginationEnum = "buttons" | "virtualScrolling" | "loadMore"; export type ShowPagingButtonsEnum = "always" | "auto"; @@ -57,8 +73,6 @@ export type PagingPositionEnum = "bottom" | "top" | "both"; export type ShowEmptyPlaceholderEnum = "none" | "custom"; -export type OnClickTriggerEnum = "single" | "double"; - export type ConfigurationStorageTypeEnum = "attribute" | "localStorage"; export interface ColumnsPreviewType { @@ -90,20 +104,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; + enableSelectAll: boolean; selectionCountPosition: SelectionCountPositionEnum; - clearSelectionButtonLabel?: DynamicValue; loadingType: LoadingTypeEnum; refreshIndicator: boolean; - columns: ColumnsType[]; - columnsFilterable: boolean; pageSize: number; pagination: PaginationEnum; showPagingButtons: ShowPagingButtonsEnum; @@ -113,10 +130,6 @@ export interface DatagridContainerProps { showEmptyPlaceholder: ShowEmptyPlaceholderEnum; emptyPlaceholder?: ReactNode; rowClass?: ListExpressionValue; - onClickTrigger: OnClickTriggerEnum; - onClick?: ListActionValue; - onSelectionChange?: ActionValue; - filtersPlaceholder?: ReactNode; columnsSortable: boolean; columnsResizable: boolean; columnsDraggable: boolean; @@ -129,8 +142,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 { @@ -144,20 +163,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; + enableSelectAll: boolean; selectionCountPosition: SelectionCountPositionEnum; - clearSelectionButtonLabel: string; loadingType: LoadingTypeEnum; refreshIndicator: boolean; - columns: ColumnsPreviewType[]; - columnsFilterable: boolean; pageSize: number | null; pagination: PaginationEnum; showPagingButtons: ShowPagingButtonsEnum; @@ -167,10 +189,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; @@ -184,6 +202,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/dropdown-sort-web/package.json b/packages/pluggableWidgets/dropdown-sort-web/package.json index 5a5c97f0c9..7f1f72acf1 100644 --- a/packages/pluggableWidgets/dropdown-sort-web/package.json +++ b/packages/pluggableWidgets/dropdown-sort-web/package.json @@ -1,7 +1,7 @@ { "name": "@mendix/dropdown-sort-web", "widgetName": "DropdownSort", - "version": "3.4.0", + "version": "3.7.0", "description": "Adds sorting functionality to Gallery widget.", "copyright": "© Mendix Technology BV 2025. All rights reserved.", "license": "Apache-2.0", diff --git a/packages/pluggableWidgets/dropdown-sort-web/src/package.xml b/packages/pluggableWidgets/dropdown-sort-web/src/package.xml index 7db3ce3bf9..8950f2c68f 100644 --- a/packages/pluggableWidgets/dropdown-sort-web/src/package.xml +++ b/packages/pluggableWidgets/dropdown-sort-web/src/package.xml @@ -1,6 +1,6 @@ - + diff --git a/packages/pluggableWidgets/gallery-web/CHANGELOG.md b/packages/pluggableWidgets/gallery-web/CHANGELOG.md index 6c1b7c3507..dcd62d05e9 100644 --- a/packages/pluggableWidgets/gallery-web/CHANGELOG.md +++ b/packages/pluggableWidgets/gallery-web/CHANGELOG.md @@ -14,6 +14,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - We fixed an issue where setting the gallery gap to 0 caused an offset, which made the bottom border of items to dissapear. +### Added + +- We introduced a new caption setting for the clear selection button. + ## [3.6.1] - 2025-10-14 ### Fixed diff --git a/packages/pluggableWidgets/gallery-web/package.json b/packages/pluggableWidgets/gallery-web/package.json index 61a95142a8..de7e720fd1 100644 --- a/packages/pluggableWidgets/gallery-web/package.json +++ b/packages/pluggableWidgets/gallery-web/package.json @@ -1,7 +1,7 @@ { "name": "@mendix/gallery-web", "widgetName": "Gallery", - "version": "3.6.1", + "version": "3.7.0", "description": "A flexible gallery widget that renders columns, rows and layouts.", "copyright": "© Mendix Technology BV 2025. All rights reserved.", "license": "Apache-2.0", diff --git a/packages/pluggableWidgets/gallery-web/src/Gallery.tsx b/packages/pluggableWidgets/gallery-web/src/Gallery.tsx index 4e2262c42a..35df28e373 100644 --- a/packages/pluggableWidgets/gallery-web/src/Gallery.tsx +++ b/packages/pluggableWidgets/gallery-web/src/Gallery.tsx @@ -1,4 +1,6 @@ -import { useClickActionHelper } from "@mendix/widget-plugin-grid/helpers/ClickActionHelper"; +import { ReactElement } from "react"; + +/* import { useClickActionHelper } from "@mendix/widget-plugin-grid/helpers/ClickActionHelper"; import { useFocusTargetController } from "@mendix/widget-plugin-grid/keyboard-navigation/useFocusTargetController"; import { getColumnAndRowBasedOnIndex, useSelectionHelper } from "@mendix/widget-plugin-grid/selection"; import { useConst } from "@mendix/widget-plugin-mobx-kit/react/useConst"; @@ -124,3 +126,8 @@ export function Gallery(props: GalleryContainerProps): ReactElement { ); } + */ export function Gallery(): ReactElement { + // const scope = useCreateGalleryScope(props); + + return
; +} diff --git a/packages/pluggableWidgets/gallery-web/src/Gallery.xml b/packages/pluggableWidgets/gallery-web/src/Gallery.xml index 72e82eb550..2da99615bc 100644 --- a/packages/pluggableWidgets/gallery-web/src/Gallery.xml +++ b/packages/pluggableWidgets/gallery-web/src/Gallery.xml @@ -214,6 +214,13 @@ Item count plural Must include '%d' to denote number position ('%d items selected') + + Clear selection caption + + + Clear selection + + diff --git a/packages/pluggableWidgets/gallery-web/src/components/SelectionCounter.tsx b/packages/pluggableWidgets/gallery-web/src/components/SelectionCounter.tsx index e311e5b834..072ffced93 100644 --- a/packages/pluggableWidgets/gallery-web/src/components/SelectionCounter.tsx +++ b/packages/pluggableWidgets/gallery-web/src/components/SelectionCounter.tsx @@ -16,20 +16,12 @@ export const SelectionCounter = observer(function SelectionCounter({ const clearButtonAriaLabel = `${selectionCountStore.clearButtonLabel} (${selectionCountStore.selectedCount} selected)`; return ( - -
- - {selectionCountStore.displayCount} - -  |  - -
+ + {selectionCountStore.selectedCountText} +   + ); }); diff --git a/packages/pluggableWidgets/gallery-web/src/helpers/root-context.ts b/packages/pluggableWidgets/gallery-web/src/helpers/root-context.ts index 6a200bb941..e428cb74d7 100644 --- a/packages/pluggableWidgets/gallery-web/src/helpers/root-context.ts +++ b/packages/pluggableWidgets/gallery-web/src/helpers/root-context.ts @@ -1,5 +1,5 @@ import { SelectActionHandler, SelectionHelper } from "@mendix/widget-plugin-grid/selection"; -import { SelectionCountStore } from "@mendix/widget-plugin-grid/selection/stores/SelectionCountStore"; +import { SelectionCountStore } from "@mendix/widget-plugin-grid/stores/SelectionCountStore"; import { createContext, useContext } from "react"; import { GalleryStore } from "../stores/GalleryStore"; diff --git a/packages/pluggableWidgets/gallery-web/src/package.xml b/packages/pluggableWidgets/gallery-web/src/package.xml index c62921623f..cc89a26e97 100644 --- a/packages/pluggableWidgets/gallery-web/src/package.xml +++ b/packages/pluggableWidgets/gallery-web/src/package.xml @@ -1,6 +1,6 @@ - + diff --git a/packages/pluggableWidgets/gallery-web/src/stores/GalleryStore.ts b/packages/pluggableWidgets/gallery-web/src/stores/GalleryStore.ts index 4c45d00ca0..cf888674cd 100644 --- a/packages/pluggableWidgets/gallery-web/src/stores/GalleryStore.ts +++ b/packages/pluggableWidgets/gallery-web/src/stores/GalleryStore.ts @@ -4,14 +4,14 @@ import { CustomFilterHost } from "@mendix/widget-plugin-filtering/stores/generic import { DatasourceController } from "@mendix/widget-plugin-grid/query/DatasourceController"; import { PaginationController } from "@mendix/widget-plugin-grid/query/PaginationController"; import { RefreshController } from "@mendix/widget-plugin-grid/query/RefreshController"; -import { SelectionCountStore } from "@mendix/widget-plugin-grid/selection/stores/SelectionCountStore"; +import { SelectionCounterViewModel } from "@mendix/widget-plugin-grid/view-models/SelectionCounterViewModel"; import { BaseControllerHost } from "@mendix/widget-plugin-mobx-kit/BaseControllerHost"; import { DerivedPropsGate } from "@mendix/widget-plugin-mobx-kit/props-gate"; import { generateUUID } from "@mendix/widget-plugin-platform/framework/generate-uuid"; import { SortAPI } from "@mendix/widget-plugin-sorting/react/context"; import { SortStoreHost } from "@mendix/widget-plugin-sorting/stores/SortStoreHost"; import { DynamicValue, EditableValue, ListValue, SelectionMultiValue, SelectionSingleValue } from "mendix"; -import { PaginationEnum, StateStorageTypeEnum } from "../../typings/GalleryProps"; +import { PaginationEnum, SelectionCountPositionEnum, StateStorageTypeEnum } from "../../typings/GalleryProps"; import { DerivedLoaderController } from "../controllers/DerivedLoaderController"; import { QueryParamsController } from "../controllers/QueryParamsController"; import { ObservableStorage } from "../typings/storage"; @@ -23,8 +23,10 @@ interface DynamicProps { datasource: ListValue; stateStorageAttr?: EditableValue; itemSelection?: SelectionSingleValue | SelectionMultiValue; - sCountFmtSingular?: DynamicValue; - sCountFmtPlural?: DynamicValue; + selectedCountTemplateSingular?: DynamicValue; + selectedCountTemplatePlural?: DynamicValue; + // This is static prop, but because it's required in SelectionCounterViewModel, we keep it here + selectionCountPosition: SelectionCountPositionEnum; } interface StaticProps { @@ -57,7 +59,7 @@ export class GalleryStore extends BaseControllerHost { readonly filterAPI: FilterAPI; readonly sortAPI: SortAPI; loaderCtrl: DerivedLoaderController; - selectionCountStore: SelectionCountStore; + selectionCounterViewModel: SelectionCounterViewModel; constructor(spec: GalleryStoreSpec) { super(); @@ -74,10 +76,7 @@ export class GalleryStore extends BaseControllerHost { showTotalCount: spec.showTotalCount }); - this.selectionCountStore = new SelectionCountStore(spec.gate, { - singular: "%d item selected", - plural: "%d items selected" - }); + this.selectionCounterViewModel = new SelectionCounterViewModel(spec.gate); this._filtersHost = new CustomFilterHost(); diff --git a/packages/pluggableWidgets/gallery-web/src/utils/test-utils.tsx b/packages/pluggableWidgets/gallery-web/src/utils/test-utils.tsx index 4fc8a0b41b..f7ef20fa72 100644 --- a/packages/pluggableWidgets/gallery-web/src/utils/test-utils.tsx +++ b/packages/pluggableWidgets/gallery-web/src/utils/test-utils.tsx @@ -4,7 +4,7 @@ import { PositionController } from "@mendix/widget-plugin-grid/keyboard-navigati import { VirtualGridLayout } from "@mendix/widget-plugin-grid/keyboard-navigation/VirtualGridLayout"; import { getColumnAndRowBasedOnIndex, SelectActionHandler } from "@mendix/widget-plugin-grid/selection"; import { GateProvider } from "@mendix/widget-plugin-mobx-kit/GateProvider"; -import { list, listAction, objectItems } from "@mendix/widget-plugin-test-utils"; +import { dynamic, list, listAction, objectItems } from "@mendix/widget-plugin-test-utils"; import { render, RenderResult } from "@testing-library/react"; import userEvent, { UserEvent } from "@testing-library/user-event"; import { ObjectItem } from "mendix"; @@ -58,7 +58,7 @@ export function createMockGalleryContext(): GalleryRootScope { storeSort: false, refreshIndicator: false, keepSelection: false, - selectionCountPosition: "bottom" + clearSelectionCaption: dynamic("Clear selection") }; // Create a proper gate provider and gate diff --git a/packages/pluggableWidgets/gallery-web/typings/GalleryProps.d.ts b/packages/pluggableWidgets/gallery-web/typings/GalleryProps.d.ts index b0cfa98317..660cc1e278 100644 --- a/packages/pluggableWidgets/gallery-web/typings/GalleryProps.d.ts +++ b/packages/pluggableWidgets/gallery-web/typings/GalleryProps.d.ts @@ -61,6 +61,7 @@ export interface GalleryContainerProps { ariaLabelItem?: ListExpressionValue; selectedCountTemplateSingular?: DynamicValue; selectedCountTemplatePlural?: DynamicValue; + clearSelectionCaption: DynamicValue; } export interface GalleryPreviewProps { @@ -109,4 +110,5 @@ export interface GalleryPreviewProps { ariaLabelItem: string; selectedCountTemplateSingular: string; selectedCountTemplatePlural: string; + clearSelectionCaption: string; } diff --git a/packages/pluggableWidgets/selection-helper-web/package.json b/packages/pluggableWidgets/selection-helper-web/package.json index 07fb627ba8..fe338b1af2 100644 --- a/packages/pluggableWidgets/selection-helper-web/package.json +++ b/packages/pluggableWidgets/selection-helper-web/package.json @@ -1,7 +1,7 @@ { "name": "@mendix/selection-helper-web", "widgetName": "SelectionHelper", - "version": "3.6.1", + "version": "3.7.0", "description": "Makes it easier for users to select multiple items in Gallery widget.", "copyright": "© Mendix Technology BV 2025. All rights reserved.", "license": "Apache-2.0", diff --git a/packages/pluggableWidgets/selection-helper-web/src/package.xml b/packages/pluggableWidgets/selection-helper-web/src/package.xml index db5475fe2c..21d09064cf 100644 --- a/packages/pluggableWidgets/selection-helper-web/src/package.xml +++ b/packages/pluggableWidgets/selection-helper-web/src/package.xml @@ -1,6 +1,6 @@ - + diff --git a/packages/pluggableWidgets/tree-node-web/package.json b/packages/pluggableWidgets/tree-node-web/package.json index 78c19dcddb..ee5851b81f 100644 --- a/packages/pluggableWidgets/tree-node-web/package.json +++ b/packages/pluggableWidgets/tree-node-web/package.json @@ -1,7 +1,7 @@ { "name": "@mendix/tree-node-web", "widgetName": "TreeNode", - "version": "3.6.0", + "version": "3.7.0", "description": "A Mendix pluggable widget to display a tree view structure.", "copyright": "© Mendix Technology BV 2025. All rights reserved.", "license": "Apache-2.0", diff --git a/packages/pluggableWidgets/tree-node-web/src/package.xml b/packages/pluggableWidgets/tree-node-web/src/package.xml index 945c112ee4..852eae20d8 100644 --- a/packages/pluggableWidgets/tree-node-web/src/package.xml +++ b/packages/pluggableWidgets/tree-node-web/src/package.xml @@ -1,6 +1,6 @@ - + diff --git a/packages/shared/widget-plugin-grid/src/selection/__tests__/SelectionCountStore.spec.ts b/packages/shared/widget-plugin-grid/src/__tests__/SelectionCounterViewModel.spec.ts similarity index 72% rename from packages/shared/widget-plugin-grid/src/selection/__tests__/SelectionCountStore.spec.ts rename to packages/shared/widget-plugin-grid/src/__tests__/SelectionCounterViewModel.spec.ts index e9026e01dc..87fde82e8c 100644 --- a/packages/shared/widget-plugin-grid/src/selection/__tests__/SelectionCountStore.spec.ts +++ b/packages/shared/widget-plugin-grid/src/__tests__/SelectionCounterViewModel.spec.ts @@ -1,22 +1,20 @@ import { GateProvider } from "@mendix/widget-plugin-mobx-kit/GateProvider"; import { objectItems, SelectionMultiValueBuilder, SelectionSingleValueBuilder } from "@mendix/widget-plugin-test-utils"; -import { SelectionMultiValue, SelectionSingleValue } from "mendix"; -import { SelectionCountStore } from "../stores/SelectionCountStore"; +import { Props, SelectionCounterViewModel } from "../view-models/SelectionCounterViewModel"; -type Props = { - itemSelection?: SelectionSingleValue | SelectionMultiValue; -}; - -const createMinimalMockProps = (overrides: Props = {}): Props => ({ ...overrides }); +const createMinimalMockProps = (overrides: Partial = {}): Props => ({ + selectionCountPosition: "bottom", + ...overrides +}); -describe("SelectionCountStore", () => { +describe("SelectionCounterViewModel", () => { let gateProvider: GateProvider; - let selectionCountStore: SelectionCountStore; + let selectionCounterVM: SelectionCounterViewModel; beforeEach(() => { const mockProps = createMinimalMockProps(); gateProvider = new GateProvider(mockProps); - selectionCountStore = new SelectionCountStore(gateProvider.gate); + selectionCounterVM = new SelectionCounterViewModel(gateProvider.gate); }); describe("when itemSelection is undefined", () => { @@ -24,7 +22,7 @@ describe("SelectionCountStore", () => { const props = createMinimalMockProps({ itemSelection: undefined }); gateProvider.setProps(props); - expect(selectionCountStore.selectedCount).toBe(0); + expect(selectionCounterVM.selectedCount).toBe(0); }); }); @@ -34,16 +32,16 @@ describe("SelectionCountStore", () => { const props = createMinimalMockProps({ itemSelection: singleSelection }); gateProvider.setProps(props); - expect(selectionCountStore.selectedCount).toBe(0); + expect(selectionCounterVM.selectedCount).toBe(0); }); - it("should return 1 when one item is selected", () => { + it("should return 0 even when one item is selected (single selection mode)", () => { const items = objectItems(3); const singleSelection = new SelectionSingleValueBuilder().withSelected(items[0]).build(); const props = createMinimalMockProps({ itemSelection: singleSelection }); gateProvider.setProps(props); - expect(selectionCountStore.selectedCount).toBe(1); + expect(selectionCounterVM.selectedCount).toBe(0); }); }); @@ -53,7 +51,7 @@ describe("SelectionCountStore", () => { const props = createMinimalMockProps({ itemSelection: multiSelection }); gateProvider.setProps(props); - expect(selectionCountStore.selectedCount).toBe(0); + expect(selectionCounterVM.selectedCount).toBe(0); }); it("should return correct count when multiple items are selected", () => { @@ -63,7 +61,7 @@ describe("SelectionCountStore", () => { const props = createMinimalMockProps({ itemSelection: multiSelection }); gateProvider.setProps(props); - expect(selectionCountStore.selectedCount).toBe(3); + expect(selectionCounterVM.selectedCount).toBe(3); }); it("should return correct count when all items are selected", () => { @@ -72,7 +70,7 @@ describe("SelectionCountStore", () => { const props = createMinimalMockProps({ itemSelection: multiSelection }); gateProvider.setProps(props); - expect(selectionCountStore.selectedCount).toBe(4); + expect(selectionCounterVM.selectedCount).toBe(4); }); it("should reactively update when selection changes", () => { @@ -82,19 +80,19 @@ describe("SelectionCountStore", () => { gateProvider.setProps(props); // Initially no items selected - expect(selectionCountStore.selectedCount).toBe(0); + expect(selectionCounterVM.selectedCount).toBe(0); // Select one item multiSelection.setSelection([items[0]]); - expect(selectionCountStore.selectedCount).toBe(1); + expect(selectionCounterVM.selectedCount).toBe(1); // Select two more items multiSelection.setSelection([items[0], items[1], items[2]]); - expect(selectionCountStore.selectedCount).toBe(3); + expect(selectionCounterVM.selectedCount).toBe(3); // Clear selection multiSelection.setSelection([]); - expect(selectionCountStore.selectedCount).toBe(0); + expect(selectionCounterVM.selectedCount).toBe(0); }); }); }); diff --git a/packages/shared/widget-plugin-grid/src/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..10bad641e7 --- /dev/null +++ b/packages/shared/widget-plugin-grid/src/select-all/SelectAllController.ts @@ -0,0 +1,182 @@ +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"; + +interface DynamicProps { + itemSelection?: SelectionMultiValue | SelectionSingleValue; +} + +type SelectAllEventType = "loadstart" | "progress" | "abort" | "loadend"; + +export class SelectAllController implements ReactiveController { + private locked = false; + private abortController?: AbortController; + private readonly emitter = new EventTarget(); + private readonly pageSize = 1024; + + constructor( + host: ReactiveControllerHost, + private readonly gate: DerivedPropsGate, + private readonly query: QueryController + ) { + host.addController(this); + type PrivateMembers = "setIsLocked" | "locked"; + makeObservable(this, { + setIsLocked: action, + canExecute: computed, + isExecuting: computed, + 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. + */ + 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..15cce5d440 --- /dev/null +++ b/packages/shared/widget-plugin-grid/src/select-all/SelectAllHost.ts @@ -0,0 +1,51 @@ +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"; + +interface DynamicProps { + itemSelection?: SelectionMultiValue | SelectionSingleValue; + datasource: ListValue; +} + +export class SelectAllHost extends BaseControllerHost { + readonly selectAllController: SelectAllController; + + constructor( + gate: DerivedPropsGate, + private readonly selectAllProgressStore: ProgressStore + ) { + super(); + const query = new DatasourceController(this, { gate }); + this.selectAllController = new SelectAllController(this, gate, query); + } + + 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/stores/SelectionCountStore.ts b/packages/shared/widget-plugin-grid/src/selection/stores/SelectionCountStore.ts deleted file mode 100644 index 73b2a6a93a..0000000000 --- a/packages/shared/widget-plugin-grid/src/selection/stores/SelectionCountStore.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { DerivedPropsGate } from "@mendix/widget-plugin-mobx-kit/props-gate"; -import { DynamicValue, SelectionMultiValue, SelectionSingleValue } from "mendix"; -import { computed, makeObservable } from "mobx"; - -type Gate = DerivedPropsGate<{ - itemSelection?: SelectionSingleValue | SelectionMultiValue; - selectedCountTemplateSingular?: DynamicValue; - selectedCountTemplatePlural?: DynamicValue; - clearSelectionButtonLabel?: DynamicValue; -}>; - -export class SelectionCountStore { - private gate: Gate; - private singular: string = "%d row selected"; - private plural: string = "%d rows selected"; - private defaultClearLabel: string = "Clear selection"; - - constructor(gate: Gate, spec: { singular?: string; plural?: string; clearLabel?: string } = {}) { - this.singular = spec.singular ?? this.singular; - this.plural = spec.plural ?? this.plural; - this.defaultClearLabel = spec.clearLabel ?? this.defaultClearLabel; - this.gate = gate; - - makeObservable(this, { - displayCount: computed, - selectedCount: computed, - fmtSingular: computed, - fmtPlural: computed, - clearButtonLabel: computed - }); - } - - get clearButtonLabel(): string { - return this.gate.props.clearSelectionButtonLabel?.value || this.defaultClearLabel; - } - - get fmtSingular(): string { - return this.gate.props.selectedCountTemplateSingular?.value || this.singular; - } - - get fmtPlural(): string { - return this.gate.props.selectedCountTemplatePlural?.value || this.plural; - } - - get selectedCount(): number { - const { itemSelection } = this.gate.props; - - if (!itemSelection) { - return 0; - } - - // For single selection - if (itemSelection.type === "Single") { - return itemSelection.selection ? 1 : 0; - } - - return itemSelection.selection?.length ?? 0; - } - - get displayCount(): string { - const count = this.selectedCount; - if (count === 0) return ""; - if (count === 1) return this.fmtSingular.replace("%d", "1"); - return this.fmtPlural.replace("%d", `${count}`); - } -} 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/view-models/SelectionCounterViewModel.ts b/packages/shared/widget-plugin-grid/src/view-models/SelectionCounterViewModel.ts new file mode 100644 index 0000000000..e1ec18ace5 --- /dev/null +++ b/packages/shared/widget-plugin-grid/src/view-models/SelectionCounterViewModel.ts @@ -0,0 +1,59 @@ +import { DerivedPropsGate } from "@mendix/widget-plugin-mobx-kit/props-gate"; +import { DynamicValue, SelectionMultiValue, SelectionSingleValue } from "mendix"; +import { makeAutoObservable } from "mobx"; + +interface DynamicProps { + itemSelection?: SelectionSingleValue | SelectionMultiValue; + selectedCountTemplateSingular?: DynamicValue; + selectedCountTemplatePlural?: DynamicValue; + clearSelectionCaption?: DynamicValue; +} + +interface StaticProps { + selectionCountPosition: "top" | "bottom" | "off"; +} + +export type Props = DynamicProps & StaticProps; + +export class SelectionCounterViewModel { + private readonly position: "top" | "bottom" | "off"; + + constructor(private gate: DerivedPropsGate) { + makeAutoObservable(this); + this.position = gate.props.selectionCountPosition; + } + + get formatSingular(): string { + return this.gate.props.selectedCountTemplateSingular?.value || "%d.row.count"; + } + + get formatPlural(): string { + return this.gate.props.selectedCountTemplatePlural?.value || "%d.rows.count"; + } + + get selectedCount(): number { + const { itemSelection } = this.gate.props; + + if (itemSelection === undefined) return 0; + if (itemSelection.type === "Single") return 0; + return itemSelection.selection.length; + } + + get selectedCountText(): string { + if (this.selectedCount === 0) return ""; + if (this.selectedCount === 1) return this.formatSingular.replace("%d", "1"); + return this.formatPlural.replace("%d", `${this.selectedCount}`); + } + + get clearSelectionText(): string { + return this.gate.props.clearSelectionCaption?.value ?? "clear.selection.caption"; + } + + get isTopCounterVisible(): boolean { + return this.position === "top" && this.selectedCount > 0; + } + + get isBottomCounterVisible(): boolean { + return this.position === "bottom" && this.selectedCount > 0; + } +}