From acf751720d1dc6b68e07026f5e1a6c5980203e53 Mon Sep 17 00:00:00 2001 From: Illia Obukhau <8282906+iobuhov@users.noreply.github.com> Date: Wed, 13 Aug 2025 12:22:33 +0200 Subject: [PATCH 01/16] feat: add selection counter --- .../datawidgets/web/_datagrid.scss | 19 ++- .../datagrid-web/CHANGELOG.md | 1 + .../src/Datagrid.editorPreview.tsx | 153 ++++++++++-------- .../datagrid-web/src/Datagrid.tsx | 144 +++++++++-------- .../datagrid-web/src/Datagrid.xml | 8 + .../__tests__/Datagrid.editorPreview.spec.tsx | 0 .../src/components/CheckboxCell.tsx | 9 +- .../src/components/CheckboxColumnHeader.tsx | 5 +- .../datagrid-web/src/components/Widget.tsx | 77 ++++----- .../src/components/WidgetFooter.tsx | 43 +++-- .../components/loader/RowSkeletonLoader.tsx | 6 +- .../datagrid-web/src/helpers/root-context.ts | 28 ++++ .../src/helpers/state/GridBasicData.ts | 50 ++++++ .../helpers/state/GridPersonalizationStore.ts | 10 +- .../src/helpers/state/RootGridStore.ts | 31 +++- .../src/helpers/state/SelectionCountStore.ts | 54 +++++++ .../__tests__/SelectionCountStore.spec.ts | 100 ++++++++++++ .../AttributePersonalizationStorage.ts | 12 +- .../helpers/storage/PersonalizationStorage.ts | 4 +- .../datagrid-web/src/utils/test-utils.tsx | 17 +- .../datagrid-web/test-ct/preview.spec.tsx | 0 .../datagrid-web/typings/DatagridProps.d.ts | 4 + .../useFocusTargetController.ts | 5 +- 23 files changed, 562 insertions(+), 218 deletions(-) create mode 100644 packages/pluggableWidgets/datagrid-web/src/__tests__/Datagrid.editorPreview.spec.tsx create mode 100644 packages/pluggableWidgets/datagrid-web/src/helpers/root-context.ts create mode 100644 packages/pluggableWidgets/datagrid-web/src/helpers/state/GridBasicData.ts create mode 100644 packages/pluggableWidgets/datagrid-web/src/helpers/state/SelectionCountStore.ts create mode 100644 packages/pluggableWidgets/datagrid-web/src/helpers/state/__tests__/SelectionCountStore.spec.ts create mode 100644 packages/pluggableWidgets/datagrid-web/test-ct/preview.spec.tsx 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..05af47e4a3 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,23 @@ $root: ".widget-datagrid"; z-index: 1; } +:where(#{$root}-paging-bottom) { + display: flex; + flex-flow: row nowrap; + align-items: center; +} + +:where(#{$root}-pb-left, #{$root}-pb-right, #{$root}-pb-middle) { + flex-grow: 1; + flex-basis: 33.3%; + min-height: 20px; +} + +:where(#{$root}-pb-left) { + margin-block: var(--spacing-medium); + padding-inline: var(--spacing-medium); +} + @keyframes skeleton-loading { 0% { background-position: right; 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/src/Datagrid.editorPreview.tsx b/packages/pluggableWidgets/datagrid-web/src/Datagrid.editorPreview.tsx index 26839e163d..7a7042c33a 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,10 @@ 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 "./helpers/state/SelectionCountStore"; import "./ui/DatagridPreview.scss"; // Fix type definition for Selectable @@ -80,74 +85,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..b70da10d0e 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"; @@ -57,74 +59,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..06c254aa4e 100644 --- a/packages/pluggableWidgets/datagrid-web/src/Datagrid.xml +++ b/packages/pluggableWidgets/datagrid-web/src/Datagrid.xml @@ -367,6 +367,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/__tests__/Datagrid.editorPreview.spec.tsx b/packages/pluggableWidgets/datagrid-web/src/__tests__/Datagrid.editorPreview.spec.tsx new file mode 100644 index 0000000000..e69de29bb2 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 } = useDatagridRootScope(); + if (selectionCountStore.displayCount) { + return {selectionCountStore.displayCount}; + } + return null; +}); 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..a1b56638f1 --- /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 { createContext, useContext } from "react"; +import { GridBasicData } from "../helpers/state/GridBasicData"; +import { EventsController } from "../typings/CellComponent"; +import { SelectActionHelper } from "./SelectActionHelper"; +import { SelectionCountStore } from "./state/SelectionCountStore"; + +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..bc2da28f4d 100644 --- a/packages/pluggableWidgets/datagrid-web/src/helpers/state/RootGridStore.ts +++ b/packages/pluggableWidgets/datagrid-web/src/helpers/state/RootGridStore.ts @@ -8,6 +8,7 @@ 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"; @@ -16,8 +17,26 @@ import { ProgressStore } from "../../features/data-export/ProgressStore"; import { StaticInfo } from "../../typings/static-info"; import { ColumnGroupStore } from "./ColumnGroupStore"; import { GridPersonalizationStore } from "./GridPersonalizationStore"; - -type Gate = DerivedPropsGate; +import { SelectionCountStore } from "./SelectionCountStore"; + +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/state/SelectionCountStore.ts b/packages/pluggableWidgets/datagrid-web/src/helpers/state/SelectionCountStore.ts new file mode 100644 index 0000000000..1ff8de5cb8 --- /dev/null +++ b/packages/pluggableWidgets/datagrid-web/src/helpers/state/SelectionCountStore.ts @@ -0,0 +1,54 @@ +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; + sCountFmtSingular?: DynamicValue; + sCountFmtPlural?: DynamicValue; +}>; + +export class SelectionCountStore { + private gate: Gate; + + constructor(gate: Gate) { + this.gate = gate; + + makeObservable(this, { + displayCount: computed, + selectedCount: computed, + fmtSingular: computed, + fmtPlural: computed + }); + } + + get fmtSingular(): string { + return this.gate.props.sCountFmtSingular?.value || "%d row selected"; + } + + get fmtPlural(): string { + return this.gate.props.sCountFmtPlural?.value || "%d rows selected"; + } + + 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/pluggableWidgets/datagrid-web/src/helpers/state/__tests__/SelectionCountStore.spec.ts b/packages/pluggableWidgets/datagrid-web/src/helpers/state/__tests__/SelectionCountStore.spec.ts new file mode 100644 index 0000000000..fc4d63c563 --- /dev/null +++ b/packages/pluggableWidgets/datagrid-web/src/helpers/state/__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 "../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/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/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..c53f735855 100644 --- a/packages/pluggableWidgets/datagrid-web/typings/DatagridProps.d.ts +++ b/packages/pluggableWidgets/datagrid-web/typings/DatagridProps.d.ts @@ -124,6 +124,8 @@ export interface DatagridContainerProps { cancelExportLabel?: DynamicValue; selectRowLabel?: DynamicValue; selectAllRowsLabel?: DynamicValue; + sCountFmtSingular?: DynamicValue; + sCountFmtPlural?: DynamicValue; } export interface DatagridPreviewProps { @@ -174,4 +176,6 @@ export interface DatagridPreviewProps { cancelExportLabel: string; selectRowLabel: string; selectAllRowsLabel: string; + sCountFmtSingular: string; + sCountFmtPlural: 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); From ba0633d5aac1092446ab81968241ff40e205cdb6 Mon Sep 17 00:00:00 2001 From: Illia Obukhau <8282906+iobuhov@users.noreply.github.com> Date: Wed, 13 Aug 2025 13:02:19 +0200 Subject: [PATCH 02/16] test: cleanup and update --- .../__tests__/Datagrid.editorPreview.spec.tsx | 0 .../src/components/__tests__/Table.spec.tsx | 200 +++-- .../__snapshots__/Table.spec.tsx.snap | 688 +++++++++++------- .../src/helpers/useWidgetProps.ts | 23 - 4 files changed, 569 insertions(+), 342 deletions(-) delete mode 100644 packages/pluggableWidgets/datagrid-web/src/__tests__/Datagrid.editorPreview.spec.tsx delete mode 100644 packages/pluggableWidgets/datagrid-web/src/helpers/useWidgetProps.ts diff --git a/packages/pluggableWidgets/datagrid-web/src/__tests__/Datagrid.editorPreview.spec.tsx b/packages/pluggableWidgets/datagrid-web/src/__tests__/Datagrid.editorPreview.spec.tsx deleted file mode 100644 index e69de29bb2..0000000000 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..be1cce9ac6 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 { 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 { SelectionCountStore } from "../../helpers/state/SelectionCountStore"; 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); @@ -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..eb583144f6 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/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; -} From ff60f666be281a36454ee21c2449445bd7461cd5 Mon Sep 17 00:00:00 2001 From: Illia Obukhau <8282906+iobuhov@users.noreply.github.com> Date: Mon, 18 Aug 2025 10:32:06 +0200 Subject: [PATCH 03/16] chore: switch to if component --- .../datagrid-web/src/components/WidgetFooter.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/pluggableWidgets/datagrid-web/src/components/WidgetFooter.tsx b/packages/pluggableWidgets/datagrid-web/src/components/WidgetFooter.tsx index 14537cd1eb..ee86378c33 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/WidgetFooter.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/WidgetFooter.tsx @@ -1,3 +1,4 @@ +import { If } from "@mendix/widget-plugin-component-kit/If"; import { observer } from "mobx-react-lite"; import { createElement, ReactElement, ReactNode } from "react"; import { PaginationEnum, PagingPositionEnum } from "../../typings/DatagridProps"; @@ -41,8 +42,10 @@ export function WidgetFooter(props: WidgetFooterProps): ReactElement | null { const SelectionCounter = observer(function SelectionCounter() { const { selectionCountStore } = useDatagridRootScope(); - if (selectionCountStore.displayCount) { - return {selectionCountStore.displayCount}; - } - return null; + + return ( + + {selectionCountStore.displayCount} + + ); }); From f799df7bb7e1dab9c9f8417c80aefdd3d2c82065 Mon Sep 17 00:00:00 2001 From: Illia Obukhau <8282906+iobuhov@users.noreply.github.com> Date: Thu, 21 Aug 2025 12:03:10 +0200 Subject: [PATCH 04/16] feat: add keep selection logic --- .../datagrid-web/src/Datagrid.tsx | 7 +++++- .../src/components/__tests__/Table.spec.tsx | 2 +- .../gallery-web/src/Gallery.tsx | 7 +++++- .../src/selection/helpers.ts | 25 ++++++++++++++++--- 4 files changed, 34 insertions(+), 7 deletions(-) diff --git a/packages/pluggableWidgets/datagrid-web/src/Datagrid.tsx b/packages/pluggableWidgets/datagrid-web/src/Datagrid.tsx index b70da10d0e..fe84d46b7c 100644 --- a/packages/pluggableWidgets/datagrid-web/src/Datagrid.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/Datagrid.tsx @@ -34,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, + "always keep" + ); const selectActionHelper = useSelectActionHelper(props, selectionHelper); 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 be1cce9ac6..dfa5efe314 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/__tests__/Table.spec.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/__tests__/Table.spec.tsx @@ -473,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, diff --git a/packages/pluggableWidgets/gallery-web/src/Gallery.tsx b/packages/pluggableWidgets/gallery-web/src/Gallery.tsx index d769ffbc1e..87a52460e1 100644 --- a/packages/pluggableWidgets/gallery-web/src/Gallery.tsx +++ b/packages/pluggableWidgets/gallery-web/src/Gallery.tsx @@ -98,7 +98,12 @@ 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, + "always clear" + ); const itemSelectHelper = useItemSelectHelper(props.itemSelection, selectionHelper); return useConst({ diff --git a/packages/shared/widget-plugin-grid/src/selection/helpers.ts b/packages/shared/widget-plugin-grid/src/selection/helpers.ts index 87643244a3..fd42e7aab4 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; @@ -269,10 +269,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 +316,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; From 585c02836ba662ae25093bdcca14f366dc85aeec Mon Sep 17 00:00:00 2001 From: Illia Obukhau <8282906+iobuhov@users.noreply.github.com> Date: Thu, 21 Aug 2025 15:37:21 +0200 Subject: [PATCH 05/16] chore: fix the selection status, select none and select all --- .../src/selection/helpers.ts | 57 ++++++++++++++++--- 1 file changed, 50 insertions(+), 7 deletions(-) diff --git a/packages/shared/widget-plugin-grid/src/selection/helpers.ts b/packages/shared/widget-plugin-grid/src/selection/helpers.ts index fd42e7aab4..a464ed34ae 100644 --- a/packages/shared/widget-plugin-grid/src/selection/helpers.ts +++ b/packages/shared/widget-plugin-grid/src/selection/helpers.ts @@ -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,12 +218,14 @@ 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(); } selectNone(): void { - this.selectionValue.setSelection([]); + const newSelection = this._diff(this.selectionValue.selection, this.selectableItems); + this.selectionValue.setSelection(newSelection); this._resetRange(); } From 3221c81ba9237b780fdeaf2fa03a460f3356da22 Mon Sep 17 00:00:00 2001 From: Illia Obukhau <8282906+iobuhov@users.noreply.github.com> Date: Fri, 22 Aug 2025 12:00:42 +0200 Subject: [PATCH 06/16] feat: add "clear selection" button --- .../src/themesource/datawidgets/web/_datagrid.scss | 10 ++++++++++ .../datagrid-web/src/components/WidgetFooter.tsx | 7 +++++-- .../widget-plugin-grid/src/selection/helpers.ts | 14 ++++++++++++++ .../src/selection/select-action-handler.ts | 6 ++++++ 4 files changed, 35 insertions(+), 2 deletions(-) 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 05af47e4a3..c5889b42b4 100644 --- a/packages/modules/data-widgets/src/themesource/datawidgets/web/_datagrid.scss +++ b/packages/modules/data-widgets/src/themesource/datawidgets/web/_datagrid.scss @@ -566,6 +566,16 @@ $root: ".widget-datagrid"; 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/pluggableWidgets/datagrid-web/src/components/WidgetFooter.tsx b/packages/pluggableWidgets/datagrid-web/src/components/WidgetFooter.tsx index ee86378c33..c5cebb7523 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/WidgetFooter.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/WidgetFooter.tsx @@ -41,11 +41,14 @@ export function WidgetFooter(props: WidgetFooterProps): ReactElement | null { } const SelectionCounter = observer(function SelectionCounter() { - const { selectionCountStore } = useDatagridRootScope(); + const { selectionCountStore, selectActionHelper } = useDatagridRootScope(); return ( - {selectionCountStore.displayCount} + {selectionCountStore.displayCount} |  + ); }); diff --git a/packages/shared/widget-plugin-grid/src/selection/helpers.ts b/packages/shared/widget-plugin-grid/src/selection/helpers.ts index a464ed34ae..2e11a8a52e 100644 --- a/packages/shared/widget-plugin-grid/src/selection/helpers.ts +++ b/packages/shared/widget-plugin-grid/src/selection/helpers.ts @@ -223,12 +223,26 @@ export class MultiSelectionHelper { 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(); + } + selectUpTo(value: ObjectItem, selectionMode: SelectionMode): void { if (this.rangeStart === undefined) { this._add(value); 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; From de790d5a998eadd54feacc25af1a681137153fcc Mon Sep 17 00:00:00 2001 From: Illia Obukhau <8282906+iobuhov@users.noreply.github.com> Date: Fri, 22 Aug 2025 15:00:47 +0200 Subject: [PATCH 07/16] feat: change selection strategy for gallery --- packages/pluggableWidgets/gallery-web/src/Gallery.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/pluggableWidgets/gallery-web/src/Gallery.tsx b/packages/pluggableWidgets/gallery-web/src/Gallery.tsx index 87a52460e1..2102448d42 100644 --- a/packages/pluggableWidgets/gallery-web/src/Gallery.tsx +++ b/packages/pluggableWidgets/gallery-web/src/Gallery.tsx @@ -102,7 +102,7 @@ function useCreateGalleryScope(props: GalleryContainerProps): GalleryRootScope { props.itemSelection, props.datasource, props.onSelectionChange, - "always clear" + "always keep" ); const itemSelectHelper = useItemSelectHelper(props.itemSelection, selectionHelper); From cc9c10e63d4936637c88db4f06bd38233acaca9e Mon Sep 17 00:00:00 2001 From: Illia Obukhau <8282906+iobuhov@users.noreply.github.com> Date: Fri, 22 Aug 2025 15:06:51 +0200 Subject: [PATCH 08/16] feat: move selection store to shared package --- .../datagrid-web/src/helpers/state/RootGridStore.ts | 2 +- .../src/selection/stores}/SelectionCountStore.ts | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename packages/{pluggableWidgets/datagrid-web/src/helpers/state => shared/widget-plugin-grid/src/selection/stores}/SelectionCountStore.ts (100%) diff --git a/packages/pluggableWidgets/datagrid-web/src/helpers/state/RootGridStore.ts b/packages/pluggableWidgets/datagrid-web/src/helpers/state/RootGridStore.ts index bc2da28f4d..7e64c08ec0 100644 --- a/packages/pluggableWidgets/datagrid-web/src/helpers/state/RootGridStore.ts +++ b/packages/pluggableWidgets/datagrid-web/src/helpers/state/RootGridStore.ts @@ -3,6 +3,7 @@ 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"; @@ -17,7 +18,6 @@ import { ProgressStore } from "../../features/data-export/ProgressStore"; import { StaticInfo } from "../../typings/static-info"; import { ColumnGroupStore } from "./ColumnGroupStore"; import { GridPersonalizationStore } from "./GridPersonalizationStore"; -import { SelectionCountStore } from "./SelectionCountStore"; type RequiredProps = Pick< DatagridContainerProps, diff --git a/packages/pluggableWidgets/datagrid-web/src/helpers/state/SelectionCountStore.ts b/packages/shared/widget-plugin-grid/src/selection/stores/SelectionCountStore.ts similarity index 100% rename from packages/pluggableWidgets/datagrid-web/src/helpers/state/SelectionCountStore.ts rename to packages/shared/widget-plugin-grid/src/selection/stores/SelectionCountStore.ts From 4b4dfa4ead7d10e3b6fe6b4c16efbb7b7a6132a3 Mon Sep 17 00:00:00 2001 From: Illia Obukhau <8282906+iobuhov@users.noreply.github.com> Date: Mon, 25 Aug 2025 16:28:57 +0200 Subject: [PATCH 09/16] feat: add selection to gallery --- .../datawidgets/web/_datagrid.scss | 6 ++--- .../themesource/datawidgets/web/_gallery.scss | 25 +++++++++++++++++-- .../src/Datagrid.editorPreview.tsx | 3 ++- .../src/components/WidgetFooter.tsx | 4 +-- .../datagrid-web/src/helpers/root-context.ts | 2 +- .../gallery-web/src/Gallery.editorPreview.tsx | 1 + .../gallery-web/src/Gallery.tsx | 3 ++- .../gallery-web/src/Gallery.xml | 8 ++++++ .../gallery-web/src/components/Gallery.tsx | 14 +++++++---- .../src/components/SelectionCounter.tsx | 17 +++++++++++++ .../gallery-web/src/helpers/root-context.ts | 2 ++ .../gallery-web/src/stores/GalleryStore.ts | 9 ++++++- .../gallery-web/src/utils/test-utils.tsx | 3 ++- .../gallery-web/typings/GalleryProps.d.ts | 4 +++ 14 files changed, 84 insertions(+), 17 deletions(-) create mode 100644 packages/pluggableWidgets/gallery-web/src/components/SelectionCounter.tsx 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 c5889b42b4..75944994c5 100644 --- a/packages/modules/data-widgets/src/themesource/datawidgets/web/_datagrid.scss +++ b/packages/modules/data-widgets/src/themesource/datawidgets/web/_datagrid.scss @@ -555,13 +555,13 @@ $root: ".widget-datagrid"; align-items: center; } -:where(#{$root}-pb-left, #{$root}-pb-right, #{$root}-pb-middle) { +:where(#{$root}-pb-start, #{$root}-pb-end, #{$root}-pb-middle) { flex-grow: 1; - flex-basis: 33.3%; + flex-basis: 33.33%; min-height: 20px; } -:where(#{$root}-pb-left) { +:where(#{$root}-pb-start) { margin-block: var(--spacing-medium); padding-inline: var(--spacing-medium); } 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/src/Datagrid.editorPreview.tsx b/packages/pluggableWidgets/datagrid-web/src/Datagrid.editorPreview.tsx index 7a7042c33a..60d2c3845d 100644 --- a/packages/pluggableWidgets/datagrid-web/src/Datagrid.editorPreview.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/Datagrid.editorPreview.tsx @@ -18,7 +18,8 @@ import { ColumnPreview } from "./helpers/ColumnPreview"; import { DatagridContext } from "./helpers/root-context"; import { useSelectActionHelper } from "./helpers/SelectActionHelper"; import { GridBasicData } from "./helpers/state/GridBasicData"; -import { SelectionCountStore } from "./helpers/state/SelectionCountStore"; + +import { SelectionCountStore } from "@mendix/widget-plugin-grid/selection/stores/SelectionCountStore"; import "./ui/DatagridPreview.scss"; // Fix type definition for Selectable diff --git a/packages/pluggableWidgets/datagrid-web/src/components/WidgetFooter.tsx b/packages/pluggableWidgets/datagrid-web/src/components/WidgetFooter.tsx index c5cebb7523..ebd732a3f0 100644 --- a/packages/pluggableWidgets/datagrid-web/src/components/WidgetFooter.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/components/WidgetFooter.tsx @@ -18,7 +18,7 @@ export function WidgetFooter(props: WidgetFooterProps): ReactElement | null { return (
-
+
{hasMoreItems && paginationType === "loadMore" && ( @@ -32,7 +32,7 @@ export function WidgetFooter(props: WidgetFooterProps): ReactElement | null {
)} -
+
{(pagingPosition === "bottom" || pagingPosition === "both") && pagination}
diff --git a/packages/pluggableWidgets/datagrid-web/src/helpers/root-context.ts b/packages/pluggableWidgets/datagrid-web/src/helpers/root-context.ts index a1b56638f1..51386f8d90 100644 --- a/packages/pluggableWidgets/datagrid-web/src/helpers/root-context.ts +++ b/packages/pluggableWidgets/datagrid-web/src/helpers/root-context.ts @@ -1,10 +1,10 @@ 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"; -import { SelectionCountStore } from "./state/SelectionCountStore"; export interface DatagridRootScope { basicData: GridBasicData; 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 2102448d42..a41edffaf2 100644 --- a/packages/pluggableWidgets/gallery-web/src/Gallery.tsx +++ b/packages/pluggableWidgets/gallery-web/src/Gallery.tsx @@ -109,7 +109,8 @@ function useCreateGalleryScope(props: GalleryContainerProps): GalleryRootScope { 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..bee23795ef 100644 --- a/packages/pluggableWidgets/gallery-web/src/Gallery.xml +++ b/packages/pluggableWidgets/gallery-web/src/Gallery.xml @@ -186,6 +186,14 @@ Item description Assistive technology will read this upon reaching each gallery item. + + 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/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/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..fa06400c7a 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,8 @@ export class GalleryStore extends BaseControllerHost { showTotalCount: spec.showTotalCount }); + this.selectionCountStore = new SelectionCountStore(spec.gate); + 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..ad588d224b 100644 --- a/packages/pluggableWidgets/gallery-web/src/utils/test-utils.tsx +++ b/packages/pluggableWidgets/gallery-web/src/utils/test-utils.tsx @@ -82,7 +82,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..db3e96bbef 100644 --- a/packages/pluggableWidgets/gallery-web/typings/GalleryProps.d.ts +++ b/packages/pluggableWidgets/gallery-web/typings/GalleryProps.d.ts @@ -54,6 +54,8 @@ export interface GalleryContainerProps { emptyMessageTitle?: DynamicValue; ariaLabelListBox?: DynamicValue; ariaLabelItem?: ListExpressionValue; + sCountFmtSingular?: DynamicValue; + sCountFmtPlural?: DynamicValue; } export interface GalleryPreviewProps { @@ -97,4 +99,6 @@ export interface GalleryPreviewProps { emptyMessageTitle: string; ariaLabelListBox: string; ariaLabelItem: string; + sCountFmtSingular: string; + sCountFmtPlural: string; } From 0729b6eb944c8d3f44acd377ec3e93df409705bb Mon Sep 17 00:00:00 2001 From: Illia Obukhau <8282906+iobuhov@users.noreply.github.com> Date: Mon, 25 Aug 2025 16:37:47 +0200 Subject: [PATCH 10/16] feat: update gallery xml --- .../pluggableWidgets/gallery-web/src/Gallery.editorConfig.ts | 4 ++++ packages/pluggableWidgets/gallery-web/src/Gallery.xml | 4 ++++ .../pluggableWidgets/gallery-web/src/utils/test-utils.tsx | 3 ++- .../pluggableWidgets/gallery-web/typings/GalleryProps.d.ts | 2 ++ 4 files changed, 12 insertions(+), 1 deletion(-) 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.xml b/packages/pluggableWidgets/gallery-web/src/Gallery.xml index bee23795ef..7cec35279b 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 diff --git a/packages/pluggableWidgets/gallery-web/src/utils/test-utils.tsx b/packages/pluggableWidgets/gallery-web/src/utils/test-utils.tsx index ad588d224b..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 diff --git a/packages/pluggableWidgets/gallery-web/typings/GalleryProps.d.ts b/packages/pluggableWidgets/gallery-web/typings/GalleryProps.d.ts index db3e96bbef..e29a8bbe35 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; @@ -73,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; From d579e13c5ddb9ce6c04c9c2bd984667df63477bc Mon Sep 17 00:00:00 2001 From: Illia Obukhau <8282906+iobuhov@users.noreply.github.com> Date: Mon, 25 Aug 2025 16:57:46 +0200 Subject: [PATCH 11/16] feat: change wording and add switch --- packages/pluggableWidgets/gallery-web/src/Gallery.tsx | 2 +- packages/pluggableWidgets/gallery-web/src/Gallery.xml | 8 ++++---- .../gallery-web/src/stores/GalleryStore.ts | 5 ++++- .../src/selection/stores/SelectionCountStore.ts | 10 +++++++--- 4 files changed, 16 insertions(+), 9 deletions(-) diff --git a/packages/pluggableWidgets/gallery-web/src/Gallery.tsx b/packages/pluggableWidgets/gallery-web/src/Gallery.tsx index a41edffaf2..14f1dd5a95 100644 --- a/packages/pluggableWidgets/gallery-web/src/Gallery.tsx +++ b/packages/pluggableWidgets/gallery-web/src/Gallery.tsx @@ -102,7 +102,7 @@ function useCreateGalleryScope(props: GalleryContainerProps): GalleryRootScope { props.itemSelection, props.datasource, props.onSelectionChange, - "always keep" + props.keepSelection ? "always keep" : "always clear" ); const itemSelectHelper = useItemSelectHelper(props.itemSelection, selectionHelper); diff --git a/packages/pluggableWidgets/gallery-web/src/Gallery.xml b/packages/pluggableWidgets/gallery-web/src/Gallery.xml index 7cec35279b..33133fdce5 100644 --- a/packages/pluggableWidgets/gallery-web/src/Gallery.xml +++ b/packages/pluggableWidgets/gallery-web/src/Gallery.xml @@ -191,12 +191,12 @@ Assistive technology will read this upon reaching each gallery item. - Row count singular - Must include '%d' to denote number position ('%d row selected') + Item count singular + Must include '%d' to denote number position ('%d item selected') - Row count plural - Must include '%d' to denote number position ('%d rows selected') + Item count plural + Must include '%d' to denote number position ('%d items selected') diff --git a/packages/pluggableWidgets/gallery-web/src/stores/GalleryStore.ts b/packages/pluggableWidgets/gallery-web/src/stores/GalleryStore.ts index fa06400c7a..4c45d00ca0 100644 --- a/packages/pluggableWidgets/gallery-web/src/stores/GalleryStore.ts +++ b/packages/pluggableWidgets/gallery-web/src/stores/GalleryStore.ts @@ -74,7 +74,10 @@ export class GalleryStore extends BaseControllerHost { showTotalCount: spec.showTotalCount }); - this.selectionCountStore = new SelectionCountStore(spec.gate); + this.selectionCountStore = new SelectionCountStore(spec.gate, { + singular: "%d item selected", + plural: "%d items selected" + }); this._filtersHost = new CustomFilterHost(); diff --git a/packages/shared/widget-plugin-grid/src/selection/stores/SelectionCountStore.ts b/packages/shared/widget-plugin-grid/src/selection/stores/SelectionCountStore.ts index 1ff8de5cb8..58c158fea6 100644 --- a/packages/shared/widget-plugin-grid/src/selection/stores/SelectionCountStore.ts +++ b/packages/shared/widget-plugin-grid/src/selection/stores/SelectionCountStore.ts @@ -10,8 +10,12 @@ type Gate = DerivedPropsGate<{ export class SelectionCountStore { private gate: Gate; + private singular: string = "%d row selected"; + private plural: string = "%d rows selected"; - constructor(gate: Gate) { + 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, { @@ -23,11 +27,11 @@ export class SelectionCountStore { } get fmtSingular(): string { - return this.gate.props.sCountFmtSingular?.value || "%d row selected"; + return this.gate.props.sCountFmtSingular?.value || this.singular; } get fmtPlural(): string { - return this.gate.props.sCountFmtPlural?.value || "%d rows selected"; + return this.gate.props.sCountFmtPlural?.value || this.plural; } get selectedCount(): number { From 83388df5b1424d57bd88a75f6b5ed4568d5afd26 Mon Sep 17 00:00:00 2001 From: Illia Obukhau <8282906+iobuhov@users.noreply.github.com> Date: Mon, 25 Aug 2025 17:11:34 +0200 Subject: [PATCH 12/16] feat: update data grid settings --- .../datagrid-web/src/Datagrid.editorConfig.ts | 4 ++++ packages/pluggableWidgets/datagrid-web/src/Datagrid.tsx | 2 +- packages/pluggableWidgets/datagrid-web/src/Datagrid.xml | 4 ++++ .../pluggableWidgets/datagrid-web/typings/DatagridProps.d.ts | 2 ++ 4 files changed, 11 insertions(+), 1 deletion(-) 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.tsx b/packages/pluggableWidgets/datagrid-web/src/Datagrid.tsx index fe84d46b7c..b987d824a6 100644 --- a/packages/pluggableWidgets/datagrid-web/src/Datagrid.tsx +++ b/packages/pluggableWidgets/datagrid-web/src/Datagrid.tsx @@ -38,7 +38,7 @@ const Container = observer((props: Props): ReactElement => { props.itemSelection, props.datasource, props.onSelectionChange, - "always keep" + props.keepSelection ? "always keep" : "always clear" ); const selectActionHelper = useSelectActionHelper(props, selectionHelper); diff --git a/packages/pluggableWidgets/datagrid-web/src/Datagrid.xml b/packages/pluggableWidgets/datagrid-web/src/Datagrid.xml index 06c254aa4e..d130c8fa11 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 diff --git a/packages/pluggableWidgets/datagrid-web/typings/DatagridProps.d.ts b/packages/pluggableWidgets/datagrid-web/typings/DatagridProps.d.ts index c53f735855..377d875761 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[]; @@ -146,6 +147,7 @@ export interface DatagridPreviewProps { itemSelectionMethod: ItemSelectionMethodEnum; itemSelectionMode: ItemSelectionModeEnum; showSelectAllToggle: boolean; + keepSelection: boolean; loadingType: LoadingTypeEnum; refreshIndicator: boolean; columns: ColumnsPreviewType[]; From a1c81a1459926aa57ac30380cb201aa3c2da5454 Mon Sep 17 00:00:00 2001 From: Illia Obukhau <8282906+iobuhov@users.noreply.github.com> Date: Tue, 26 Aug 2025 09:50:06 +0200 Subject: [PATCH 13/16] test: update tests --- .../src/components/__tests__/Table.spec.tsx | 2 +- .../__snapshots__/Table.spec.tsx.snap | 64 +++++++-------- .../__snapshots__/Gallery.spec.tsx.snap | 77 +++++++++++++++---- .../__tests__/SelectionCountStore.spec.ts | 2 +- 4 files changed, 97 insertions(+), 48 deletions(-) rename packages/{pluggableWidgets/datagrid-web/src/helpers/state => shared/widget-plugin-grid/src/selection}/__tests__/SelectionCountStore.spec.ts (98%) 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 dfa5efe314..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,5 +1,6 @@ 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"; @@ -15,7 +16,6 @@ import { import { SelectActionHelper, useSelectActionHelper } from "../../helpers/SelectActionHelper"; import { DatagridContext, DatagridRootScope } from "../../helpers/root-context"; import { GridBasicData } from "../../helpers/state/GridBasicData"; -import { SelectionCountStore } from "../../helpers/state/SelectionCountStore"; import { GridColumn } from "../../typings/GridColumn"; import { column, mockGridColumn, mockWidgetProps } from "../../utils/test-utils"; import { Widget, WidgetProps } from "../Widget"; 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 eb583144f6..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 @@ -75,10 +75,10 @@ exports[`Table renders the structure correctly 1`] = ` class="widget-datagrid-paging-bottom" >
@@ -161,10 +161,10 @@ exports[`Table renders the structure correctly for preview when no header is pro class="widget-datagrid-paging-bottom" >
@@ -280,10 +280,10 @@ exports[`Table renders the structure correctly with column alignments 1`] = ` class="widget-datagrid-paging-bottom" >
@@ -370,10 +370,10 @@ exports[`Table renders the structure correctly with custom filtering 1`] = ` class="widget-datagrid-paging-bottom" >
@@ -456,10 +456,10 @@ exports[`Table renders the structure correctly with dragging 1`] = ` class="widget-datagrid-paging-bottom" >
@@ -542,10 +542,10 @@ exports[`Table renders the structure correctly with dynamic row class 1`] = ` class="widget-datagrid-paging-bottom" >
@@ -628,10 +628,10 @@ exports[`Table renders the structure correctly with empty placeholder 1`] = ` class="widget-datagrid-paging-bottom" >
@@ -718,10 +718,10 @@ exports[`Table renders the structure correctly with filtering 1`] = ` class="widget-datagrid-paging-bottom" >
@@ -814,10 +814,10 @@ exports[`Table renders the structure correctly with header filters and a11y 1`] class="widget-datagrid-paging-bottom" >
@@ -904,10 +904,10 @@ exports[`Table renders the structure correctly with header wrapper 1`] = ` class="widget-datagrid-paging-bottom" >
@@ -1031,10 +1031,10 @@ exports[`Table renders the structure correctly with hiding 1`] = ` class="widget-datagrid-paging-bottom" >
@@ -1117,10 +1117,10 @@ exports[`Table renders the structure correctly with paging 1`] = ` class="widget-datagrid-paging-bottom" >
@@ -1379,10 +1379,10 @@ exports[`Table renders the structure correctly with sorting 1`] = ` class="widget-datagrid-paging-bottom" >
@@ -1546,10 +1546,10 @@ exports[`Table with selection method checkbox render an extra column and add cla class="widget-datagrid-paging-bottom" >