From 8182d8eb4efedfeb3e40de2cede79f2bdf842922 Mon Sep 17 00:00:00 2001 From: Alyar <> Date: Tue, 18 Mar 2025 04:50:24 +0400 Subject: [PATCH 1/8] CardView: Implement Single Selection --- .../content_view/content/card/_index.scss | 4 + .../content_view/content/card/_variables.scss | 1 + .../scss/widgets/fluent/cardView/_colors.scss | 3 +- .../content/card/card.events.test.tsx | 30 +-- .../content_view/content/card/card.tsx | 18 +- .../grids/new/card_view/content_view/view.tsx | 9 + .../grids/new/grid_core/content_view/view.tsx | 3 + .../items_controller.test.ts.snap | 60 ++++++ .../items_controller/items_controller.test.ts | 16 +- .../items_controller/items_controller.ts | 15 +- .../__internal/grids/new/grid_core/options.ts | 3 + .../__snapshots__/controller.test.ts.snap | 173 ++++++++++++++++++ .../__snapshots__/options.test.ts.snap | 171 +++++++++++++++++ .../grid_core/selection/controller.test.ts | 149 +++++++++++++++ .../new/grid_core/selection/controller.ts | 149 +++++++++++++++ .../grids/new/grid_core/selection/index.ts | 3 + .../new/grid_core/selection/options.test.ts | 96 ++++++++++ .../grids/new/grid_core/selection/options.ts | 13 ++ .../new/grid_core/selection/public_methods.ts | 21 +++ .../grids/new/grid_core/selection/types.ts | 23 +++ .../__internal/grids/new/grid_core/widget.ts | 9 +- packages/devextreme/js/ui/card_view.d.ts | 10 + 22 files changed, 954 insertions(+), 25 deletions(-) create mode 100644 packages/devextreme/js/__internal/grids/new/grid_core/selection/__snapshots__/controller.test.ts.snap create mode 100644 packages/devextreme/js/__internal/grids/new/grid_core/selection/__snapshots__/options.test.ts.snap create mode 100644 packages/devextreme/js/__internal/grids/new/grid_core/selection/controller.test.ts create mode 100644 packages/devextreme/js/__internal/grids/new/grid_core/selection/controller.ts create mode 100644 packages/devextreme/js/__internal/grids/new/grid_core/selection/index.ts create mode 100644 packages/devextreme/js/__internal/grids/new/grid_core/selection/options.test.ts create mode 100644 packages/devextreme/js/__internal/grids/new/grid_core/selection/options.ts create mode 100644 packages/devextreme/js/__internal/grids/new/grid_core/selection/public_methods.ts create mode 100644 packages/devextreme/js/__internal/grids/new/grid_core/selection/types.ts diff --git a/packages/devextreme-scss/scss/widgets/base/cardView/content_view/content/card/_index.scss b/packages/devextreme-scss/scss/widgets/base/cardView/content_view/content/card/_index.scss index caa209062759..cdd89b6143a7 100644 --- a/packages/devextreme-scss/scss/widgets/base/cardView/content_view/content/card/_index.scss +++ b/packages/devextreme-scss/scss/widgets/base/cardView/content_view/content/card/_index.scss @@ -12,3 +12,7 @@ background-color: $cardview-card-background-color; overflow: hidden; } + +.dx-cardview-card-selection { + background-color: $cardview-card-selection-background-color; +} diff --git a/packages/devextreme-scss/scss/widgets/base/cardView/content_view/content/card/_variables.scss b/packages/devextreme-scss/scss/widgets/base/cardView/content_view/content/card/_variables.scss index 6ed020add8f6..b4528656fde3 100644 --- a/packages/devextreme-scss/scss/widgets/base/cardView/content_view/content/card/_variables.scss +++ b/packages/devextreme-scss/scss/widgets/base/cardView/content_view/content/card/_variables.scss @@ -4,3 +4,4 @@ $cardview-card-border-size: null !default; $cardview-card-min-width: null !default; $cardview-card-border-radius: null !default; $cardview-card-background-color: null !default; +$cardview-card-selection-background-color: null !default; diff --git a/packages/devextreme-scss/scss/widgets/fluent/cardView/_colors.scss b/packages/devextreme-scss/scss/widgets/fluent/cardView/_colors.scss index 960790ff4120..8cab6255c17e 100644 --- a/packages/devextreme-scss/scss/widgets/fluent/cardView/_colors.scss +++ b/packages/devextreme-scss/scss/widgets/fluent/cardView/_colors.scss @@ -8,7 +8,6 @@ @use '../../base/cardView/content_view/content/variables' as *; // adduse - $cardview-background-color: $base-typography-bg !default; $cardview-header-item-background-color: #F0F0F0 !default; @@ -24,3 +23,5 @@ $cardview-header-filter-icon-selected-color: $base-accent !default; $cardview-card-content-field-value-highlight-color: $base-inverted-text-color !default; $cardview-card-content-field-value-highlight-background: $base-accent !default; + +$cardview-card-selection-background-color: #EBF3FC; diff --git a/packages/devextreme/js/__internal/grids/new/card_view/content_view/content/card/card.events.test.tsx b/packages/devextreme/js/__internal/grids/new/card_view/content_view/content/card/card.events.test.tsx index d91b4b2c49ae..6fc64fb81da0 100644 --- a/packages/devextreme/js/__internal/grids/new/card_view/content_view/content/card/card.events.test.tsx +++ b/packages/devextreme/js/__internal/grids/new/card_view/content_view/content/card/card.events.test.tsx @@ -5,21 +5,18 @@ import { describe, expect, it } from '@jest/globals'; import { render } from 'inferno'; -import { Card } from './card'; +import { Card, CLASSES } from './card'; -const mockOnDblClick = { +const createMockEvent = () => ({ called: false, call() { this.called = true; }, -}; +}); -const mockOnClick = { - called: false, - call() { - this.called = true; - }, -}; +const mockOnDblClick = createMockEvent(); +const mockOnClick = createMockEvent(); +const mockOnSelectClick = createMockEvent(); const props = { row: { @@ -72,10 +69,7 @@ const props = { minWidth: 300, onDblClick: mockOnDblClick.call(), onClick: mockOnClick.call(), -}; - -const CLASSES = { - card: 'dx-cardview-card', + onSelectClick: mockOnSelectClick.call(), }; describe('Events', () => { @@ -97,6 +91,16 @@ describe('Events', () => { expect(mockOnClick.called).toBe(true); }); + it('should trigger onSelectClick event', () => { + // @ts-expect-error + render(, container); + + const cardElement = container.querySelector(`.${CLASSES.card}`); + cardElement?.dispatchEvent(new MouseEvent('click')); + + expect(mockOnSelectClick.called).toBe(true); + }); + it('should trigger onDblClick event', () => { // @ts-expect-error render(, container); diff --git a/packages/devextreme/js/__internal/grids/new/card_view/content_view/content/card/card.tsx b/packages/devextreme/js/__internal/grids/new/card_view/content_view/content/card/card.tsx index eab7b6ee26b7..7855ce77d3df 100644 --- a/packages/devextreme/js/__internal/grids/new/card_view/content_view/content/card/card.tsx +++ b/packages/devextreme/js/__internal/grids/new/card_view/content_view/content/card/card.tsx @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/no-non-null-assertion */ /* eslint-disable @typescript-eslint/prefer-nullish-coalescing */ - +import { combineClasses } from '@ts/core/utils/combine_classes'; import type { DataRow } from '@ts/grids/new/grid_core/columns_controller/types'; import type { DataObject } from '@ts/grids/new/grid_core/data_controller/types'; import { CollectionController } from '@ts/grids/new/grid_core/keyboard_navigation/collection_controller'; @@ -16,6 +16,7 @@ export const CLASSES = { card: 'dx-cardview-card', cardHover: 'dx-cardview-card-hoverable', content: 'dx-cardview-card-content', + selectCard: 'dx-cardview-card-selection', }; export interface CardClickEvent { @@ -58,6 +59,8 @@ export interface CardProps { onClick?: (e: CardClickEvent) => void; + onSelectClick?: (e: CardClickEvent) => void; + onDblClick?: (e: CardClickEvent) => void; onHoverChanged?: (e: CardHoverEvent) => void; @@ -83,12 +86,14 @@ export class Card extends Component { fieldTemplate: FieldTemplate = Field, hoverStateEnabled, cover, + row, } = this.props; - const className = [ - CLASSES.card, - hoverStateEnabled ? CLASSES.cardHover : '', - ].filter(Boolean).join(' '); + const className = combineClasses({ + [CLASSES.card]: true, + [CLASSES.cardHover]: !!hoverStateEnabled, + [CLASSES.selectCard]: !!row.isSelected, + }); const imageSrc = cover?.imageExpr?.(this.props.row.data); const alt = cover?.altExpr?.(this.props.row.data); @@ -158,8 +163,9 @@ export class Card extends Component { }; handleClick = (event: MouseEvent): void => { - const { onClick, row } = this.props; + const { onClick, onSelectClick, row } = this.props; onClick?.({ event, row }); + onSelectClick?.({ event, row }); }; handleDoubleClick = (event: MouseEvent): void => { diff --git a/packages/devextreme/js/__internal/grids/new/card_view/content_view/view.tsx b/packages/devextreme/js/__internal/grids/new/card_view/content_view/view.tsx index 8829c4a6619c..8cb7684bf73e 100644 --- a/packages/devextreme/js/__internal/grids/new/card_view/content_view/view.tsx +++ b/packages/devextreme/js/__internal/grids/new/card_view/content_view/view.tsx @@ -1,5 +1,6 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ +import { isCommandKeyPressed } from '@js/common/core/events/utils/index'; import { compileGetter } from '@js/core/utils/data'; import { isDefined } from '@js/core/utils/type'; import { combined, computed, state } from '@ts/core/reactive/index'; @@ -7,6 +8,7 @@ import type { OptionsController } from '@ts/grids/new/card_view/options_controll import { ContentView as ContentViewBase } from '../../grid_core/content_view/view'; import type { DataObject } from '../../grid_core/data_controller/types'; +import type { CardClickEvent } from './content/card/card'; import type { ContentViewProps } from './content_view'; import { ContentView as ContentViewComponent } from './content_view'; import { factors } from './utils'; @@ -83,6 +85,7 @@ export class ContentView extends ContentViewBase { minWidth: this.cardMinWidth, maxWidth: this.options.oneWay('cardMaxWidth'), onClick: this.options.action('onCardClick'), + onSelectClick: this.onSelectClick.bind(this), onDblClick: this.options.action('onCardDblClick'), onHoverChanged: this.options.action('onCardHoverChanged'), onPrepared: this.options.action('onCardPrepared'), @@ -127,4 +130,10 @@ export class ContentView extends ContentViewBase { // @ts-expect-error return compileGetter(expr); } + + private onSelectClick(e: CardClickEvent) { + this.selectionController.changeCardSelection(e.row.index, { + control: isCommandKeyPressed(e.event), + }); + } } diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/content_view/view.tsx b/packages/devextreme/js/__internal/grids/new/grid_core/content_view/view.tsx index 388830a55fea..2c353d7f1944 100644 --- a/packages/devextreme/js/__internal/grids/new/grid_core/content_view/view.tsx +++ b/packages/devextreme/js/__internal/grids/new/grid_core/content_view/view.tsx @@ -7,6 +7,7 @@ import { ColumnsController } from '@ts/grids/new/grid_core/columns_controller/co import { View } from '@ts/grids/new/grid_core/core/view'; import { DataController } from '@ts/grids/new/grid_core/data_controller/index'; import { ErrorController } from '@ts/grids/new/grid_core/error_controller/error_controller'; +import { SelectionController } from '@ts/grids/new/grid_core/selection/controller'; import { createRef } from 'inferno'; import { ItemsController } from '../items_controller/items_controller'; @@ -33,6 +34,7 @@ export abstract class ContentView extends View { OptionsController, ErrorController, ColumnsController, + SelectionController, ItemsController, ] as const; @@ -41,6 +43,7 @@ export abstract class ContentView extends View { protected readonly options: OptionsController, protected readonly errorController: ErrorController, protected readonly columnsController: ColumnsController, + protected readonly selectionController: SelectionController, protected readonly itemsController: ItemsController, ) { super(); diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/items_controller/__snapshots__/items_controller.test.ts.snap b/packages/devextreme/js/__internal/grids/new/grid_core/items_controller/__snapshots__/items_controller.test.ts.snap index c21034559638..e37de819196e 100644 --- a/packages/devextreme/js/__internal/grids/new/grid_core/items_controller/__snapshots__/items_controller.test.ts.snap +++ b/packages/devextreme/js/__internal/grids/new/grid_core/items_controller/__snapshots__/items_controller.test.ts.snap @@ -82,6 +82,7 @@ exports[`ItemsController createDataRow should process data object to data row us "id": 1, }, "index": 0, + "isSelected": false, "key": 1, } `; @@ -168,6 +169,65 @@ exports[`ItemsController createDataRow should process data object to data row us "id": 1, }, "index": 0, + "isSelected": true, "key": 1, } `; + +exports[`ItemsController setSelectionState should update the select state of the item 1`] = ` +InterruptableComputed { + "callbacks": Set {}, + "depInitialized": [ + true, + true, + true, + true, + ], + "depValues": [ + [ + { + "a": "my a value", + "id": 1, + }, + ], + [], + [ + 1, + ], + { + "caseSensitive": false, + "enabled": true, + "searchStr": "", + }, + ], + "isInitialized": true, + "subscriptions": SubscriptionBag { + "subscriptions": [ + { + "unsubscribe": [Function], + }, + { + "unsubscribe": [Function], + }, + { + "unsubscribe": [Function], + }, + { + "unsubscribe": [Function], + }, + ], + }, + "value": [ + { + "cells": [], + "data": { + "a": "my a value", + "id": 1, + }, + "index": 0, + "isSelected": true, + "key": 1, + }, + ], +} +`; diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/items_controller/items_controller.test.ts b/packages/devextreme/js/__internal/grids/new/grid_core/items_controller/items_controller.test.ts index d1c56d77af3b..34c6a954dcc5 100644 --- a/packages/devextreme/js/__internal/grids/new/grid_core/items_controller/items_controller.test.ts +++ b/packages/devextreme/js/__internal/grids/new/grid_core/items_controller/items_controller.test.ts @@ -12,7 +12,6 @@ import { ItemsController } from './items_controller'; const setup = (config: Options = {}) => { const options = new OptionsControllerMock(config); - const columnsController = new ColumnsController(options); const filterController = new FilterController(options); const sortingController = new SortingController(options, columnsController); @@ -62,8 +61,21 @@ describe('ItemsController', () => { }); const columns = columnsController.columns.unreactive_get(); - const dataRow = itemsController.createDataRow(dataObject, columns, 0); + const dataRow = itemsController.createDataRow(dataObject, columns, 0, [1]); expect(dataRow).toMatchSnapshot(); }); }); + + describe('setSelectionState', () => { + it('should update the select state of the item', () => { + const { itemsController } = setup({ + keyExpr: 'id', + dataSource: [{ id: 1, a: 'my a value' }], + }); + + itemsController.setSelectionState([1]); + + expect(itemsController.items).toMatchSnapshot(); + }); + }); }); diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/items_controller/items_controller.ts b/packages/devextreme/js/__internal/grids/new/grid_core/items_controller/items_controller.ts index 49b75d741bf7..1f31a58b97ad 100644 --- a/packages/devextreme/js/__internal/grids/new/grid_core/items_controller/items_controller.ts +++ b/packages/devextreme/js/__internal/grids/new/grid_core/items_controller/items_controller.ts @@ -1,13 +1,15 @@ import formatHelper from '@js/format_helper'; -import { computed } from '@ts/core/reactive/index'; +import { computed, state } from '@ts/core/reactive'; import { ColumnsController } from '@ts/grids/new/grid_core/columns_controller/columns_controller'; import { DataController } from '@ts/grids/new/grid_core/data_controller/data_controller'; import { SearchController } from '@ts/grids/new/grid_core/search'; import type { Column, DataRow } from '../columns_controller/types'; -import type { DataObject } from '../data_controller/types'; +import type { DataObject, Key } from '../data_controller/types'; export class ItemsController { + private readonly selectedCardKeys = state([]); + public static dependencies = [ DataController, ColumnsController, @@ -18,6 +20,7 @@ export class ItemsController { ( dataItems, columns: Column[], + selectedCardKeys, // NOTE: We should trigger computed by search options change // But all work with these options encapsulated in SearchHighlightTextProcessor // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -27,11 +30,13 @@ export class ItemsController { item, columns, itemIndex, + selectedCardKeys, ), ), [ this.dataController.items, this.columnsController.visibleColumns, + this.selectedCardKeys, this.searchController.highlightTextOptions, ], ); @@ -42,10 +47,15 @@ export class ItemsController { private readonly searchController: SearchController, ) {} + public setSelectionState(keys: Key[]): void { + this.selectedCardKeys.update(keys); + } + public createDataRow( data: DataObject, columns: Column[], itemIndex: number, + selectedCardKeys?: Key[], ): DataRow { const itemKey = this.dataController.getDataKey(data); @@ -72,6 +82,7 @@ export class ItemsController { }), key: itemKey, index: itemIndex, + isSelected: !!selectedCardKeys?.includes(itemKey), data, }; } diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/options.ts b/packages/devextreme/js/__internal/grids/new/grid_core/options.ts index 2c6c89a440e6..494f64990f9d 100644 --- a/packages/devextreme/js/__internal/grids/new/grid_core/options.ts +++ b/packages/devextreme/js/__internal/grids/new/grid_core/options.ts @@ -13,6 +13,7 @@ import { filterPanel } from './filtering/index'; import * as pager from './pager/index'; import * as searchPanel from './search/index'; import type { SearchProperties } from './search/types'; +import * as selection from './selection/index'; import * as sortingController from './sorting_controller/index'; import type * as toolbar from './toolbar/index'; import type { GridCoreNew } from './widget'; @@ -33,6 +34,7 @@ export type Options = & contentView.Options & editing.Options & searchPanel.Options + & selection.Options // TODO: Remove this mock search options during search implementation & SearchProperties & columnChooser.Options @@ -51,6 +53,7 @@ export const defaultOptions = { ...editing.defaultOptions, ...searchPanel.defaultOptions, ...columnChooser.defaultOptions, + ...selection.defaultOptions, searchText: '', } satisfies Options; diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/selection/__snapshots__/controller.test.ts.snap b/packages/devextreme/js/__internal/grids/new/grid_core/selection/__snapshots__/controller.test.ts.snap new file mode 100644 index 000000000000..8852d12a036c --- /dev/null +++ b/packages/devextreme/js/__internal/grids/new/grid_core/selection/__snapshots__/controller.test.ts.snap @@ -0,0 +1,173 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SelectionController changeCardSelection when the control arg equal to false should update the select state of the item 1`] = ` +InterruptableComputed { + "callbacks": Set {}, + "depInitialized": [ + true, + true, + true, + true, + ], + "depValues": [ + [ + { + "id": 1, + "value": "test", + }, + ], + [], + [ + 1, + ], + { + "caseSensitive": false, + "enabled": true, + "searchStr": "", + }, + ], + "isInitialized": true, + "subscriptions": SubscriptionBag { + "subscriptions": [ + { + "unsubscribe": [Function], + }, + { + "unsubscribe": [Function], + }, + { + "unsubscribe": [Function], + }, + { + "unsubscribe": [Function], + }, + ], + }, + "value": [ + { + "cells": [], + "data": { + "id": 1, + "value": "test", + }, + "index": 0, + "isSelected": true, + "key": 1, + }, + ], +} +`; + +exports[`SelectionController changeCardSelection when the control arg equal to true should update the select state of the item 1`] = ` +InterruptableComputed { + "callbacks": Set {}, + "depInitialized": [ + true, + true, + true, + true, + ], + "depValues": [ + [ + { + "id": 1, + "value": "test", + }, + ], + [], + [], + { + "caseSensitive": false, + "enabled": true, + "searchStr": "", + }, + ], + "isInitialized": true, + "subscriptions": SubscriptionBag { + "subscriptions": [ + { + "unsubscribe": [Function], + }, + { + "unsubscribe": [Function], + }, + { + "unsubscribe": [Function], + }, + { + "unsubscribe": [Function], + }, + ], + }, + "value": [ + { + "cells": [], + "data": { + "id": 1, + "value": "test", + }, + "index": 0, + "isSelected": false, + "key": 1, + }, + ], +} +`; + +exports[`SelectionController selectCards should select item 1`] = ` +InterruptableComputed { + "callbacks": Set {}, + "depInitialized": [ + true, + true, + true, + true, + ], + "depValues": [ + [ + { + "id": 1, + "value": "test", + }, + ], + [], + [ + 1, + ], + { + "caseSensitive": false, + "enabled": true, + "searchStr": "", + }, + ], + "isInitialized": true, + "subscriptions": SubscriptionBag { + "subscriptions": [ + { + "unsubscribe": [Function], + }, + { + "unsubscribe": [Function], + }, + { + "unsubscribe": [Function], + }, + { + "unsubscribe": [Function], + }, + ], + }, + "value": [ + { + "cells": [], + "data": { + "id": 1, + "value": "test", + }, + "index": 0, + "isSelected": true, + "key": 1, + }, + ], +} +`; diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/selection/__snapshots__/options.test.ts.snap b/packages/devextreme/js/__internal/grids/new/grid_core/selection/__snapshots__/options.test.ts.snap new file mode 100644 index 000000000000..c876530a843b --- /dev/null +++ b/packages/devextreme/js/__internal/grids/new/grid_core/selection/__snapshots__/options.test.ts.snap @@ -0,0 +1,171 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Options selectedCardKeys when given should set the select state of the item 1`] = ` +InterruptableComputed { + "callbacks": Set {}, + "depInitialized": [ + true, + true, + true, + true, + ], + "depValues": [ + [ + { + "id": 1, + "value": "test", + }, + ], + [], + [ + 1, + ], + { + "caseSensitive": false, + "enabled": true, + "searchStr": "", + }, + ], + "isInitialized": true, + "subscriptions": SubscriptionBag { + "subscriptions": [ + { + "unsubscribe": [Function], + }, + { + "unsubscribe": [Function], + }, + { + "unsubscribe": [Function], + }, + { + "unsubscribe": [Function], + }, + ], + }, + "value": [ + { + "cells": [], + "data": { + "id": 1, + "value": "test", + }, + "index": 0, + "isSelected": true, + "key": 1, + }, + ], +} +`; + +exports[`Options selection mode when it is 'none' and the selectedCardKeys is specified selection should not apply 1`] = ` +InterruptableComputed { + "callbacks": Set {}, + "depInitialized": [ + true, + true, + true, + true, + ], + "depValues": [ + [ + { + "id": 1, + "value": "test", + }, + ], + [], + [], + { + "caseSensitive": false, + "enabled": true, + "searchStr": "", + }, + ], + "isInitialized": true, + "subscriptions": SubscriptionBag { + "subscriptions": [ + { + "unsubscribe": [Function], + }, + { + "unsubscribe": [Function], + }, + { + "unsubscribe": [Function], + }, + { + "unsubscribe": [Function], + }, + ], + }, + "value": [ + { + "cells": [], + "data": { + "id": 1, + "value": "test", + }, + "index": 0, + "isSelected": false, + "key": 1, + }, + ], +} +`; + +exports[`Options selection mode when it is 'none' selection should not work 1`] = ` +InterruptableComputed { + "callbacks": Set {}, + "depInitialized": [ + true, + true, + true, + true, + ], + "depValues": [ + [ + { + "id": 1, + "value": "test", + }, + ], + [], + [], + { + "caseSensitive": false, + "enabled": true, + "searchStr": "", + }, + ], + "isInitialized": true, + "subscriptions": SubscriptionBag { + "subscriptions": [ + { + "unsubscribe": [Function], + }, + { + "unsubscribe": [Function], + }, + { + "unsubscribe": [Function], + }, + { + "unsubscribe": [Function], + }, + ], + }, + "value": [ + { + "cells": [], + "data": { + "id": 1, + "value": "test", + }, + "index": 0, + "isSelected": false, + "key": 1, + }, + ], +} +`; diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/selection/controller.test.ts b/packages/devextreme/js/__internal/grids/new/grid_core/selection/controller.test.ts new file mode 100644 index 000000000000..b54f301d83f1 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/new/grid_core/selection/controller.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it } from '@jest/globals'; + +import { ColumnsController } from '../columns_controller/columns_controller'; +import { DataController } from '../data_controller'; +import { FilterController } from '../filtering/filter_controller'; +import { ItemsController } from '../items_controller/items_controller'; +import type { Options } from '../options'; +import { OptionsControllerMock } from '../options_controller/options_controller.mock'; +import { SearchController } from '../search/controller'; +import { SortingController } from '../sorting_controller/sorting_controller'; +import { SelectionController } from './controller'; + +const setup = (config: Options = {}) => { + const optionsController = new OptionsControllerMock({ + selection: { + mode: 'single', + }, + ...config, + }); + + const filterController = new FilterController(optionsController); + const columnsController = new ColumnsController(optionsController); + const sortingController = new SortingController(optionsController, columnsController); + + const dataController = new DataController(optionsController, sortingController, filterController); + + const searchController = new SearchController(optionsController); + const itemsController = new ItemsController(dataController, columnsController, searchController); + + const selectionController = new SelectionController( + optionsController, + dataController, + itemsController, + ); + + return { + optionsController, + selectionController, + itemsController, + }; +}; + +describe('SelectionController', () => { + describe('selectCards', () => { + it('should select item', () => { + const { + selectionController, + itemsController, + } = setup({ + keyExpr: 'id', + dataSource: [{ id: 1, value: 'test' }], + }); + + selectionController.selectCards([1]); + expect(itemsController.items).toMatchSnapshot(); + }); + }); + + describe('changeCardSelection', () => { + describe('when the control arg equal to false', () => { + it('should update the select state of the item', () => { + const { + selectionController, + itemsController, + } = setup({ + keyExpr: 'id', + dataSource: [{ id: 1, value: 'test' }], + }); + + selectionController.changeCardSelection(0, { control: false }); + expect(itemsController.items).toMatchSnapshot(); + }); + }); + + describe('when the control arg equal to true', () => { + it('should update the select state of the item', () => { + const { + selectionController, + itemsController, + } = setup({ + keyExpr: 'id', + dataSource: [{ id: 1, value: 'test' }], + selectedCardKeys: [1], + }); + + selectionController.changeCardSelection(0, { control: true }); + expect(itemsController.items).toMatchSnapshot(); + }); + }); + }); + + describe('isCardSelected', () => { + describe('when the selectedCardKeys is specified', () => { + it('should return true', () => { + const { + selectionController, + } = setup({ + keyExpr: 'id', + dataSource: [{ id: 1, value: 'test' }], + selectedCardKeys: [1], + }); + + expect(selectionController.isCardSelected(1)).toBe(true); + }); + }); + + describe('when the selectedCardKeys isn\'t specified', () => { + it('should return false', () => { + const { + selectionController, + } = setup({ + keyExpr: 'id', + dataSource: [{ id: 1, value: 'test' }], + }); + + expect(selectionController.isCardSelected(1)).toBe(false); + }); + }); + }); + + describe('getSelectedCardKeys', () => { + it('should return the selected card keys', () => { + const { + selectionController, + } = setup({ + keyExpr: 'id', + dataSource: [{ id: 1, value: 'test' }], + selectedCardKeys: [1], + }); + + expect(selectionController.getSelectedCardKeys()).toEqual([1]); + }); + }); + + describe('clearSelection', () => { + it('should clear the selection', () => { + const { + selectionController, + } = setup({ + keyExpr: 'id', + dataSource: [{ id: 1, value: 'test' }], + selectedCardKeys: [1], + }); + + selectionController.clearSelection(); + expect(selectionController.getSelectedCardKeys().length).toBe(0); + }); + }); +}); diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/selection/controller.ts b/packages/devextreme/js/__internal/grids/new/grid_core/selection/controller.ts new file mode 100644 index 000000000000..b42cba01eea7 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/new/grid_core/selection/controller.ts @@ -0,0 +1,149 @@ +/* eslint-disable @typescript-eslint/no-unsafe-return */ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +/* eslint-disable spellcheck/spell-checker */ +import type { DeferredObj } from '@js/core/utils/deferred'; +import { isDefined } from '@js/core/utils/type'; +import type { SubsGets } from '@ts/core/reactive/index'; +import { computed, effect } from '@ts/core/reactive/index'; +import { DataController } from '@ts/grids/new/grid_core/data_controller'; +import Selection from '@ts/ui/selection/m_selection'; + +import type { DataObject, Key } from '../data_controller/types'; +import { ItemsController } from '../items_controller/items_controller'; +import { OptionsController } from '../options_controller/options_controller'; +import type { SelectionChangedEvent, SelectionOptions } from './types'; + +export class SelectionController { + public static dependencies = [OptionsController, DataController, ItemsController] as const; + + private readonly selectedCardKeys = this.options.twoWay('selectedCardKeys'); + + private readonly selectionOption: SubsGets = this.options.oneWay('selection'); + + private readonly selectionHelper: SubsGets; + + constructor( + private readonly options: OptionsController, + private readonly dataController: DataController, + private readonly itemsController: ItemsController, + ) { + this.selectionHelper = computed( + ( + dataSource, + selectionOption, + ) => { + if (selectionOption.mode === 'none') { + return undefined; + } + + const selectionConfig = this.getSelectionConfig( + dataSource, + selectionOption, + ); + + return new Selection(selectionConfig); + }, + [ + this.dataController.dataSource, + this.selectionOption, + ], + ); + + effect((selectedCardKeys, selectionOption) => { + if (selectionOption.mode !== 'none') { + this.itemsController.setSelectionState(selectedCardKeys); + } + }, [this.selectedCardKeys, this.selectionOption]); + } + + private getSelectionConfig(dataSource, selectionOption): object { + const selectedCardKeys = this.selectedCardKeys.unreactive_get(); + const { itemsController } = this; + + return { + selectedKeys: selectedCardKeys, + mode: selectionOption.mode, + maxFilterLengthInRequest: selectionOption.maxFilterLengthInRequest, + ignoreDisabledItems: true, + key() { + return dataSource.key(); + }, + keyOf(item) { + return dataSource.store().keyOf(item); + }, + dataFields() { + return dataSource.select(); + }, + load(options) { + return dataSource.load(options); + }, + plainItems() { + return itemsController.items.unreactive_get(); + }, + isItemSelected(item) { + return item.isSelected; + }, + isSelectableItem(item) { + return !!item?.data; + }, + getItemData(item) { + return item?.data ?? item; + }, + filter() { + // TODO Salimov: Need to take combined filter + return dataSource.filter(); + }, + totalCount: () => dataSource.totalCount(), + getLoadOptions(loadItemIndex, focusedItemIndex, shiftItemIndex) { + const { sort, filter } = dataSource.loadOptions(); + let minIndex = Math.min(loadItemIndex, focusedItemIndex); + let maxIndex = Math.max(loadItemIndex, focusedItemIndex); + + if (isDefined(shiftItemIndex)) { + minIndex = Math.min(shiftItemIndex, minIndex); + maxIndex = Math.max(shiftItemIndex, maxIndex); + } + + const take = maxIndex - minIndex + 1; + + return { + skip: minIndex, + take, + filter, + sort, + }; + }, + onSelectionChanged: this.onSelectionChanged.bind(this), + }; + } + + private onSelectionChanged(e: SelectionChangedEvent): void { + this.selectedCardKeys.update([...e.selectedItemKeys]); + } + + public changeCardSelection(cardIndex: number, options: { control: boolean }): void { + const selectionHelper = this.selectionHelper?.unreactive_get(); + + selectionHelper?.changeItemSelection(cardIndex, options, false); + } + + public selectCards(keys: Key[], preserve = false): DeferredObj | undefined { + const selectionHelper = this.selectionHelper?.unreactive_get(); + + return selectionHelper?.selectedItemKeys(keys, preserve); + } + + public isCardSelected(key: Key): boolean { + const selectedCardKeys = this.selectedCardKeys.unreactive_get(); + + return selectedCardKeys.includes(key); + } + + public clearSelection(): void { + this.selectedCardKeys.update([]); + } + + public getSelectedCardKeys(): Key[] { + return this.selectedCardKeys.unreactive_get(); + } +} diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/selection/index.ts b/packages/devextreme/js/__internal/grids/new/grid_core/selection/index.ts new file mode 100644 index 000000000000..afd0cbb3be7d --- /dev/null +++ b/packages/devextreme/js/__internal/grids/new/grid_core/selection/index.ts @@ -0,0 +1,3 @@ +export { SelectionController as Controller } from './controller'; +export { defaultOptions, type Options } from './options'; +export { PublicMethods } from './public_methods'; diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/selection/options.test.ts b/packages/devextreme/js/__internal/grids/new/grid_core/selection/options.test.ts new file mode 100644 index 000000000000..c76e0c7dfc0b --- /dev/null +++ b/packages/devextreme/js/__internal/grids/new/grid_core/selection/options.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from '@jest/globals'; + +import { ColumnsController } from '../columns_controller/columns_controller'; +import { DataController } from '../data_controller'; +import { FilterController } from '../filtering/filter_controller'; +import { ItemsController } from '../items_controller/items_controller'; +import type { Options } from '../options'; +import { OptionsControllerMock } from '../options_controller/options_controller.mock'; +import { SearchController } from '../search/controller'; +import { SortingController } from '../sorting_controller/sorting_controller'; +import { SelectionController } from './controller'; + +const setup = (config: Options = {}) => { + const optionsController = new OptionsControllerMock({ + selection: { + mode: 'single', + }, + ...config, + }); + + const filterController = new FilterController(optionsController); + const columnsController = new ColumnsController(optionsController); + const sortingController = new SortingController(optionsController, columnsController); + + const dataController = new DataController(optionsController, sortingController, filterController); + + const searchController = new SearchController(optionsController); + const itemsController = new ItemsController(dataController, columnsController, searchController); + + const selectionController = new SelectionController( + optionsController, + dataController, + itemsController, + ); + + return { + selectionController, + itemsController, + }; +}; + +describe('Options', () => { + describe('selectedCardKeys', () => { + describe('when given', () => { + it('should set the select state of the item', () => { + const { + itemsController, + } = setup({ + keyExpr: 'id', + dataSource: [{ id: 1, value: 'test' }], + selectedCardKeys: [1], + }); + + expect(itemsController.items).toMatchSnapshot(); + }); + }); + }); + + describe('selection', () => { + describe('mode', () => { + describe('when it is \'none\'', () => { + it('selection should not work', () => { + const { + itemsController, + selectionController, + } = setup({ + keyExpr: 'id', + dataSource: [{ id: 1, value: 'test' }], + selection: { + mode: 'none', + }, + }); + + selectionController.selectCards([1]); + expect(itemsController.items).toMatchSnapshot(); + }); + }); + describe('when it is \'none\' and the selectedCardKeys is specified', () => { + it('selection should not apply', () => { + const { + itemsController, + } = setup({ + keyExpr: 'id', + dataSource: [{ id: 1, value: 'test' }], + selectedCardKeys: [1], + selection: { + mode: 'none', + }, + }); + + expect(itemsController.items).toMatchSnapshot(); + }); + }); + }); + }); +}); diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/selection/options.ts b/packages/devextreme/js/__internal/grids/new/grid_core/selection/options.ts new file mode 100644 index 000000000000..1d784be2f597 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/new/grid_core/selection/options.ts @@ -0,0 +1,13 @@ +import type { SelectedCardKeys, SelectionOptions } from './types'; + +export interface Options { + selectedCardKeys?: SelectedCardKeys; + selection?: SelectionOptions; +} + +export const defaultOptions: Options = { + selectedCardKeys: [], + selection: { + mode: 'none', + }, +}; diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/selection/public_methods.ts b/packages/devextreme/js/__internal/grids/new/grid_core/selection/public_methods.ts new file mode 100644 index 000000000000..123a9a1d7776 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/new/grid_core/selection/public_methods.ts @@ -0,0 +1,21 @@ +/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import type { Key } from '../data_controller/types'; +import type { Constructor } from '../types'; +import type { GridCoreNewBase } from '../widget'; + +export function PublicMethods>(GridCore: TBase) { + return class GridCoreWithSelectionController extends GridCore { + public isCardSelected(key: Key): boolean { + return this.selectionController.isCardSelected(key); + } + + public clearSelection(): void { + this.selectionController.clearSelection(); + } + + public getSelectedCardKeys(): Key[] { + return this.selectionController.getSelectedCardKeys(); + } + }; +} diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/selection/types.ts b/packages/devextreme/js/__internal/grids/new/grid_core/selection/types.ts new file mode 100644 index 000000000000..6388d68a7606 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/new/grid_core/selection/types.ts @@ -0,0 +1,23 @@ +import type { SingleMultipleOrNone } from '@js/common'; + +import type { DataObject, Key } from '../data_controller/types'; + +export type SelectedCardKeys = any[]; + +export interface SelectionChangedEvent { + selectedItems: DataObject[]; + + selectedItemKeys: Key[]; + + addedItemKeys: Key[]; + + removedItemKeys: Key[]; + + addedItems: DataObject[]; + + removedItems: DataObject[]; +} + +export interface SelectionOptions { + mode: SingleMultipleOrNone; +} diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/widget.ts b/packages/devextreme/js/__internal/grids/new/grid_core/widget.ts index e686ec75df56..fb1f742f00aa 100644 --- a/packages/devextreme/js/__internal/grids/new/grid_core/widget.ts +++ b/packages/devextreme/js/__internal/grids/new/grid_core/widget.ts @@ -27,6 +27,7 @@ import { MainView } from './main_view'; import { defaultOptions, defaultOptionsRules, type Options } from './options'; import { PagerView } from './pager/view'; import { SearchController } from './search/controller'; +import * as SelectionControllerModule from './selection'; import * as SortingControllerModule from './sorting_controller/index'; import type { SortingController } from './sorting_controller/sorting_controller'; import { ToolbarController } from './toolbar/controller'; @@ -48,6 +49,8 @@ export class GridCoreNewBase< protected sortingController!: SortingController; + protected selectionController!: SelectionControllerModule.Controller; + // eslint-disable-next-line @typescript-eslint/prefer-readonly private editingController!: EditingController; @@ -77,6 +80,7 @@ export class GridCoreNewBase< this.diContext.register(DataControllerModule.CompatibilityDataController); this.diContext.register(ItemsController); this.diContext.register(ColumnsControllerModule.ColumnsController); + this.diContext.register(SelectionControllerModule.Controller); this.diContext.register(ColumnsControllerModule.CompatibilityColumnsController); this.diContext.register(SortingControllerModule.SortingController); this.diContext.register(ToolbarController); @@ -107,6 +111,7 @@ export class GridCoreNewBase< this.dataController = this.diContext.get(DataControllerModule.DataController); this.columnsController = this.diContext.get(ColumnsControllerModule.ColumnsController); this.sortingController = this.diContext.get(SortingControllerModule.SortingController); + this.selectionController = this.diContext.get(SelectionControllerModule.Controller); this.itemsController = this.diContext.get(ItemsController); this.toolbarController = this.diContext.get(ToolbarController); this.toolbarView = this.diContext.get(ToolbarView); @@ -180,7 +185,9 @@ export class GridCoreNew extends ColumnsControllerModule.PublicMethods( SortingControllerModule.PublicMethods( FilterControllerModule.PublicMethods( ColumnChooserModule.PublicMethods( - GridCoreNewBase, + SelectionControllerModule.PublicMethods( + GridCoreNewBase, + ), ), ), ), diff --git a/packages/devextreme/js/ui/card_view.d.ts b/packages/devextreme/js/ui/card_view.d.ts index 2754141cb958..c352fdf8a70a 100644 --- a/packages/devextreme/js/ui/card_view.d.ts +++ b/packages/devextreme/js/ui/card_view.d.ts @@ -225,6 +225,16 @@ export interface DataRow { * @docid */ data: TRowData; + /** + * @public + * @docid + */ + index: number; + /** + * @public + * @docid + */ + isSelected: boolean; } type InheritedColumnProps = From f00d6a039a89607b3e0fee3bfb4acbb0a71dfb6e Mon Sep 17 00:00:00 2001 From: Alyar <> Date: Mon, 24 Mar 2025 08:30:03 +0400 Subject: [PATCH 2/8] CardView: Implement Multiple Selection with showCheckBoxesMode option --- .../content/card/header/_index.scss | 24 ++ .../content/card/card.events.test.tsx | 84 +++-- .../content_view/content/card/card.tsx | 55 ++- .../content_view/content/card/header.test.tsx | 18 +- .../content_view/content/card/header.tsx | 49 ++- .../content_view/content/content.tsx | 11 +- .../grids/new/card_view/content_view/types.ts | 12 + .../grids/new/card_view/content_view/view.tsx | 26 +- .../items_controller/items_controller.ts | 8 + .../__snapshots__/controller.test.ts.snap | 123 +++++++ .../grids/new/grid_core/selection/const.ts | 17 + .../grid_core/selection/controller.test.ts | 346 ++++++++++++++++++ .../new/grid_core/selection/controller.ts | 137 ++++++- .../grids/new/grid_core/selection/options.ts | 1 + .../grids/new/grid_core/selection/types.ts | 5 + 15 files changed, 867 insertions(+), 49 deletions(-) create mode 100644 packages/devextreme/js/__internal/grids/new/card_view/content_view/types.ts create mode 100644 packages/devextreme/js/__internal/grids/new/grid_core/selection/const.ts diff --git a/packages/devextreme-scss/scss/widgets/base/cardView/content_view/content/card/header/_index.scss b/packages/devextreme-scss/scss/widgets/base/cardView/content_view/content/card/header/_index.scss index 3cc44c0919c0..104a2dea31a6 100644 --- a/packages/devextreme-scss/scss/widgets/base/cardView/content_view/content/card/header/_index.scss +++ b/packages/devextreme-scss/scss/widgets/base/cardView/content_view/content/card/header/_index.scss @@ -11,3 +11,27 @@ } } } + +.dx-cardview-select-checkboxes-hidden .dx-cardview-card:not(.dx-cardview-card-selection) .dx-cardview-select-checkbox { + .dx-checkbox { + display: none; + } + + .dx-toolbar-item-content::before { + content: ''; + width: 20px; + height: 20px; + display: inline-block; + pointer-events: none; + } + + .dx-toolbar-item-content:hover { + &::before { + display: none; + } + + .dx-checkbox { + display: inline-block; + } + } +} \ No newline at end of file diff --git a/packages/devextreme/js/__internal/grids/new/card_view/content_view/content/card/card.events.test.tsx b/packages/devextreme/js/__internal/grids/new/card_view/content_view/content/card/card.events.test.tsx index 6fc64fb81da0..52d831b15266 100644 --- a/packages/devextreme/js/__internal/grids/new/card_view/content_view/content/card/card.events.test.tsx +++ b/packages/devextreme/js/__internal/grids/new/card_view/content_view/content/card/card.events.test.tsx @@ -3,20 +3,21 @@ /* eslint-disable @typescript-eslint/init-declarations */ import { describe, expect, it } from '@jest/globals'; -import { render } from 'inferno'; +import { createRef, render } from 'inferno'; import { Card, CLASSES } from './card'; -const createMockEvent = () => ({ +const createMockCallback = () => ({ called: false, - call() { + call(): void { this.called = true; }, }); -const mockOnDblClick = createMockEvent(); -const mockOnClick = createMockEvent(); -const mockOnSelectClick = createMockEvent(); +const mockSelectCard = createMockCallback(); +const mockOnDblClick = createMockCallback(); +const mockOnClick = createMockCallback(); +const mockOnHold = createMockCallback(); const props = { row: { @@ -67,9 +68,10 @@ const props = { maxWidth: 300, width: 300, minWidth: 300, - onDblClick: mockOnDblClick.call(), - onClick: mockOnClick.call(), - onSelectClick: mockOnSelectClick.call(), + selectCard: mockSelectCard.call.bind(mockSelectCard), + onDblClick: mockOnDblClick.call.bind(mockOnDblClick), + onClick: mockOnClick.call.bind(mockOnClick), + onHold: mockOnHold.call.bind(mockOnHold), }; describe('Events', () => { @@ -78,37 +80,30 @@ describe('Events', () => { beforeEach(() => { container = document.createElement('div'); // @ts-expect-error - render(, container); + render(, container); }); it('should trigger onClick event', () => { - // @ts-expect-error - render(, container); - const cardElement = container.querySelector(`.${CLASSES.card}`); cardElement?.dispatchEvent(new MouseEvent('click')); expect(mockOnClick.called).toBe(true); }); - it('should trigger onSelectClick event', () => { - // @ts-expect-error - render(, container); - + it.skip('should trigger onDblClick event', () => { const cardElement = container.querySelector(`.${CLASSES.card}`); - cardElement?.dispatchEvent(new MouseEvent('click')); - expect(mockOnSelectClick.called).toBe(true); - }); + cardElement?.dispatchEvent(new MouseEvent('dblclick')); - it('should trigger onDblClick event', () => { - // @ts-expect-error - render(, container); + expect(mockOnDblClick.called).toBe(true); + }); + it('should trigger onHold event', () => { const cardElement = container.querySelector(`.${CLASSES.card}`); - cardElement?.dispatchEvent(new MouseEvent('dblclick')); - expect(mockOnDblClick.called).toBe(true); + cardElement?.dispatchEvent(new MouseEvent('dxhold')); + + expect(mockOnHold.called).toBe(true); }); it('should trigger onHoverChanged event on mouse enter', () => { @@ -165,3 +160,42 @@ describe('Events', () => { expect(fieldValue?.textContent).toBe('devextreme'); }); }); + +describe('Callbacks', () => { + describe('selectCard', () => { + // @ts-expect-errors + beforeEach(() => { + mockSelectCard.called = false; + }); + + describe('when allowSelectOnClick = true', () => { + it('should rise it', () => { + const container = document.createElement('div'); + const newProps = { ...props, elementRef: createRef(), allowSelectOnClick: true }; + // @ts-expect-error + render(, container); + + const cardElement = container.querySelector(`.${CLASSES.card}`); + + cardElement?.dispatchEvent(new MouseEvent('click')); + + expect(mockSelectCard.called).toBe(true); + }); + }); + + describe('when allowSelectOnClick = false', () => { + it('should not rise it', () => { + const container = document.createElement('div'); + const newProps = { ...props, elementRef: createRef(), allowSelectOnClick: false }; + // @ts-expect-error + render(, container); + + const cardElement = container.querySelector(`.${CLASSES.card}`); + + cardElement?.dispatchEvent(new MouseEvent('click')); + + expect(mockSelectCard.called).toBe(false); + }); + }); + }); +}); diff --git a/packages/devextreme/js/__internal/grids/new/card_view/content_view/content/card/card.tsx b/packages/devextreme/js/__internal/grids/new/card_view/content_view/content/card/card.tsx index 7855ce77d3df..5e46e632a409 100644 --- a/packages/devextreme/js/__internal/grids/new/card_view/content_view/content/card/card.tsx +++ b/packages/devextreme/js/__internal/grids/new/card_view/content_view/content/card/card.tsx @@ -1,5 +1,6 @@ /* eslint-disable @typescript-eslint/no-non-null-assertion */ -/* eslint-disable @typescript-eslint/prefer-nullish-coalescing */ +import { isCommandKeyPressed } from '@js/common/core/events/utils/index'; +import { off, on } from '@js/events'; import { combineClasses } from '@ts/core/utils/combine_classes'; import type { DataRow } from '@ts/grids/new/grid_core/columns_controller/types'; import type { DataObject } from '@ts/grids/new/grid_core/data_controller/types'; @@ -7,6 +8,7 @@ import { CollectionController } from '@ts/grids/new/grid_core/keyboard_navigatio import type { InfernoNode, RefObject } from 'inferno'; import { Component, createRef } from 'inferno'; +import type { SelectCardOptions } from '../../types'; import { Cover } from './cover'; import { Field } from './field'; import type { CardHeaderItem } from './header'; @@ -20,7 +22,7 @@ export const CLASSES = { }; export interface CardClickEvent { - event: MouseEvent; + event?: MouseEvent; row: DataRow; } @@ -36,6 +38,8 @@ export interface CardPreparedEvent { export interface CardProps { row: DataRow; + allowSelectOnClick?: boolean; + cover?: { imageExpr?: (data: DataObject) => string; @@ -55,17 +59,23 @@ export interface CardProps { toolbar?: CardHeaderItem[]; + width?: number; + + isCheckBoxesRendered?: boolean; + template?: (row: DataRow) => JSX.Element; onClick?: (e: CardClickEvent) => void; - onSelectClick?: (e: CardClickEvent) => void; + onHold?: (e: CardClickEvent) => void; onDblClick?: (e: CardClickEvent) => void; onHoverChanged?: (e: CardHoverEvent) => void; onPrepared?: (e: CardPreparedEvent) => void; + + selectCard?: (row: DataRow, options: SelectCardOptions) => void; } export class Card extends Component { @@ -104,13 +114,15 @@ export class Card extends Component { tabIndex={0} ref={this.props.elementRef} onKeyDown={(e): void => this.keyboardController.onKeyDown(e)} - onClick={this.handleClick} onDblClick={this.handleDoubleClick} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave} > {imageSrc && ( { componentDidMount(): void { this.updateKeyboardController(); const { onPrepared } = this.props; + if (onPrepared) { onPrepared({ instance: this }); } + + on(this.containerRef.current!, 'dxclick', this.handleClick); + + if (this.props.onHold) { + on(this.containerRef.current!, 'dxhold', this.handleHold); + } + } + + componentWillUnmount(): void { + off(this.containerRef.current!, 'dxclick', this.handleClick); + + if (this.props.onHold) { + off(this.containerRef.current!, 'dxhold', this.handleHold); + } } componentDidUpdate(): void { @@ -163,13 +190,29 @@ export class Card extends Component { }; handleClick = (event: MouseEvent): void => { - const { onClick, onSelectClick, row } = this.props; + const { + allowSelectOnClick, + onClick, + selectCard, + row, + } = this.props; + onClick?.({ event, row }); - onSelectClick?.({ event, row }); + + if (allowSelectOnClick) { + selectCard?.(row, { control: isCommandKeyPressed(event), shift: event.shiftKey }); + } }; handleDoubleClick = (event: MouseEvent): void => { const { onDblClick, row } = this.props; onDblClick?.({ event, row }); }; + + handleHold = (event: MouseEvent): void => { + const { onHold, row } = this.props; + + onHold?.({ event, row }); + event.stopPropagation(); + }; } diff --git a/packages/devextreme/js/__internal/grids/new/card_view/content_view/content/card/header.test.tsx b/packages/devextreme/js/__internal/grids/new/card_view/content_view/content/card/header.test.tsx index 7b11faed033a..7cf06f9421d6 100644 --- a/packages/devextreme/js/__internal/grids/new/card_view/content_view/content/card/header.test.tsx +++ b/packages/devextreme/js/__internal/grids/new/card_view/content_view/content/card/header.test.tsx @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/explicit-function-return-type */ -/* eslint-disable import/no-extraneous-dependencies */ + import { describe, expect, it } from '@jest/globals'; import { render } from 'inferno'; @@ -68,4 +68,20 @@ describe('CardHeader', () => { expect(customHeader).not.toBeNull(); expect(customHeader?.textContent).toBe('Custom Header'); }); + + it('should render a selection checkbox', () => { + const container = document.createElement('div'); + render( + , + container, + ); + + const checkboxItem = container.querySelector('.dx-cardview-select-checkbox'); + expect(checkboxItem).not.toBeNull(); + }); }); diff --git a/packages/devextreme/js/__internal/grids/new/card_view/content_view/content/card/header.tsx b/packages/devextreme/js/__internal/grids/new/card_view/content_view/content/card/header.tsx index 8641fc8f36e5..9da452c883d4 100644 --- a/packages/devextreme/js/__internal/grids/new/card_view/content_view/content/card/header.tsx +++ b/packages/devextreme/js/__internal/grids/new/card_view/content_view/content/card/header.tsx @@ -1,17 +1,28 @@ -import type { dxToolbarItem } from '@js/ui/toolbar'; +import { isCommandKeyPressed } from '@js/common/core/events/utils/index'; +import type { ValueChangedEvent } from '@js/ui/check_box'; import type { DataRow } from '@ts/grids/new/grid_core/columns_controller/types'; import { Toolbar } from '@ts/grids/new/grid_core/inferno_wrappers/toolbar'; import { Component } from 'inferno'; +import type { SelectCardOptions } from '../../types'; + export const CLASSES = { cardHeader: 'dx-cardview-card-header', + cardSelectCheckBox: 'dx-cardview-select-checkbox', }; +export interface CheckBoxClickEvent { + event?: MouseEvent; + row: DataRow; +} + export interface CardHeaderItem { location: 'before' | 'after'; widget?: string; text?: string; - options?: dxToolbarItem; + cssClass?: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + options?: any; } export interface CardHeaderProps { @@ -20,9 +31,38 @@ export interface CardHeaderProps { captionExpr?: string; template?: (items: CardHeaderItem[]) => JSX.Element; row?: DataRow; + isCheckBoxesRendered?: boolean; + selectCard?: (row: DataRow, options: SelectCardOptions) => void; } export class CardHeader extends Component { + private getCheckBoxItem(): CardHeaderItem | null { + const { isCheckBoxesRendered, selectCard, row } = this.props; + + if (row && isCheckBoxesRendered) { + return { + location: 'before', + widget: 'dxCheckBox', + cssClass: CLASSES.cardSelectCheckBox, + options: { + value: row.isSelected, + onValueChanged: (e: ValueChangedEvent): void => { + const event = e.event as MouseEvent; + + selectCard?.(row, { + control: isCommandKeyPressed(event), + shift: event.shiftKey, + needToUpdateCheckboxes: true, + }); + event.stopPropagation(); + }, + }, + }; + } + + return null; + } + render(): JSX.Element | null { const { visible = true, @@ -36,11 +76,14 @@ export class CardHeader extends Component { return null; } + const checkBoxItem = this.getCheckBoxItem(); + const captionItem: CardHeaderItem | null = captionExpr && row?.[captionExpr] ? { location: 'before', text: row[captionExpr] } : null; - const finalItems = captionItem ? [captionItem, ...items] : items; + const finalItems = [checkBoxItem, captionItem, ...items] + .filter((item): item is CardHeaderItem => !!item); if (template) { return template(finalItems); diff --git a/packages/devextreme/js/__internal/grids/new/card_view/content_view/content/content.tsx b/packages/devextreme/js/__internal/grids/new/card_view/content_view/content/content.tsx index 0d504e2db4c6..6c26af768042 100644 --- a/packages/devextreme/js/__internal/grids/new/card_view/content_view/content/content.tsx +++ b/packages/devextreme/js/__internal/grids/new/card_view/content_view/content/content.tsx @@ -1,4 +1,5 @@ /* eslint-disable @typescript-eslint/no-non-null-assertion */ +import { combineClasses } from '@ts/core/utils/combine_classes'; import type { DataRow } from '@ts/grids/new/grid_core/columns_controller/types'; import { CollectionController } from '@ts/grids/new/grid_core/keyboard_navigation/collection_controller'; import type { RefObject } from 'inferno'; @@ -17,6 +18,8 @@ export interface ContentProps { cardsPerRow?: number; + needToHiddenCheckBoxes?: boolean; + cardProps?: { toolbar?: CardHeaderItem[]; minWidth?: number; @@ -27,6 +30,7 @@ export interface ContentProps { export const CLASSES = { content: 'dx-cardview-content', grid: 'dx-cardview-content-grid', + selectCheckBoxesHidden: 'dx-cardview-select-checkboxes-hidden', }; function getInfernoCardKey(card: DataRow): undefined | string | number { @@ -76,10 +80,15 @@ export class Content extends Component { render(): JSX.Element { this.cardRefs = new Array(this.props.items.length).fill(undefined).map(() => createRef()); + const className = combineClasses({ + [CLASSES.content]: true, + [CLASSES.grid]: true, + [CLASSES.selectCheckBoxesHidden]: !!this.props.needToHiddenCheckBoxes, + }); return (
this.keyboardController.onKeyDown(e)} diff --git a/packages/devextreme/js/__internal/grids/new/card_view/content_view/types.ts b/packages/devextreme/js/__internal/grids/new/card_view/content_view/types.ts new file mode 100644 index 000000000000..a871461422f9 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/new/card_view/content_view/types.ts @@ -0,0 +1,12 @@ +import type { DataRow } from '@ts/grids/new/grid_core/columns_controller/types'; + +export interface SelectCardOptions { + control?: boolean; + shift?: boolean; + needToUpdateCheckboxes?: boolean; +} + +export interface CardHoldEvent { + event?: MouseEvent; + row: DataRow; +} diff --git a/packages/devextreme/js/__internal/grids/new/card_view/content_view/view.tsx b/packages/devextreme/js/__internal/grids/new/card_view/content_view/view.tsx index 8cb7684bf73e..6dc3158b5abd 100644 --- a/packages/devextreme/js/__internal/grids/new/card_view/content_view/view.tsx +++ b/packages/devextreme/js/__internal/grids/new/card_view/content_view/view.tsx @@ -1,16 +1,16 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ -import { isCommandKeyPressed } from '@js/common/core/events/utils/index'; import { compileGetter } from '@js/core/utils/data'; import { isDefined } from '@js/core/utils/type'; import { combined, computed, state } from '@ts/core/reactive/index'; import type { OptionsController } from '@ts/grids/new/card_view/options_controller'; +import type { DataRow } from '@ts/grids/new/grid_core/columns_controller/types'; import { ContentView as ContentViewBase } from '../../grid_core/content_view/view'; import type { DataObject } from '../../grid_core/data_controller/types'; -import type { CardClickEvent } from './content/card/card'; import type { ContentViewProps } from './content_view'; import { ContentView as ContentViewComponent } from './content_view'; +import type { CardHoldEvent, SelectCardOptions } from './types'; import { factors } from './utils'; export class ContentView extends ContentViewBase { @@ -73,10 +73,13 @@ export class ContentView extends ContentViewBase { protected override component = ContentViewComponent; protected override getProps() { + const allowSelectOnClick = this.selectionController.allowSelectOnClick(); + return combined({ ...this.getBaseProps(), contentProps: combined({ items: this.itemsController.items, + needToHiddenCheckBoxes: this.selectionController.needToHiddenCheckBoxes, // items: computed((virtualState) => virtualState.virtualItems, [this.virtualState]), fieldTemplate: this.options.template('fieldTemplate'), cardsPerRow: this.cardsPerRow, @@ -84,8 +87,10 @@ export class ContentView extends ContentViewBase { cardProps: combined({ minWidth: this.cardMinWidth, maxWidth: this.options.oneWay('cardMaxWidth'), + isCheckBoxesRendered: this.selectionController.isCheckBoxesRendered, + allowSelectOnClick, + onHold: this.onCardHold.bind(this), onClick: this.options.action('onCardClick'), - onSelectClick: this.onSelectClick.bind(this), onDblClick: this.options.action('onCardDblClick'), onHoverChanged: this.options.action('onCardHoverChanged'), onPrepared: this.options.action('onCardPrepared'), @@ -110,6 +115,7 @@ export class ContentView extends ContentViewBase { }), // eslint-disable-next-line @typescript-eslint/no-explicit-any toolbar: this.options.oneWay('cardHeader.items') as any, + selectCard: this.selectCard.bind(this), }), }), virtualScrollingProps: combined({ @@ -131,9 +137,15 @@ export class ContentView extends ContentViewBase { return compileGetter(expr); } - private onSelectClick(e: CardClickEvent) { - this.selectionController.changeCardSelection(e.row.index, { - control: isCommandKeyPressed(e.event), - }); + private selectCard(row: DataRow, options: SelectCardOptions) { + if (options.needToUpdateCheckboxes) { + this.selectionController.updateSelectionCheckBoxesVisible(true); + } + + this.selectionController.changeCardSelection(row.index, options); + } + + private onCardHold(e: CardHoldEvent) { + this.selectionController.processLongTap(e.row); } } diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/items_controller/items_controller.ts b/packages/devextreme/js/__internal/grids/new/grid_core/items_controller/items_controller.ts index 1f31a58b97ad..fd8dea5687de 100644 --- a/packages/devextreme/js/__internal/grids/new/grid_core/items_controller/items_controller.ts +++ b/packages/devextreme/js/__internal/grids/new/grid_core/items_controller/items_controller.ts @@ -1,3 +1,4 @@ +import { equalByValue } from '@js/core/utils/common'; import formatHelper from '@js/format_helper'; import { computed, state } from '@ts/core/reactive'; import { ColumnsController } from '@ts/grids/new/grid_core/columns_controller/columns_controller'; @@ -86,4 +87,11 @@ export class ItemsController { data, }; } + + public getRowByKey(key: Key): DataRow | undefined { + // eslint-disable-next-line spellcheck/spell-checker + const items = this.items.unreactive_get(); + + return items.find((item) => equalByValue(item.key, key)); + } } diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/selection/__snapshots__/controller.test.ts.snap b/packages/devextreme/js/__internal/grids/new/grid_core/selection/__snapshots__/controller.test.ts.snap index 8852d12a036c..f1a669237598 100644 --- a/packages/devextreme/js/__internal/grids/new/grid_core/selection/__snapshots__/controller.test.ts.snap +++ b/packages/devextreme/js/__internal/grids/new/grid_core/selection/__snapshots__/controller.test.ts.snap @@ -114,6 +114,129 @@ InterruptableComputed { } `; +exports[`SelectionController deselectCards should select item 1`] = ` +InterruptableComputed { + "callbacks": Set {}, + "depInitialized": [ + true, + true, + true, + ], + "depValues": [ + [ + { + "id": 1, + "value": "test", + }, + ], + [ + { + "alignment": "left", + "allowHiding": true, + "allowReordering": true, + "calculateCellValue": [Function], + "calculateDisplayValue": [Function], + "caption": "Id", + "dataField": "id", + "dataType": "string", + "falseText": "false", + "headerItemTemplate": undefined, + "name": "id", + "trueText": "true", + "visible": true, + "visibleIndex": 0, + }, + { + "alignment": "left", + "allowHiding": true, + "allowReordering": true, + "calculateCellValue": [Function], + "calculateDisplayValue": [Function], + "caption": "Value", + "dataField": "value", + "dataType": "string", + "falseText": "false", + "headerItemTemplate": undefined, + "name": "value", + "trueText": "true", + "visible": true, + "visibleIndex": 1, + }, + ], + [], + ], + "isInitialized": true, + "subscriptions": SubscriptionBag { + "subscriptions": [ + { + "unsubscribe": [Function], + }, + { + "unsubscribe": [Function], + }, + { + "unsubscribe": [Function], + }, + ], + }, + "value": [ + { + "cells": [ + { + "column": { + "alignment": "left", + "allowHiding": true, + "allowReordering": true, + "calculateCellValue": [Function], + "calculateDisplayValue": [Function], + "caption": "Id", + "dataField": "id", + "dataType": "string", + "falseText": "false", + "headerItemTemplate": undefined, + "name": "id", + "trueText": "true", + "visible": true, + "visibleIndex": 0, + }, + "displayValue": 1, + "text": "1", + "value": 1, + }, + { + "column": { + "alignment": "left", + "allowHiding": true, + "allowReordering": true, + "calculateCellValue": [Function], + "calculateDisplayValue": [Function], + "caption": "Value", + "dataField": "value", + "dataType": "string", + "falseText": "false", + "headerItemTemplate": undefined, + "name": "value", + "trueText": "true", + "visible": true, + "visibleIndex": 1, + }, + "displayValue": "test", + "text": "test", + "value": "test", + }, + ], + "data": { + "id": 1, + "value": "test", + }, + "index": 0, + "isSelected": false, + "key": 1, + }, + ], +} +`; + exports[`SelectionController selectCards should select item 1`] = ` InterruptableComputed { "callbacks": Set {}, diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/selection/const.ts b/packages/devextreme/js/__internal/grids/new/grid_core/selection/const.ts new file mode 100644 index 000000000000..0664d40bec41 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/new/grid_core/selection/const.ts @@ -0,0 +1,17 @@ +export enum SelectionMode { + Multiple = 'multiple', + + Single = 'single', + + None = 'none', +} + +export enum ShowCheckBoxesMode { + Always = 'always', + + OnClick = 'onClick', + + OnLongTap = 'onLongTap', + + None = 'none', +} diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/selection/controller.test.ts b/packages/devextreme/js/__internal/grids/new/grid_core/selection/controller.test.ts index b54f301d83f1..d839ae99eb7d 100644 --- a/packages/devextreme/js/__internal/grids/new/grid_core/selection/controller.test.ts +++ b/packages/devextreme/js/__internal/grids/new/grid_core/selection/controller.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable spellcheck/spell-checker */ import { describe, expect, it } from '@jest/globals'; import { ColumnsController } from '../columns_controller/columns_controller'; @@ -15,6 +16,7 @@ const setup = (config: Options = {}) => { selection: { mode: 'single', }, + selectedCardKeys: [], ...config, }); @@ -56,6 +58,22 @@ describe('SelectionController', () => { }); }); + describe('deselectCards', () => { + it('should select item', () => { + const { + selectionController, + itemsController, + } = setup({ + keyExpr: 'id', + dataSource: [{ id: 1, value: 'test' }], + selectedCardKeys: [1], + }); + + selectionController.deselectCards([1]); + expect(itemsController.items).toMatchSnapshot(); + }); + }); + describe('changeCardSelection', () => { describe('when the control arg equal to false', () => { it('should update the select state of the item', () => { @@ -87,6 +105,25 @@ describe('SelectionController', () => { expect(itemsController.items).toMatchSnapshot(); }); }); + + describe('when item is selected and multiple selection enabled', () => { + it('should update the select state of the item', () => { + const { + selectionController, + } = setup({ + keyExpr: 'id', + dataSource: [{ id: 1, value: 'test' }], + selectedCardKeys: [1], + selection: { + mode: 'multiple', + showCheckBoxesMode: 'always', + }, + }); + + selectionController.changeCardSelection(0); + expect(selectionController.getSelectedCardKeys()).toEqual([]); + }); + }); }); describe('isCardSelected', () => { @@ -132,6 +169,22 @@ describe('SelectionController', () => { }); }); + describe('getSelectedCards', () => { + it('should return the selected card keys', () => { + const { + selectionController, + itemsController, + } = setup({ + keyExpr: 'id', + dataSource: [{ id: 1, value: 'test' }], + selectedCardKeys: [1], + }); + + expect(selectionController.getSelectedCards()) + .toEqual(itemsController.items.unreactive_get()); + }); + }); + describe('clearSelection', () => { it('should clear the selection', () => { const { @@ -146,4 +199,297 @@ describe('SelectionController', () => { expect(selectionController.getSelectedCardKeys().length).toBe(0); }); }); + + describe('isCheckBoxesRendered', () => { + describe('when the selection mode is equal to \'none\'', () => { + it('should return false', () => { + const { + selectionController, + } = setup({ + keyExpr: 'id', + dataSource: [{ id: 1, value: 'test' }], + selection: { + mode: 'none', + }, + }); + + expect(selectionController.isCheckBoxesRendered.unreactive_get()).toBe(false); + }); + }); + + describe('when the selection mode is equal to \'multiple\' and the showCheckBoxesMode is equal to \'always\'', () => { + it('should return true', () => { + const { + selectionController, + } = setup({ + keyExpr: 'id', + dataSource: [{ id: 1, value: 'test' }], + selection: { + mode: 'multiple', + showCheckBoxesMode: 'always', + }, + }); + + expect(selectionController.isCheckBoxesRendered.unreactive_get()).toBe(true); + }); + }); + + describe('when the selection mode is equal to \'multiple\' and the showCheckBoxesMode is equal to \'onClick\'', () => { + it('should return true', () => { + const { + selectionController, + } = setup({ + keyExpr: 'id', + dataSource: [{ id: 1, value: 'test' }], + selection: { + mode: 'multiple', + showCheckBoxesMode: 'onClick', + }, + }); + + expect(selectionController.isCheckBoxesRendered.unreactive_get()).toBe(true); + }); + }); + + describe('when the selection mode is equal to \'multiple\' and the showCheckBoxesMode is equal to \'onLongTap\'', () => { + it('should return false', () => { + const { + selectionController, + } = setup({ + keyExpr: 'id', + dataSource: [{ id: 1, value: 'test' }], + selection: { + mode: 'multiple', + showCheckBoxesMode: 'onLongTap', + }, + }); + + expect(selectionController.isCheckBoxesRendered.unreactive_get()).toBe(false); + }); + }); + }); + + describe('isCheckBoxesVisible', () => { + describe('when the selection mode is equal to \'multiple\' and the showCheckBoxesMode is equal to \'onClick\'', () => { + it('should return false', () => { + const { + selectionController, + } = setup({ + keyExpr: 'id', + dataSource: [{ id: 1, value: 'test' }], + selection: { + mode: 'multiple', + showCheckBoxesMode: 'onClick', + }, + }); + + expect(selectionController.isCheckBoxesVisible.unreactive_get()).toBe(false); + }); + }); + + describe('when selecting one card', () => { + it('should return false', () => { + const { + selectionController, + } = setup({ + keyExpr: 'id', + dataSource: [{ id: 1, value: 'test1' }, { id: 2, value: 'test2' }, { id: 3, value: 'test3' }], + selection: { + mode: 'multiple', + showCheckBoxesMode: 'onClick', + }, + }); + + selectionController.selectCards([1]); + expect(selectionController.isCheckBoxesVisible.unreactive_get()).toBe(false); + }); + }); + + describe('when selecting two cards', () => { + it('should return true', () => { + const { + selectionController, + } = setup({ + keyExpr: 'id', + dataSource: [{ id: 1, value: 'test1' }, { id: 2, value: 'test2' }, { id: 3, value: 'test3' }], + selection: { + mode: 'multiple', + showCheckBoxesMode: 'onClick', + }, + }); + + selectionController.selectCards([1, 2]); + expect(selectionController.isCheckBoxesVisible.unreactive_get()).toBe(true); + }); + }); + + describe('when deselecting all cards', () => { + it('should return false', () => { + const { + selectionController, + } = setup({ + keyExpr: 'id', + dataSource: [{ id: 1, value: 'test1' }, { id: 2, value: 'test2' }, { id: 3, value: 'test3' }], + selection: { + mode: 'multiple', + showCheckBoxesMode: 'onClick', + }, + selectedCardKeys: [1, 2], + }); + + selectionController.deselectCards([1, 2]); + expect(selectionController.isCheckBoxesVisible.unreactive_get()).toBe(false); + }); + }); + }); + + describe('needToHiddenCheckBoxes', () => { + describe('when the selection mode is equal to \'multiple\' and the showCheckBoxesMode is equal to \'onClick\'', () => { + it('should return true', () => { + const { + selectionController, + } = setup({ + keyExpr: 'id', + dataSource: [{ id: 1, value: 'test' }], + selection: { + mode: 'multiple', + showCheckBoxesMode: 'onClick', + }, + }); + + expect(selectionController.needToHiddenCheckBoxes.unreactive_get()).toBe(true); + }); + }); + + describe('when the selection mode is equal to \'multiple\' and the showCheckBoxesMode is equal to \'always\'', () => { + it('should return false', () => { + const { + selectionController, + } = setup({ + keyExpr: 'id', + dataSource: [{ id: 1, value: 'test' }], + selection: { + mode: 'multiple', + showCheckBoxesMode: 'always', + }, + }); + + expect(selectionController.needToHiddenCheckBoxes.unreactive_get()).toBe(false); + }); + }); + }); + + describe('updateSelectionCheckBoxesVisible', () => { + describe('when the selection mode is equal to \'multiple\' and the showCheckBoxesMode is equal to \'onClick\'', () => { + it('should show the selection checkboxes', () => { + const { + selectionController, + } = setup({ + keyExpr: 'id', + dataSource: [{ id: 1, value: 'test' }], + selection: { + mode: 'multiple', + showCheckBoxesMode: 'onClick', + }, + }); + + selectionController.updateSelectionCheckBoxesVisible(true); + expect(selectionController.isCheckBoxesVisible.unreactive_get()).toBe(true); + }); + + it('should hide the selection checkboxes', () => { + const { + selectionController, + } = setup({ + keyExpr: 'id', + dataSource: [{ id: 1, value: 'test' }], + selection: { + mode: 'multiple', + showCheckBoxesMode: 'onClick', + }, + }); + + selectionController.updateSelectionCheckBoxesVisible(false); + expect(selectionController.isCheckBoxesVisible.unreactive_get()).toBe(false); + }); + }); + }); + + describe('processLongTap', () => { + describe('when the selection mode is equal to \'multiple\' and the showCheckBoxesMode is equal to \'onLongTap\'', () => { + it('should render the selection checkbox', () => { + const { + selectionController, + } = setup({ + keyExpr: 'id', + dataSource: [{ id: 1, value: 'test' }], + selection: { + mode: 'multiple', + showCheckBoxesMode: 'onLongTap', + }, + }); + + // @ts-expect-error + selectionController.processLongTap({ index: 0 }); + expect(selectionController.isCheckBoxesRendered.unreactive_get()).toBe(true); + }); + }); + + describe('when the selection mode is equal to \'multiple\' and the showCheckBoxesMode is equal to \'onClick\'', () => { + it('should show the selection checkbox', () => { + const { + selectionController, + } = setup({ + keyExpr: 'id', + dataSource: [{ id: 1, value: 'test' }], + selection: { + mode: 'multiple', + showCheckBoxesMode: 'onClick', + }, + }); + + // @ts-expect-error + selectionController.processLongTap({ index: 0 }); + expect(selectionController.isCheckBoxesVisible.unreactive_get()).toBe(true); + }); + }); + + describe('when the selection mode is equal to \'multiple\' and the showCheckBoxesMode is equal to \'none\'', () => { + it('should select a first item', () => { + const { + selectionController, + } = setup({ + keyExpr: 'id', + dataSource: [{ id: 1, value: 'test' }], + selection: { + mode: 'multiple', + showCheckBoxesMode: 'none', + }, + }); + + // @ts-expect-error + selectionController.processLongTap({ index: 0 }); + expect(selectionController.getSelectedCardKeys()).toEqual([1]); + }); + }); + + describe('when the selection mode is equal to \'multiple\' and the showCheckBoxesMode is equal to \'none\'', () => { + it('should not select a first item', () => { + const { + selectionController, + } = setup({ + keyExpr: 'id', + dataSource: [{ id: 1, value: 'test' }], + selection: { + mode: 'multiple', + showCheckBoxesMode: 'always', + }, + }); + + // @ts-expect-error + selectionController.processLongTap({ index: 0 }); + expect(selectionController.getSelectedCardKeys()).toEqual([]); + }); + }); + }); }); diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/selection/controller.ts b/packages/devextreme/js/__internal/grids/new/grid_core/selection/controller.ts index b42cba01eea7..b7e7292cd423 100644 --- a/packages/devextreme/js/__internal/grids/new/grid_core/selection/controller.ts +++ b/packages/devextreme/js/__internal/grids/new/grid_core/selection/controller.ts @@ -4,13 +4,16 @@ import type { DeferredObj } from '@js/core/utils/deferred'; import { isDefined } from '@js/core/utils/type'; import type { SubsGets } from '@ts/core/reactive/index'; -import { computed, effect } from '@ts/core/reactive/index'; +import { computed, effect, state } from '@ts/core/reactive/index'; import { DataController } from '@ts/grids/new/grid_core/data_controller'; +import { ShowCheckBoxesMode } from '@ts/grids/new/grid_core/selection/const'; import Selection from '@ts/ui/selection/m_selection'; -import type { DataObject, Key } from '../data_controller/types'; +import type { DataRow } from '../columns_controller/types'; +import type { Key } from '../data_controller/types'; import { ItemsController } from '../items_controller/items_controller'; import { OptionsController } from '../options_controller/options_controller'; +import { SelectionMode } from './const'; import type { SelectionChangedEvent, SelectionOptions } from './types'; export class SelectionController { @@ -22,6 +25,65 @@ export class SelectionController { private readonly selectionHelper: SubsGets; + private readonly _isCheckBoxesRendered = state(false); + + public readonly isCheckBoxesRendered = computed( + (selectionMode, showCheckBoxesMode, _isCheckBoxesRendered) => { + if (selectionMode === SelectionMode.Multiple) { + switch (showCheckBoxesMode) { + case ShowCheckBoxesMode.Always: + case ShowCheckBoxesMode.OnClick: + return true; + case ShowCheckBoxesMode.OnLongTap: + return _isCheckBoxesRendered; + default: + return false; + } + } + + return false; + }, + [ + this.options.oneWay('selection.mode'), + this.options.oneWay('selection.showCheckBoxesMode'), + this._isCheckBoxesRendered, + ], + ); + + public readonly _isCheckBoxesVisible = state(false); + + public readonly isCheckBoxesVisible = computed( + (isCheckBoxesRendered, _isCheckBoxesVisible) => { + if (isCheckBoxesRendered) { + const { showCheckBoxesMode } = this.selectionOption.unreactive_get(); + + return showCheckBoxesMode !== ShowCheckBoxesMode.OnClick || _isCheckBoxesVisible; + } + + return false; + }, + [ + this.isCheckBoxesRendered, + this._isCheckBoxesVisible, + ], + ); + + public readonly needToHiddenCheckBoxes = computed( + (isCheckBoxesVisible) => { + const { showCheckBoxesMode } = this.selectionOption.unreactive_get(); + const isCheckBoxesRendered = this.isCheckBoxesRendered.unreactive_get(); + + if (isCheckBoxesRendered && showCheckBoxesMode === ShowCheckBoxesMode.OnClick) { + return !isCheckBoxesVisible; + } + + return false; + }, + [ + this.isCheckBoxesVisible, + ], + ); + constructor( private readonly options: OptionsController, private readonly dataController: DataController, @@ -32,7 +94,7 @@ export class SelectionController { dataSource, selectionOption, ) => { - if (selectionOption.mode === 'none') { + if (selectionOption.mode === SelectionMode.None) { return undefined; } @@ -50,8 +112,14 @@ export class SelectionController { ); effect((selectedCardKeys, selectionOption) => { - if (selectionOption.mode !== 'none') { + if (selectionOption.mode !== SelectionMode.None) { this.itemsController.setSelectionState(selectedCardKeys); + + if (selectedCardKeys.length > 1) { + this._isCheckBoxesVisible.update(true); + } else if (selectedCardKeys.length === 0) { + this._isCheckBoxesVisible.update(false); + } } }, [this.selectedCardKeys, this.selectionOption]); } @@ -65,6 +133,7 @@ export class SelectionController { mode: selectionOption.mode, maxFilterLengthInRequest: selectionOption.maxFilterLengthInRequest, ignoreDisabledItems: true, + alwaysSelectByShift: false, key() { return dataSource.key(); }, @@ -121,10 +190,19 @@ export class SelectionController { this.selectedCardKeys.update([...e.selectedItemKeys]); } - public changeCardSelection(cardIndex: number, options: { control: boolean }): void { + public changeCardSelection( + cardIndex: number, + options?: { control?: boolean; shift?: boolean }, + ): void { const selectionHelper = this.selectionHelper?.unreactive_get(); + const isCheckBoxesVisible = this.isCheckBoxesVisible.unreactive_get(); + const keys = options ?? {}; + + if (isCheckBoxesVisible) { + keys.control = isCheckBoxesVisible; + } - selectionHelper?.changeItemSelection(cardIndex, options, false); + selectionHelper?.changeItemSelection(cardIndex, keys, false); } public selectCards(keys: Key[], preserve = false): DeferredObj | undefined { @@ -133,6 +211,12 @@ export class SelectionController { return selectionHelper?.selectedItemKeys(keys, preserve); } + public deselectCards(keys: Key[]): DeferredObj | undefined { + const selectionHelper = this.selectionHelper?.unreactive_get(); + + return selectionHelper?.selectedItemKeys(keys, true, true); + } + public isCardSelected(key: Key): boolean { const selectedCardKeys = this.selectedCardKeys.unreactive_get(); @@ -143,7 +227,48 @@ export class SelectionController { this.selectedCardKeys.update([]); } + public getSelectedCards(): DataRow[] { + const selectedCardKey = this.getSelectedCardKeys(); + + return selectedCardKey + .map((key) => this.itemsController.getRowByKey(key)) + .filter((item): item is DataRow => !!item); + } + public getSelectedCardKeys(): Key[] { return this.selectedCardKeys.unreactive_get(); } + + private toggleSelectionCheckBoxes(): void { + const isCheckBoxesRendered = this._isCheckBoxesRendered.unreactive_get(); + + this._isCheckBoxesRendered.update(!isCheckBoxesRendered); + } + + public updateSelectionCheckBoxesVisible(value: boolean): void { + this._isCheckBoxesVisible.update(value); + } + + public allowSelectOnClick(): boolean { + const { mode, showCheckBoxesMode } = this.selectionOption.unreactive_get(); + + return mode !== SelectionMode.Multiple || showCheckBoxesMode !== ShowCheckBoxesMode.Always; + } + + public processLongTap(row: DataRow): void { + const { mode, showCheckBoxesMode } = this.selectionOption.unreactive_get(); + + if (mode !== SelectionMode.None) { + if (showCheckBoxesMode === ShowCheckBoxesMode.OnLongTap) { + this.toggleSelectionCheckBoxes(); + } else { + if (showCheckBoxesMode === ShowCheckBoxesMode.OnClick) { + this._isCheckBoxesVisible.update(true); + } + if (showCheckBoxesMode !== ShowCheckBoxesMode.Always) { + this.changeCardSelection(row.index, { control: true }); + } + } + } + } } diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/selection/options.ts b/packages/devextreme/js/__internal/grids/new/grid_core/selection/options.ts index 1d784be2f597..ed1bbf88f434 100644 --- a/packages/devextreme/js/__internal/grids/new/grid_core/selection/options.ts +++ b/packages/devextreme/js/__internal/grids/new/grid_core/selection/options.ts @@ -9,5 +9,6 @@ export const defaultOptions: Options = { selectedCardKeys: [], selection: { mode: 'none', + showCheckBoxesMode: 'always', }, }; diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/selection/types.ts b/packages/devextreme/js/__internal/grids/new/grid_core/selection/types.ts index 6388d68a7606..ff30889ea5b5 100644 --- a/packages/devextreme/js/__internal/grids/new/grid_core/selection/types.ts +++ b/packages/devextreme/js/__internal/grids/new/grid_core/selection/types.ts @@ -1,4 +1,5 @@ import type { SingleMultipleOrNone } from '@js/common'; +import type { SelectionColumnDisplayMode } from '@js/common/grids'; import type { DataObject, Key } from '../data_controller/types'; @@ -18,6 +19,10 @@ export interface SelectionChangedEvent { removedItems: DataObject[]; } +export type { SelectionColumnDisplayMode as ShowCheckBoxesMode }; + export interface SelectionOptions { mode: SingleMultipleOrNone; + + showCheckBoxesMode?: SelectionColumnDisplayMode; } From fd77e234de3fd722b375bc1bb2edda2e38381712 Mon Sep 17 00:00:00 2001 From: Alyar <> Date: Wed, 26 Mar 2025 15:55:08 +0400 Subject: [PATCH 3/8] CardView: Implement SelectAll and DeselectAll features --- .../data_controller/data_controller.ts | 3 + .../__snapshots__/controller.test.ts.snap | 311 +++++++++---- .../__snapshots__/options.test.ts.snap | 33 ++ .../grid_core/selection/controller.test.ts | 407 ++++++++++++++++-- .../new/grid_core/selection/controller.ts | 174 ++++++-- .../new/grid_core/selection/options.test.ts | 58 +++ .../grids/new/grid_core/selection/options.ts | 11 +- .../grids/new/grid_core/selection/types.ts | 34 +- .../grids/new/grid_core/toolbar/defaults.tsx | 2 + .../js/localization/messages/ar.json | 3 + .../js/localization/messages/bg.json | 3 + .../js/localization/messages/ca.json | 3 + .../js/localization/messages/cs.json | 3 + .../js/localization/messages/da.json | 3 + .../js/localization/messages/de.json | 3 + .../js/localization/messages/el.json | 3 + .../js/localization/messages/en.json | 3 + .../js/localization/messages/es.json | 3 + .../js/localization/messages/fa.json | 3 + .../js/localization/messages/fi.json | 3 + .../js/localization/messages/fr.json | 3 + .../js/localization/messages/hu.json | 3 + .../js/localization/messages/it.json | 3 + .../js/localization/messages/ja.json | 3 + .../js/localization/messages/lt.json | 3 + .../js/localization/messages/lv.json | 3 + .../js/localization/messages/nb.json | 3 + .../js/localization/messages/nl.json | 3 + .../js/localization/messages/pl.json | 3 + .../js/localization/messages/pt.json | 3 + .../js/localization/messages/ro.json | 3 + .../js/localization/messages/ru.json | 3 + .../js/localization/messages/sl.json | 3 + .../js/localization/messages/sv.json | 3 + .../js/localization/messages/tr.json | 3 + .../js/localization/messages/uk.json | 3 + .../js/localization/messages/vi.json | 3 + .../js/localization/messages/zh-tw.json | 3 + .../js/localization/messages/zh.json | 3 + 39 files changed, 955 insertions(+), 168 deletions(-) diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/data_controller/data_controller.ts b/packages/devextreme/js/__internal/grids/new/grid_core/data_controller/data_controller.ts index 7a578bb7438a..b14e85fefd25 100644 --- a/packages/devextreme/js/__internal/grids/new/grid_core/data_controller/data_controller.ts +++ b/packages/devextreme/js/__internal/grids/new/grid_core/data_controller/data_controller.ts @@ -79,6 +79,8 @@ export class DataController { [this.totalCount, this.pageSize], ); + public readonly isLoaded = state(false); + private readonly normalizedRemoteOptions = computed( (remoteOperations, dataSource) => { const store = dataSource.store(); @@ -102,6 +104,7 @@ export class DataController { effect( (dataSource) => { const changedCallback = (e?): void => { + this.isLoaded.update(true); this.onChanged(dataSource, e); }; const loadingChangedCallback = (): void => { diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/selection/__snapshots__/controller.test.ts.snap b/packages/devextreme/js/__internal/grids/new/grid_core/selection/__snapshots__/controller.test.ts.snap index f1a669237598..83f018ce5358 100644 --- a/packages/devextreme/js/__internal/grids/new/grid_core/selection/__snapshots__/controller.test.ts.snap +++ b/packages/devextreme/js/__internal/grids/new/grid_core/selection/__snapshots__/controller.test.ts.snap @@ -114,13 +114,14 @@ InterruptableComputed { } `; -exports[`SelectionController deselectCards should select item 1`] = ` +exports[`SelectionController deselectCards should deselect item 1`] = ` InterruptableComputed { "callbacks": Set {}, "depInitialized": [ true, true, true, + true, ], "depValues": [ [ @@ -129,41 +130,71 @@ InterruptableComputed { "value": "test", }, ], + [], + [], + { + "caseSensitive": false, + "enabled": true, + "searchStr": "", + }, + ], + "isInitialized": true, + "subscriptions": SubscriptionBag { + "subscriptions": [ + { + "unsubscribe": [Function], + }, + { + "unsubscribe": [Function], + }, + { + "unsubscribe": [Function], + }, + { + "unsubscribe": [Function], + }, + ], + }, + "value": [ + { + "cells": [], + "data": { + "id": 1, + "value": "test", + }, + "index": 0, + "isSelected": false, + "key": 1, + }, + ], +} +`; + +exports[`SelectionController public methods changeCardSelection when the control arg equal to false should update the select state of the item 1`] = ` +InterruptableComputed { + "callbacks": Set {}, + "depInitialized": [ + true, + true, + true, + true, + ], + "depValues": [ [ { - "alignment": "left", - "allowHiding": true, - "allowReordering": true, - "calculateCellValue": [Function], - "calculateDisplayValue": [Function], - "caption": "Id", - "dataField": "id", - "dataType": "string", - "falseText": "false", - "headerItemTemplate": undefined, - "name": "id", - "trueText": "true", - "visible": true, - "visibleIndex": 0, - }, - { - "alignment": "left", - "allowHiding": true, - "allowReordering": true, - "calculateCellValue": [Function], - "calculateDisplayValue": [Function], - "caption": "Value", - "dataField": "value", - "dataType": "string", - "falseText": "false", - "headerItemTemplate": undefined, - "name": "value", - "trueText": "true", - "visible": true, - "visibleIndex": 1, + "id": 1, + "value": "test", }, ], [], + [ + 1, + ], + { + "caseSensitive": false, + "enabled": true, + "searchStr": "", + }, ], "isInitialized": true, "subscriptions": SubscriptionBag { @@ -177,54 +208,126 @@ InterruptableComputed { { "unsubscribe": [Function], }, + { + "unsubscribe": [Function], + }, ], }, "value": [ { - "cells": [ - { - "column": { - "alignment": "left", - "allowHiding": true, - "allowReordering": true, - "calculateCellValue": [Function], - "calculateDisplayValue": [Function], - "caption": "Id", - "dataField": "id", - "dataType": "string", - "falseText": "false", - "headerItemTemplate": undefined, - "name": "id", - "trueText": "true", - "visible": true, - "visibleIndex": 0, - }, - "displayValue": 1, - "text": "1", - "value": 1, - }, - { - "column": { - "alignment": "left", - "allowHiding": true, - "allowReordering": true, - "calculateCellValue": [Function], - "calculateDisplayValue": [Function], - "caption": "Value", - "dataField": "value", - "dataType": "string", - "falseText": "false", - "headerItemTemplate": undefined, - "name": "value", - "trueText": "true", - "visible": true, - "visibleIndex": 1, - }, - "displayValue": "test", - "text": "test", - "value": "test", - }, - ], + "cells": [], + "data": { + "id": 1, + "value": "test", + }, + "index": 0, + "isSelected": true, + "key": 1, + }, + ], +} +`; + +exports[`SelectionController public methods changeCardSelection when the control arg equal to true should update the select state of the item 1`] = ` +InterruptableComputed { + "callbacks": Set {}, + "depInitialized": [ + true, + true, + true, + true, + ], + "depValues": [ + [ + { + "id": 1, + "value": "test", + }, + ], + [], + [], + { + "caseSensitive": false, + "enabled": true, + "searchStr": "", + }, + ], + "isInitialized": true, + "subscriptions": SubscriptionBag { + "subscriptions": [ + { + "unsubscribe": [Function], + }, + { + "unsubscribe": [Function], + }, + { + "unsubscribe": [Function], + }, + { + "unsubscribe": [Function], + }, + ], + }, + "value": [ + { + "cells": [], + "data": { + "id": 1, + "value": "test", + }, + "index": 0, + "isSelected": false, + "key": 1, + }, + ], +} +`; + +exports[`SelectionController public methods deselectCards should deselect item 1`] = ` +InterruptableComputed { + "callbacks": Set {}, + "depInitialized": [ + true, + true, + true, + true, + ], + "depValues": [ + [ + { + "id": 1, + "value": "test", + }, + ], + [], + [], + { + "caseSensitive": false, + "enabled": true, + "searchStr": "", + }, + ], + "isInitialized": true, + "subscriptions": SubscriptionBag { + "subscriptions": [ + { + "unsubscribe": [Function], + }, + { + "unsubscribe": [Function], + }, + { + "unsubscribe": [Function], + }, + { + "unsubscribe": [Function], + }, + ], + }, + "value": [ + { + "cells": [], "data": { "id": 1, "value": "test", @@ -237,6 +340,64 @@ InterruptableComputed { } `; +exports[`SelectionController public methods selectCards should select item 1`] = ` +InterruptableComputed { + "callbacks": Set {}, + "depInitialized": [ + true, + true, + true, + true, + ], + "depValues": [ + [ + { + "id": 1, + "value": "test", + }, + ], + [], + [ + 1, + ], + { + "caseSensitive": false, + "enabled": true, + "searchStr": "", + }, + ], + "isInitialized": true, + "subscriptions": SubscriptionBag { + "subscriptions": [ + { + "unsubscribe": [Function], + }, + { + "unsubscribe": [Function], + }, + { + "unsubscribe": [Function], + }, + { + "unsubscribe": [Function], + }, + ], + }, + "value": [ + { + "cells": [], + "data": { + "id": 1, + "value": "test", + }, + "index": 0, + "isSelected": true, + "key": 1, + }, + ], +} +`; + exports[`SelectionController selectCards should select item 1`] = ` InterruptableComputed { "callbacks": Set {}, diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/selection/__snapshots__/options.test.ts.snap b/packages/devextreme/js/__internal/grids/new/grid_core/selection/__snapshots__/options.test.ts.snap index c876530a843b..450dc1044be7 100644 --- a/packages/devextreme/js/__internal/grids/new/grid_core/selection/__snapshots__/options.test.ts.snap +++ b/packages/devextreme/js/__internal/grids/new/grid_core/selection/__snapshots__/options.test.ts.snap @@ -58,6 +58,39 @@ InterruptableComputed { } `; +exports[`Options selection allowSelectAll when it is false and selection mode is 'multiple' selection should not work 1`] = `[]`; + +exports[`Options selection allowSelectAll when it is true and selection mode is 'multiple' selection should not work 1`] = ` +[ + { + "locateInMenu": "auto", + "location": "before", + "name": "selectAllButton", + "options": { + "disabled": false, + "icon": "selectall", + "onClick": [Function], + "text": "Select All", + }, + "widget": "dxButton", + }, + { + "locateInMenu": "auto", + "location": "before", + "name": "clearSelectionButton", + "options": { + "disabled": true, + "icon": "close", + "onClick": [Function], + "text": "Clear selection", + }, + "widget": "dxButton", + }, +] +`; + +exports[`Options selection allowSelectAll when it is true and selection mode isn't 'multiple' selection should not work 1`] = `[]`; + exports[`Options selection mode when it is 'none' and the selectedCardKeys is specified selection should not apply 1`] = ` InterruptableComputed { "callbacks": Set {}, diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/selection/controller.test.ts b/packages/devextreme/js/__internal/grids/new/grid_core/selection/controller.test.ts index d839ae99eb7d..8c535227e833 100644 --- a/packages/devextreme/js/__internal/grids/new/grid_core/selection/controller.test.ts +++ b/packages/devextreme/js/__internal/grids/new/grid_core/selection/controller.test.ts @@ -1,5 +1,7 @@ /* eslint-disable spellcheck/spell-checker */ -import { describe, expect, it } from '@jest/globals'; +import { + describe, expect, it, jest, +} from '@jest/globals'; import { ColumnsController } from '../columns_controller/columns_controller'; import { DataController } from '../data_controller'; @@ -9,6 +11,7 @@ import type { Options } from '../options'; import { OptionsControllerMock } from '../options_controller/options_controller.mock'; import { SearchController } from '../search/controller'; import { SortingController } from '../sorting_controller/sorting_controller'; +import { ToolbarController } from '../toolbar/controller'; import { SelectionController } from './controller'; const setup = (config: Options = {}) => { @@ -28,11 +31,13 @@ const setup = (config: Options = {}) => { const searchController = new SearchController(optionsController); const itemsController = new ItemsController(dataController, columnsController, searchController); + const toolbarController = new ToolbarController(optionsController); const selectionController = new SelectionController( optionsController, dataController, itemsController, + toolbarController, ); return { @@ -43,6 +48,8 @@ const setup = (config: Options = {}) => { }; describe('SelectionController', () => { + // Public methods + describe('selectCards', () => { it('should select item', () => { const { @@ -59,7 +66,7 @@ describe('SelectionController', () => { }); describe('deselectCards', () => { - it('should select item', () => { + it('should deselect item', () => { const { selectionController, itemsController, @@ -200,6 +207,121 @@ describe('SelectionController', () => { }); }); + describe('updateSelectionCheckBoxesVisible', () => { + describe('when the selection mode is equal to \'multiple\' and the showCheckBoxesMode is equal to \'onClick\'', () => { + it('should show the selection checkboxes', () => { + const { + selectionController, + } = setup({ + keyExpr: 'id', + dataSource: [{ id: 1, value: 'test' }], + selection: { + mode: 'multiple', + showCheckBoxesMode: 'onClick', + }, + }); + + selectionController.updateSelectionCheckBoxesVisible(true); + expect(selectionController.isCheckBoxesVisible.unreactive_get()).toBe(true); + }); + + it('should hide the selection checkboxes', () => { + const { + selectionController, + } = setup({ + keyExpr: 'id', + dataSource: [{ id: 1, value: 'test' }], + selection: { + mode: 'multiple', + showCheckBoxesMode: 'onClick', + }, + }); + + selectionController.updateSelectionCheckBoxesVisible(false); + expect(selectionController.isCheckBoxesVisible.unreactive_get()).toBe(false); + }); + }); + }); + + describe('processLongTap', () => { + describe('when the selection mode is equal to \'multiple\' and the showCheckBoxesMode is equal to \'onLongTap\'', () => { + it('should render the selection checkbox', () => { + const { + selectionController, + } = setup({ + keyExpr: 'id', + dataSource: [{ id: 1, value: 'test' }], + selection: { + mode: 'multiple', + showCheckBoxesMode: 'onLongTap', + }, + }); + + // @ts-expect-error + selectionController.processLongTap({ index: 0 }); + expect(selectionController.isCheckBoxesRendered.unreactive_get()).toBe(true); + }); + }); + + describe('when the selection mode is equal to \'multiple\' and the showCheckBoxesMode is equal to \'onClick\'', () => { + it('should show the selection checkbox', () => { + const { + selectionController, + } = setup({ + keyExpr: 'id', + dataSource: [{ id: 1, value: 'test' }], + selection: { + mode: 'multiple', + showCheckBoxesMode: 'onClick', + }, + }); + + // @ts-expect-error + selectionController.processLongTap({ index: 0 }); + expect(selectionController.isCheckBoxesVisible.unreactive_get()).toBe(true); + }); + }); + + describe('when the selection mode is equal to \'multiple\' and the showCheckBoxesMode is equal to \'none\'', () => { + it('should select a first item', () => { + const { + selectionController, + } = setup({ + keyExpr: 'id', + dataSource: [{ id: 1, value: 'test' }], + selection: { + mode: 'multiple', + showCheckBoxesMode: 'none', + }, + }); + + // @ts-expect-error + selectionController.processLongTap({ index: 0 }); + expect(selectionController.getSelectedCardKeys()).toEqual([1]); + }); + }); + + describe('when the selection mode is equal to \'multiple\' and the showCheckBoxesMode is equal to \'none\'', () => { + it('should not select a first item', () => { + const { + selectionController, + } = setup({ + keyExpr: 'id', + dataSource: [{ id: 1, value: 'test' }], + selection: { + mode: 'multiple', + showCheckBoxesMode: 'always', + }, + }); + + // @ts-expect-error + selectionController.processLongTap({ index: 0 }); + expect(selectionController.getSelectedCardKeys()).toEqual([]); + }); + }); + }); + + // Public properties describe('isCheckBoxesRendered', () => { describe('when the selection mode is equal to \'none\'', () => { it('should return false', () => { @@ -379,117 +501,316 @@ describe('SelectionController', () => { }); }); - describe('updateSelectionCheckBoxesVisible', () => { - describe('when the selection mode is equal to \'multiple\' and the showCheckBoxesMode is equal to \'onClick\'', () => { - it('should show the selection checkboxes', () => { + // Events + + describe('onSelectionChanging', () => { + describe('when selecting a card', () => { + it('should be called', () => { + const selectionChangingMockFn = jest.fn(); + const cardData = { id: 1, value: 'test' }; const { selectionController, } = setup({ keyExpr: 'id', - dataSource: [{ id: 1, value: 'test' }], + dataSource: [cardData], selection: { mode: 'multiple', - showCheckBoxesMode: 'onClick', }, + onSelectionChanging: selectionChangingMockFn, }); - selectionController.updateSelectionCheckBoxesVisible(true); - expect(selectionController.isCheckBoxesVisible.unreactive_get()).toBe(true); + selectionController.selectCards([1]); + + expect(selectionChangingMockFn.mock.calls).toHaveLength(1); + expect(selectionChangingMockFn.mock.lastCall).toMatchObject([{ + cancel: false, + currentDeselectedCardKeys: [], + currentSelectedCardKeys: [1], + isDeselectAll: false, + isSelectAll: false, + selectedCardKeys: [1], + selectedCardsData: [cardData], + }]); }); + }); - it('should hide the selection checkboxes', () => { + describe('when deselecting a card', () => { + it('should be called', () => { + const selectionChangingMockFn = jest.fn(); + const cardData = { id: 1, value: 'test' }; const { selectionController, } = setup({ keyExpr: 'id', - dataSource: [{ id: 1, value: 'test' }], + dataSource: [cardData], selection: { mode: 'multiple', - showCheckBoxesMode: 'onClick', }, + selectedCardKeys: [1], + onSelectionChanging: selectionChangingMockFn, }); - selectionController.updateSelectionCheckBoxesVisible(false); - expect(selectionController.isCheckBoxesVisible.unreactive_get()).toBe(false); + selectionController.deselectCards([1]); + + expect(selectionChangingMockFn.mock.calls).toHaveLength(1); + expect(selectionChangingMockFn.mock.lastCall).toMatchObject([{ + cancel: false, + currentDeselectedCardKeys: [1], + currentSelectedCardKeys: [], + isDeselectAll: false, + isSelectAll: false, + selectedCardKeys: [], + selectedCardsData: [], + }]); }); }); - }); - describe('processLongTap', () => { - describe('when the selection mode is equal to \'multiple\' and the showCheckBoxesMode is equal to \'onLongTap\'', () => { - it('should render the selection checkbox', () => { + describe('when selecting all cards', () => { + it('should be called', () => { + const selectionChangingMockFn = jest.fn(); + const data = [{ id: 1, value: 'test1' }, { id: 2, value: 'test2' }]; const { selectionController, } = setup({ keyExpr: 'id', - dataSource: [{ id: 1, value: 'test' }], + dataSource: data, selection: { mode: 'multiple', - showCheckBoxesMode: 'onLongTap', + allowSelectAll: true, }, + onSelectionChanging: selectionChangingMockFn, }); - // @ts-expect-error - selectionController.processLongTap({ index: 0 }); - expect(selectionController.isCheckBoxesRendered.unreactive_get()).toBe(true); + selectionController.selectAll(); + + expect(selectionChangingMockFn.mock.calls).toHaveLength(1); + expect(selectionChangingMockFn.mock.lastCall).toMatchObject([{ + cancel: false, + currentDeselectedCardKeys: [], + currentSelectedCardKeys: [1, 2], + isDeselectAll: false, + isSelectAll: false, + selectedCardKeys: [1, 2], + selectedCardsData: data, + }]); }); }); - describe('when the selection mode is equal to \'multiple\' and the showCheckBoxesMode is equal to \'onClick\'', () => { - it('should show the selection checkbox', () => { + describe('when deselecting all cards', () => { + it('should be called', () => { + const selectionChangingMockFn = jest.fn(); + const data = [{ id: 1, value: 'test1' }, { id: 2, value: 'test2' }]; const { selectionController, } = setup({ keyExpr: 'id', - dataSource: [{ id: 1, value: 'test' }], + dataSource: data, selection: { mode: 'multiple', - showCheckBoxesMode: 'onClick', + allowSelectAll: true, }, + selectedCardKeys: [1, 2], + onSelectionChanging: selectionChangingMockFn, }); - // @ts-expect-error - selectionController.processLongTap({ index: 0 }); - expect(selectionController.isCheckBoxesVisible.unreactive_get()).toBe(true); + selectionController.deselectAll(); + + expect(selectionChangingMockFn.mock.calls).toHaveLength(1); + expect(selectionChangingMockFn.mock.lastCall).toMatchObject([{ + cancel: false, + currentDeselectedCardKeys: [1, 2], + currentSelectedCardKeys: [], + isDeselectAll: false, + isSelectAll: false, + selectedCardKeys: [], + selectedCardsData: [], + }]); }); }); - describe('when the selection mode is equal to \'multiple\' and the showCheckBoxesMode is equal to \'none\'', () => { - it('should select a first item', () => { + describe('when a cancel arg is specified as true', () => { + it('should be called', () => { + const selectionChangingMockFn = jest.fn((e: any) => { e.cancel = true; }); + const cardData = { id: 1, value: 'test' }; const { selectionController, } = setup({ keyExpr: 'id', - dataSource: [{ id: 1, value: 'test' }], + dataSource: [cardData], selection: { mode: 'multiple', - showCheckBoxesMode: 'none', }, + onSelectionChanging: selectionChangingMockFn, }); - // @ts-expect-error - selectionController.processLongTap({ index: 0 }); - expect(selectionController.getSelectedCardKeys()).toEqual([1]); + selectionController.selectCards([1]); + + expect(selectionChangingMockFn.mock.calls).toHaveLength(1); + expect(selectionChangingMockFn.mock.lastCall).toMatchObject([{ + cancel: true, + currentDeselectedCardKeys: [], + currentSelectedCardKeys: [1], + isDeselectAll: false, + isSelectAll: false, + selectedCardKeys: [1], + selectedCardsData: [cardData], + }]); + expect(selectionController.getSelectedCardKeys()).toEqual([]); }); }); - describe('when the selection mode is equal to \'multiple\' and the showCheckBoxesMode is equal to \'none\'', () => { - it('should not select a first item', () => { + describe('when a cancel arg is specified as Promise', () => { + it('should be called', () => { + const cancel = Promise.resolve(true); + const selectionChangingMockFn = jest.fn((e: any) => { e.cancel = cancel; }); + const cardData = { id: 1, value: 'test' }; const { selectionController, } = setup({ keyExpr: 'id', - dataSource: [{ id: 1, value: 'test' }], + dataSource: [cardData], selection: { mode: 'multiple', - showCheckBoxesMode: 'always', }, + onSelectionChanging: selectionChangingMockFn, }); - // @ts-expect-error - selectionController.processLongTap({ index: 0 }); + selectionController.selectCards([1]); + + expect(selectionChangingMockFn.mock.calls).toHaveLength(1); + expect(selectionChangingMockFn.mock.lastCall).toMatchObject([{ + cancel, + currentDeselectedCardKeys: [], + currentSelectedCardKeys: [1], + isDeselectAll: false, + isSelectAll: false, + selectedCardKeys: [1], + selectedCardsData: [cardData], + }]); expect(selectionController.getSelectedCardKeys()).toEqual([]); }); }); }); + + describe('onSelectionChanged', () => { + describe('when selecting a card', () => { + it('should be called', () => { + const selectionChangedMockFn = jest.fn(); + const cardData = { id: 1, value: 'test' }; + const { + selectionController, + } = setup({ + keyExpr: 'id', + dataSource: [cardData], + selection: { + mode: 'multiple', + }, + onSelectionChanged: selectionChangedMockFn, + }); + + selectionController.selectCards([1]); + + expect(selectionChangedMockFn.mock.calls).toHaveLength(1); + expect(selectionChangedMockFn.mock.lastCall).toMatchObject([{ + currentDeselectedCardKeys: [], + currentSelectedCardKeys: [1], + isDeselectAll: false, + isSelectAll: false, + selectedCardKeys: [1], + selectedCardsData: [cardData], + }]); + }); + }); + + describe('when deselecting a card', () => { + it('should be called', () => { + const selectionChangedMockFn = jest.fn(); + const cardData = { id: 1, value: 'test' }; + const { + selectionController, + } = setup({ + keyExpr: 'id', + dataSource: [cardData], + selection: { + mode: 'multiple', + }, + selectedCardKeys: [1], + onSelectionChanged: selectionChangedMockFn, + }); + + selectionController.deselectCards([1]); + + expect(selectionChangedMockFn.mock.calls).toHaveLength(1); + expect(selectionChangedMockFn.mock.lastCall).toMatchObject([{ + currentDeselectedCardKeys: [1], + currentSelectedCardKeys: [], + isDeselectAll: false, + isSelectAll: false, + selectedCardKeys: [], + selectedCardsData: [], + }]); + }); + }); + + describe('when selecting all cards', () => { + it('should be called', () => { + const selectionChangedMockFn = jest.fn(); + const data = [{ id: 1, value: 'test1' }, { id: 2, value: 'test2' }]; + const { + selectionController, + } = setup({ + keyExpr: 'id', + dataSource: data, + selection: { + mode: 'multiple', + allowSelectAll: true, + }, + onSelectionChanged: selectionChangedMockFn, + }); + + selectionController.selectAll(); + + expect(selectionChangedMockFn.mock.calls).toHaveLength(1); + expect(selectionChangedMockFn.mock.lastCall).toMatchObject([{ + currentDeselectedCardKeys: [], + currentSelectedCardKeys: [1, 2], + isDeselectAll: false, + isSelectAll: false, + selectedCardKeys: [1, 2], + selectedCardsData: data, + }]); + }); + }); + + describe('when deselecting all cards', () => { + it('should be called', () => { + const selectionChangedMockFn = jest.fn(); + const data = [{ id: 1, value: 'test1' }, { id: 2, value: 'test2' }]; + const { + selectionController, + } = setup({ + keyExpr: 'id', + dataSource: data, + selection: { + mode: 'multiple', + allowSelectAll: true, + }, + selectedCardKeys: [1, 2], + onSelectionChanged: selectionChangedMockFn, + }); + + selectionController.deselectAll(); + + expect(selectionChangedMockFn.mock.calls).toHaveLength(1); + expect(selectionChangedMockFn.mock.lastCall).toMatchObject([{ + currentDeselectedCardKeys: [1, 2], + currentSelectedCardKeys: [], + isDeselectAll: false, + isSelectAll: false, + selectedCardKeys: [], + selectedCardsData: [], + }]); + }); + }); + }); }); diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/selection/controller.ts b/packages/devextreme/js/__internal/grids/new/grid_core/selection/controller.ts index b7e7292cd423..867c2ec89b9a 100644 --- a/packages/devextreme/js/__internal/grids/new/grid_core/selection/controller.ts +++ b/packages/devextreme/js/__internal/grids/new/grid_core/selection/controller.ts @@ -2,7 +2,7 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ /* eslint-disable spellcheck/spell-checker */ import type { DeferredObj } from '@js/core/utils/deferred'; -import { isDefined } from '@js/core/utils/type'; +import messageLocalization from '@js/localization/message'; import type { SubsGets } from '@ts/core/reactive/index'; import { computed, effect, state } from '@ts/core/reactive/index'; import { DataController } from '@ts/grids/new/grid_core/data_controller'; @@ -13,11 +13,19 @@ import type { DataRow } from '../columns_controller/types'; import type { Key } from '../data_controller/types'; import { ItemsController } from '../items_controller/items_controller'; import { OptionsController } from '../options_controller/options_controller'; +import { ToolbarController } from '../toolbar/controller'; import { SelectionMode } from './const'; -import type { SelectionChangedEvent, SelectionOptions } from './types'; +import type { + SelectedCardKeys, SelectionEventInfo, SelectionOptions, +} from './types'; export class SelectionController { - public static dependencies = [OptionsController, DataController, ItemsController] as const; + public static dependencies = [ + OptionsController, + DataController, + ItemsController, + ToolbarController, + ] as const; private readonly selectedCardKeys = this.options.twoWay('selectedCardKeys'); @@ -27,6 +35,10 @@ export class SelectionController { private readonly _isCheckBoxesRendered = state(false); + private readonly onSelectionChanging = this.options.action('onSelectionChanging'); + + private readonly onSelectionChanged = this.options.action('onSelectionChanged'); + public readonly isCheckBoxesRendered = computed( (selectionMode, showCheckBoxesMode, _isCheckBoxesRendered) => { if (selectionMode === SelectionMode.Multiple) { @@ -88,6 +100,7 @@ export class SelectionController { private readonly options: OptionsController, private readonly dataController: DataController, private readonly itemsController: ItemsController, + private readonly toolbarController: ToolbarController, ) { this.selectionHelper = computed( ( @@ -122,18 +135,28 @@ export class SelectionController { } } }, [this.selectedCardKeys, this.selectionOption]); + + effect((selectedCardKeys, selectionOption) => { + this.updateSelectionToolbarButtons(selectedCardKeys, selectionOption); + }, [this.selectedCardKeys, this.selectionOption, this.dataController.items]); + + effect((isLoaded) => { + if (isLoaded) { + const selectedCardKeys = this.selectedCardKeys.unreactive_get(); + + this.selectCards(selectedCardKeys); + } + }, [this.dataController.isLoaded]); } private getSelectionConfig(dataSource, selectionOption): object { const selectedCardKeys = this.selectedCardKeys.unreactive_get(); - const { itemsController } = this; return { selectedKeys: selectedCardKeys, mode: selectionOption.mode, maxFilterLengthInRequest: selectionOption.maxFilterLengthInRequest, ignoreDisabledItems: true, - alwaysSelectByShift: false, key() { return dataSource.key(); }, @@ -144,50 +167,109 @@ export class SelectionController { return dataSource.select(); }, load(options) { - return dataSource.load(options); + return dataSource.store().load(options); }, plainItems() { - return itemsController.items.unreactive_get(); - }, - isItemSelected(item) { - return item.isSelected; - }, - isSelectableItem(item) { - return !!item?.data; - }, - getItemData(item) { - return item?.data ?? item; + return dataSource.items(); }, filter() { // TODO Salimov: Need to take combined filter return dataSource.filter(); }, totalCount: () => dataSource.totalCount(), - getLoadOptions(loadItemIndex, focusedItemIndex, shiftItemIndex) { - const { sort, filter } = dataSource.loadOptions(); - let minIndex = Math.min(loadItemIndex, focusedItemIndex); - let maxIndex = Math.max(loadItemIndex, focusedItemIndex); - - if (isDefined(shiftItemIndex)) { - minIndex = Math.min(shiftItemIndex, minIndex); - maxIndex = Math.max(shiftItemIndex, maxIndex); - } - - const take = maxIndex - minIndex + 1; + onSelectionChanging: this.selectionChanging.bind(this), + onSelectionChanged: this.selectionChanged.bind(this), + }; + } - return { - skip: minIndex, - take, - filter, - sort, - }; - }, - onSelectionChanged: this.onSelectionChanged.bind(this), + private getSelectionEventArgs(e): SelectionEventInfo { + return { + currentSelectedCardKeys: [...e.addedItemKeys], + currentDeselectedCardKeys: [...e.removedItemKeys], + selectedCardKeys: [...e.selectedItemKeys], + selectedCardsData: [...e.selectedItems], + isSelectAll: false, + isDeselectAll: false, }; } - private onSelectionChanged(e: SelectionChangedEvent): void { - this.selectedCardKeys.update([...e.selectedItemKeys]); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private selectionChanging(e: any): void { + if (e.addedItemKeys.length || e.removedItemKeys.length) { + const onSelectionChanging = this.onSelectionChanging.unreactive_get(); + const eventArgs = { + ...this.getSelectionEventArgs(e), + cancel: false, + }; + + onSelectionChanging?.(eventArgs); + e.cancel = eventArgs.cancel; + } + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private selectionChanged(e: any): void { + if (e.addedItemKeys.length || e.removedItemKeys.length) { + const onSelectionChanged = this.onSelectionChanged.unreactive_get(); + const eventArgs = this.getSelectionEventArgs(e); + + this.selectedCardKeys.update([...e.selectedItemKeys]); + onSelectionChanged?.(eventArgs); + } + } + + private isOnePageSelectAll(): boolean { + const selectionOption = this.selectionOption.unreactive_get(); + + return selectionOption?.selectAllMode === 'page'; + } + + private isSelectAll(): boolean | undefined { + const selectionHelper = this.selectionHelper.unreactive_get(); + + return selectionHelper?.getSelectAllState(this.isOnePageSelectAll()); + } + + private updateSelectionToolbarButtons( + selectedCardKeys: SelectedCardKeys, + selectionOption: SelectionOptions, + ) { + if (selectionOption.mode === SelectionMode.Multiple && selectionOption.allowSelectAll) { + const isSelectAll = this.isSelectAll(); + const isOnePageSelectAll = this.isOnePageSelectAll(); + + this.toolbarController.addDefaultItem({ + name: 'selectAllButton', + widget: 'dxButton', + options: { + icon: 'selectall', + onClick: () => { + this.selectAll(); + }, + disabled: !!isSelectAll, + text: messageLocalization.format('dxCardView-selectAll'), + }, + location: 'before', + locateInMenu: 'auto', + }); + this.toolbarController.addDefaultItem({ + name: 'clearSelectionButton', + widget: 'dxButton', + options: { + icon: 'close', + onClick: () => { + this.deselectAll(); + }, + disabled: isOnePageSelectAll ? isSelectAll === false : selectedCardKeys.length === 0, + text: messageLocalization.format('dxCardView-clearSelection'), + }, + location: 'before', + locateInMenu: 'auto', + }); + } else { + this.toolbarController.removeDefaultItem('selectAllButton'); + this.toolbarController.removeDefaultItem('clearSelectionButton'); + } } public changeCardSelection( @@ -223,8 +305,22 @@ export class SelectionController { return selectedCardKeys.includes(key); } - public clearSelection(): void { - this.selectedCardKeys.update([]); + public selectAll(): DeferredObj | undefined { + const selectionHelper = this.selectionHelper.unreactive_get(); + + return selectionHelper?.selectAll(this.isOnePageSelectAll()); + } + + public deselectAll(): DeferredObj | undefined { + const selectionHelper = this.selectionHelper.unreactive_get(); + + return selectionHelper?.deselectAll(this.isOnePageSelectAll()); + } + + public clearSelection(): DeferredObj | undefined { + const selectionHelper = this.selectionHelper.unreactive_get(); + + return selectionHelper?.clearSelection(); } public getSelectedCards(): DataRow[] { diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/selection/options.test.ts b/packages/devextreme/js/__internal/grids/new/grid_core/selection/options.test.ts index c76e0c7dfc0b..2b763c0f86b7 100644 --- a/packages/devextreme/js/__internal/grids/new/grid_core/selection/options.test.ts +++ b/packages/devextreme/js/__internal/grids/new/grid_core/selection/options.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable spellcheck/spell-checker */ import { describe, expect, it } from '@jest/globals'; import { ColumnsController } from '../columns_controller/columns_controller'; @@ -8,6 +9,7 @@ import type { Options } from '../options'; import { OptionsControllerMock } from '../options_controller/options_controller.mock'; import { SearchController } from '../search/controller'; import { SortingController } from '../sorting_controller/sorting_controller'; +import { ToolbarController } from '../toolbar/controller'; import { SelectionController } from './controller'; const setup = (config: Options = {}) => { @@ -26,16 +28,19 @@ const setup = (config: Options = {}) => { const searchController = new SearchController(optionsController); const itemsController = new ItemsController(dataController, columnsController, searchController); + const toolbarController = new ToolbarController(optionsController); const selectionController = new SelectionController( optionsController, dataController, itemsController, + toolbarController, ); return { selectionController, itemsController, + toolbarController, }; }; @@ -92,5 +97,58 @@ describe('Options', () => { }); }); }); + + describe('allowSelectAll', () => { + describe('when it is true and selection mode is \'multiple\'', () => { + it('selection should not work', () => { + const { + toolbarController, + } = setup({ + keyExpr: 'id', + dataSource: [{ id: 1, value: 'test' }], + selection: { + mode: 'multiple', + allowSelectAll: true, + }, + }); + + expect(toolbarController.items.unreactive_get()).toMatchSnapshot(); + }); + }); + + describe('when it is false and selection mode is \'multiple\'', () => { + it('selection should not work', () => { + const { + toolbarController, + } = setup({ + keyExpr: 'id', + dataSource: [{ id: 1, value: 'test' }], + selection: { + mode: 'multiple', + allowSelectAll: false, + }, + }); + + expect(toolbarController.items.unreactive_get()).toMatchSnapshot(); + }); + }); + + describe('when it is true and selection mode isn\'t \'multiple\'', () => { + it('selection should not work', () => { + const { + toolbarController, + } = setup({ + keyExpr: 'id', + dataSource: [{ id: 1, value: 'test' }], + selection: { + mode: 'single', + allowSelectAll: true, + }, + }); + + expect(toolbarController.items.unreactive_get()).toMatchSnapshot(); + }); + }); + }); }); }); diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/selection/options.ts b/packages/devextreme/js/__internal/grids/new/grid_core/selection/options.ts index ed1bbf88f434..f8378f15251e 100644 --- a/packages/devextreme/js/__internal/grids/new/grid_core/selection/options.ts +++ b/packages/devextreme/js/__internal/grids/new/grid_core/selection/options.ts @@ -1,8 +1,15 @@ -import type { SelectedCardKeys, SelectionOptions } from './types'; +import type { + SelectedCardKeys, + SelectionChangedEvent, + SelectionChangingEvent, + SelectionOptions, +} from './types'; export interface Options { selectedCardKeys?: SelectedCardKeys; selection?: SelectionOptions; + onSelectionChanging?: ((e: SelectionChangingEvent) => void); + onSelectionChanged?: ((e: SelectionChangedEvent) => void); } export const defaultOptions: Options = { @@ -10,5 +17,7 @@ export const defaultOptions: Options = { selection: { mode: 'none', showCheckBoxesMode: 'always', + allowSelectAll: true, + selectAllMode: 'allPages', }, }; diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/selection/types.ts b/packages/devextreme/js/__internal/grids/new/grid_core/selection/types.ts index ff30889ea5b5..bc6f533c538d 100644 --- a/packages/devextreme/js/__internal/grids/new/grid_core/selection/types.ts +++ b/packages/devextreme/js/__internal/grids/new/grid_core/selection/types.ts @@ -1,28 +1,42 @@ -import type { SingleMultipleOrNone } from '@js/common'; +import type { SelectAllMode, SingleMultipleOrNone } from '@js/common'; +import type { EventInfo } from '@js/common/core/events'; import type { SelectionColumnDisplayMode } from '@js/common/grids'; +import type dxCardView from '@js/ui/card_view'; -import type { DataObject, Key } from '../data_controller/types'; +import type { Key } from '../data_controller/types'; -export type SelectedCardKeys = any[]; +export type SelectedCardKeys = Key[]; -export interface SelectionChangedEvent { - selectedItems: DataObject[]; +export interface SelectionEventInfo { + readonly currentSelectedCardKeys: TKey[]; - selectedItemKeys: Key[]; + readonly currentDeselectedCardKeys: TKey[]; - addedItemKeys: Key[]; + readonly selectedCardKeys: TKey[]; - removedItemKeys: Key[]; + readonly selectedCardsData: TCardData[]; - addedItems: DataObject[]; + readonly isSelectAll: boolean; - removedItems: DataObject[]; + readonly isDeselectAll: boolean; } +export type SelectionChangingEvent = + EventInfo> & SelectionEventInfo & { + cancel: boolean | PromiseLike | PromiseLike; + }; + +export type SelectionChangedEvent = + EventInfo> & SelectionEventInfo; + export type { SelectionColumnDisplayMode as ShowCheckBoxesMode }; export interface SelectionOptions { mode: SingleMultipleOrNone; showCheckBoxesMode?: SelectionColumnDisplayMode; + + allowSelectAll?: boolean; + + selectAllMode?: SelectAllMode; } diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/toolbar/defaults.tsx b/packages/devextreme/js/__internal/grids/new/grid_core/toolbar/defaults.tsx index 44e079e6dabf..b757d432e3ff 100644 --- a/packages/devextreme/js/__internal/grids/new/grid_core/toolbar/defaults.tsx +++ b/packages/devextreme/js/__internal/grids/new/grid_core/toolbar/defaults.tsx @@ -5,4 +5,6 @@ export const DEFAULT_TOOLBAR_ITEMS = [ 'columnChooserButton', 'searchPanel', 'addCardButton', + 'selectAllButton', + 'clearSelectionButton', ] as const; diff --git a/packages/devextreme/js/localization/messages/ar.json b/packages/devextreme/js/localization/messages/ar.json index 039cacfe0db0..bc06a65226a7 100644 --- a/packages/devextreme/js/localization/messages/ar.json +++ b/packages/devextreme/js/localization/messages/ar.json @@ -350,6 +350,9 @@ "dxCalendar-selectedDateRangeCount": "There are {0} selected date ranges", "dxCalendar-readOnlyLabel": "Read-only calendar", + "dxCardView-selectAll": "Select All", + "dxCardView-clearSelection": "Clear selection", + "dxAvatar-defaultImageAlt": "Avatar", "dxChat-elementAriaLabel": "Chat", diff --git a/packages/devextreme/js/localization/messages/bg.json b/packages/devextreme/js/localization/messages/bg.json index ffe66183eb3f..23b770647304 100644 --- a/packages/devextreme/js/localization/messages/bg.json +++ b/packages/devextreme/js/localization/messages/bg.json @@ -350,6 +350,9 @@ "dxCalendar-selectedDateRangeCount": "There are {0} selected date ranges", "dxCalendar-readOnlyLabel": "Read-only calendar", + "dxCardView-selectAll": "Select All", + "dxCardView-clearSelection": "Clear selection", + "dxAvatar-defaultImageAlt": "Avatar", "dxChat-elementAriaLabel": "Chat", diff --git a/packages/devextreme/js/localization/messages/ca.json b/packages/devextreme/js/localization/messages/ca.json index 3ad6dc2eb2bc..cccd4195eea1 100644 --- a/packages/devextreme/js/localization/messages/ca.json +++ b/packages/devextreme/js/localization/messages/ca.json @@ -350,6 +350,9 @@ "dxCalendar-selectedDateRangeCount": "There are {0} selected date ranges", "dxCalendar-readOnlyLabel": "Read-only calendar", + "dxCardView-selectAll": "Select All", + "dxCardView-clearSelection": "Clear selection", + "dxAvatar-defaultImageAlt": "Avatar", "dxChat-elementAriaLabel": "Chat", diff --git a/packages/devextreme/js/localization/messages/cs.json b/packages/devextreme/js/localization/messages/cs.json index 0afcdba63d40..eda09c001363 100644 --- a/packages/devextreme/js/localization/messages/cs.json +++ b/packages/devextreme/js/localization/messages/cs.json @@ -350,6 +350,9 @@ "dxCalendar-selectedDateRangeCount": "There are {0} selected date ranges", "dxCalendar-readOnlyLabel": "Read-only calendar", + "dxCardView-selectAll": "Select All", + "dxCardView-clearSelection": "Clear selection", + "dxAvatar-defaultImageAlt": "Avatar", "dxChat-elementAriaLabel": "Chat", diff --git a/packages/devextreme/js/localization/messages/da.json b/packages/devextreme/js/localization/messages/da.json index b64093972d77..98eb60d22787 100644 --- a/packages/devextreme/js/localization/messages/da.json +++ b/packages/devextreme/js/localization/messages/da.json @@ -350,6 +350,9 @@ "dxCalendar-selectedDateRangeCount": "Der er {0} valgte datointervaller", "dxCalendar-readOnlyLabel": "Skrivebeskyttet kalender", + "dxCardView-selectAll": "Select All", + "dxCardView-clearSelection": "Clear selection", + "dxAvatar-defaultImageAlt": "Avatar", "dxChat-elementAriaLabel": "Snak", diff --git a/packages/devextreme/js/localization/messages/de.json b/packages/devextreme/js/localization/messages/de.json index d79eb59fd1f6..feab06796cba 100644 --- a/packages/devextreme/js/localization/messages/de.json +++ b/packages/devextreme/js/localization/messages/de.json @@ -350,6 +350,9 @@ "dxCalendar-selectedDateRangeCount": "There are {0} selected date ranges", "dxCalendar-readOnlyLabel": "Read-only calendar", + "dxCardView-selectAll": "Select All", + "dxCardView-clearSelection": "Clear selection", + "dxAvatar-defaultImageAlt": "Avatar", "dxChat-elementAriaLabel": "Chat", diff --git a/packages/devextreme/js/localization/messages/el.json b/packages/devextreme/js/localization/messages/el.json index cedbfab47c4b..3b30bdcbb976 100644 --- a/packages/devextreme/js/localization/messages/el.json +++ b/packages/devextreme/js/localization/messages/el.json @@ -350,6 +350,9 @@ "dxCalendar-selectedDateRangeCount": "There are {0} selected date ranges", "dxCalendar-readOnlyLabel": "Read-only calendar", + "dxCardView-selectAll": "Select All", + "dxCardView-clearSelection": "Clear selection", + "dxAvatar-defaultImageAlt": "Avatar", "dxChat-elementAriaLabel": "Chat", diff --git a/packages/devextreme/js/localization/messages/en.json b/packages/devextreme/js/localization/messages/en.json index cbaaed3d3027..bd5179956c5e 100644 --- a/packages/devextreme/js/localization/messages/en.json +++ b/packages/devextreme/js/localization/messages/en.json @@ -350,6 +350,9 @@ "dxCalendar-selectedDateRangeCount": "There are {0} selected date ranges", "dxCalendar-readOnlyLabel": "Read-only calendar", + "dxCardView-selectAll": "Select All", + "dxCardView-clearSelection": "Clear selection", + "dxAvatar-defaultImageAlt": "Avatar", "dxChat-elementAriaLabel": "Chat", diff --git a/packages/devextreme/js/localization/messages/es.json b/packages/devextreme/js/localization/messages/es.json index 7d12b2a5034e..b59c5a02be81 100644 --- a/packages/devextreme/js/localization/messages/es.json +++ b/packages/devextreme/js/localization/messages/es.json @@ -350,6 +350,9 @@ "dxCalendar-selectedDateRangeCount": "There are {0} selected date ranges", "dxCalendar-readOnlyLabel": "Read-only calendar", + "dxCardView-selectAll": "Select All", + "dxCardView-clearSelection": "Clear selection", + "dxAvatar-defaultImageAlt": "Avatar", "dxChat-elementAriaLabel": "Chat", diff --git a/packages/devextreme/js/localization/messages/fa.json b/packages/devextreme/js/localization/messages/fa.json index 4ff324cf091e..5c6f6c96bcc9 100644 --- a/packages/devextreme/js/localization/messages/fa.json +++ b/packages/devextreme/js/localization/messages/fa.json @@ -350,6 +350,9 @@ "dxCalendar-selectedDateRangeCount": "There are {0} selected date ranges", "dxCalendar-readOnlyLabel": "Read-only calendar", + "dxCardView-selectAll": "Select All", + "dxCardView-clearSelection": "Clear selection", + "dxAvatar-defaultImageAlt": "Avatar", "dxChat-elementAriaLabel": "Chat", diff --git a/packages/devextreme/js/localization/messages/fi.json b/packages/devextreme/js/localization/messages/fi.json index afc16d4fb591..f540c9ae48cf 100644 --- a/packages/devextreme/js/localization/messages/fi.json +++ b/packages/devextreme/js/localization/messages/fi.json @@ -350,6 +350,9 @@ "dxCalendar-selectedDateRangeCount": "There are {0} selected date ranges", "dxCalendar-readOnlyLabel": "Read-only calendar", + "dxCardView-selectAll": "Select All", + "dxCardView-clearSelection": "Clear selection", + "dxAvatar-defaultImageAlt": "Avatar", "dxChat-elementAriaLabel": "Chat", diff --git a/packages/devextreme/js/localization/messages/fr.json b/packages/devextreme/js/localization/messages/fr.json index 32a0d26db48f..17c283aebe5b 100644 --- a/packages/devextreme/js/localization/messages/fr.json +++ b/packages/devextreme/js/localization/messages/fr.json @@ -350,6 +350,9 @@ "dxCalendar-selectedDateRangeCount": "There are {0} selected date ranges", "dxCalendar-readOnlyLabel": "Read-only calendar", + "dxCardView-selectAll": "Select All", + "dxCardView-clearSelection": "Clear selection", + "dxAvatar-defaultImageAlt": "Avatar", "dxChat-elementAriaLabel": "Chat", diff --git a/packages/devextreme/js/localization/messages/hu.json b/packages/devextreme/js/localization/messages/hu.json index 76fcd527f557..5e81c2d81db1 100644 --- a/packages/devextreme/js/localization/messages/hu.json +++ b/packages/devextreme/js/localization/messages/hu.json @@ -350,6 +350,9 @@ "dxCalendar-selectedDateRangeCount": "There are {0} selected date ranges", "dxCalendar-readOnlyLabel": "Read-only calendar", + "dxCardView-selectAll": "Select All", + "dxCardView-clearSelection": "Clear selection", + "dxAvatar-defaultImageAlt": "Avatar", "dxChat-elementAriaLabel": "Chat", diff --git a/packages/devextreme/js/localization/messages/it.json b/packages/devextreme/js/localization/messages/it.json index cc0787857a50..b07dfe033415 100644 --- a/packages/devextreme/js/localization/messages/it.json +++ b/packages/devextreme/js/localization/messages/it.json @@ -350,6 +350,9 @@ "dxCalendar-selectedDateRangeCount": "There are {0} selected date ranges", "dxCalendar-readOnlyLabel": "Read-only calendar", + "dxCardView-selectAll": "Select All", + "dxCardView-clearSelection": "Clear selection", + "dxAvatar-defaultImageAlt": "Avatar", "dxChat-elementAriaLabel": "Chat", diff --git a/packages/devextreme/js/localization/messages/ja.json b/packages/devextreme/js/localization/messages/ja.json index a7edb92ab04b..747c34fb4e28 100644 --- a/packages/devextreme/js/localization/messages/ja.json +++ b/packages/devextreme/js/localization/messages/ja.json @@ -350,6 +350,9 @@ "dxCalendar-selectedDateRangeCount": "There are {0} selected date ranges", "dxCalendar-readOnlyLabel": "Read-only calendar", + "dxCardView-selectAll": "Select All", + "dxCardView-clearSelection": "Clear selection", + "dxAvatar-defaultImageAlt": "Avatar", "dxChat-elementAriaLabel": "Chat", diff --git a/packages/devextreme/js/localization/messages/lt.json b/packages/devextreme/js/localization/messages/lt.json index 039b0c1cd435..b821d6171d88 100644 --- a/packages/devextreme/js/localization/messages/lt.json +++ b/packages/devextreme/js/localization/messages/lt.json @@ -350,6 +350,9 @@ "dxCalendar-selectedDateRangeCount": "There are {0} selected date ranges", "dxCalendar-readOnlyLabel": "Read-only calendar", + "dxCardView-selectAll": "Select All", + "dxCardView-clearSelection": "Clear selection", + "dxAvatar-defaultImageAlt": "Avatar", "dxChat-elementAriaLabel": "Chat", diff --git a/packages/devextreme/js/localization/messages/lv.json b/packages/devextreme/js/localization/messages/lv.json index 59fe1dcca45a..ec702a5b8d18 100644 --- a/packages/devextreme/js/localization/messages/lv.json +++ b/packages/devextreme/js/localization/messages/lv.json @@ -350,6 +350,9 @@ "dxCalendar-selectedDateRangeCount": "There are {0} selected date ranges", "dxCalendar-readOnlyLabel": "Read-only calendar", + "dxCardView-selectAll": "Select All", + "dxCardView-clearSelection": "Clear selection", + "dxAvatar-defaultImageAlt": "Avatar", "dxChat-elementAriaLabel": "Chat", diff --git a/packages/devextreme/js/localization/messages/nb.json b/packages/devextreme/js/localization/messages/nb.json index 8ef0f8fbd19d..6e736afc2df4 100644 --- a/packages/devextreme/js/localization/messages/nb.json +++ b/packages/devextreme/js/localization/messages/nb.json @@ -350,6 +350,9 @@ "dxCalendar-selectedDateRangeCount": "There are {0} selected date ranges", "dxCalendar-readOnlyLabel": "Read-only calendar", + "dxCardView-selectAll": "Select All", + "dxCardView-clearSelection": "Clear selection", + "dxAvatar-defaultImageAlt": "Avatar", "dxChat-elementAriaLabel": "Chat", diff --git a/packages/devextreme/js/localization/messages/nl.json b/packages/devextreme/js/localization/messages/nl.json index 021676f8099d..f6c0d62d7d6d 100644 --- a/packages/devextreme/js/localization/messages/nl.json +++ b/packages/devextreme/js/localization/messages/nl.json @@ -350,6 +350,9 @@ "dxCalendar-selectedDateRangeCount": "There are {0} selected date ranges", "dxCalendar-readOnlyLabel": "Read-only calendar", + "dxCardView-selectAll": "Select All", + "dxCardView-clearSelection": "Clear selection", + "dxAvatar-defaultImageAlt": "Avatar", "dxChat-elementAriaLabel": "Chat", diff --git a/packages/devextreme/js/localization/messages/pl.json b/packages/devextreme/js/localization/messages/pl.json index 6da54c267144..11d71b7bbb3f 100644 --- a/packages/devextreme/js/localization/messages/pl.json +++ b/packages/devextreme/js/localization/messages/pl.json @@ -350,6 +350,9 @@ "dxCalendar-selectedDateRangeCount": "There are {0} selected date ranges", "dxCalendar-readOnlyLabel": "Read-only calendar", + "dxCardView-selectAll": "Select All", + "dxCardView-clearSelection": "Clear selection", + "dxAvatar-defaultImageAlt": "Avatar", "dxChat-elementAriaLabel": "Chat", diff --git a/packages/devextreme/js/localization/messages/pt.json b/packages/devextreme/js/localization/messages/pt.json index c81a2dfc14e2..ef3bda25b497 100644 --- a/packages/devextreme/js/localization/messages/pt.json +++ b/packages/devextreme/js/localization/messages/pt.json @@ -350,6 +350,9 @@ "dxCalendar-selectedDateRangeCount": "Há {0} intervalos de datas selecionados", "dxCalendar-readOnlyLabel": "Calendário somente leitura", + "dxCardView-selectAll": "Select All", + "dxCardView-clearSelection": "Clear selection", + "dxAvatar-defaultImageAlt": "Avatar", "dxChat-elementAriaLabel": "Chat", diff --git a/packages/devextreme/js/localization/messages/ro.json b/packages/devextreme/js/localization/messages/ro.json index 27caf28773cd..b2cad2bbdabe 100644 --- a/packages/devextreme/js/localization/messages/ro.json +++ b/packages/devextreme/js/localization/messages/ro.json @@ -350,6 +350,9 @@ "dxCalendar-selectedDateRangeCount": "There are {0} selected date ranges", "dxCalendar-readOnlyLabel": "Read-only calendar", + "dxCardView-selectAll": "Select All", + "dxCardView-clearSelection": "Clear selection", + "dxAvatar-defaultImageAlt": "Avatar", "dxChat-elementAriaLabel": "Chat", diff --git a/packages/devextreme/js/localization/messages/ru.json b/packages/devextreme/js/localization/messages/ru.json index b6c09107489d..fc00e71181b0 100644 --- a/packages/devextreme/js/localization/messages/ru.json +++ b/packages/devextreme/js/localization/messages/ru.json @@ -350,6 +350,9 @@ "dxCalendar-selectedDateRangeCount": "There are {0} selected date ranges", "dxCalendar-readOnlyLabel": "Read-only calendar", + "dxCardView-selectAll": "Select All", + "dxCardView-clearSelection": "Clear selection", + "dxAvatar-defaultImageAlt": "Avatar", "dxChat-elementAriaLabel": "Chat", diff --git a/packages/devextreme/js/localization/messages/sl.json b/packages/devextreme/js/localization/messages/sl.json index 0d4e55b25975..d11b0f56989b 100644 --- a/packages/devextreme/js/localization/messages/sl.json +++ b/packages/devextreme/js/localization/messages/sl.json @@ -350,6 +350,9 @@ "dxCalendar-selectedDateRangeCount": "There are {0} selected date ranges", "dxCalendar-readOnlyLabel": "Read-only calendar", + "dxCardView-selectAll": "Select All", + "dxCardView-clearSelection": "Clear selection", + "dxAvatar-defaultImageAlt": "Avatar", "dxChat-elementAriaLabel": "Chat", diff --git a/packages/devextreme/js/localization/messages/sv.json b/packages/devextreme/js/localization/messages/sv.json index c4accb5360e7..91524c06072d 100644 --- a/packages/devextreme/js/localization/messages/sv.json +++ b/packages/devextreme/js/localization/messages/sv.json @@ -350,6 +350,9 @@ "dxCalendar-selectedDateRangeCount": "There are {0} selected date ranges", "dxCalendar-readOnlyLabel": "Read-only calendar", + "dxCardView-selectAll": "Select All", + "dxCardView-clearSelection": "Clear selection", + "dxAvatar-defaultImageAlt": "Avatar", "dxChat-elementAriaLabel": "Chat", diff --git a/packages/devextreme/js/localization/messages/tr.json b/packages/devextreme/js/localization/messages/tr.json index 959aefea14b1..d1d017401101 100644 --- a/packages/devextreme/js/localization/messages/tr.json +++ b/packages/devextreme/js/localization/messages/tr.json @@ -350,6 +350,9 @@ "dxCalendar-selectedDateRangeCount": "There are {0} selected date ranges", "dxCalendar-readOnlyLabel": "Read-only calendar", + "dxCardView-selectAll": "Select All", + "dxCardView-clearSelection": "Clear selection", + "dxAvatar-defaultImageAlt": "Avatar", "dxChat-elementAriaLabel": "Chat", diff --git a/packages/devextreme/js/localization/messages/uk.json b/packages/devextreme/js/localization/messages/uk.json index 484699c38ec7..028231065dce 100644 --- a/packages/devextreme/js/localization/messages/uk.json +++ b/packages/devextreme/js/localization/messages/uk.json @@ -350,6 +350,9 @@ "dxCalendar-selectedDateRangeCount": "There are {0} selected date ranges", "dxCalendar-readOnlyLabel": "Read-only calendar", + "dxCardView-selectAll": "Select All", + "dxCardView-clearSelection": "Clear selection", + "dxAvatar-defaultImageAlt": "Avatar", "dxChat-elementAriaLabel": "Chat", diff --git a/packages/devextreme/js/localization/messages/vi.json b/packages/devextreme/js/localization/messages/vi.json index a76af1a4c789..b627205680bf 100644 --- a/packages/devextreme/js/localization/messages/vi.json +++ b/packages/devextreme/js/localization/messages/vi.json @@ -350,6 +350,9 @@ "dxCalendar-selectedDateRangeCount": "There are {0} selected date ranges", "dxCalendar-readOnlyLabel": "Read-only calendar", + "dxCardView-selectAll": "Select All", + "dxCardView-clearSelection": "Clear selection", + "dxAvatar-defaultImageAlt": "Avatar", "dxChat-elementAriaLabel": "Chat", diff --git a/packages/devextreme/js/localization/messages/zh-tw.json b/packages/devextreme/js/localization/messages/zh-tw.json index 4a67b665b093..590f28e05536 100644 --- a/packages/devextreme/js/localization/messages/zh-tw.json +++ b/packages/devextreme/js/localization/messages/zh-tw.json @@ -350,6 +350,9 @@ "dxCalendar-selectedDateRangeCount": "There are {0} selected date ranges", "dxCalendar-readOnlyLabel": "Read-only calendar", + "dxCardView-selectAll": "Select All", + "dxCardView-clearSelection": "Clear selection", + "dxAvatar-defaultImageAlt": "Avatar", "dxChat-elementAriaLabel": "Chat", diff --git a/packages/devextreme/js/localization/messages/zh.json b/packages/devextreme/js/localization/messages/zh.json index 9d12d9a79f4b..5cb3b1fd6022 100644 --- a/packages/devextreme/js/localization/messages/zh.json +++ b/packages/devextreme/js/localization/messages/zh.json @@ -350,6 +350,9 @@ "dxCalendar-selectedDateRangeCount": "There are {0} selected date ranges", "dxCalendar-readOnlyLabel": "Read-only calendar", + "dxCardView-selectAll": "Select All", + "dxCardView-clearSelection": "Clear selection", + "dxAvatar-defaultImageAlt": "Avatar", "dxChat-elementAriaLabel": "Chat", From cd5c94cad4a8ceb042f9dba778e63441e404e4cb Mon Sep 17 00:00:00 2001 From: Alyar <> Date: Wed, 26 Mar 2025 20:06:00 +0400 Subject: [PATCH 4/8] Add storybook example --- .../stories/card_view/CardView.stories.tsx | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/apps/react-storybook/stories/card_view/CardView.stories.tsx b/apps/react-storybook/stories/card_view/CardView.stories.tsx index fabeedd51b81..2164c9c04ae2 100644 --- a/apps/react-storybook/stories/card_view/CardView.stories.tsx +++ b/apps/react-storybook/stories/card_view/CardView.stories.tsx @@ -253,3 +253,17 @@ export const HeaderFilterStory: Story = { } } +export const SelectionStory: Story = { + ...DefaultMode, + args: { + ...DefaultMode.args, + keyExpr: 'id', + selection: { + mode: 'multiple', + showCheckBoxesMode: 'onClick', + allowSelectAll: true, + selectAllMode: 'allPages', + } + } +} + From 56c4f4104c45a7135fb444d6fe7a1b2564ab16ef Mon Sep 17 00:00:00 2001 From: Alyar <> Date: Thu, 27 Mar 2025 01:30:35 +0400 Subject: [PATCH 5/8] CardView: Implement selectCardsByIndexes and deselectCardsByIndexes methods --- .../__snapshots__/controller.test.ts.snap | 114 ++++++++++++++++++ .../grid_core/selection/controller.test.ts | 31 +++++ .../new/grid_core/selection/controller.ts | 20 +++ 3 files changed, 165 insertions(+) diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/selection/__snapshots__/controller.test.ts.snap b/packages/devextreme/js/__internal/grids/new/grid_core/selection/__snapshots__/controller.test.ts.snap index 83f018ce5358..875389acbdd4 100644 --- a/packages/devextreme/js/__internal/grids/new/grid_core/selection/__snapshots__/controller.test.ts.snap +++ b/packages/devextreme/js/__internal/grids/new/grid_core/selection/__snapshots__/controller.test.ts.snap @@ -170,6 +170,62 @@ InterruptableComputed { } `; +exports[`SelectionController deselectCardsByIndexes should deselect item 1`] = ` +InterruptableComputed { + "callbacks": Set {}, + "depInitialized": [ + true, + true, + true, + true, + ], + "depValues": [ + [ + { + "id": 1, + "value": "test", + }, + ], + [], + [], + { + "caseSensitive": false, + "enabled": true, + "searchStr": "", + }, + ], + "isInitialized": true, + "subscriptions": SubscriptionBag { + "subscriptions": [ + { + "unsubscribe": [Function], + }, + { + "unsubscribe": [Function], + }, + { + "unsubscribe": [Function], + }, + { + "unsubscribe": [Function], + }, + ], + }, + "value": [ + { + "cells": [], + "data": { + "id": 1, + "value": "test", + }, + "index": 0, + "isSelected": false, + "key": 1, + }, + ], +} +`; + exports[`SelectionController public methods changeCardSelection when the control arg equal to false should update the select state of the item 1`] = ` InterruptableComputed { "callbacks": Set {}, @@ -455,3 +511,61 @@ InterruptableComputed { ], } `; + +exports[`SelectionController selectCardsByIndexes should select item 1`] = ` +InterruptableComputed { + "callbacks": Set {}, + "depInitialized": [ + true, + true, + true, + true, + ], + "depValues": [ + [ + { + "id": 1, + "value": "test", + }, + ], + [], + [ + 1, + ], + { + "caseSensitive": false, + "enabled": true, + "searchStr": "", + }, + ], + "isInitialized": true, + "subscriptions": SubscriptionBag { + "subscriptions": [ + { + "unsubscribe": [Function], + }, + { + "unsubscribe": [Function], + }, + { + "unsubscribe": [Function], + }, + { + "unsubscribe": [Function], + }, + ], + }, + "value": [ + { + "cells": [], + "data": { + "id": 1, + "value": "test", + }, + "index": 0, + "isSelected": true, + "key": 1, + }, + ], +} +`; diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/selection/controller.test.ts b/packages/devextreme/js/__internal/grids/new/grid_core/selection/controller.test.ts index 8c535227e833..fbed51d7649f 100644 --- a/packages/devextreme/js/__internal/grids/new/grid_core/selection/controller.test.ts +++ b/packages/devextreme/js/__internal/grids/new/grid_core/selection/controller.test.ts @@ -81,6 +81,37 @@ describe('SelectionController', () => { }); }); + describe('selectCardsByIndexes', () => { + it('should select item', () => { + const { + selectionController, + itemsController, + } = setup({ + keyExpr: 'id', + dataSource: [{ id: 1, value: 'test' }], + }); + + selectionController.selectCardsByIndexes([0]); + expect(itemsController.items).toMatchSnapshot(); + }); + }); + + describe('deselectCardsByIndexes', () => { + it('should deselect item', () => { + const { + selectionController, + itemsController, + } = setup({ + keyExpr: 'id', + dataSource: [{ id: 1, value: 'test' }], + selectedCardKeys: [1], + }); + + selectionController.deselectCardsByIndexes([0]); + expect(itemsController.items).toMatchSnapshot(); + }); + }); + describe('changeCardSelection', () => { describe('when the control arg equal to false', () => { it('should update the select state of the item', () => { diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/selection/controller.ts b/packages/devextreme/js/__internal/grids/new/grid_core/selection/controller.ts index 867c2ec89b9a..5e10621efd5d 100644 --- a/packages/devextreme/js/__internal/grids/new/grid_core/selection/controller.ts +++ b/packages/devextreme/js/__internal/grids/new/grid_core/selection/controller.ts @@ -272,6 +272,14 @@ export class SelectionController { } } + private getItemKeysByIndexes(indexes: number[]): Key[] { + const items = this.itemsController.items.unreactive_get(); + + return indexes + .map((index) => items[index]?.key) + .filter((key) => key !== undefined); + } + public changeCardSelection( cardIndex: number, options?: { control?: boolean; shift?: boolean }, @@ -293,12 +301,24 @@ export class SelectionController { return selectionHelper?.selectedItemKeys(keys, preserve); } + public selectCardsByIndexes(indexes: number[]): DeferredObj | undefined { + const keys = this.getItemKeysByIndexes(indexes); + + return this.selectCards(keys); + } + public deselectCards(keys: Key[]): DeferredObj | undefined { const selectionHelper = this.selectionHelper?.unreactive_get(); return selectionHelper?.selectedItemKeys(keys, true, true); } + public deselectCardsByIndexes(indexes: number[]): DeferredObj | undefined { + const keys = this.getItemKeysByIndexes(indexes); + + return this.deselectCards(keys); + } + public isCardSelected(key: Key): boolean { const selectedCardKeys = this.selectedCardKeys.unreactive_get(); From 7b082d00cd43865ebd7e03c01049d7ddd3a19293 Mon Sep 17 00:00:00 2001 From: Alyar <> Date: Fri, 28 Mar 2025 05:13:35 +0400 Subject: [PATCH 6/8] CardView: Add e2e tests --- .../tests/cardView/selection/functional.ts | 970 ++++++++++++++++++ .../tests/cardView/selection/visual.ts | 355 +++++++ .../grids/new/card_view/content_view/view.tsx | 4 +- .../new/grid_core/selection/controller.ts | 39 +- packages/testcafe-models/cardView/card.ts | 16 + packages/testcafe-models/cardView/index.ts | 30 + packages/testcafe-models/cardView/toolbar.ts | 27 + 7 files changed, 1420 insertions(+), 21 deletions(-) create mode 100644 e2e/testcafe-devextreme/tests/cardView/selection/functional.ts create mode 100644 e2e/testcafe-devextreme/tests/cardView/selection/visual.ts create mode 100644 packages/testcafe-models/cardView/toolbar.ts diff --git a/e2e/testcafe-devextreme/tests/cardView/selection/functional.ts b/e2e/testcafe-devextreme/tests/cardView/selection/functional.ts new file mode 100644 index 000000000000..49a82523f63e --- /dev/null +++ b/e2e/testcafe-devextreme/tests/cardView/selection/functional.ts @@ -0,0 +1,970 @@ +import CardView from 'devextreme-testcafe-models/cardView'; +import { ClientFunction } from 'testcafe'; +import url from '../../../helpers/getPageUrl'; +import { createWidget } from '../../../helpers/createWidget'; + +fixture.disablePageReloads`Selection.Functional` + .page(url(__dirname, '../../container.html')); + +const CARD_VIEW_SELECTOR = '#container'; + +test('Single mode: select a first card -> select a second card -> deselect a second card', async (t) => { + // arrange + const cardView = new CardView(CARD_VIEW_SELECTOR); + const firstCard = cardView.getCard(0); + const secondCard = cardView.getCard(1); + + // act + await t.click(firstCard.element); + + // assert + await t.expect(firstCard.isSelected).ok(); + + // act + await t.click(secondCard.element); + + // assert + await t + .expect(firstCard.isSelected) + .notOk() + .expect(secondCard.isSelected) + .ok(); + + // act + await t.click(secondCard.element, { modifiers: { ctrl: true } }); + + // assert + await t + .expect(firstCard.isSelected) + .notOk() + .expect(secondCard.isSelected) + .notOk(); +}).before(async () => createWidget('dxCardView', { + dataSource: [ + { + id: 0, title: 'header1', A: 'A_0', B: 'B_0', C: 'C_0', + }, + { + id: 1, title: 'header2', A: 'A_1', B: 'B_1', C: 'C_1', + }, + { + id: 2, title: 'header3', A: 'A_2', B: 'B_2', C: 'C_2', + }, + { + id: 3, title: 'header4', A: 'A_3', B: 'B_3', C: 'C_3', + }, + { + id: 4, title: 'header5', A: 'A_4', B: 'B_4', C: 'C_4', + }, + ], + cardHeader: { + captionExpr: () => 'title', + }, + columns: ['A', 'B', 'C'], + keyExpr: 'id', + height: 700, + selection: { + mode: 'single', + }, +})); + +test('Multiple mode with showCheckBoxesMode=\'always\': select a first card -> select a second card -> deselect a first card -> deselect a second card', async (t) => { + // arrange + const cardView = new CardView(CARD_VIEW_SELECTOR); + const firstCard = cardView.getCard(0); + const firstSelectCheckbox = firstCard.getSelectCheckbox(); + const secondCard = cardView.getCard(1); + const secondSelectCheckbox = secondCard.getSelectCheckbox(); + + // act + await t.click(firstSelectCheckbox); + + // assert + await t.expect(firstCard.isSelected).ok(); + + // act + await t.click(secondSelectCheckbox); + + // assert + await t + .expect(firstCard.isSelected) + .ok() + .expect(secondCard.isSelected) + .ok(); + + // act + await t.click(firstSelectCheckbox); + + // assert + await t + .expect(firstCard.isSelected) + .notOk() + .expect(secondCard.isSelected) + .ok(); + + // act + await t.click(secondSelectCheckbox); + + // assert + await t + .expect(firstCard.isSelected) + .notOk() + .expect(secondCard.isSelected) + .notOk(); +}).before(async () => createWidget('dxCardView', { + dataSource: [ + { + id: 0, title: 'header1', A: 'A_0', B: 'B_0', C: 'C_0', + }, + { + id: 1, title: 'header2', A: 'A_1', B: 'B_1', C: 'C_1', + }, + { + id: 2, title: 'header3', A: 'A_2', B: 'B_2', C: 'C_2', + }, + { + id: 3, title: 'header4', A: 'A_3', B: 'B_3', C: 'C_3', + }, + { + id: 4, title: 'header5', A: 'A_4', B: 'B_4', C: 'C_4', + }, + ], + cardHeader: { + captionExpr: () => 'title', + }, + columns: ['A', 'B', 'C'], + keyExpr: 'id', + height: 700, + selection: { + mode: 'multiple', + showCheckBoxesMode: 'always', + allowSelectAll: true, + }, +})); + +test('Multiple mode with showCheckBoxesMode=\'always\': select a several cards with shift -> unselect a several cards with shift', async (t) => { + // arrange + const cardView = new CardView(CARD_VIEW_SELECTOR); + const firstCard = cardView.getCard(0); + const secondCard = cardView.getCard(1); + const thirdCard = cardView.getCard(2); + const firstSelectCheckbox = firstCard.getSelectCheckbox(); + const thirdSelectCheckbox = thirdCard.getSelectCheckbox(); + + // act + await t.click(firstSelectCheckbox); + + // assert + await t.expect(firstCard.isSelected).ok(); + + // act + await t.click(thirdSelectCheckbox, { modifiers: { shift: true } }); + + // assert + await t + .expect(firstCard.isSelected) + .ok() + .expect(secondCard.isSelected) + .ok() + .expect(thirdCard.isSelected) + .ok(); + + // act + await t.click(firstSelectCheckbox, { modifiers: { shift: true } }); + + // assert + await t + .expect(firstCard.isSelected) + .ok() + .expect(secondCard.isSelected) + .notOk() + .expect(thirdCard.isSelected) + .notOk(); +}).before(async () => createWidget('dxCardView', { + dataSource: [ + { + id: 0, title: 'header1', A: 'A_0', B: 'B_0', C: 'C_0', + }, + { + id: 1, title: 'header2', A: 'A_1', B: 'B_1', C: 'C_1', + }, + { + id: 2, title: 'header3', A: 'A_2', B: 'B_2', C: 'C_2', + }, + { + id: 3, title: 'header4', A: 'A_3', B: 'B_3', C: 'C_3', + }, + { + id: 4, title: 'header5', A: 'A_4', B: 'B_4', C: 'C_4', + }, + ], + cardHeader: { + captionExpr: () => 'title', + }, + columns: ['A', 'B', 'C'], + keyExpr: 'id', + height: 700, + selection: { + mode: 'multiple', + showCheckBoxesMode: 'always', + allowSelectAll: true, + }, +})); + +test('Multiple mode with showCheckBoxesMode=\'onClick\': select a first card by clicking a checkbox -> deselect a first card by clicking a checkbox', async (t) => { + // arrange + const cardView = new CardView(CARD_VIEW_SELECTOR); + const firstCard = cardView.getCard(0); + const firstSelectCheckbox = firstCard.getSelectCheckbox(); + const firstSelectCheckboxItemContent = firstCard.getToolbarItemContent(0); + + // act + await t.hover(firstSelectCheckboxItemContent); + + // assert + await t.expect(firstSelectCheckbox.visible).ok(); + + // act + await t.click(firstSelectCheckbox); + + // assert + await t + .expect(firstCard.isSelected) + .ok() + .expect(cardView.isCheckBoxesHidden()) + .notOk(); + + // act + await t.click(firstSelectCheckbox); + + // assert + await t + .expect(firstCard.isSelected) + .notOk() + .expect(cardView.isCheckBoxesHidden()) + .ok(); +}).before(async () => createWidget('dxCardView', { + dataSource: [ + { + id: 0, title: 'header1', A: 'A_0', B: 'B_0', C: 'C_0', + }, + { + id: 1, title: 'header2', A: 'A_1', B: 'B_1', C: 'C_1', + }, + { + id: 2, title: 'header3', A: 'A_2', B: 'B_2', C: 'C_2', + }, + { + id: 3, title: 'header4', A: 'A_3', B: 'B_3', C: 'C_3', + }, + { + id: 4, title: 'header5', A: 'A_4', B: 'B_4', C: 'C_4', + }, + ], + cardHeader: { + captionExpr: () => 'title', + }, + columns: ['A', 'B', 'C'], + keyExpr: 'id', + height: 700, + selection: { + mode: 'multiple', + showCheckBoxesMode: 'onClick', + allowSelectAll: true, + }, +})); + +test('Multiple mode with showCheckBoxesMode=\'onClick\': select a first card by clicking a card -> deselect a first card by clicking a card', async (t) => { + // arrange + const cardView = new CardView(CARD_VIEW_SELECTOR); + const firstCard = cardView.getCard(0); + + // act + await t.click(firstCard.element); + + // assert + await t + .expect(firstCard.isSelected) + .ok() + .expect(cardView.isCheckBoxesHidden()) + .ok(); + + // act + await t.click(firstCard.element, { modifiers: { ctrl: true } }); + + // assert + await t + .expect(firstCard.isSelected) + .notOk() + .expect(cardView.isCheckBoxesHidden()) + .ok(); +}).before(async () => createWidget('dxCardView', { + dataSource: [ + { + id: 0, title: 'header1', A: 'A_0', B: 'B_0', C: 'C_0', + }, + { + id: 1, title: 'header2', A: 'A_1', B: 'B_1', C: 'C_1', + }, + { + id: 2, title: 'header3', A: 'A_2', B: 'B_2', C: 'C_2', + }, + { + id: 3, title: 'header4', A: 'A_3', B: 'B_3', C: 'C_3', + }, + { + id: 4, title: 'header5', A: 'A_4', B: 'B_4', C: 'C_4', + }, + ], + cardHeader: { + captionExpr: () => 'title', + }, + columns: ['A', 'B', 'C'], + keyExpr: 'id', + height: 700, + selection: { + mode: 'multiple', + showCheckBoxesMode: 'onClick', + allowSelectAll: true, + }, +})); + +test('Multiple mode with showCheckBoxesMode=\'onClick\': select a first card -> select a second card (first card selection state is reset) -> select a first card with ctrl', async (t) => { + // arrange + const cardView = new CardView(CARD_VIEW_SELECTOR); + const firstCard = cardView.getCard(0); + const secondCard = cardView.getCard(1); + + // act + await t.click(firstCard.element); + + // assert + await t + .expect(firstCard.isSelected) + .ok() + .expect(cardView.isCheckBoxesHidden()) + .ok(); + + // act + await t.click(secondCard.element); + + // assert + await t + .expect(firstCard.isSelected) + .notOk() + .expect(secondCard.isSelected) + .ok() + .expect(cardView.isCheckBoxesHidden()) + .ok(); + + // act + await t.click(firstCard.element, { modifiers: { ctrl: true } }); + + // assert + await t + .expect(firstCard.isSelected) + .ok() + .expect(secondCard.isSelected) + .ok() + .expect(cardView.isCheckBoxesHidden()) + .notOk(); +}).before(async () => createWidget('dxCardView', { + dataSource: [ + { + id: 0, title: 'header1', A: 'A_0', B: 'B_0', C: 'C_0', + }, + { + id: 1, title: 'header2', A: 'A_1', B: 'B_1', C: 'C_1', + }, + { + id: 2, title: 'header3', A: 'A_2', B: 'B_2', C: 'C_2', + }, + { + id: 3, title: 'header4', A: 'A_3', B: 'B_3', C: 'C_3', + }, + { + id: 4, title: 'header5', A: 'A_4', B: 'B_4', C: 'C_4', + }, + ], + cardHeader: { + captionExpr: () => 'title', + }, + columns: ['A', 'B', 'C'], + keyExpr: 'id', + height: 700, + selection: { + mode: 'multiple', + showCheckBoxesMode: 'onClick', + allowSelectAll: true, + }, +})); + +test('Multiple mode with showCheckBoxesMode=\'onClick\': select a first card by card hold -> deselect a first card by card hold', async (t) => { + // arrange + const cardView = new CardView(CARD_VIEW_SELECTOR); + const firstCard = cardView.getCard(0); + + // act + await ClientFunction((card) => { + $(card()).trigger('dxhold'); + })(firstCard.element); + + // assert + await t + .expect(firstCard.isSelected) + .ok() + .expect(cardView.isCheckBoxesHidden()) + .notOk(); + + // act + await ClientFunction((card) => { + $(card()).trigger('dxhold'); + })(firstCard.element); + + // assert + await t + .expect(firstCard.isSelected) + .notOk() + .expect(cardView.isCheckBoxesHidden()) + .ok(); +}).before(async () => createWidget('dxCardView', { + dataSource: [ + { + id: 0, title: 'header1', A: 'A_0', B: 'B_0', C: 'C_0', + }, + { + id: 1, title: 'header2', A: 'A_1', B: 'B_1', C: 'C_1', + }, + { + id: 2, title: 'header3', A: 'A_2', B: 'B_2', C: 'C_2', + }, + { + id: 3, title: 'header4', A: 'A_3', B: 'B_3', C: 'C_3', + }, + { + id: 4, title: 'header5', A: 'A_4', B: 'B_4', C: 'C_4', + }, + ], + cardHeader: { + captionExpr: () => 'title', + }, + columns: ['A', 'B', 'C'], + keyExpr: 'id', + height: 700, + selection: { + mode: 'multiple', + showCheckBoxesMode: 'onClick', + allowSelectAll: true, + }, +})); + +test('Multiple mode with showCheckBoxesMode=\'onLongTap\': select a several cards', async (t) => { + // arrange + const cardView = new CardView(CARD_VIEW_SELECTOR); + const firstCard = cardView.getCard(0); + const secondCard = cardView.getCard(1); + + // act + await ClientFunction((card) => { + $(card()).trigger('dxhold'); + })(firstCard.element); + + // assert + await t + .expect(firstCard.isSelected) + .notOk() + .expect(cardView.isCheckBoxesHidden()) + .notOk(); + + // act + await t.click(firstCard.element); + + // assert + await t + .expect(firstCard.isSelected) + .ok(); + + await t.click(secondCard.element); + + // assert + await t + .expect(secondCard.isSelected) + .ok(); +}).before(async () => createWidget('dxCardView', { + dataSource: [ + { + id: 0, title: 'header1', A: 'A_0', B: 'B_0', C: 'C_0', + }, + { + id: 1, title: 'header2', A: 'A_1', B: 'B_1', C: 'C_1', + }, + { + id: 2, title: 'header3', A: 'A_2', B: 'B_2', C: 'C_2', + }, + { + id: 3, title: 'header4', A: 'A_3', B: 'B_3', C: 'C_3', + }, + { + id: 4, title: 'header5', A: 'A_4', B: 'B_4', C: 'C_4', + }, + ], + cardHeader: { + captionExpr: () => 'title', + }, + columns: ['A', 'B', 'C'], + keyExpr: 'id', + height: 700, + selection: { + mode: 'multiple', + showCheckBoxesMode: 'onLongTap', + allowSelectAll: true, + }, +})); + +test('Select all when selectAllMode = \'allPages\'', async (t) => { + // arrange + const cardView = new CardView(CARD_VIEW_SELECTOR); + const toolbar = cardView.getToolbar(); + const selectAllButton = toolbar.getSelectAllButton(); + + // act + await t.click(selectAllButton); + + // assert + await t + .expect(toolbar.isSelectAllButtonDisabled()) + .ok() + .expect(toolbar.isClearSelectionButtonDisabled()) + .notOk() + .expect(cardView.getSelectedCardKeys()) + .eql([0, 1, 2, 3, 4]); +}).before(async () => createWidget('dxCardView', { + dataSource: [ + { + id: 0, title: 'header1', A: 'A_0', B: 'B_0', C: 'C_0', + }, + { + id: 1, title: 'header2', A: 'A_1', B: 'B_1', C: 'C_1', + }, + { + id: 2, title: 'header3', A: 'A_2', B: 'B_2', C: 'C_2', + }, + { + id: 3, title: 'header4', A: 'A_3', B: 'B_3', C: 'C_3', + }, + { + id: 4, title: 'header5', A: 'A_4', B: 'B_4', C: 'C_4', + }, + ], + cardHeader: { + captionExpr: () => 'title', + }, + columns: ['A', 'B', 'C'], + keyExpr: 'id', + height: 700, + selection: { + mode: 'multiple', + showCheckBoxesMode: 'always', + allowSelectAll: true, + selectAllMode: 'allPages', + }, +})); + +test('Deselect all when selectAllMode = \'allPages\'', async (t) => { + // arrange + const cardView = new CardView(CARD_VIEW_SELECTOR); + const toolbar = cardView.getToolbar(); + const clearSelectionButton = toolbar.getClearSelectionButton(); + + // assert + await t + .expect(toolbar.isSelectAllButtonDisabled()) + .ok() + .expect(toolbar.isClearSelectionButtonDisabled()) + .notOk(); + + // act + await t.click(clearSelectionButton); + + // assert + await t + .expect(toolbar.isSelectAllButtonDisabled()) + .notOk() + .expect(toolbar.isClearSelectionButtonDisabled()) + .ok() + .expect(cardView.getSelectedCardKeys()) + .eql([]); +}).before(async () => createWidget('dxCardView', { + dataSource: [ + { + id: 0, title: 'header1', A: 'A_0', B: 'B_0', C: 'C_0', + }, + { + id: 1, title: 'header2', A: 'A_1', B: 'B_1', C: 'C_1', + }, + { + id: 2, title: 'header3', A: 'A_2', B: 'B_2', C: 'C_2', + }, + { + id: 3, title: 'header4', A: 'A_3', B: 'B_3', C: 'C_3', + }, + { + id: 4, title: 'header5', A: 'A_4', B: 'B_4', C: 'C_4', + }, + ], + cardHeader: { + captionExpr: () => 'title', + }, + columns: ['A', 'B', 'C'], + keyExpr: 'id', + height: 700, + selectedCardKeys: [0, 1, 2, 3, 4], + selection: { + mode: 'multiple', + showCheckBoxesMode: 'always', + allowSelectAll: true, + selectAllMode: 'allPages', + }, +})); + +test('Select all when selectAllMode = \'page\'', async (t) => { + // arrange + const cardView = new CardView(CARD_VIEW_SELECTOR); + const toolbar = cardView.getToolbar(); + const selectAllButton = toolbar.getSelectAllButton(); + + // act + await t.click(selectAllButton); + + // assert + await t + .expect(toolbar.isSelectAllButtonDisabled()) + .ok() + .expect(toolbar.isClearSelectionButtonDisabled()) + .notOk() + .expect(cardView.getSelectedCardKeys()) + .eql([0, 1, 2]); +}).before(async () => createWidget('dxCardView', { + dataSource: [ + { + id: 0, title: 'header1', A: 'A_0', B: 'B_0', C: 'C_0', + }, + { + id: 1, title: 'header2', A: 'A_1', B: 'B_1', C: 'C_1', + }, + { + id: 2, title: 'header3', A: 'A_2', B: 'B_2', C: 'C_2', + }, + { + id: 3, title: 'header4', A: 'A_3', B: 'B_3', C: 'C_3', + }, + { + id: 4, title: 'header5', A: 'A_4', B: 'B_4', C: 'C_4', + }, + ], + cardHeader: { + captionExpr: () => 'title', + }, + paging: { + pageSize: 3, + }, + columns: ['A', 'B', 'C'], + keyExpr: 'id', + height: 700, + selection: { + mode: 'multiple', + showCheckBoxesMode: 'always', + allowSelectAll: true, + selectAllMode: 'page', + }, +})); + +test('Deselect all when selectAllMode = \'page\'', async (t) => { + // arrange + const cardView = new CardView(CARD_VIEW_SELECTOR); + const toolbar = cardView.getToolbar(); + const clearSelectionButton = toolbar.getClearSelectionButton(); + + // assert + await t + .expect(toolbar.isSelectAllButtonDisabled()) + .ok() + .expect(toolbar.isClearSelectionButtonDisabled()) + .notOk(); + + // act + await t.click(clearSelectionButton); + + // assert + await t + .expect(toolbar.isSelectAllButtonDisabled()) + .notOk() + .expect(toolbar.isClearSelectionButtonDisabled()) + .ok() + .expect(cardView.getSelectedCardKeys()) + .eql([]); +}).before(async () => createWidget('dxCardView', { + dataSource: [ + { + id: 0, title: 'header1', A: 'A_0', B: 'B_0', C: 'C_0', + }, + { + id: 1, title: 'header2', A: 'A_1', B: 'B_1', C: 'C_1', + }, + { + id: 2, title: 'header3', A: 'A_2', B: 'B_2', C: 'C_2', + }, + { + id: 3, title: 'header4', A: 'A_3', B: 'B_3', C: 'C_3', + }, + { + id: 4, title: 'header5', A: 'A_4', B: 'B_4', C: 'C_4', + }, + ], + cardHeader: { + captionExpr: () => 'title', + }, + paging: { + pageSize: 3, + }, + columns: ['A', 'B', 'C'], + keyExpr: 'id', + height: 700, + selectedCardKeys: [0, 1, 2], + selection: { + mode: 'multiple', + showCheckBoxesMode: 'always', + allowSelectAll: true, + selectAllMode: 'page', + }, +})); + +test('The states of the Select All and Clear selection buttons should update correctly after changing the page when selectAllMode = \'allPages\'', async (t) => { + // arrange + const cardView = new CardView(CARD_VIEW_SELECTOR); + const toolbar = cardView.getToolbar(); + + // assert + await t + .expect(toolbar.isSelectAllButtonDisabled()) + .ok() + .expect(toolbar.isClearSelectionButtonDisabled()) + .notOk(); + + // act + await cardView.apiPageIndex(1); + + // assert + await t + .expect(toolbar.isSelectAllButtonDisabled()) + .ok() + .expect(toolbar.isClearSelectionButtonDisabled()) + .notOk(); +}).before(async () => createWidget('dxCardView', { + dataSource: [ + { + id: 0, title: 'header1', A: 'A_0', B: 'B_0', C: 'C_0', + }, + { + id: 1, title: 'header2', A: 'A_1', B: 'B_1', C: 'C_1', + }, + { + id: 2, title: 'header3', A: 'A_2', B: 'B_2', C: 'C_2', + }, + { + id: 3, title: 'header4', A: 'A_3', B: 'B_3', C: 'C_3', + }, + { + id: 4, title: 'header5', A: 'A_4', B: 'B_4', C: 'C_4', + }, + ], + cardHeader: { + captionExpr: () => 'title', + }, + paging: { + pageSize: 3, + }, + columns: ['A', 'B', 'C'], + keyExpr: 'id', + height: 700, + selectedCardKeys: [0, 1, 2, 3, 4], + selection: { + mode: 'multiple', + showCheckBoxesMode: 'always', + allowSelectAll: true, + selectAllMode: 'allPages', + }, +})); + +test('The states of the Select All and Clear selection buttons should update correctly after changing the page when selectAllMode = \'page\'', async (t) => { + // arrange + const cardView = new CardView(CARD_VIEW_SELECTOR); + const toolbar = cardView.getToolbar(); + + // assert + await t + .expect(toolbar.isSelectAllButtonDisabled()) + .ok() + .expect(toolbar.isClearSelectionButtonDisabled()) + .notOk(); + + // act + await cardView.apiPageIndex(1); + + // assert + await t + .expect(toolbar.isSelectAllButtonDisabled()) + .notOk() + .expect(toolbar.isClearSelectionButtonDisabled()) + .ok() + .expect(cardView.getSelectedCardKeys()) + .eql([0, 1, 2]); +}).before(async () => createWidget('dxCardView', { + dataSource: [ + { + id: 0, title: 'header1', A: 'A_0', B: 'B_0', C: 'C_0', + }, + { + id: 1, title: 'header2', A: 'A_1', B: 'B_1', C: 'C_1', + }, + { + id: 2, title: 'header3', A: 'A_2', B: 'B_2', C: 'C_2', + }, + { + id: 3, title: 'header4', A: 'A_3', B: 'B_3', C: 'C_3', + }, + { + id: 4, title: 'header5', A: 'A_4', B: 'B_4', C: 'C_4', + }, + ], + cardHeader: { + captionExpr: () => 'title', + }, + paging: { + pageSize: 3, + }, + columns: ['A', 'B', 'C'], + keyExpr: 'id', + height: 700, + selectedCardKeys: [0, 1, 2], + selection: { + mode: 'multiple', + showCheckBoxesMode: 'always', + allowSelectAll: true, + selectAllMode: 'page', + }, +})); + +test('Switching the showCheckBoxesMode option from onClick to always at runtime should work correctly', async (t) => { + // arrange + const cardView = new CardView(CARD_VIEW_SELECTOR); + const firstCard = cardView.getCard(0); + + // assert + await t + .expect(cardView.isCheckBoxesHidden()) + .ok(); + + // act + await cardView.apiOption('selection.showCheckBoxesMode', 'always'); + + // assert + await t + .expect(cardView.isCheckBoxesHidden()) + .notOk(); + + // act + await t.click(firstCard.element); + + // assert + await t + .expect(firstCard.isSelected) + .notOk() + .expect(cardView.getSelectedCardKeys()) + .eql([]); +}).before(async () => createWidget('dxCardView', { + dataSource: [ + { + id: 0, title: 'header1', A: 'A_0', B: 'B_0', C: 'C_0', + }, + { + id: 1, title: 'header2', A: 'A_1', B: 'B_1', C: 'C_1', + }, + { + id: 2, title: 'header3', A: 'A_2', B: 'B_2', C: 'C_2', + }, + { + id: 3, title: 'header4', A: 'A_3', B: 'B_3', C: 'C_3', + }, + { + id: 4, title: 'header5', A: 'A_4', B: 'B_4', C: 'C_4', + }, + ], + cardHeader: { + captionExpr: () => 'title', + }, + columns: ['A', 'B', 'C'], + keyExpr: 'id', + height: 700, + selection: { + mode: 'multiple', + showCheckBoxesMode: 'onClick', + }, +})); + +test('Switching the showCheckBoxesMode option from always to onClick at runtime should work correctly', async (t) => { + // arrange + const cardView = new CardView(CARD_VIEW_SELECTOR); + const firstCard = cardView.getCard(0); + + // assert + await t + .expect(cardView.isCheckBoxesHidden()) + .notOk(); + + // act + await cardView.apiOption('selection.showCheckBoxesMode', 'onClick'); + + // assert + await t + .expect(cardView.isCheckBoxesHidden()) + .ok(); + + // act + await t.click(firstCard.element); + + // assert + await t + .expect(firstCard.isSelected) + .ok() + .expect(cardView.getSelectedCardKeys()) + .eql([0]); +}).before(async () => createWidget('dxCardView', { + dataSource: [ + { + id: 0, title: 'header1', A: 'A_0', B: 'B_0', C: 'C_0', + }, + { + id: 1, title: 'header2', A: 'A_1', B: 'B_1', C: 'C_1', + }, + { + id: 2, title: 'header3', A: 'A_2', B: 'B_2', C: 'C_2', + }, + { + id: 3, title: 'header4', A: 'A_3', B: 'B_3', C: 'C_3', + }, + { + id: 4, title: 'header5', A: 'A_4', B: 'B_4', C: 'C_4', + }, + ], + cardHeader: { + captionExpr: () => 'title', + }, + columns: ['A', 'B', 'C'], + keyExpr: 'id', + height: 700, + selection: { + mode: 'multiple', + showCheckBoxesMode: 'always', + }, +})); diff --git a/e2e/testcafe-devextreme/tests/cardView/selection/visual.ts b/e2e/testcafe-devextreme/tests/cardView/selection/visual.ts new file mode 100644 index 000000000000..cd453c1b8aa3 --- /dev/null +++ b/e2e/testcafe-devextreme/tests/cardView/selection/visual.ts @@ -0,0 +1,355 @@ +import CardView from 'devextreme-testcafe-models/cardView'; +import { createScreenshotsComparer } from 'devextreme-screenshot-comparer'; +import { ClientFunction } from 'testcafe'; +import url from '../../../helpers/getPageUrl'; +import { createWidget } from '../../../helpers/createWidget'; +import { testScreenshot } from '../../../helpers/themeUtils'; + +fixture.skip`Selection.Visual` + .page(url(__dirname, '../../container.html')); + +const CARD_VIEW_SELECTOR = '#container'; + +test('Single mode', async (t) => { + const { takeScreenshot, compareResults } = createScreenshotsComparer(t); + const cardView = new CardView(CARD_VIEW_SELECTOR); + + await testScreenshot(t, takeScreenshot, 'card-view_single_selection.png', { element: cardView.element }); + + await t + .expect(compareResults.isValid()) + .ok(compareResults.errorMessages()); +}).before(async () => createWidget('dxCardView', { + dataSource: [ + { + id: 0, title: 'header1', A: 'A_0', B: 'B_0', C: 'C_0', + }, + { + id: 1, title: 'header2', A: 'A_1', B: 'B_1', C: 'C_1', + }, + { + id: 2, title: 'header3', A: 'A_2', B: 'B_2', C: 'C_2', + }, + { + id: 3, title: 'header4', A: 'A_3', B: 'B_3', C: 'C_3', + }, + { + id: 4, title: 'header5', A: 'A_4', B: 'B_4', C: 'C_4', + }, + ], + cardHeader: { + captionExpr: () => 'title', + }, + columns: ['A', 'B', 'C'], + keyExpr: 'id', + height: 700, + selectedCardKeys: [0], + selection: { + mode: 'single', + }, +})); + +test('Multiple mode with Select All/Deselect All and showCheckBoxesMode = \'none\'', async (t) => { + const { takeScreenshot, compareResults } = createScreenshotsComparer(t); + const cardView = new CardView(CARD_VIEW_SELECTOR); + + await testScreenshot(t, takeScreenshot, 'card-view_miltiple_selection_with_showCheckBoxesMode_=_none.png', { element: cardView.element }); + + await t + .expect(compareResults.isValid()) + .ok(compareResults.errorMessages()); +}).before(async () => createWidget('dxCardView', { + dataSource: [ + { + id: 0, title: 'header1', A: 'A_0', B: 'B_0', C: 'C_0', + }, + { + id: 1, title: 'header2', A: 'A_1', B: 'B_1', C: 'C_1', + }, + { + id: 2, title: 'header3', A: 'A_2', B: 'B_2', C: 'C_2', + }, + { + id: 3, title: 'header4', A: 'A_3', B: 'B_3', C: 'C_3', + }, + { + id: 4, title: 'header5', A: 'A_4', B: 'B_4', C: 'C_4', + }, + ], + cardHeader: { + captionExpr: () => 'title', + }, + columns: ['A', 'B', 'C'], + keyExpr: 'id', + height: 700, + selection: { + mode: 'multiple', + showCheckBoxesMode: 'none', + allowSelectAll: true, + }, +})); + +test('Multiple mode with Select All/Deselect All and showCheckBoxesMode = \'always\'', async (t) => { + const { takeScreenshot, compareResults } = createScreenshotsComparer(t); + const cardView = new CardView(CARD_VIEW_SELECTOR); + + await testScreenshot(t, takeScreenshot, 'card-view_miltiple_selection_with_showCheckBoxesMode_=_always.png', { element: cardView.element }); + + await t + .expect(compareResults.isValid()) + .ok(compareResults.errorMessages()); +}).before(async () => createWidget('dxCardView', { + dataSource: [ + { + id: 0, title: 'header1', A: 'A_0', B: 'B_0', C: 'C_0', + }, + { + id: 1, title: 'header2', A: 'A_1', B: 'B_1', C: 'C_1', + }, + { + id: 2, title: 'header3', A: 'A_2', B: 'B_2', C: 'C_2', + }, + { + id: 3, title: 'header4', A: 'A_3', B: 'B_3', C: 'C_3', + }, + { + id: 4, title: 'header5', A: 'A_4', B: 'B_4', C: 'C_4', + }, + ], + cardHeader: { + captionExpr: () => 'title', + }, + columns: ['A', 'B', 'C'], + keyExpr: 'id', + height: 700, + selection: { + mode: 'multiple', + showCheckBoxesMode: 'always', + allowSelectAll: true, + }, +})); + +test('Multiple mode with Select All/Deselect All and showCheckBoxesMode = \'onClick\'', async (t) => { + const { takeScreenshot, compareResults } = createScreenshotsComparer(t); + const cardView = new CardView(CARD_VIEW_SELECTOR); + const firstSelectCheckboxItemContent = cardView + .getCard(0) + .getToolbarItemContent(0); + + await testScreenshot(t, takeScreenshot, 'card-view_miltiple_selection_with_showCheckBoxesMode_=_onClick_1.png', { element: cardView.element }); + + await t.hover(firstSelectCheckboxItemContent); + + await testScreenshot(t, takeScreenshot, 'card-view_miltiple_selection_with_showCheckBoxesMode_=_onClick_2.png', { element: cardView.element }); + + await t.hover(cardView.element); + + await testScreenshot(t, takeScreenshot, 'card-view_miltiple_selection_with_showCheckBoxesMode_=_onClick_3.png', { element: cardView.element }); + + await t + .expect(compareResults.isValid()) + .ok(compareResults.errorMessages()); +}).before(async () => createWidget('dxCardView', { + dataSource: [ + { + id: 0, title: 'header1', A: 'A_0', B: 'B_0', C: 'C_0', + }, + { + id: 1, title: 'header2', A: 'A_1', B: 'B_1', C: 'C_1', + }, + { + id: 2, title: 'header3', A: 'A_2', B: 'B_2', C: 'C_2', + }, + { + id: 3, title: 'header4', A: 'A_3', B: 'B_3', C: 'C_3', + }, + { + id: 4, title: 'header5', A: 'A_4', B: 'B_4', C: 'C_4', + }, + ], + cardHeader: { + captionExpr: () => 'title', + }, + columns: ['A', 'B', 'C'], + keyExpr: 'id', + height: 700, + selection: { + mode: 'multiple', + showCheckBoxesMode: 'onClick', + allowSelectAll: true, + }, +})); + +test('Multiple mode with a selected card and showCheckBoxesMode = \'onClick\'', async (t) => { + const { takeScreenshot, compareResults } = createScreenshotsComparer(t); + const cardView = new CardView(CARD_VIEW_SELECTOR); + + await testScreenshot(t, takeScreenshot, 'card-view_checkbox_visibility_with_showCheckBoxesMode_=_onClick.png', { element: cardView.element }); + + await t + .expect(compareResults.isValid()) + .ok(compareResults.errorMessages()); +}).before(async () => createWidget('dxCardView', { + dataSource: [ + { + id: 0, title: 'header1', A: 'A_0', B: 'B_0', C: 'C_0', + }, + { + id: 1, title: 'header2', A: 'A_1', B: 'B_1', C: 'C_1', + }, + { + id: 2, title: 'header3', A: 'A_2', B: 'B_2', C: 'C_2', + }, + { + id: 3, title: 'header4', A: 'A_3', B: 'B_3', C: 'C_3', + }, + { + id: 4, title: 'header5', A: 'A_4', B: 'B_4', C: 'C_4', + }, + ], + cardHeader: { + captionExpr: () => 'title', + }, + columns: ['A', 'B', 'C'], + selectedCardKeys: [0], + keyExpr: 'id', + height: 700, + selection: { + mode: 'multiple', + showCheckBoxesMode: 'onClick', + allowSelectAll: true, + }, +})); + +test('Multiple mode with selected cards and showCheckBoxesMode = \'onClick\'', async (t) => { + const { takeScreenshot, compareResults } = createScreenshotsComparer(t); + const cardView = new CardView(CARD_VIEW_SELECTOR); + + await testScreenshot(t, takeScreenshot, 'card-view_checkboxes_visibility_with_showCheckBoxesMode_=_onClick.png', { element: cardView.element }); + + await t + .expect(compareResults.isValid()) + .ok(compareResults.errorMessages()); +}).before(async () => createWidget('dxCardView', { + dataSource: [ + { + id: 0, title: 'header1', A: 'A_0', B: 'B_0', C: 'C_0', + }, + { + id: 1, title: 'header2', A: 'A_1', B: 'B_1', C: 'C_1', + }, + { + id: 2, title: 'header3', A: 'A_2', B: 'B_2', C: 'C_2', + }, + { + id: 3, title: 'header4', A: 'A_3', B: 'B_3', C: 'C_3', + }, + { + id: 4, title: 'header5', A: 'A_4', B: 'B_4', C: 'C_4', + }, + ], + cardHeader: { + captionExpr: () => 'title', + }, + columns: ['A', 'B', 'C'], + selectedCardKeys: [0, 1], + keyExpr: 'id', + height: 700, + selection: { + mode: 'multiple', + showCheckBoxesMode: 'onClick', + allowSelectAll: true, + }, +})); + +test('Multiple mode with Select All/Deselect All and showCheckBoxesMode = \'onLongTap\'', async (t) => { + const { takeScreenshot, compareResults } = createScreenshotsComparer(t); + const cardView = new CardView(CARD_VIEW_SELECTOR); + const firstCard = cardView.getCard(0); + + await testScreenshot(t, takeScreenshot, 'card-view_miltiple_selection_with_showCheckBoxesMode_=_onLongTap_1.png', { element: cardView.element }); + + await ClientFunction((card) => { + $(card()).trigger('dxhold'); + })(firstCard.element); + + await testScreenshot(t, takeScreenshot, 'card-view_miltiple_selection_with_showCheckBoxesMode_=_onLongTap_2.png', { element: cardView.element }); + + await ClientFunction((card) => { + $(card()).trigger('dxhold'); + })(firstCard.element); + + await testScreenshot(t, takeScreenshot, 'card-view_miltiple_selection_with_showCheckBoxesMode_=_onLongTap_3.png', { element: cardView.element }); + + await t + .expect(compareResults.isValid()) + .ok(compareResults.errorMessages()); +}).before(async () => createWidget('dxCardView', { + dataSource: [ + { + id: 0, title: 'header1', A: 'A_0', B: 'B_0', C: 'C_0', + }, + { + id: 1, title: 'header2', A: 'A_1', B: 'B_1', C: 'C_1', + }, + { + id: 2, title: 'header3', A: 'A_2', B: 'B_2', C: 'C_2', + }, + { + id: 3, title: 'header4', A: 'A_3', B: 'B_3', C: 'C_3', + }, + { + id: 4, title: 'header5', A: 'A_4', B: 'B_4', C: 'C_4', + }, + ], + cardHeader: { + captionExpr: () => 'title', + }, + columns: ['A', 'B', 'C'], + keyExpr: 'id', + height: 700, + selection: { + mode: 'multiple', + showCheckBoxesMode: 'onLongTap', + allowSelectAll: true, + }, +})); + +test('Multiple mode without Select All/Deselect All', async (t) => { + const { takeScreenshot, compareResults } = createScreenshotsComparer(t); + const cardView = new CardView(CARD_VIEW_SELECTOR); + + await testScreenshot(t, takeScreenshot, 'card-view_miltiple_selection_without_select-all.png', { element: cardView.element }); + + await t + .expect(compareResults.isValid()) + .ok(compareResults.errorMessages()); +}).before(async () => createWidget('dxCardView', { + dataSource: [ + { + id: 0, title: 'header1', A: 'A_0', B: 'B_0', C: 'C_0', + }, + { + id: 1, title: 'header2', A: 'A_1', B: 'B_1', C: 'C_1', + }, + { + id: 2, title: 'header3', A: 'A_2', B: 'B_2', C: 'C_2', + }, + { + id: 3, title: 'header4', A: 'A_3', B: 'B_3', C: 'C_3', + }, + { + id: 4, title: 'header5', A: 'A_4', B: 'B_4', C: 'C_4', + }, + ], + cardHeader: { + captionExpr: () => 'title', + }, + columns: ['A', 'B', 'C'], + keyExpr: 'id', + height: 700, + selection: { + mode: 'multiple', + allowSelectAll: false, + }, +})); diff --git a/packages/devextreme/js/__internal/grids/new/card_view/content_view/view.tsx b/packages/devextreme/js/__internal/grids/new/card_view/content_view/view.tsx index 6dc3158b5abd..e88dacf2ee08 100644 --- a/packages/devextreme/js/__internal/grids/new/card_view/content_view/view.tsx +++ b/packages/devextreme/js/__internal/grids/new/card_view/content_view/view.tsx @@ -73,8 +73,6 @@ export class ContentView extends ContentViewBase { protected override component = ContentViewComponent; protected override getProps() { - const allowSelectOnClick = this.selectionController.allowSelectOnClick(); - return combined({ ...this.getBaseProps(), contentProps: combined({ @@ -88,7 +86,7 @@ export class ContentView extends ContentViewBase { minWidth: this.cardMinWidth, maxWidth: this.options.oneWay('cardMaxWidth'), isCheckBoxesRendered: this.selectionController.isCheckBoxesRendered, - allowSelectOnClick, + allowSelectOnClick: this.selectionController.allowSelectOnClick, onHold: this.onCardHold.bind(this), onClick: this.options.action('onCardClick'), onDblClick: this.options.action('onCardDblClick'), diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/selection/controller.ts b/packages/devextreme/js/__internal/grids/new/grid_core/selection/controller.ts index 5e10621efd5d..c830051f2bb6 100644 --- a/packages/devextreme/js/__internal/grids/new/grid_core/selection/controller.ts +++ b/packages/devextreme/js/__internal/grids/new/grid_core/selection/controller.ts @@ -65,27 +65,26 @@ export class SelectionController { public readonly _isCheckBoxesVisible = state(false); public readonly isCheckBoxesVisible = computed( - (isCheckBoxesRendered, _isCheckBoxesVisible) => { - if (isCheckBoxesRendered) { - const { showCheckBoxesMode } = this.selectionOption.unreactive_get(); + (selectionOption, _isCheckBoxesVisible) => { + const { mode, showCheckBoxesMode } = selectionOption; + if (mode === SelectionMode.Multiple) { return showCheckBoxesMode !== ShowCheckBoxesMode.OnClick || _isCheckBoxesVisible; } return false; }, [ - this.isCheckBoxesRendered, + this.selectionOption, this._isCheckBoxesVisible, ], ); public readonly needToHiddenCheckBoxes = computed( - (isCheckBoxesVisible) => { - const { showCheckBoxesMode } = this.selectionOption.unreactive_get(); - const isCheckBoxesRendered = this.isCheckBoxesRendered.unreactive_get(); + (isCheckBoxesVisible, selectionOption) => { + const { mode, showCheckBoxesMode } = selectionOption; - if (isCheckBoxesRendered && showCheckBoxesMode === ShowCheckBoxesMode.OnClick) { + if (mode === SelectionMode.Multiple && showCheckBoxesMode === ShowCheckBoxesMode.OnClick) { return !isCheckBoxesVisible; } @@ -93,9 +92,19 @@ export class SelectionController { }, [ this.isCheckBoxesVisible, + this.selectionOption, ], ); + public readonly allowSelectOnClick = computed( + (selectionOption) => { + const { mode, showCheckBoxesMode } = selectionOption; + + return mode !== SelectionMode.Multiple || showCheckBoxesMode !== ShowCheckBoxesMode.Always; + }, + [this.selectionOption], + ); + constructor( private readonly options: OptionsController, private readonly dataController: DataController, @@ -136,10 +145,6 @@ export class SelectionController { } }, [this.selectedCardKeys, this.selectionOption]); - effect((selectedCardKeys, selectionOption) => { - this.updateSelectionToolbarButtons(selectedCardKeys, selectionOption); - }, [this.selectedCardKeys, this.selectionOption, this.dataController.items]); - effect((isLoaded) => { if (isLoaded) { const selectedCardKeys = this.selectedCardKeys.unreactive_get(); @@ -147,6 +152,10 @@ export class SelectionController { this.selectCards(selectedCardKeys); } }, [this.dataController.isLoaded]); + + effect((selectedCardKeys, selectionOption) => { + this.updateSelectionToolbarButtons(selectedCardKeys, selectionOption); + }, [this.selectedCardKeys, this.selectionOption, this.dataController.items]); } private getSelectionConfig(dataSource, selectionOption): object { @@ -365,12 +374,6 @@ export class SelectionController { this._isCheckBoxesVisible.update(value); } - public allowSelectOnClick(): boolean { - const { mode, showCheckBoxesMode } = this.selectionOption.unreactive_get(); - - return mode !== SelectionMode.Multiple || showCheckBoxesMode !== ShowCheckBoxesMode.Always; - } - public processLongTap(row: DataRow): void { const { mode, showCheckBoxesMode } = this.selectionOption.unreactive_get(); diff --git a/packages/testcafe-models/cardView/card.ts b/packages/testcafe-models/cardView/card.ts index e11eb4fce919..15ef47212e09 100644 --- a/packages/testcafe-models/cardView/card.ts +++ b/packages/testcafe-models/cardView/card.ts @@ -4,13 +4,29 @@ export const CLASS = { field: 'dx-cardview-field', fieldName: 'dx-cardview-field-name', fieldValue: 'dx-cardview-field-value', + toolbarItem: 'dx-toolbar-item', + toolbarItemContent: 'dx-toolbar-item-content', + selectCheckbox: 'dx-cardview-select-checkbox', + selectCard: 'dx-cardview-card-selection', + checkbox: 'dx-checkbox', } export default class Card { + isSelected: Promise; + element: Selector; constructor(selector: Selector) { this.element = selector; + this.isSelected = this.element.hasClass(CLASS.selectCard); + } + + getToolbarItemContent(index: number): Selector { + return this.element.find(`.${CLASS.toolbarItem}`).nth(index).child(`.${CLASS.toolbarItemContent}`); + } + + getSelectCheckbox(): Selector { + return this.element.find(`.${CLASS.selectCheckbox} .${CLASS.checkbox}`); } getFieldCaptionCell(fieldCaption: String): Selector { diff --git a/packages/testcafe-models/cardView/index.ts b/packages/testcafe-models/cardView/index.ts index 4dd5100c5b32..fb8f8b519296 100644 --- a/packages/testcafe-models/cardView/index.ts +++ b/packages/testcafe-models/cardView/index.ts @@ -4,6 +4,7 @@ import { CLASS as CLASS_BASE } from '../gridCore'; import Card from './card'; import HeadersElement from './headers/headers'; import HeaderPanel from './headerPanel'; +import Toolbar from './toolbar'; import Popup from "../popup"; import List from "../list"; import {ClientFunction} from "testcafe"; @@ -11,10 +12,13 @@ import {ClientFunction} from "testcafe"; export const CLASS = { ...CLASS_BASE, cardView: 'dx-cardview', + cardViewContent: 'dx-cardview-content', + selectCheckBoxesHidden: 'dx-cardview-select-checkboxes-hidden', headers: 'headers', headerItem: 'header-item', headerFilterMenu: 'dx-header-filter-menu', card: 'card', + toolbar: 'dx-toolbar', } export default class CardView extends GridCore { @@ -68,4 +72,30 @@ export default class CardView extends GridCore { { dependencies: { getInstance, columnName } }, )(); } + + getSelectedCardKeys(): Promise> { + const { getInstance } = this; + + return ClientFunction( + () => (getInstance() as any).getSelectedCardKeys(), + { dependencies: { getInstance } }, + )(); + } + + getToolbar(): Toolbar { + return new Toolbar(this.element.child(`.${CLASS.toolbar}`)); + } + + isCheckBoxesHidden(): Promise { + return this.element.find(`.${CLASS.cardViewContent}`).hasClass(`${CLASS.selectCheckBoxesHidden}`); + } + + apiPageIndex(pageIndex: number): Promise { + const { getInstance } = this; + + return ClientFunction( + () => (getInstance() as any).pageIndex(pageIndex), + { dependencies: { getInstance, pageIndex } }, + )(); + } } diff --git a/packages/testcafe-models/cardView/toolbar.ts b/packages/testcafe-models/cardView/toolbar.ts new file mode 100644 index 000000000000..b795f76b8e36 --- /dev/null +++ b/packages/testcafe-models/cardView/toolbar.ts @@ -0,0 +1,27 @@ +const CLASS = { + disabledState: 'dx-state-disabled', +}; + +export default class Toolbar { + element: Selector; + + constructor(selector: Selector) { + this.element = selector; + } + + getSelectAllButton(): Selector { + return this.element.find('[aria-label=\'Select All\']'); + } + + getClearSelectionButton(): Selector { + return this.element.find('[aria-label=\'Clear selection\']'); + } + + isSelectAllButtonDisabled(): Promise { + return this.getSelectAllButton().hasClass(CLASS.disabledState); + } + + isClearSelectionButtonDisabled(): Promise { + return this.getClearSelectionButton().hasClass(CLASS.disabledState); + } +} From 82c976e66ab5330cb9689b2919fb8510102ef8ea Mon Sep 17 00:00:00 2001 From: Alyar <> Date: Fri, 28 Mar 2025 05:26:01 +0400 Subject: [PATCH 7/8] CardView: Add public methods --- .../new/grid_core/selection/public_methods.ts | 39 +++++++++++++++++-- 1 file changed, 35 insertions(+), 4 deletions(-) diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/selection/public_methods.ts b/packages/devextreme/js/__internal/grids/new/grid_core/selection/public_methods.ts index 123a9a1d7776..8d1ac090b53f 100644 --- a/packages/devextreme/js/__internal/grids/new/grid_core/selection/public_methods.ts +++ b/packages/devextreme/js/__internal/grids/new/grid_core/selection/public_methods.ts @@ -1,5 +1,8 @@ /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ /* eslint-disable @typescript-eslint/explicit-function-return-type */ +import type { DeferredObj } from '@js/core/utils/deferred'; + +import type { DataRow } from '../columns_controller/types'; import type { Key } from '../data_controller/types'; import type { Constructor } from '../types'; import type { GridCoreNewBase } from '../widget'; @@ -10,12 +13,40 @@ export function PublicMethods>(GridCo return this.selectionController.isCardSelected(key); } - public clearSelection(): void { - this.selectionController.clearSelection(); - } - public getSelectedCardKeys(): Key[] { return this.selectionController.getSelectedCardKeys(); } + + public getSelectedCards(): DataRow[] { + return this.selectionController.getSelectedCards(); + } + + public selectCards(keys: Key[], preserve = false): DeferredObj | undefined { + return this.selectionController.selectCards(keys, preserve); + } + + public deselectCards(keys: Key[]): DeferredObj | undefined { + return this.selectionController.deselectCards(keys); + } + + public selectCardsByIndexes(indexes: number[]): DeferredObj | undefined { + return this.selectionController.selectCardsByIndexes(indexes); + } + + public deselectCardsByIndexes(indexes: number[]): DeferredObj | undefined { + return this.selectionController.deselectCardsByIndexes(indexes); + } + + public selectAll(): DeferredObj | undefined { + return this.selectionController.selectAll(); + } + + public deselectAll(): DeferredObj | undefined { + return this.selectionController.deselectAll(); + } + + public clearSelection(): void { + this.selectionController.clearSelection(); + } }; } From c933a4147bc98efc1e08ee319f33ddd7e48ce1fc Mon Sep 17 00:00:00 2001 From: Alyar <> Date: Fri, 28 Mar 2025 05:55:56 +0400 Subject: [PATCH 8/8] Fix comments --- .../content_view/content/card/card.tsx | 6 +- .../new/grid_core/selection/controller.ts | 78 ++++++++++--------- 2 files changed, 42 insertions(+), 42 deletions(-) diff --git a/packages/devextreme/js/__internal/grids/new/card_view/content_view/content/card/card.tsx b/packages/devextreme/js/__internal/grids/new/card_view/content_view/content/card/card.tsx index 5e46e632a409..b24c31f5a5ab 100644 --- a/packages/devextreme/js/__internal/grids/new/card_view/content_view/content/card/card.tsx +++ b/packages/devextreme/js/__internal/grids/new/card_view/content_view/content/card/card.tsx @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/no-non-null-assertion */ import { isCommandKeyPressed } from '@js/common/core/events/utils/index'; -import { off, on } from '@js/events'; +import { off, on } from '@js/events/index'; import { combineClasses } from '@ts/core/utils/combine_classes'; import type { DataRow } from '@ts/grids/new/grid_core/columns_controller/types'; import type { DataObject } from '@ts/grids/new/grid_core/data_controller/types'; @@ -59,8 +59,6 @@ export interface CardProps { toolbar?: CardHeaderItem[]; - width?: number; - isCheckBoxesRendered?: boolean; template?: (row: DataRow) => JSX.Element; @@ -120,7 +118,7 @@ export class Card extends Component { > diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/selection/controller.ts b/packages/devextreme/js/__internal/grids/new/grid_core/selection/controller.ts index c830051f2bb6..77521ef65c84 100644 --- a/packages/devextreme/js/__internal/grids/new/grid_core/selection/controller.ts +++ b/packages/devextreme/js/__internal/grids/new/grid_core/selection/controller.ts @@ -105,6 +105,14 @@ export class SelectionController { [this.selectionOption], ); + public readonly needToAddSelectionButtons = computed( + (selectionMode, allowSelectAll) => selectionMode === SelectionMode.Multiple && allowSelectAll, + [ + this.options.oneWay('selection.mode'), + this.options.oneWay('selection.allowSelectAll'), + ], + ); + constructor( private readonly options: OptionsController, private readonly dataController: DataController, @@ -153,9 +161,9 @@ export class SelectionController { } }, [this.dataController.isLoaded]); - effect((selectedCardKeys, selectionOption) => { - this.updateSelectionToolbarButtons(selectedCardKeys, selectionOption); - }, [this.selectedCardKeys, this.selectionOption, this.dataController.items]); + effect((selectedCardKeys) => { + this.updateSelectionToolbarButtons(selectedCardKeys); + }, [this.selectedCardKeys, this.dataController.items]); } private getSelectionConfig(dataSource, selectionOption): object { @@ -241,44 +249,38 @@ export class SelectionController { private updateSelectionToolbarButtons( selectedCardKeys: SelectedCardKeys, - selectionOption: SelectionOptions, ) { - if (selectionOption.mode === SelectionMode.Multiple && selectionOption.allowSelectAll) { - const isSelectAll = this.isSelectAll(); - const isOnePageSelectAll = this.isOnePageSelectAll(); - - this.toolbarController.addDefaultItem({ - name: 'selectAllButton', - widget: 'dxButton', - options: { - icon: 'selectall', - onClick: () => { - this.selectAll(); - }, - disabled: !!isSelectAll, - text: messageLocalization.format('dxCardView-selectAll'), + const isSelectAll = this.isSelectAll(); + const isOnePageSelectAll = this.isOnePageSelectAll(); + + this.toolbarController.addDefaultItem({ + name: 'selectAllButton', + widget: 'dxButton', + options: { + icon: 'selectall', + onClick: () => { + this.selectAll(); }, - location: 'before', - locateInMenu: 'auto', - }); - this.toolbarController.addDefaultItem({ - name: 'clearSelectionButton', - widget: 'dxButton', - options: { - icon: 'close', - onClick: () => { - this.deselectAll(); - }, - disabled: isOnePageSelectAll ? isSelectAll === false : selectedCardKeys.length === 0, - text: messageLocalization.format('dxCardView-clearSelection'), + disabled: !!isSelectAll, + text: messageLocalization.format('dxCardView-selectAll'), + }, + location: 'before', + locateInMenu: 'auto', + }, this.needToAddSelectionButtons); + this.toolbarController.addDefaultItem({ + name: 'clearSelectionButton', + widget: 'dxButton', + options: { + icon: 'close', + onClick: () => { + this.deselectAll(); }, - location: 'before', - locateInMenu: 'auto', - }); - } else { - this.toolbarController.removeDefaultItem('selectAllButton'); - this.toolbarController.removeDefaultItem('clearSelectionButton'); - } + disabled: isOnePageSelectAll ? isSelectAll === false : selectedCardKeys.length === 0, + text: messageLocalization.format('dxCardView-clearSelection'), + }, + location: 'before', + locateInMenu: 'auto', + }, this.needToAddSelectionButtons); } private getItemKeysByIndexes(indexes: number[]): Key[] {