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 e311d2e0d9..75944994c5 100644 --- a/packages/modules/data-widgets/src/themesource/datawidgets/web/_datagrid.scss +++ b/packages/modules/data-widgets/src/themesource/datawidgets/web/_datagrid.scss @@ -335,7 +335,7 @@ $root: ".widget-datagrid"; color: $dg-pagination-caption-color; .paging-status { - padding: 0 8px 8px; + padding: 0 8px 0; } .pagination-button { @@ -549,6 +549,33 @@ $root: ".widget-datagrid"; z-index: 1; } +:where(#{$root}-paging-bottom) { + display: flex; + flex-flow: row nowrap; + align-items: center; +} + +:where(#{$root}-pb-start, #{$root}-pb-end, #{$root}-pb-middle) { + flex-grow: 1; + flex-basis: 33.33%; + min-height: 20px; +} + +:where(#{$root}-pb-start) { + margin-block: var(--spacing-medium); + padding-inline: var(--spacing-medium); +} + +#{$root}-clear-selection { + cursor: pointer; + background: transparent; + border: none; + text-decoration: underline; + color: var(--link-color); + padding: 0; + display: inline-block; +} + @keyframes skeleton-loading { 0% { background-position: right; diff --git a/packages/modules/data-widgets/src/themesource/datawidgets/web/_gallery.scss b/packages/modules/data-widgets/src/themesource/datawidgets/web/_gallery.scss index f9031af6ce..8e1ac0b54d 100644 --- a/packages/modules/data-widgets/src/themesource/datawidgets/web/_gallery.scss +++ b/packages/modules/data-widgets/src/themesource/datawidgets/web/_gallery.scss @@ -89,7 +89,28 @@ $gallery-screen-md: $screen-md; width: inherit; } -.widget-gallery-load-more { +:where(.widget-gallery-footer-controls) { display: flex; - justify-content: center; + flex-flow: row nowrap; +} + +:where(.widget-gallery-fc-start) { + margin-block: var(--spacing-medium); + padding-inline: var(--spacing-medium); +} + +:where(.widget-gallery-fc-start, .widget-gallery-fc-middle, .widget-gallery-fc-end) { + flex-grow: 1; + flex-basis: 33.33%; + min-height: 20px; +} + +.widget-gallery-clear-selection { + cursor: pointer; + background: transparent; + border: none; + text-decoration: underline; + color: var(--link-color); + padding: 0; + display: inline-block; } diff --git a/packages/pluggableWidgets/datagrid-web/CHANGELOG.md b/packages/pluggableWidgets/datagrid-web/CHANGELOG.md index 5fd325a808..ac62a0f515 100644 --- a/packages/pluggableWidgets/datagrid-web/CHANGELOG.md +++ b/packages/pluggableWidgets/datagrid-web/CHANGELOG.md @@ -9,6 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Added - We implemented a new property to show a refresh indicator. With the refresh indicator, any datasource change shows a progress bar on top of Datagrid 2. +- We added a selection count display that shows the number of selected rows in the grid footer. The count appears automatically when items are selected and supports customizable text formats for singular and plural forms via the new "Row count singular" and "Row count plural" properties. ## [3.2.0] - 2025-08-18 diff --git a/packages/pluggableWidgets/datagrid-web/e2e/DataGrid.spec.js-snapshots/dataGridColumnContent-chromium-linux.png b/packages/pluggableWidgets/datagrid-web/e2e/DataGrid.spec.js-snapshots/dataGridColumnContent-chromium-linux.png index ca8a6f6f17..904451f8be 100644 Binary files a/packages/pluggableWidgets/datagrid-web/e2e/DataGrid.spec.js-snapshots/dataGridColumnContent-chromium-linux.png and b/packages/pluggableWidgets/datagrid-web/e2e/DataGrid.spec.js-snapshots/dataGridColumnContent-chromium-linux.png differ diff --git a/packages/pluggableWidgets/datagrid-web/e2e/DataGrid.spec.js-snapshots/datagrid-chromium-linux.png b/packages/pluggableWidgets/datagrid-web/e2e/DataGrid.spec.js-snapshots/datagrid-chromium-linux.png index 338db228be..719a348f10 100644 Binary files a/packages/pluggableWidgets/datagrid-web/e2e/DataGrid.spec.js-snapshots/datagrid-chromium-linux.png and b/packages/pluggableWidgets/datagrid-web/e2e/DataGrid.spec.js-snapshots/datagrid-chromium-linux.png differ diff --git a/packages/pluggableWidgets/datagrid-web/e2e/DataGridSelection.spec.js-snapshots/datagridMultiSelectionCheckbox-chromium-linux.png b/packages/pluggableWidgets/datagrid-web/e2e/DataGridSelection.spec.js-snapshots/datagridMultiSelectionCheckbox-chromium-linux.png index a4cdfd7f47..d5d46df616 100644 Binary files a/packages/pluggableWidgets/datagrid-web/e2e/DataGridSelection.spec.js-snapshots/datagridMultiSelectionCheckbox-chromium-linux.png and b/packages/pluggableWidgets/datagrid-web/e2e/DataGridSelection.spec.js-snapshots/datagridMultiSelectionCheckbox-chromium-linux.png differ diff --git a/packages/pluggableWidgets/datagrid-web/e2e/DataGridSelection.spec.js-snapshots/datagridMultiSelectionRowClick-chromium-linux.png b/packages/pluggableWidgets/datagrid-web/e2e/DataGridSelection.spec.js-snapshots/datagridMultiSelectionRowClick-chromium-linux.png index 6942dfb849..cf39adffbe 100644 Binary files a/packages/pluggableWidgets/datagrid-web/e2e/DataGridSelection.spec.js-snapshots/datagridMultiSelectionRowClick-chromium-linux.png and b/packages/pluggableWidgets/datagrid-web/e2e/DataGridSelection.spec.js-snapshots/datagridMultiSelectionRowClick-chromium-linux.png differ diff --git a/packages/pluggableWidgets/datagrid-web/e2e/DataGridSelection.spec.js-snapshots/datagridSingleSelectionCheckbox-chromium-linux.png b/packages/pluggableWidgets/datagrid-web/e2e/DataGridSelection.spec.js-snapshots/datagridSingleSelectionCheckbox-chromium-linux.png index de868bed90..90437916d1 100644 Binary files a/packages/pluggableWidgets/datagrid-web/e2e/DataGridSelection.spec.js-snapshots/datagridSingleSelectionCheckbox-chromium-linux.png and b/packages/pluggableWidgets/datagrid-web/e2e/DataGridSelection.spec.js-snapshots/datagridSingleSelectionCheckbox-chromium-linux.png differ diff --git a/packages/pluggableWidgets/datagrid-web/e2e/DataGridSelection.spec.js-snapshots/datagridSingleSelectionRowClick-chromium-linux.png b/packages/pluggableWidgets/datagrid-web/e2e/DataGridSelection.spec.js-snapshots/datagridSingleSelectionRowClick-chromium-linux.png index 3b918f6f42..f99184cfa4 100644 Binary files a/packages/pluggableWidgets/datagrid-web/e2e/DataGridSelection.spec.js-snapshots/datagridSingleSelectionRowClick-chromium-linux.png and b/packages/pluggableWidgets/datagrid-web/e2e/DataGridSelection.spec.js-snapshots/datagridSingleSelectionRowClick-chromium-linux.png differ diff --git a/packages/pluggableWidgets/datagrid-web/src/Datagrid.editorConfig.ts b/packages/pluggableWidgets/datagrid-web/src/Datagrid.editorConfig.ts index 411f28f843..2139f6870d 100644 --- a/packages/pluggableWidgets/datagrid-web/src/Datagrid.editorConfig.ts +++ b/packages/pluggableWidgets/datagrid-web/src/Datagrid.editorConfig.ts @@ -167,6 +167,10 @@ function hideSelectionProperties(defaultProperties: Properties, values: Datagrid if (itemSelection !== "Multi" || itemSelectionMethod !== "checkbox") { hidePropertyIn(defaultProperties, values, "showSelectAllToggle"); } + + if (itemSelection !== "Multi") { + hidePropertyIn(defaultProperties, values, "keepSelection"); + } } export const getPreview = ( diff --git a/packages/pluggableWidgets/datagrid-web/src/Datagrid.editorPreview.tsx b/packages/pluggableWidgets/datagrid-web/src/Datagrid.editorPreview.tsx index 26839e163d..60d2c3845d 100644 --- a/packages/pluggableWidgets/datagrid-web/src/Datagrid.editorPreview.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/Datagrid.editorPreview.tsx @@ -4,6 +4,9 @@ import { enableStaticRendering } from "mobx-react-lite"; enableStaticRendering(true); +import { useFocusTargetController } from "@mendix/widget-plugin-grid/keyboard-navigation/useFocusTargetController"; +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"; import { GUID, ObjectItem } from "mendix"; import { Selectable } from "mendix/preview/Selectable"; @@ -12,8 +15,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 { useSelectActionHelper } from "./helpers/SelectActionHelper"; -import { useFocusTargetController } from "@mendix/widget-plugin-grid/keyboard-navigation/useFocusTargetController"; +import { GridBasicData } from "./helpers/state/GridBasicData"; + +import { SelectionCountStore } from "@mendix/widget-plugin-grid/selection/stores/SelectionCountStore"; import "./ui/DatagridPreview.scss"; // Fix type definition for Selectable @@ -80,74 +86,88 @@ 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); + return { + basicData, + selectionHelper: undefined, + selectActionHelper, + cellEventsController: eventsController, + checkboxEventsController: eventsController, + focusController, + selectionCountStore + }; + }); + return ( - ReactElement) => ( - - {renderWrapper(null)} - - ), - [EmptyPlaceholder] - )} - exporting={false} - filterRenderer={useCallback( - (renderWrapper, columnIndex) => { - const column = props.columns.at(columnIndex); - return column?.filter ? ( - + + ReactElement) => ( + {renderWrapper(null)} - - ) : ( - renderWrapper(null) - ); - }, - [props.columns] - )} - headerContent={ - -
- - } - hasMoreItems={false} - headerWrapperRenderer={selectableWrapperRenderer(previewColumns)} - numberOfItems={props.pageSize ?? numberOfItems} - page={0} - paginationType={props.pagination} - pageSize={props.pageSize ?? numberOfItems} - showPagingButtons={props.showPagingButtons} - loadMoreButtonCaption={props.loadMoreButtonCaption} - paging={props.pagination === "buttons" || props.showNumberOfRows} - pagingPosition={props.pagingPosition} - preview - processedRows={0} - styles={parseStyle(props.style)} - selectionStatus={"none"} - id={gridId} - gridInteractive={!!(props.itemSelection !== "None" || props.onClick)} - selectActionHelper={selectActionHelper} - cellEventsController={eventsController} - checkboxEventsController={eventsController} - focusController={focusController} - isFirstLoad={false} - isLoading={false} - isFetchingNextBatch={false} - loadingType="spinner" - columnsLoading={false} - showRefreshIndicator={false} - /> + + ), + [EmptyPlaceholder] + )} + exporting={false} + filterRenderer={useCallback( + (renderWrapper, columnIndex) => { + const column = props.columns.at(columnIndex); + return column?.filter ? ( + + {renderWrapper(null)} + + ) : ( + renderWrapper(null) + ); + }, + [props.columns] + )} + headerContent={ + +
+ + } + hasMoreItems={false} + headerWrapperRenderer={selectableWrapperRenderer(previewColumns)} + numberOfItems={props.pageSize ?? numberOfItems} + page={0} + paginationType={props.pagination} + pageSize={props.pageSize ?? numberOfItems} + showPagingButtons={props.showPagingButtons} + loadMoreButtonCaption={props.loadMoreButtonCaption} + paging={props.pagination === "buttons" || props.showNumberOfRows} + pagingPosition={props.pagingPosition} + preview + processedRows={0} + styles={parseStyle(props.style)} + id={gridId} + selectActionHelper={selectActionHelper} + cellEventsController={eventsController} + checkboxEventsController={eventsController} + focusController={focusController} + isFetchingNextBatch={false} + loadingType="spinner" + columnsLoading={false} + isFirstLoad={false} + showRefreshIndicator={false} + /> + ); } diff --git a/packages/pluggableWidgets/datagrid-web/src/Datagrid.tsx b/packages/pluggableWidgets/datagrid-web/src/Datagrid.tsx index d62e598cfe..b987d824a6 100644 --- a/packages/pluggableWidgets/datagrid-web/src/Datagrid.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/Datagrid.tsx @@ -2,6 +2,7 @@ import { useOnResetFiltersEvent } from "@mendix/widget-plugin-external-events/ho import { useClickActionHelper } from "@mendix/widget-plugin-grid/helpers/ClickActionHelper"; import { useFocusTargetController } from "@mendix/widget-plugin-grid/keyboard-navigation/useFocusTargetController"; import { useSelectionHelper } from "@mendix/widget-plugin-grid/selection"; +import { useConst } from "@mendix/widget-plugin-mobx-kit/react/useConst"; import { generateUUID } from "@mendix/widget-plugin-platform/framework/generate-uuid"; import { observer } from "mobx-react-lite"; import { ReactElement, ReactNode, createElement, useCallback, useMemo } from "react"; @@ -13,6 +14,7 @@ import { ProgressStore } from "./features/data-export/ProgressStore"; import { useDataExport } from "./features/data-export/useDataExport"; import { useCellEventsController } from "./features/row-interaction/CellEventsController"; import { useCheckboxEventsController } from "./features/row-interaction/CheckboxEventsController"; +import { DatagridContext } from "./helpers/root-context"; import { useSelectActionHelper } from "./helpers/SelectActionHelper"; import { IColumnGroupStore } from "./helpers/state/ColumnGroupStore"; import { RootGridStore } from "./helpers/state/RootGridStore"; @@ -32,7 +34,12 @@ const Container = observer((props: Props): ReactElement => { const [exportProgress, abortExport] = useDataExport(props, props.columnsStore, props.progressStore); - const selectionHelper = useSelectionHelper(props.itemSelection, props.datasource, props.onSelectionChange); + const selectionHelper = useSelectionHelper( + props.itemSelection, + props.datasource, + props.onSelectionChange, + props.keepSelection ? "always keep" : "always clear" + ); const selectActionHelper = useSelectActionHelper(props, selectionHelper); @@ -57,74 +64,82 @@ const Container = observer((props: Props): ReactElement => { const checkboxEventsController = useCheckboxEventsController(selectActionHelper, focusController); + const ctx = useConst(() => { + rootStore.basicData.setSelectionHelper(selectionHelper); + return { + basicData: rootStore.basicData, + selectionHelper, + selectActionHelper, + cellEventsController, + checkboxEventsController, + focusController, + selectionCountStore: rootStore.selectionCountStore + }; + }); + return ( - ReactElement) => - props.showEmptyPlaceholder === "custom" ? renderWrapper(props.emptyPlaceholder) :
, - [props.emptyPlaceholder, props.showEmptyPlaceholder] - )} - filterRenderer={useCallback( - (renderWrapper, columnIndex) => { - const columnFilter = columnsStore.columnFilters[columnIndex]; - return renderWrapper(columnFilter.renderFilterWidgets()); - }, - [columnsStore.columnFilters] - )} - headerTitle={props.filterSectionTitle?.value} - headerContent={ - props.filtersPlaceholder && ( - - {props.filtersPlaceholder} - - ) - } - hasMoreItems={props.datasource.hasMoreItems ?? false} - headerWrapperRenderer={useCallback((_columnIndex: number, header: ReactElement) => header, [])} - id={useMemo(() => `DataGrid${generateUUID()}`, [])} - numberOfItems={props.datasource.totalCount} - onExportCancel={abortExport} - page={paginationCtrl.currentPage} - pageSize={props.pageSize} - paginationType={props.pagination} - loadMoreButtonCaption={props.loadMoreButtonCaption?.value} - paging={paginationCtrl.showPagination} - pagingPosition={props.pagingPosition} - showPagingButtons={props.showPagingButtons} - rowClass={useCallback((value: any) => props.rowClass?.get(value)?.value ?? "", [props.rowClass])} - gridInteractive={!!(props.itemSelection || props.onClick)} - setPage={paginationCtrl.setPage} - styles={props.style} - selectionStatus={selectionHelper?.type === "Multi" ? selectionHelper.selectionStatus : "unknown"} - exporting={exportProgress.exporting} - processedRows={exportProgress.loaded} - exportDialogLabel={props.exportDialogLabel?.value} - cancelExportLabel={props.cancelExportLabel?.value} - selectRowLabel={props.selectRowLabel?.value} - selectAllRowsLabel={props.selectAllRowsLabel?.value} - visibleColumns={columnsStore.visibleColumns} - availableColumns={columnsStore.availableColumns} - setIsResizing={(status: boolean) => columnsStore.setIsResizing(status)} - columnsSwap={(moved, [target, placement]) => columnsStore.swapColumns(moved, [target, placement])} - selectActionHelper={selectActionHelper} - cellEventsController={cellEventsController} - checkboxEventsController={checkboxEventsController} - focusController={focusController} - isFirstLoad={rootStore.loaderCtrl.isFirstLoad} - isFetchingNextBatch={rootStore.loaderCtrl.isFetchingNextBatch} - isLoading={props.datasource.status === "loading"} - loadingType={props.loadingType} - columnsLoading={!columnsStore.loaded} - showRefreshIndicator={rootStore.loaderCtrl.showRefreshIndicator} - /> + + ReactElement) => + props.showEmptyPlaceholder === "custom" ? renderWrapper(props.emptyPlaceholder) :
, + [props.emptyPlaceholder, props.showEmptyPlaceholder] + )} + filterRenderer={useCallback( + (renderWrapper, columnIndex) => { + const columnFilter = columnsStore.columnFilters[columnIndex]; + return renderWrapper(columnFilter.renderFilterWidgets()); + }, + [columnsStore.columnFilters] + )} + headerTitle={props.filterSectionTitle?.value} + headerContent={ + props.filtersPlaceholder && ( + + {props.filtersPlaceholder} + + ) + } + hasMoreItems={props.datasource.hasMoreItems ?? false} + headerWrapperRenderer={useCallback((_columnIndex: number, header: ReactElement) => header, [])} + id={useMemo(() => `DataGrid${generateUUID()}`, [])} + numberOfItems={props.datasource.totalCount} + onExportCancel={abortExport} + page={paginationCtrl.currentPage} + pageSize={props.pageSize} + paginationType={props.pagination} + loadMoreButtonCaption={props.loadMoreButtonCaption?.value} + paging={paginationCtrl.showPagination} + pagingPosition={props.pagingPosition} + showPagingButtons={props.showPagingButtons} + rowClass={useCallback((value: any) => props.rowClass?.get(value)?.value ?? "", [props.rowClass])} + setPage={paginationCtrl.setPage} + styles={props.style} + exporting={exportProgress.exporting} + processedRows={exportProgress.loaded} + visibleColumns={columnsStore.visibleColumns} + availableColumns={columnsStore.availableColumns} + setIsResizing={(status: boolean) => columnsStore.setIsResizing(status)} + columnsSwap={(moved, [target, placement]) => columnsStore.swapColumns(moved, [target, placement])} + selectActionHelper={selectActionHelper} + cellEventsController={cellEventsController} + checkboxEventsController={checkboxEventsController} + focusController={focusController} + isFirstLoad={rootStore.loaderCtrl.isFirstLoad} + isFetchingNextBatch={rootStore.loaderCtrl.isFetchingNextBatch} + showRefreshIndicator={rootStore.loaderCtrl.showRefreshIndicator} + loadingType={props.loadingType} + columnsLoading={!columnsStore.loaded} + /> + ); }); diff --git a/packages/pluggableWidgets/datagrid-web/src/Datagrid.xml b/packages/pluggableWidgets/datagrid-web/src/Datagrid.xml index 98b1ccd98f..c19285b1cd 100644 --- a/packages/pluggableWidgets/datagrid-web/src/Datagrid.xml +++ b/packages/pluggableWidgets/datagrid-web/src/Datagrid.xml @@ -49,6 +49,10 @@ Show (un)check all toggle Show a checkbox in the grid header to check or uncheck multiple items. + + Keep selection + If enabled, selected items will stay selected unless cleared by the user or a Nanoflow. + Loading type @@ -367,6 +371,14 @@ Select all rows + + Row count singular + Must include '%d' to denote number position ('%d row selected') + + + Row count plural + Must include '%d' to denote number position ('%d rows selected') + diff --git a/packages/pluggableWidgets/datagrid-web/src/components/CheckboxCell.tsx b/packages/pluggableWidgets/datagrid-web/src/components/CheckboxCell.tsx index f60ea106d2..233efa9b47 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/CheckboxCell.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/CheckboxCell.tsx @@ -1,8 +1,8 @@ -import { createElement, ReactElement } from "react"; -import { ObjectItem } from "mendix"; import { useFocusTargetProps } from "@mendix/widget-plugin-grid/keyboard-navigation/useFocusTargetProps"; +import { ObjectItem } from "mendix"; +import { createElement, ReactElement } from "react"; +import { useDatagridRootScope } from "../helpers/root-context"; import { CellElement, CellElementProps } from "./CellElement"; -import { useWidgetProps } from "../helpers/useWidgetProps"; export type CheckboxCellProps = CellElementProps & { rowIndex: number; @@ -16,7 +16,8 @@ export function CheckboxCell({ item, rowIndex, lastRow, ...rest }: CheckboxCellP rowIndex }); - const { selectActionHelper, checkboxEventsController, selectRowLabel, gridInteractive } = useWidgetProps(); + const { selectActionHelper, checkboxEventsController, basicData } = useDatagridRootScope(); + const { selectRowLabel, gridInteractive } = basicData; return ( onSelectAll(), [onSelectAll]); diff --git a/packages/pluggableWidgets/datagrid-web/src/components/Widget.tsx b/packages/pluggableWidgets/datagrid-web/src/components/Widget.tsx index f94ad45f91..032fd9f74d 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/Widget.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/Widget.tsx @@ -1,31 +1,30 @@ import { RefreshIndicator } from "@mendix/widget-plugin-component-kit/RefreshIndicator"; import { Pagination } from "@mendix/widget-plugin-grid/components/Pagination"; -import { SelectionStatus } from "@mendix/widget-plugin-grid/selection"; +import { FocusTargetController } from "@mendix/widget-plugin-grid/keyboard-navigation/FocusTargetController"; import classNames from "classnames"; import { ListActionValue, ObjectItem } from "mendix"; -import { CSSProperties, ReactElement, ReactNode, createElement, Fragment } from "react"; +import { observer } from "mobx-react-lite"; +import { createElement, CSSProperties, Fragment, ReactElement, ReactNode } from "react"; import { - PagingPositionEnum, + LoadingTypeEnum, PaginationEnum, - ShowPagingButtonsEnum, - LoadingTypeEnum + PagingPositionEnum, + ShowPagingButtonsEnum } from "../../typings/DatagridProps"; -import { WidgetPropsProvider } from "../helpers/useWidgetProps"; +import { SelectActionHelper } from "../helpers/SelectActionHelper"; +import { useDatagridRootScope } from "../helpers/root-context"; import { CellComponent, EventsController } from "../typings/CellComponent"; import { ColumnId, GridColumn } from "../typings/GridColumn"; +import { ExportWidget } from "./ExportWidget"; import { Grid } from "./Grid"; import { GridBody } from "./GridBody"; +import { GridHeader } from "./GridHeader"; +import { RowsRenderer } from "./RowsRenderer"; import { WidgetContent } from "./WidgetContent"; import { WidgetFooter } from "./WidgetFooter"; import { WidgetHeader } from "./WidgetHeader"; import { WidgetRoot } from "./WidgetRoot"; import { WidgetTopBar } from "./WidgetTopBar"; -import { ExportWidget } from "./ExportWidget"; -import { SelectActionHelper } from "../helpers/SelectActionHelper"; -import { FocusTargetController } from "@mendix/widget-plugin-grid/keyboard-navigation/FocusTargetController"; -import { observer } from "mobx-react-lite"; -import { RowsRenderer } from "./RowsRenderer"; -import { GridHeader } from "./GridHeader"; export interface WidgetProps { CellComponent: CellComponent; @@ -56,18 +55,11 @@ export interface WidgetProps string; - gridInteractive: boolean; setPage?: (computePage: (prevPage: number) => number) => void; styles?: CSSProperties; rowAction?: ListActionValue; - selectionStatus: SelectionStatus; showSelectAllToggle?: boolean; - exportDialogLabel?: string; - cancelExportLabel?: string; - selectRowLabel?: string; - selectAllRowsLabel?: string; isFirstLoad: boolean; - isLoading: boolean; isFetchingNextBatch: boolean; loadingType: LoadingTypeEnum; columnsLoading: boolean; @@ -88,32 +80,31 @@ export interface WidgetProps(props: WidgetProps): ReactElement => { const { className, exporting, numberOfItems, onExportCancel, selectActionHelper } = props; + const { basicData } = useDatagridRootScope(); const selectionEnabled = selectActionHelper.selectionType !== "None"; return ( - - -
- {exporting && ( - - )} - - + +
+ {exporting && ( + + )} + ); }); @@ -140,6 +131,8 @@ const Main = observer((props: WidgetProps): ReactElemen visibleColumns } = props; + const { basicData } = useDatagridRootScope(); + const showHeader = !!headerContent; const showTopBar = paging && (pagingPosition === "top" || pagingPosition === "both"); @@ -205,7 +198,7 @@ const Main = observer((props: WidgetProps): ReactElemen > - {(pagingPosition === "bottom" || pagingPosition === "both") && pagination} - {hasMoreItems && paginationType === "loadMore" && ( - - )} +
+
+ +
+ {hasMoreItems && paginationType === "loadMore" && ( +
+ +
+ )} +
+ {(pagingPosition === "bottom" || pagingPosition === "both") && pagination} +
+
); } + +const SelectionCounter = observer(function SelectionCounter() { + const { selectionCountStore, selectActionHelper } = useDatagridRootScope(); + + return ( + + {selectionCountStore.displayCount} |  + + + ); +}); diff --git a/packages/pluggableWidgets/datagrid-web/src/components/__tests__/Table.spec.tsx b/packages/pluggableWidgets/datagrid-web/src/components/__tests__/Table.spec.tsx index 15edc5fdba..7cd0d85a79 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/__tests__/Table.spec.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/__tests__/Table.spec.tsx @@ -1,21 +1,24 @@ -import "@testing-library/jest-dom"; import { ClickActionHelper } from "@mendix/widget-plugin-grid/helpers/ClickActionHelper"; import { MultiSelectionStatus, useSelectionHelper } from "@mendix/widget-plugin-grid/selection"; +import { SelectionCountStore } from "@mendix/widget-plugin-grid/selection/stores/SelectionCountStore"; import { SelectionMultiValueBuilder, list, listWidget, objectItems } from "@mendix/widget-plugin-test-utils"; +import "@testing-library/jest-dom"; import { cleanup, getAllByRole, getByRole, queryByRole, render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { ListValue, ObjectItem, SelectionMultiValue } from "mendix"; import { ReactElement, createElement } from "react"; +import { ItemSelectionMethodEnum } from "typings/DatagridProps"; import { CellEventsController, useCellEventsController } from "../../features/row-interaction/CellEventsController"; import { CheckboxEventsController, useCheckboxEventsController } from "../../features/row-interaction/CheckboxEventsController"; import { SelectActionHelper, useSelectActionHelper } from "../../helpers/SelectActionHelper"; +import { DatagridContext, DatagridRootScope } from "../../helpers/root-context"; +import { GridBasicData } from "../../helpers/state/GridBasicData"; import { GridColumn } from "../../typings/GridColumn"; import { column, mockGridColumn, mockWidgetProps } from "../../utils/test-utils"; import { Widget, WidgetProps } from "../Widget"; -import { ItemSelectionMethodEnum } from "typings/DatagridProps"; // you can also pass the mock implementation // to jest.fn as an argument @@ -29,45 +32,92 @@ window.IntersectionObserver = jest.fn(() => ({ takeRecords: jest.fn() })); +function withCtx( + widgetProps: WidgetProps, + contextOverrides: Partial = {} +): React.ReactElement { + const defaultBasicData = { + gridInteractive: false, + selectionStatus: "none" as const, + setSelectionHelper: jest.fn(), + exportDialogLabel: undefined, + cancelExportLabel: undefined, + selectRowLabel: undefined, + selectAllRowsLabel: undefined + }; + + const defaultSelectionCountStore = { + selectedCount: 0, + displayCount: "", + fmtSingular: "%d row selected", + fmtPlural: "%d rows selected" + }; + + const mockContext = { + basicData: defaultBasicData as unknown as GridBasicData, + selectionHelper: undefined, + selectActionHelper: widgetProps.selectActionHelper, + cellEventsController: widgetProps.cellEventsController, + checkboxEventsController: widgetProps.checkboxEventsController, + focusController: widgetProps.focusController, + selectionCountStore: defaultSelectionCountStore as unknown as SelectionCountStore, + ...contextOverrides + }; + + return ( + + + + ); +} + +// Helper function to render Widget with root context +function renderWithRootContext( + widgetProps: WidgetProps, + contextOverrides: Partial = {} +): ReturnType { + return render(withCtx(widgetProps, contextOverrides)); +} + describe("Table", () => { it("renders the structure correctly", () => { - const component = render(); + const component = renderWithRootContext(mockWidgetProps()); expect(component.asFragment()).toMatchSnapshot(); }); it("renders the structure correctly with sorting", () => { - const component = render(); + const component = renderWithRootContext({ ...mockWidgetProps(), columnsSortable: true }); expect(component.asFragment()).toMatchSnapshot(); }); it("renders the structure correctly with resizing", () => { - const component = render(); + const component = renderWithRootContext({ ...mockWidgetProps(), columnsResizable: true }); expect(component.asFragment()).toMatchSnapshot(); }); it("renders the structure correctly with dragging", () => { - const component = render(); + const component = renderWithRootContext({ ...mockWidgetProps(), columnsDraggable: true }); expect(component.asFragment()).toMatchSnapshot(); }); it("renders the structure correctly with filtering", () => { - const component = render(); + const component = renderWithRootContext({ ...mockWidgetProps(), columnsFilterable: true }); expect(component.asFragment()).toMatchSnapshot(); }); it("renders the structure correctly with hiding", () => { - const component = render(); + const component = renderWithRootContext({ ...mockWidgetProps(), columnsHidable: true }); expect(component.asFragment()).toMatchSnapshot(); }); it("renders the structure correctly with paging", () => { - const component = render(); + const component = renderWithRootContext({ ...mockWidgetProps(), paging: true }); expect(component.asFragment()).toMatchSnapshot(); }); @@ -78,15 +128,16 @@ describe("Table", () => { props.columnsFilterable = true; props.visibleColumns = columns; props.availableColumns = columns; - const component = render(); + const component = renderWithRootContext(props); expect(component.asFragment()).toMatchSnapshot(); }); it("renders the structure correctly with empty placeholder", () => { - const component = render( - renderWrapper(
)} /> - ); + const component = renderWithRootContext({ + ...mockWidgetProps(), + emptyPlaceholderRenderer: renderWrapper => renderWrapper(
) + }); expect(component.asFragment()).toMatchSnapshot(); }); @@ -103,13 +154,13 @@ describe("Table", () => { props.visibleColumns = columns; props.availableColumns = columns; - const component = render(); + const component = renderWithRootContext(props); expect(component.asFragment()).toMatchSnapshot(); }); it("renders the structure correctly with dynamic row class", () => { - const component = render( "myclass"} />); + const component = renderWithRootContext({ ...mockWidgetProps(), rowClass: () => "myclass" }); expect(component.asFragment()).toMatchSnapshot(); }); @@ -121,38 +172,34 @@ describe("Table", () => { props.visibleColumns = columns; props.availableColumns = columns; - const component = render(); + const component = renderWithRootContext(props); expect(component.asFragment()).toMatchSnapshot(); }); it("renders the structure correctly with header wrapper", () => { - const component = render( - ( -
- {header} -
- )} - /> - ); + const component = renderWithRootContext({ + ...mockWidgetProps(), + headerWrapperRenderer: (index, header) => ( +
+ {header} +
+ ) + }); expect(component.asFragment()).toMatchSnapshot(); }); it("renders the structure correctly with header filters and a11y", () => { - const component = render( - - -
- } - headerTitle="filter title" - /> - ); + const component = renderWithRootContext({ + ...mockWidgetProps(), + headerContent: ( +
+ +
+ ), + headerTitle: "filter title" + }); expect(component.asFragment()).toMatchSnapshot(); }); @@ -163,13 +210,14 @@ describe("Table", () => { beforeEach(() => { props = mockWidgetProps(); props.selectActionHelper = new SelectActionHelper("Single", undefined, "checkbox", false, 5, "clear"); - props.gridInteractive = true; props.paging = true; props.data = objectItems(3); }); it("render method class", () => { - const { container } = render(); + const { container } = renderWithRootContext(props, { + basicData: { gridInteractive: true } as unknown as GridBasicData + }); expect(container.firstChild).toHaveClass("widget-datagrid-selection-method-checkbox"); }); @@ -177,7 +225,9 @@ describe("Table", () => { it("render an extra column and add class to each selected row", () => { props.selectActionHelper.isSelected = () => true; - const { asFragment } = render(); + const { asFragment } = renderWithRootContext(props, { + basicData: { gridInteractive: true } as unknown as GridBasicData + }); expect(asFragment()).toMatchSnapshot(); }); @@ -190,24 +240,24 @@ describe("Table", () => { // eslint-disable-next-line @typescript-eslint/explicit-function-return-type const getChecked = () => screen.getAllByRole("checkbox").filter(elt => elt.checked); - const { rerender } = render(); + const { rerender } = render(withCtx(props)); expect(getChecked()).toHaveLength(0); selection = [a, b, c]; - rerender(); + rerender(withCtx({ ...props, data: [a, b, c, d, e, f] })); expect(getChecked()).toHaveLength(3); selection = [c]; - rerender(); + rerender(withCtx({ ...props, data: [a, b, c, d, e, f] })); expect(getChecked()).toHaveLength(1); selection = [d, e]; - rerender(); + rerender(withCtx({ ...props, data: [a, b, c, d, e, f] })); expect(getChecked()).toHaveLength(2); selection = [f, e, d, a]; - rerender(); + rerender(withCtx({ ...props, data: [a, b, c, d, e, f] })); expect(getChecked()).toHaveLength(4); }); @@ -229,7 +279,9 @@ describe("Table", () => { jest.fn() ); - render(); + renderWithRootContext(props, { + basicData: { gridInteractive: true } as unknown as GridBasicData + }); const checkbox1 = screen.getAllByRole("checkbox")[0]; const checkbox3 = screen.getAllByRole("checkbox")[2]; @@ -257,7 +309,7 @@ describe("Table", () => { props.data = objectItems(5); props.paging = true; props.selectActionHelper = new SelectActionHelper("Multi", undefined, "checkbox", false, 5, "clear"); - render(); + renderWithRootContext(props); const colheader = screen.getAllByRole("columnheader")[0]; expect(queryByRole(colheader, "checkbox")).toBeNull(); @@ -271,7 +323,9 @@ describe("Table", () => { props.selectActionHelper = new SelectActionHelper("Multi", undefined, "checkbox", true, 5, "clear"); const renderWithStatus = (status: MultiSelectionStatus): ReturnType => { - return render(); + return renderWithRootContext(props, { + basicData: { selectionStatus: status } as unknown as GridBasicData + }); }; renderWithStatus("none"); @@ -290,7 +344,7 @@ describe("Table", () => { const props = mockWidgetProps(); props.selectActionHelper = new SelectActionHelper("Multi", undefined, "rowClick", false, 5, "clear"); - render(); + renderWithRootContext(props); const colheader = screen.getAllByRole("columnheader")[0]; expect(queryByRole(colheader, "checkbox")).toBeNull(); @@ -300,9 +354,10 @@ describe("Table", () => { const props = mockWidgetProps(); props.selectActionHelper = new SelectActionHelper("Multi", undefined, "checkbox", true, 5, "clear"); props.selectActionHelper.onSelectAll = jest.fn(); - props.selectionStatus = "none"; - render(); + renderWithRootContext(props, { + basicData: { selectionStatus: "none" } as unknown as GridBasicData + }); const checkbox = screen.getAllByRole("checkbox")[0]; @@ -320,13 +375,14 @@ describe("Table", () => { beforeEach(() => { props = mockWidgetProps(); props.selectActionHelper = new SelectActionHelper("Single", undefined, "rowClick", true, 5, "clear"); - props.gridInteractive = true; props.paging = true; props.data = objectItems(3); }); it("render method class", () => { - const { container } = render(); + const { container } = renderWithRootContext(props, { + basicData: { gridInteractive: true } as unknown as GridBasicData + }); expect(container.firstChild).toHaveClass("widget-datagrid-selection-method-click"); }); @@ -334,7 +390,9 @@ describe("Table", () => { it("add class to each selected cell", () => { props.selectActionHelper.isSelected = () => true; - const { asFragment } = render(); + const { asFragment } = renderWithRootContext(props, { + basicData: { gridInteractive: true } as unknown as GridBasicData + }); expect(asFragment()).toMatchSnapshot(); }); @@ -361,7 +419,9 @@ describe("Table", () => { jest.fn() ); - render(); + renderWithRootContext(props, { + basicData: { gridInteractive: true } as unknown as GridBasicData + }); const rows = screen.getAllByRole("row").slice(1); expect(rows).toHaveLength(3); @@ -413,7 +473,7 @@ describe("Table", () => { }: WidgetProps & { selectionMethod: ItemSelectionMethodEnum; }): ReactElement { - const helper = useSelectionHelper(selection, ds, undefined); + const helper = useSelectionHelper(selection, ds, undefined, "always clear"); const selectHelper = useSelectActionHelper( { itemSelection: selection, @@ -432,14 +492,28 @@ describe("Table", () => { const checkboxEventsController = useCheckboxEventsController(selectHelper, props.focusController); + const contextValue = { + basicData: { + gridInteractive: true, + selectionStatus: helper?.type === "Multi" ? helper.selectionStatus : "unknown" + } as unknown as GridBasicData, + selectionHelper: helper, + selectActionHelper: selectHelper, + cellEventsController, + checkboxEventsController, + focusController: props.focusController, + selectionCountStore: {} as unknown as SelectionCountStore + }; + return ( - + + + ); } @@ -618,7 +692,7 @@ describe("Table", () => { const user = userEvent.setup(); - render(); + renderWithRootContext({ ...props, data: items }); const [input] = screen.getAllByRole("textbox"); await user.click(input); 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 1804f162b6..cdc8ba6713 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 @@ -70,7 +70,18 @@ exports[`Table renders the structure correctly 1`] = `
@@ -1156,7 +1288,18 @@ exports[`Table renders the structure correctly with resizing 1`] = ` @@ -1592,93 +1757,104 @@ exports[`Table with selection method rowClick add class to each selected cell 1` class="widget-datagrid-footer table-footer" >
- - + - - Currently showing 11 to 20 - - - + - - - - - + Currently showing 11 to 20 + + + +
+ diff --git a/packages/pluggableWidgets/datagrid-web/src/components/loader/RowSkeletonLoader.tsx b/packages/pluggableWidgets/datagrid-web/src/components/loader/RowSkeletonLoader.tsx index f39daa8ef0..e9569e2897 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/loader/RowSkeletonLoader.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/loader/RowSkeletonLoader.tsx @@ -1,8 +1,8 @@ import { createElement, Fragment, ReactElement } from "react"; +import { useDatagridRootScope } from "../../helpers/root-context"; import { CellElement } from "../CellElement"; -import { SkeletonLoader } from "./SkeletonLoader"; -import { useWidgetProps } from "../../helpers/useWidgetProps"; import { SelectorCell } from "../SelectorCell"; +import { SkeletonLoader } from "./SkeletonLoader"; type RowSkeletonLoaderProps = { columnsHidable: boolean; @@ -17,7 +17,7 @@ export function RowSkeletonLoader({ pageSize, useBorderTop = true }: RowSkeletonLoaderProps): ReactElement { - const { selectActionHelper } = useWidgetProps(); + const { selectActionHelper } = useDatagridRootScope(); return ( {Array.from({ length: pageSize }).map((_, i) => { diff --git a/packages/pluggableWidgets/datagrid-web/src/helpers/root-context.ts b/packages/pluggableWidgets/datagrid-web/src/helpers/root-context.ts new file mode 100644 index 0000000000..51386f8d90 --- /dev/null +++ b/packages/pluggableWidgets/datagrid-web/src/helpers/root-context.ts @@ -0,0 +1,28 @@ +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 { createContext, useContext } from "react"; +import { GridBasicData } from "../helpers/state/GridBasicData"; +import { EventsController } from "../typings/CellComponent"; +import { SelectActionHelper } from "./SelectActionHelper"; + +export interface DatagridRootScope { + basicData: GridBasicData; + // Controllers + selectionHelper: SelectionHelper | undefined; + selectActionHelper: SelectActionHelper; + cellEventsController: EventsController; + checkboxEventsController: EventsController; + focusController: FocusTargetController; + selectionCountStore: SelectionCountStore; +} + +export const DatagridContext = createContext(null); + +export const useDatagridRootScope = (): DatagridRootScope => { + const contextValue = useContext(DatagridContext); + if (!contextValue) { + throw new Error("useDatagridRootScope must be used within a root context provider"); + } + return contextValue; +}; diff --git a/packages/pluggableWidgets/datagrid-web/src/helpers/state/GridBasicData.ts b/packages/pluggableWidgets/datagrid-web/src/helpers/state/GridBasicData.ts new file mode 100644 index 0000000000..1b0b1ed909 --- /dev/null +++ b/packages/pluggableWidgets/datagrid-web/src/helpers/state/GridBasicData.ts @@ -0,0 +1,50 @@ +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"; + +type Props = Pick< + DatagridContainerProps, + "exportDialogLabel" | "cancelExportLabel" | "selectRowLabel" | "selectAllRowsLabel" | "itemSelection" | "onClick" +>; + +type Gate = DerivedPropsGate; + +/** This is basic data class, just a props mapper. Don't add any state or complex logic. */ +export class GridBasicData { + private gate: Gate; + private selectionHelper: SelectionHelper | null = null; + + constructor(gate: Gate) { + this.gate = gate; + makeAutoObservable(this); + } + + get exportDialogLabel(): string | undefined { + return this.gate.props.exportDialogLabel?.value; + } + + get cancelExportLabel(): string | undefined { + return this.gate.props.cancelExportLabel?.value; + } + + get selectRowLabel(): string | undefined { + return this.gate.props.selectRowLabel?.value; + } + + get selectAllRowsLabel(): string | undefined { + return this.gate.props.selectAllRowsLabel?.value; + } + + 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/GridPersonalizationStore.ts b/packages/pluggableWidgets/datagrid-web/src/helpers/state/GridPersonalizationStore.ts index 7d0622f54c..0fe509c9cc 100644 --- a/packages/pluggableWidgets/datagrid-web/src/helpers/state/GridPersonalizationStore.ts +++ b/packages/pluggableWidgets/datagrid-web/src/helpers/state/GridPersonalizationStore.ts @@ -14,6 +14,12 @@ import { AttributePersonalizationStorage } from "../storage/AttributePersonaliza import { LocalStoragePersonalizationStorage } from "../storage/LocalStoragePersonalizationStorage"; import { PersonalizationStorage } from "../storage/PersonalizationStorage"; import { ColumnGroupStore } from "./ColumnGroupStore"; + +type RequiredProps = Pick< + DatagridContainerProps, + "name" | "configurationStorageType" | "storeFiltersInPersonalization" | "configurationAttribute" +>; + export class GridPersonalizationStore { private readonly gridName: string; private readonly gridColumnsHash: string; @@ -25,7 +31,7 @@ export class GridPersonalizationStore { private disposers: IReactionDisposer[] = []; constructor( - props: DatagridContainerProps, + props: RequiredProps, private columnsStore: ColumnGroupStore, private customFilters: ObservableFilterHost ) { @@ -52,7 +58,7 @@ export class GridPersonalizationStore { this.disposers.forEach(d => d()); } - updateProps(props: DatagridContainerProps): void { + updateProps(props: RequiredProps): void { this.storage.updateProps?.(props); } diff --git a/packages/pluggableWidgets/datagrid-web/src/helpers/state/RootGridStore.ts b/packages/pluggableWidgets/datagrid-web/src/helpers/state/RootGridStore.ts index 379b54a67a..7e64c08ec0 100644 --- a/packages/pluggableWidgets/datagrid-web/src/helpers/state/RootGridStore.ts +++ b/packages/pluggableWidgets/datagrid-web/src/helpers/state/RootGridStore.ts @@ -3,11 +3,13 @@ import { CombinedFilter } from "@mendix/widget-plugin-filtering/stores/generic/C import { CustomFilterHost } from "@mendix/widget-plugin-filtering/stores/generic/CustomFilterHost"; import { DatasourceController } from "@mendix/widget-plugin-grid/query/DatasourceController"; import { RefreshController } from "@mendix/widget-plugin-grid/query/RefreshController"; +import { SelectionCountStore } from "@mendix/widget-plugin-grid/selection/stores/SelectionCountStore"; import { BaseControllerHost } from "@mendix/widget-plugin-mobx-kit/BaseControllerHost"; import { disposeBatch } from "@mendix/widget-plugin-mobx-kit/disposeBatch"; import { DerivedPropsGate } from "@mendix/widget-plugin-mobx-kit/props-gate"; import { generateUUID } from "@mendix/widget-plugin-platform/framework/generate-uuid"; import { autorun } from "mobx"; +import { GridBasicData } from "src/helpers/state/GridBasicData"; import { DatagridContainerProps } from "../../../typings/DatagridProps"; import { DatasourceParamsController } from "../../controllers/DatasourceParamsController"; import { DerivedLoaderController } from "../../controllers/DerivedLoaderController"; @@ -17,7 +19,24 @@ import { StaticInfo } from "../../typings/static-info"; import { ColumnGroupStore } from "./ColumnGroupStore"; import { GridPersonalizationStore } from "./GridPersonalizationStore"; -type Gate = DerivedPropsGate; +type RequiredProps = Pick< + DatagridContainerProps, + | "name" + | "datasource" + | "refreshInterval" + | "refreshIndicator" + | "itemSelection" + | "columns" + | "configurationStorageType" + | "storeFiltersInPersonalization" + | "configurationAttribute" + | "pageSize" + | "pagination" + | "showPagingButtons" + | "showNumberOfRows" +>; + +type Gate = DerivedPropsGate; type Spec = { gate: Gate; @@ -27,6 +46,8 @@ type Spec = { export class RootGridStore extends BaseControllerHost { columnsStore: ColumnGroupStore; settingsStore: GridPersonalizationStore; + selectionCountStore: SelectionCountStore; + basicData: GridBasicData; staticInfo: StaticInfo; exportProgressCtrl: ProgressStore; loaderCtrl: DerivedLoaderController; @@ -64,6 +85,10 @@ export class RootGridStore extends BaseControllerHost { this.settingsStore = new GridPersonalizationStore(props, this.columnsStore, filterHost); + this.basicData = new GridBasicData(gate); + + this.selectionCountStore = new SelectionCountStore(gate); + this.paginationCtrl = new PaginationController(this, { gate, query }); this.exportProgressCtrl = exportCtrl; @@ -100,7 +125,7 @@ export class RootGridStore extends BaseControllerHost { return disposeAll; } - private updateProps(props: DatagridContainerProps): void { + private updateProps(props: RequiredProps): void { this.columnsStore.updateProps(props); this.settingsStore.updateProps(props); } diff --git a/packages/pluggableWidgets/datagrid-web/src/helpers/storage/AttributePersonalizationStorage.ts b/packages/pluggableWidgets/datagrid-web/src/helpers/storage/AttributePersonalizationStorage.ts index 33255f082c..0eb8f515a4 100644 --- a/packages/pluggableWidgets/datagrid-web/src/helpers/storage/AttributePersonalizationStorage.ts +++ b/packages/pluggableWidgets/datagrid-web/src/helpers/storage/AttributePersonalizationStorage.ts @@ -1,12 +1,18 @@ import { EditableValue, ValueStatus } from "mendix"; -import { PersonalizationStorage } from "./PersonalizationStorage"; import { action, computed, makeObservable, observable } from "mobx"; import { DatagridContainerProps } from "../../../typings/DatagridProps"; +import { PersonalizationStorage } from "./PersonalizationStorage"; + +type RequiredProps = Pick; +/** + * AttributePersonalizationStorage is a class that implements PersonalizationStorage + * and uses an editable value to store the personalization settings in a Mendix attribute. + */ export class AttributePersonalizationStorage implements PersonalizationStorage { private _storageAttr: EditableValue | undefined; - constructor(props: Pick) { + constructor(props: RequiredProps) { this._storageAttr = props.configurationAttribute; makeObservable(this, { @@ -17,7 +23,7 @@ export class AttributePersonalizationStorage implements PersonalizationStorage { }); } - updateProps(props: Pick): void { + updateProps(props: RequiredProps): void { this._storageAttr = props.configurationAttribute; } diff --git a/packages/pluggableWidgets/datagrid-web/src/helpers/storage/PersonalizationStorage.ts b/packages/pluggableWidgets/datagrid-web/src/helpers/storage/PersonalizationStorage.ts index 4c736bbb2f..4b6f170c54 100644 --- a/packages/pluggableWidgets/datagrid-web/src/helpers/storage/PersonalizationStorage.ts +++ b/packages/pluggableWidgets/datagrid-web/src/helpers/storage/PersonalizationStorage.ts @@ -1,7 +1,9 @@ import { DatagridContainerProps } from "../../../typings/DatagridProps"; +type RequiredProps = Pick; + export interface PersonalizationStorage { settings: unknown; updateSettings(newSettings: any): void; - updateProps?(props: DatagridContainerProps): void; + updateProps?(props: RequiredProps): void; } diff --git a/packages/pluggableWidgets/datagrid-web/src/helpers/useWidgetProps.ts b/packages/pluggableWidgets/datagrid-web/src/helpers/useWidgetProps.ts deleted file mode 100644 index 15f75cdc97..0000000000 --- a/packages/pluggableWidgets/datagrid-web/src/helpers/useWidgetProps.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { ObjectItem } from "mendix"; -import { createContext, Provider, useContext } from "react"; -import { GridColumn } from "../typings/GridColumn"; -import { WidgetProps } from "../components/Widget"; - -const NO_PROPS_VALUE = Symbol("NO_PROPS_VALUE"); - -type Props = WidgetProps; -type ContextValue = typeof NO_PROPS_VALUE | Props; - -const context = createContext(NO_PROPS_VALUE); - -export const WidgetPropsProvider: Provider = context.Provider; - -export function useWidgetProps(): Props { - const value = useContext(context); - - if (value === NO_PROPS_VALUE) { - throw new Error("useTableProps: failed to get value from props provider."); - } - - return value; -} diff --git a/packages/pluggableWidgets/datagrid-web/src/utils/test-utils.tsx b/packages/pluggableWidgets/datagrid-web/src/utils/test-utils.tsx index 3eb9a22f31..bc9e5953d4 100644 --- a/packages/pluggableWidgets/datagrid-web/src/utils/test-utils.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/utils/test-utils.tsx @@ -1,16 +1,16 @@ -import { createElement } from "react"; -import { GUID, ObjectItem } from "mendix"; +import { FocusTargetController } from "@mendix/widget-plugin-grid/keyboard-navigation/FocusTargetController"; +import { PositionController } from "@mendix/widget-plugin-grid/keyboard-navigation/PositionController"; +import { VirtualGridLayout } from "@mendix/widget-plugin-grid/keyboard-navigation/VirtualGridLayout"; import { dynamicValue, listAttr, listExp } from "@mendix/widget-plugin-test-utils"; -import { WidgetProps } from "../components/Widget"; +import { GUID, ObjectItem } from "mendix"; +import { createElement } from "react"; import { ColumnsType } from "../../typings/DatagridProps"; import { Cell } from "../components/Cell"; -import { ColumnId, GridColumn } from "../typings/GridColumn"; +import { WidgetProps } from "../components/Widget"; import { SelectActionHelper } from "../helpers/SelectActionHelper"; -import { FocusTargetController } from "@mendix/widget-plugin-grid/keyboard-navigation/FocusTargetController"; -import { PositionController } from "@mendix/widget-plugin-grid/keyboard-navigation/PositionController"; -import { VirtualGridLayout } from "@mendix/widget-plugin-grid/keyboard-navigation/VirtualGridLayout"; import { ColumnStore } from "../helpers/state/column/ColumnStore"; import { IColumnParentStore } from "../helpers/state/ColumnGroupStore"; +import { ColumnId, GridColumn } from "../typings/GridColumn"; export const column = (header = "Test", patch?: (col: ColumnsType) => void): ColumnsType => { const c: ColumnsType = { @@ -98,15 +98,12 @@ export function mockWidgetProps(): WidgetProps { availableColumns: columns, columnsSwap: jest.fn(), setIsResizing: jest.fn(), - selectionStatus: "unknown", setPage: jest.fn(), processedRows: 0, - gridInteractive: false, selectActionHelper: mockSelectionProps(), cellEventsController: { getProps: () => Object.create({}) }, checkboxEventsController: { getProps: () => Object.create({}) }, isFirstLoad: false, - isLoading: false, isFetchingNextBatch: false, loadingType: "spinner", columnsLoading: false, diff --git a/packages/pluggableWidgets/datagrid-web/test-ct/preview.spec.tsx b/packages/pluggableWidgets/datagrid-web/test-ct/preview.spec.tsx new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/pluggableWidgets/datagrid-web/typings/DatagridProps.d.ts b/packages/pluggableWidgets/datagrid-web/typings/DatagridProps.d.ts index 052bb22244..a3e01d2e2d 100644 --- a/packages/pluggableWidgets/datagrid-web/typings/DatagridProps.d.ts +++ b/packages/pluggableWidgets/datagrid-web/typings/DatagridProps.d.ts @@ -95,6 +95,7 @@ export interface DatagridContainerProps { itemSelectionMethod: ItemSelectionMethodEnum; itemSelectionMode: ItemSelectionModeEnum; showSelectAllToggle: boolean; + keepSelection: boolean; loadingType: LoadingTypeEnum; refreshIndicator: boolean; columns: ColumnsType[]; @@ -124,6 +125,8 @@ export interface DatagridContainerProps { cancelExportLabel?: DynamicValue; selectRowLabel?: DynamicValue; selectAllRowsLabel?: DynamicValue; + selectedCountTemplateSingular?: DynamicValue; + selectedCountTemplatePlural?: DynamicValue; } export interface DatagridPreviewProps { @@ -144,6 +147,7 @@ export interface DatagridPreviewProps { itemSelectionMethod: ItemSelectionMethodEnum; itemSelectionMode: ItemSelectionModeEnum; showSelectAllToggle: boolean; + keepSelection: boolean; loadingType: LoadingTypeEnum; refreshIndicator: boolean; columns: ColumnsPreviewType[]; @@ -174,4 +178,6 @@ export interface DatagridPreviewProps { cancelExportLabel: string; selectRowLabel: string; selectAllRowsLabel: string; + selectedCountTemplateSingular: string; + selectedCountTemplatePlural: string; } diff --git a/packages/pluggableWidgets/gallery-web/e2e/Gallery.spec.js-snapshots/galleryContent-chromium-linux.png b/packages/pluggableWidgets/gallery-web/e2e/Gallery.spec.js-snapshots/galleryContent-chromium-linux.png index 38788ea4ba..d052ec7044 100644 Binary files a/packages/pluggableWidgets/gallery-web/e2e/Gallery.spec.js-snapshots/galleryContent-chromium-linux.png and b/packages/pluggableWidgets/gallery-web/e2e/Gallery.spec.js-snapshots/galleryContent-chromium-linux.png differ diff --git a/packages/pluggableWidgets/gallery-web/e2e/Gallery.spec.js-snapshots/galleryDateFilter-chromium-linux.png b/packages/pluggableWidgets/gallery-web/e2e/Gallery.spec.js-snapshots/galleryDateFilter-chromium-linux.png index c242b96e35..031f94c31b 100644 Binary files a/packages/pluggableWidgets/gallery-web/e2e/Gallery.spec.js-snapshots/galleryDateFilter-chromium-linux.png and b/packages/pluggableWidgets/gallery-web/e2e/Gallery.spec.js-snapshots/galleryDateFilter-chromium-linux.png differ diff --git a/packages/pluggableWidgets/gallery-web/e2e/Gallery.spec.js-snapshots/galleryDropdownFilter-chromium-linux.png b/packages/pluggableWidgets/gallery-web/e2e/Gallery.spec.js-snapshots/galleryDropdownFilter-chromium-linux.png index b0572b0892..5e9225ba45 100644 Binary files a/packages/pluggableWidgets/gallery-web/e2e/Gallery.spec.js-snapshots/galleryDropdownFilter-chromium-linux.png and b/packages/pluggableWidgets/gallery-web/e2e/Gallery.spec.js-snapshots/galleryDropdownFilter-chromium-linux.png differ diff --git a/packages/pluggableWidgets/gallery-web/e2e/Gallery.spec.js-snapshots/galleryDropdownSort-chromium-linux.png b/packages/pluggableWidgets/gallery-web/e2e/Gallery.spec.js-snapshots/galleryDropdownSort-chromium-linux.png index f45f0e2ff8..dd5810be71 100644 Binary files a/packages/pluggableWidgets/gallery-web/e2e/Gallery.spec.js-snapshots/galleryDropdownSort-chromium-linux.png and b/packages/pluggableWidgets/gallery-web/e2e/Gallery.spec.js-snapshots/galleryDropdownSort-chromium-linux.png differ diff --git a/packages/pluggableWidgets/gallery-web/e2e/Gallery.spec.js-snapshots/galleryNumberFilter-chromium-linux.png b/packages/pluggableWidgets/gallery-web/e2e/Gallery.spec.js-snapshots/galleryNumberFilter-chromium-linux.png index fdc47fff93..1104e372b8 100644 Binary files a/packages/pluggableWidgets/gallery-web/e2e/Gallery.spec.js-snapshots/galleryNumberFilter-chromium-linux.png and b/packages/pluggableWidgets/gallery-web/e2e/Gallery.spec.js-snapshots/galleryNumberFilter-chromium-linux.png differ diff --git a/packages/pluggableWidgets/gallery-web/e2e/Gallery.spec.js-snapshots/galleryTextFilter-chromium-linux.png b/packages/pluggableWidgets/gallery-web/e2e/Gallery.spec.js-snapshots/galleryTextFilter-chromium-linux.png index 55e4b3ca2a..db25446ea7 100644 Binary files a/packages/pluggableWidgets/gallery-web/e2e/Gallery.spec.js-snapshots/galleryTextFilter-chromium-linux.png and b/packages/pluggableWidgets/gallery-web/e2e/Gallery.spec.js-snapshots/galleryTextFilter-chromium-linux.png differ diff --git a/packages/pluggableWidgets/gallery-web/e2e/GallerySelection.spec.js-snapshots/galleryMultiSelection-chromium-linux.png b/packages/pluggableWidgets/gallery-web/e2e/GallerySelection.spec.js-snapshots/galleryMultiSelection-chromium-linux.png index b015595c63..90d02e0f2f 100644 Binary files a/packages/pluggableWidgets/gallery-web/e2e/GallerySelection.spec.js-snapshots/galleryMultiSelection-chromium-linux.png and b/packages/pluggableWidgets/gallery-web/e2e/GallerySelection.spec.js-snapshots/galleryMultiSelection-chromium-linux.png differ diff --git a/packages/pluggableWidgets/gallery-web/e2e/GallerySelection.spec.js-snapshots/gallerySingleSelection-chromium-linux.png b/packages/pluggableWidgets/gallery-web/e2e/GallerySelection.spec.js-snapshots/gallerySingleSelection-chromium-linux.png index 582bd05fe0..bd0ead3fc6 100644 Binary files a/packages/pluggableWidgets/gallery-web/e2e/GallerySelection.spec.js-snapshots/gallerySingleSelection-chromium-linux.png and b/packages/pluggableWidgets/gallery-web/e2e/GallerySelection.spec.js-snapshots/gallerySingleSelection-chromium-linux.png differ diff --git a/packages/pluggableWidgets/gallery-web/src/Gallery.editorConfig.ts b/packages/pluggableWidgets/gallery-web/src/Gallery.editorConfig.ts index f9b0acf8c7..10de4a8530 100644 --- a/packages/pluggableWidgets/gallery-web/src/Gallery.editorConfig.ts +++ b/packages/pluggableWidgets/gallery-web/src/Gallery.editorConfig.ts @@ -18,6 +18,10 @@ export function getProperties(values: GalleryPreviewProps, defaultProperties: Pr hidePropertiesIn(defaultProperties, values, ["onSelectionChange", "itemSelectionMode"]); } + if (values.itemSelection !== "Multi") { + hidePropertiesIn(defaultProperties, values, ["keepSelection"]); + } + const usePersonalization = values.storeFilters || values.storeSort; if (!usePersonalization) { hidePropertiesIn(defaultProperties, values, ["stateStorageType", "stateStorageAttr", "onConfigurationChange"]); diff --git a/packages/pluggableWidgets/gallery-web/src/Gallery.editorPreview.tsx b/packages/pluggableWidgets/gallery-web/src/Gallery.editorPreview.tsx index 13ca8e18cf..226e81c75a 100644 --- a/packages/pluggableWidgets/gallery-web/src/Gallery.editorPreview.tsx +++ b/packages/pluggableWidgets/gallery-web/src/Gallery.editorPreview.tsx @@ -34,6 +34,7 @@ function Preview(props: GalleryPreviewProps): ReactElement { desktopItems: props.desktopItems ?? 1, totalItems: items.length }); + const getPositionCallback = useCallback( (index: number) => getColumnAndRowBasedOnIndex(numberOfColumns, items.length, index), [numberOfColumns, items.length] diff --git a/packages/pluggableWidgets/gallery-web/src/Gallery.tsx b/packages/pluggableWidgets/gallery-web/src/Gallery.tsx index d769ffbc1e..14f1dd5a95 100644 --- a/packages/pluggableWidgets/gallery-web/src/Gallery.tsx +++ b/packages/pluggableWidgets/gallery-web/src/Gallery.tsx @@ -98,13 +98,19 @@ const Container = observer(function GalleryContainer(props: GalleryContainerProp function useCreateGalleryScope(props: GalleryContainerProps): GalleryRootScope { const rootStore = useGalleryStore(props); - const selectionHelper = useSelectionHelper(props.itemSelection, props.datasource, props.onSelectionChange); + const selectionHelper = useSelectionHelper( + props.itemSelection, + props.datasource, + props.onSelectionChange, + props.keepSelection ? "always keep" : "always clear" + ); const itemSelectHelper = useItemSelectHelper(props.itemSelection, selectionHelper); return useConst({ rootStore, selectionHelper, - itemSelectHelper + itemSelectHelper, + selectionCountStore: rootStore.selectionCountStore }); } diff --git a/packages/pluggableWidgets/gallery-web/src/Gallery.xml b/packages/pluggableWidgets/gallery-web/src/Gallery.xml index 5853598503..7958540ecb 100644 --- a/packages/pluggableWidgets/gallery-web/src/Gallery.xml +++ b/packages/pluggableWidgets/gallery-web/src/Gallery.xml @@ -33,6 +33,10 @@ No + + Keep selection + If enabled, selected items will stay selected unless cleared by the user or a Nanoflow. + Content placeholder @@ -186,6 +190,14 @@ Item description Assistive technology will read this upon reaching each gallery item. + + Item count singular + Must include '%d' to denote number position ('%d item selected') + + + Item count plural + Must include '%d' to denote number position ('%d items selected') + diff --git a/packages/pluggableWidgets/gallery-web/src/components/Gallery.tsx b/packages/pluggableWidgets/gallery-web/src/components/Gallery.tsx index 9407dafaf8..23bb5b3496 100644 --- a/packages/pluggableWidgets/gallery-web/src/components/Gallery.tsx +++ b/packages/pluggableWidgets/gallery-web/src/components/Gallery.tsx @@ -17,6 +17,7 @@ import { ListItem } from "./ListItem"; import { PaginationEnum, ShowPagingButtonsEnum } from "typings/GalleryProps"; import { LoadMore, LoadMoreButton as LoadMorePreview } from "../components/LoadMore"; import { ItemEventsController } from "../typings/ItemEventsController"; +import { SelectionCounter } from "./SelectionCounter"; export interface GalleryProps { className?: string; @@ -128,12 +129,15 @@ export function Gallery(props: GalleryProps): ReactElem ))} - {showBottomPagination && pagination} -
- {props.preview && props.paginationType === "loadMore" && ( - {loadMoreButtonCaption} +
+
{!props.preview && }
+ {props.paginationType === "loadMore" && ( +
+ {props.preview && {loadMoreButtonCaption}}{" "} + {!props.preview && {loadMoreButtonCaption}} +
)} - {!props.preview && {loadMoreButtonCaption}} +
{showBottomPagination && pagination}
diff --git a/packages/pluggableWidgets/gallery-web/src/components/SelectionCounter.tsx b/packages/pluggableWidgets/gallery-web/src/components/SelectionCounter.tsx new file mode 100644 index 0000000000..7e81ba9d40 --- /dev/null +++ b/packages/pluggableWidgets/gallery-web/src/components/SelectionCounter.tsx @@ -0,0 +1,17 @@ +import { If } from "@mendix/widget-plugin-component-kit/If"; +import { observer } from "mobx-react-lite"; +import { createElement } from "react"; +import { useGalleryRootScope } from "../helpers/root-context"; + +export const SelectionCounter = observer(function SelectionCounter() { + const { selectionCountStore, itemSelectHelper } = useGalleryRootScope(); + + return ( + + {selectionCountStore.displayCount} |  + + + ); +}); diff --git a/packages/pluggableWidgets/gallery-web/src/components/__tests__/__snapshots__/Gallery.spec.tsx.snap b/packages/pluggableWidgets/gallery-web/src/components/__tests__/__snapshots__/Gallery.spec.tsx.snap index b50f5a7049..293aded5c8 100644 --- a/packages/pluggableWidgets/gallery-web/src/components/__tests__/__snapshots__/Gallery.spec.tsx.snap +++ b/packages/pluggableWidgets/gallery-web/src/components/__tests__/__snapshots__/Gallery.spec.tsx.snap @@ -53,8 +53,15 @@ exports[`Gallery DOM Structure renders correctly 1`] = ` class="widget-gallery-footer" > @@ -128,8 +135,15 @@ exports[`Gallery DOM Structure renders correctly with onclick event 1`] = ` class="widget-gallery-footer" > @@ -191,8 +205,15 @@ exports[`Gallery with accessibility properties renders correctly with items 1`] class="widget-gallery-footer" > @@ -232,8 +253,15 @@ exports[`Gallery with accessibility properties renders correctly without items 1 class="widget-gallery-footer" > @@ -272,8 +300,15 @@ exports[`Gallery with empty option renders correctly 1`] = ` class="widget-gallery-footer" > @@ -451,8 +486,15 @@ exports[`Gallery with pagination renders correctly 1`] = ` class="widget-gallery-footer" > @@ -505,8 +547,15 @@ exports[`Gallery without filters renders structure without header container 1`] class="widget-gallery-footer" > diff --git a/packages/pluggableWidgets/gallery-web/src/helpers/root-context.ts b/packages/pluggableWidgets/gallery-web/src/helpers/root-context.ts index 8669738bfa..6a200bb941 100644 --- a/packages/pluggableWidgets/gallery-web/src/helpers/root-context.ts +++ b/packages/pluggableWidgets/gallery-web/src/helpers/root-context.ts @@ -1,9 +1,11 @@ import { SelectActionHandler, SelectionHelper } from "@mendix/widget-plugin-grid/selection"; +import { SelectionCountStore } from "@mendix/widget-plugin-grid/selection/stores/SelectionCountStore"; import { createContext, useContext } from "react"; import { GalleryStore } from "../stores/GalleryStore"; export interface GalleryRootScope { rootStore: GalleryStore; + selectionCountStore: SelectionCountStore; selectionHelper: SelectionHelper | undefined; itemSelectHelper: SelectActionHandler; } diff --git a/packages/pluggableWidgets/gallery-web/src/stores/GalleryStore.ts b/packages/pluggableWidgets/gallery-web/src/stores/GalleryStore.ts index e0ab4c84f0..4c45d00ca0 100644 --- a/packages/pluggableWidgets/gallery-web/src/stores/GalleryStore.ts +++ b/packages/pluggableWidgets/gallery-web/src/stores/GalleryStore.ts @@ -4,12 +4,13 @@ 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 { 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 { EditableValue, ListValue } from "mendix"; +import { DynamicValue, EditableValue, ListValue, SelectionMultiValue, SelectionSingleValue } from "mendix"; import { PaginationEnum, StateStorageTypeEnum } from "../../typings/GalleryProps"; import { DerivedLoaderController } from "../controllers/DerivedLoaderController"; import { QueryParamsController } from "../controllers/QueryParamsController"; @@ -21,6 +22,9 @@ import { GalleryPersistentStateController } from "./GalleryPersistentStateContro interface DynamicProps { datasource: ListValue; stateStorageAttr?: EditableValue; + itemSelection?: SelectionSingleValue | SelectionMultiValue; + sCountFmtSingular?: DynamicValue; + sCountFmtPlural?: DynamicValue; } interface StaticProps { @@ -53,6 +57,7 @@ export class GalleryStore extends BaseControllerHost { readonly filterAPI: FilterAPI; readonly sortAPI: SortAPI; loaderCtrl: DerivedLoaderController; + selectionCountStore: SelectionCountStore; constructor(spec: GalleryStoreSpec) { super(); @@ -69,6 +74,11 @@ export class GalleryStore extends BaseControllerHost { showTotalCount: spec.showTotalCount }); + this.selectionCountStore = new SelectionCountStore(spec.gate, { + singular: "%d item selected", + plural: "%d items selected" + }); + this._filtersHost = new CustomFilterHost(); const combinedFilter = new CombinedFilter(this, { stableKey: spec.name, inputs: [this._filtersHost] }); diff --git a/packages/pluggableWidgets/gallery-web/src/utils/test-utils.tsx b/packages/pluggableWidgets/gallery-web/src/utils/test-utils.tsx index 5dc93b61b2..9b45fb84da 100644 --- a/packages/pluggableWidgets/gallery-web/src/utils/test-utils.tsx +++ b/packages/pluggableWidgets/gallery-web/src/utils/test-utils.tsx @@ -56,7 +56,8 @@ export function createMockGalleryContext(): GalleryRootScope { stateStorageType: "localStorage", storeFilters: false, storeSort: false, - refreshIndicator: false + refreshIndicator: false, + keepSelection: false }; // Create a proper gate provider and gate @@ -82,7 +83,8 @@ export function createMockGalleryContext(): GalleryRootScope { return { rootStore: mockStore, selectionHelper: undefined, - itemSelectHelper: mockSelectHelper + itemSelectHelper: mockSelectHelper, + selectionCountStore: mockStore.selectionCountStore }; } diff --git a/packages/pluggableWidgets/gallery-web/typings/GalleryProps.d.ts b/packages/pluggableWidgets/gallery-web/typings/GalleryProps.d.ts index 2f32acbdc7..1244aa8b18 100644 --- a/packages/pluggableWidgets/gallery-web/typings/GalleryProps.d.ts +++ b/packages/pluggableWidgets/gallery-web/typings/GalleryProps.d.ts @@ -29,6 +29,7 @@ export interface GalleryContainerProps { datasource: ListValue; itemSelection?: SelectionSingleValue | SelectionMultiValue; itemSelectionMode: ItemSelectionModeEnum; + keepSelection: boolean; content?: ListWidgetValue; refreshIndicator: boolean; desktopItems: number; @@ -54,6 +55,8 @@ export interface GalleryContainerProps { emptyMessageTitle?: DynamicValue; ariaLabelListBox?: DynamicValue; ariaLabelItem?: ListExpressionValue; + selectedCountTemplateSingular?: DynamicValue; + selectedCountTemplatePlural?: DynamicValue; } export interface GalleryPreviewProps { @@ -71,6 +74,7 @@ export interface GalleryPreviewProps { datasource: {} | { caption: string } | { type: string } | null; itemSelection: "None" | "Single" | "Multi"; itemSelectionMode: ItemSelectionModeEnum; + keepSelection: boolean; content: { widgetCount: number; renderer: ComponentType<{ children: ReactNode; caption?: string }> }; refreshIndicator: boolean; desktopItems: number | null; @@ -97,4 +101,6 @@ export interface GalleryPreviewProps { emptyMessageTitle: string; ariaLabelListBox: string; ariaLabelItem: string; + selectedCountTemplateSingular: string; + selectedCountTemplatePlural: string; } diff --git a/packages/shared/widget-plugin-grid/src/keyboard-navigation/useFocusTargetController.ts b/packages/shared/widget-plugin-grid/src/keyboard-navigation/useFocusTargetController.ts index 03d4e54edf..28bd33ee5f 100644 --- a/packages/shared/widget-plugin-grid/src/keyboard-navigation/useFocusTargetController.ts +++ b/packages/shared/widget-plugin-grid/src/keyboard-navigation/useFocusTargetController.ts @@ -1,10 +1,13 @@ import { useMemo, useRef } from "react"; import { FocusTargetController } from "./FocusTargetController"; -import { VirtualGridLayout } from "./VirtualGridLayout"; import { PositionController } from "./PositionController"; +import { VirtualGridLayout } from "./VirtualGridLayout"; export type LayoutProps = { rows: number; columns: number; pageSize: number }; +/** + * @returns {FocusTargetController} controller that manages focus targets in a grid layout. Object ref is stable across renders. + */ export function useFocusTargetController({ rows, columns, pageSize }: LayoutProps): FocusTargetController { const controllerRef = useRef(null); diff --git a/packages/shared/widget-plugin-grid/src/selection/__tests__/SelectionCountStore.spec.ts b/packages/shared/widget-plugin-grid/src/selection/__tests__/SelectionCountStore.spec.ts new file mode 100644 index 0000000000..3820bf1f46 --- /dev/null +++ b/packages/shared/widget-plugin-grid/src/selection/__tests__/SelectionCountStore.spec.ts @@ -0,0 +1,100 @@ +import { GateProvider } from "@mendix/widget-plugin-mobx-kit/GateProvider"; +import { SelectionMultiValueBuilder, SelectionSingleValueBuilder, objectItems } from "@mendix/widget-plugin-test-utils"; +import { SelectionMultiValue, SelectionSingleValue } from "mendix"; +import { SelectionCountStore } from "../stores/SelectionCountStore"; + +type Props = { + itemSelection?: SelectionSingleValue | SelectionMultiValue; +}; + +const createMinimalMockProps = (overrides: Props = {}): Props => ({ ...overrides }); + +describe("SelectionCountStore", () => { + let gateProvider: GateProvider; + let selectionCountStore: SelectionCountStore; + + beforeEach(() => { + const mockProps = createMinimalMockProps(); + gateProvider = new GateProvider(mockProps); + selectionCountStore = new SelectionCountStore(gateProvider.gate); + }); + + describe("when itemSelection is undefined", () => { + it("should return 0 selected items", () => { + const props = createMinimalMockProps({ itemSelection: undefined }); + gateProvider.setProps(props); + + expect(selectionCountStore.selectedCount).toBe(0); + }); + }); + + describe("when itemSelection is single selection", () => { + it("should return 0 when no item is selected", () => { + const singleSelection = new SelectionSingleValueBuilder().build(); + const props = createMinimalMockProps({ itemSelection: singleSelection }); + gateProvider.setProps(props); + + expect(selectionCountStore.selectedCount).toBe(0); + }); + + it("should return 1 when one item is selected", () => { + 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); + }); + }); + + describe("when itemSelection is multi selection", () => { + it("should return 0 when no items are selected", () => { + const multiSelection = new SelectionMultiValueBuilder().build(); + const props = createMinimalMockProps({ itemSelection: multiSelection }); + gateProvider.setProps(props); + + expect(selectionCountStore.selectedCount).toBe(0); + }); + + it("should return correct count when multiple items are selected", () => { + const items = objectItems(5); + const selectedItems = [items[0], items[2], items[4]]; + const multiSelection = new SelectionMultiValueBuilder().withSelected(selectedItems).build(); + const props = createMinimalMockProps({ itemSelection: multiSelection }); + gateProvider.setProps(props); + + expect(selectionCountStore.selectedCount).toBe(3); + }); + + it("should return correct count when all items are selected", () => { + const items = objectItems(4); + const multiSelection = new SelectionMultiValueBuilder().withSelected(items).build(); + const props = createMinimalMockProps({ itemSelection: multiSelection }); + gateProvider.setProps(props); + + expect(selectionCountStore.selectedCount).toBe(4); + }); + + it("should reactively update when selection changes", () => { + const items = objectItems(3); + const multiSelection = new SelectionMultiValueBuilder().build(); + const props = createMinimalMockProps({ itemSelection: multiSelection }); + gateProvider.setProps(props); + + // Initially no items selected + expect(selectionCountStore.selectedCount).toBe(0); + + // Select one item + multiSelection.setSelection([items[0]]); + expect(selectionCountStore.selectedCount).toBe(1); + + // Select two more items + multiSelection.setSelection([items[0], items[1], items[2]]); + expect(selectionCountStore.selectedCount).toBe(3); + + // Clear selection + multiSelection.setSelection([]); + expect(selectionCountStore.selectedCount).toBe(0); + }); + }); +}); diff --git a/packages/shared/widget-plugin-grid/src/selection/helpers.ts b/packages/shared/widget-plugin-grid/src/selection/helpers.ts index 87643244a3..2e11a8a52e 100644 --- a/packages/shared/widget-plugin-grid/src/selection/helpers.ts +++ b/packages/shared/widget-plugin-grid/src/selection/helpers.ts @@ -1,7 +1,7 @@ -import type { ActionValue, ListValue, ObjectItem, SelectionSingleValue, SelectionMultiValue } from "mendix"; import { executeAction } from "@mendix/widget-plugin-platform/framework/execute-action"; -import { useEffect, useRef } from "react"; -import { Direction, MultiSelectionStatus, ScrollKeyCode, SelectionMode, Size, MoveEvent1D, MoveEvent2D } from "./types"; +import type { ActionValue, ListValue, ObjectItem, SelectionMultiValue, SelectionSingleValue } from "mendix"; +import { useEffect, useRef, useState } from "react"; +import { Direction, MoveEvent1D, MoveEvent2D, MultiSelectionStatus, ScrollKeyCode, SelectionMode, Size } from "./types"; class SingleSelectionHelper { type = "Single" as const; @@ -44,11 +44,20 @@ export class MultiSelectionHelper { } get selectionStatus(): MultiSelectionStatus { - return this.selectionValue.selection.length === 0 - ? "none" - : this.selectionValue.selection.length === this.selectableItems.length - ? "all" - : "some"; + const selectionIds = new Set(this.selectionValue.selection.map(obj => obj.id)); + const numberOfSelectedOnCurrentPage = this.selectableItems.reduce((acc, item) => { + return selectionIds.has(item.id) ? acc + 1 : acc; + }, 0); + + if (numberOfSelectedOnCurrentPage === 0) { + return "none"; + } + + if (numberOfSelectedOnCurrentPage === this.selectableItems.length) { + return "all"; + } + + return "some"; } add(value: ObjectItem): void { @@ -148,6 +157,22 @@ export class MultiSelectionHelper { this._setRangeEnd(undefined); } + /** + * Creates a union of two arrays of ObjectItem, combining unique items based on their ID. + * Items from the selection array are preserved first, followed by unique items from the items array. + * + * @param selection - The primary array of ObjectItem objects + * @param items - The secondary array of ObjectItem objects to merge with selection + * @returns A new array containing all unique ObjectItem objects from both arrays, with no duplicates based on ID + * + * @example + * ```typescript + * const selection = [{ id: '1', name: 'Item 1' }, { id: '2', name: 'Item 2' }]; + * const items = [{ id: '2', name: 'Item 2' }, { id: '3', name: 'Item 3' }]; + * const result = this._union(selection, items); + * // Returns: [{ id: '1', name: 'Item 1' }, { id: '2', name: 'Item 2' }, { id: '3', name: 'Item 3' }] + * ``` + */ private _union(selection: ObjectItem[], items: ObjectItem[]): ObjectItem[] { const union = [...selection]; const ids = new Set(selection.map(o => o.id)); @@ -163,6 +188,22 @@ export class MultiSelectionHelper { return union; } + /** + * Computes the difference between two arrays of ObjectItem by removing items from the selection + * that have matching IDs in the items array. + * + * @param selection - The array of ObjectItem objects to filter from + * @param items - The array of ObjectItem objects whose IDs should be removed from selection + * @returns A new array containing items from selection that don't have matching IDs in items + * + * @example + * ```typescript + * const selection = [{ id: '1' }, { id: '2' }, { id: '3' }]; + * const items = [{ id: '2' }, { id: '4' }]; + * const result = this._diff(selection, items); + * // Returns: [{ id: '1' }, { id: '3' }] + * ``` + */ private _diff(selection: ObjectItem[], items: ObjectItem[]): ObjectItem[] { const diff = new Set(selection); const idsToDelete = new Set(items.map(o => o.id)); @@ -177,11 +218,27 @@ export class MultiSelectionHelper { } selectAll(): void { - this.selectionValue.setSelection(this.selectableItems); + const newSelection = this._union(this.selectableItems, this.selectionValue.selection); + this.selectionValue.setSelection(newSelection); this._resetRange(); } + /** + * Deselects all currently selected items by removing them from the selection. + * Resets the selection range after clearing the selection. + * @remark This method removes only items that are selectable. + * To clear the entire selection, use `clearSelection` instead. + */ selectNone(): void { + const newSelection = this._diff(this.selectionValue.selection, this.selectableItems); + this.selectionValue.setSelection(newSelection); + this._resetRange(); + } + + /** + * Clears the current selection by removing all selected items and resetting the selection range. + */ + clearSelection(): void { this.selectionValue.setSelection([]); this._resetRange(); } @@ -269,10 +326,16 @@ const clamp = (num: number, min: number, max: number): number => Math.min(Math.m export function useSelectionHelper( selection: SelectionSingleValue | SelectionMultiValue | undefined, dataSource: ListValue, - onSelectionChange: ActionValue | undefined + onSelectionChange: ActionValue | undefined, + keepSelection: Parameters[0] ): SelectionHelper | undefined { const prevObjectListRef = useRef([]); const firstLoadDone = useRef(false); + useState(() => { + if (selection) { + selection.setKeepSelection(selectionStateHandler(keepSelection)); + } + }); firstLoadDone.current ||= dataSource?.status !== "loading"; useEffect(() => { @@ -310,6 +373,17 @@ export function useSelectionHelper( return selectionHelper.current; } +type KeepSelectionHandler = (item: ObjectItem) => boolean; + +function selectionStateHandler( + keepSelection: "always keep" | "always clear" | KeepSelectionHandler +): KeepSelectionHandler { + if (typeof keepSelection === "function") { + return keepSelection; + } + return keepSelection === "always keep" ? () => true : () => false; +} + export type { SingleSelectionHelper }; export type SelectionHelper = SingleSelectionHelper | MultiSelectionHelper; diff --git a/packages/shared/widget-plugin-grid/src/selection/select-action-handler.ts b/packages/shared/widget-plugin-grid/src/selection/select-action-handler.ts index 33633cabe1..5e35c57966 100644 --- a/packages/shared/widget-plugin-grid/src/selection/select-action-handler.ts +++ b/packages/shared/widget-plugin-grid/src/selection/select-action-handler.ts @@ -85,6 +85,12 @@ export class SelectActionHandler { return this.selectionHelper ? this.selectionHelper.isSelected(item) : false; }; + onClearSelection = (): void => { + if (this.selectionHelper?.type === "Multi") { + this.selectionHelper.clearSelection(); + } + }; + private selectItem = (item: ObjectItem, toggleMode: boolean): void => { if (this.selectionHelper === undefined) { return; diff --git a/packages/shared/widget-plugin-grid/src/selection/stores/SelectionCountStore.ts b/packages/shared/widget-plugin-grid/src/selection/stores/SelectionCountStore.ts new file mode 100644 index 0000000000..96227850a2 --- /dev/null +++ b/packages/shared/widget-plugin-grid/src/selection/stores/SelectionCountStore.ts @@ -0,0 +1,58 @@ +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; +}>; + +export class SelectionCountStore { + private gate: Gate; + private singular: string = "%d row selected"; + private plural: string = "%d rows selected"; + + constructor(gate: Gate, spec: { singular?: string; plural?: string } = {}) { + this.singular = spec.singular ?? this.singular; + this.plural = spec.plural ?? this.plural; + this.gate = gate; + + makeObservable(this, { + displayCount: computed, + selectedCount: computed, + fmtSingular: computed, + fmtPlural: computed + }); + } + + 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}`); + } +}