diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/di.ts b/packages/devextreme/js/__internal/grids/new/grid_core/di.ts index 0c4616d47177..0db2f29f1b18 100644 --- a/packages/devextreme/js/__internal/grids/new/grid_core/di.ts +++ b/packages/devextreme/js/__internal/grids/new/grid_core/di.ts @@ -19,6 +19,7 @@ import { HeaderFilterViewController } from './filtering/header_filter/view_contr import * as FilterControllerModule from './filtering/index'; import { ItemsController } from './items_controller/items_controller'; import { KeyboardNavigationController } from './keyboard_navigation/index'; +import { OptionsValidationController } from './options_validation/index'; import { PagerView } from './pager/view'; import { SearchController } from './search/controller'; import { SearchView } from './search/view'; @@ -57,4 +58,5 @@ export function register(diContext: DIContext): void { diContext.register(ClearFilterVisitor); diContext.register(GetAppliedFilterVisitor); diContext.register(KeyboardNavigationController); + diContext.register(OptionsValidationController); } diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/options_validation/controller.ts b/packages/devextreme/js/__internal/grids/new/grid_core/options_validation/controller.ts new file mode 100644 index 000000000000..c997a6f6e869 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/new/grid_core/options_validation/controller.ts @@ -0,0 +1,23 @@ +import { isDefined } from '@js/core/utils/type'; +import { DataController } from '@ts/grids/new/grid_core/data_controller/index'; + +import { throwError } from './utils'; + +export class OptionsValidationController { + public static dependencies = [ + DataController, + ] as const; + + constructor( + private readonly dataController: DataController, + ) { + } + + public validateKeyExpr(): void { + const keyExpr = this.dataController.dataSource.peek().key(); + + if (!isDefined(keyExpr)) { + throwError('E1042', 'CardView'); + } + } +} diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/options_validation/index.ts b/packages/devextreme/js/__internal/grids/new/grid_core/options_validation/index.ts new file mode 100644 index 000000000000..80d9e2956d0a --- /dev/null +++ b/packages/devextreme/js/__internal/grids/new/grid_core/options_validation/index.ts @@ -0,0 +1 @@ +export { OptionsValidationController } from './controller'; diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/options_validation/utils.ts b/packages/devextreme/js/__internal/grids/new/grid_core/options_validation/utils.ts new file mode 100644 index 000000000000..d05d8d2020db --- /dev/null +++ b/packages/devextreme/js/__internal/grids/new/grid_core/options_validation/utils.ts @@ -0,0 +1,5 @@ +import errors from '@js/ui/widget/ui.errors'; + +export const throwError = (errorCode?: string, message?: string): void => { + throw errors.Error(errorCode, message); +}; diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/selection/controller.integration.test.ts b/packages/devextreme/js/__internal/grids/new/grid_core/selection/controller.integration.test.ts new file mode 100644 index 000000000000..21a3fbba3e0d --- /dev/null +++ b/packages/devextreme/js/__internal/grids/new/grid_core/selection/controller.integration.test.ts @@ -0,0 +1,176 @@ +/* eslint-disable spellcheck/spell-checker, @stylistic/max-len */ +import { + afterEach, beforeEach, describe, expect, it, jest, +} from '@jest/globals'; +import $ from '@js/core/renderer'; +import CardView from '@ts/grids/new/card_view/widget'; +import type { Options as GridCoreOptions } from '@ts/grids/new/grid_core/options'; +import { throwError } from '@ts/grids/new/grid_core/options_validation/utils'; +import { rerender } from 'inferno'; + +const SELECTORS = { + cardView: '.dx-cardview', + card: '.dx-cardview-card', + cardCheckbox: '.dx-checkbox-container', + selectAllButton: '[aria-label="Select all"]', +}; + +const setup = (options: GridCoreOptions = {}): CardView => { + const container = document.createElement('div'); + const { body } = document; + body.append(container); + + const cardView = new CardView(container, options); + rerender(); + return cardView; +}; + +const getCardElements = (): NodeListOf => document.querySelectorAll(SELECTORS.card); + +const getCardCheckboxes = (): NodeListOf => document.querySelectorAll(SELECTORS.cardCheckbox); + +const getSelectAllButton = (): Element | null => document.querySelector(SELECTORS.selectAllButton); + +const checkError = (): void => expect(throwError).toHaveBeenCalledWith('E1042', 'CardView'); + +jest.mock('@ts/grids/new/grid_core/options_validation/utils', () => ({ + throwError: jest.fn().mockImplementation(() => ({})), +})); + +describe('when keyExpr is missing', () => { + afterEach(() => { + const cardView = document.querySelector(SELECTORS.cardView); + // @ts-expect-error bad typed renderer + $(cardView ?? undefined as any)?.dxCardView('dispose'); + document.body.innerHTML = ''; + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('selection mode single', () => { + it('shouldn\'t throw E1042 on initial startup', () => { + setup({ + dataSource: [{ value: 'test1' }, { value: 'test2' }], + selection: { + mode: 'single', + }, + }); + + const cardElements = getCardElements(); + expect(cardElements.length).toEqual(2); + }); + + it('should throw E1042 error on card click selection', () => { + setup({ + dataSource: [{ value: 'test1' }, { value: 'test2' }], + selection: { + mode: 'single', + }, + }); + + const cardElements = getCardElements(); + cardElements[0].dispatchEvent(new MouseEvent('click')); + + checkError(); + }); + + it('should throw E1042 error on initial selectedCardKeys', () => { + setup({ + dataSource: [{ value: 'test1' }, { value: 'test2' }], + selection: { + mode: 'single', + }, + selectedCardKeys: [0], + }); + + checkError(); + }); + + it('should throw E1042 error on runtime selectedCardKeys update', () => { + const cardView = setup({ + dataSource: [{ value: 'test1' }, { value: 'test2' }], + selection: { + mode: 'single', + }, + }); + + cardView.instance().option('selectedCardKeys', [1]); + + checkError(); + }); + }); + + describe('selection mode multiple', () => { + it('shouldn\'t throw E1042 on initial startup', () => { + setup({ + dataSource: [{ value: 'test1' }, { value: 'test2' }], + selection: { + mode: 'multiple', + }, + }); + + const cardElements = getCardElements(); + expect(cardElements.length).toEqual(2); + }); + + it('should throw E1042 error on checkbox click selection', () => { + setup({ + dataSource: [{ value: 'test1' }, { value: 'test2' }], + selection: { + mode: 'multiple', + showCheckBoxesMode: 'always', + }, + }); + + const cardCheckboxes = getCardCheckboxes(); + cardCheckboxes[0].dispatchEvent(new MouseEvent('click', { bubbles: true })); + + checkError(); + }); + + it('should throw E1042 error on selectAll toolbar button click', () => { + setup({ + dataSource: [{ value: 'test1' }, { value: 'test2' }], + selection: { + mode: 'multiple', + showCheckBoxesMode: 'always', + allowSelectAll: true, + }, + }); + + const selectAllButton = getSelectAllButton(); + selectAllButton?.dispatchEvent(new MouseEvent('click')); + + checkError(); + }); + + it('should throw E1042 error on initial selectedCardKeys', () => { + setup({ + dataSource: [{ value: 'test1' }, { value: 'test2' }], + selection: { + mode: 'multiple', + showCheckBoxesMode: 'always', + }, + selectedCardKeys: [0, 1], + }); + + checkError(); + }); + + it('should throw E1042 error on runtime selectedCardKeys update', () => { + const cardView = setup({ + dataSource: [{ value: 'test1' }, { value: 'test2' }], + selection: { + mode: 'multiple', + showCheckBoxesMode: 'always', + }, + }); + + cardView.instance().option('selectedCardKeys', [1]); + + checkError(); + }); + }); +}); 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 fbfcabb73377..1d7b3bf82f83 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 @@ -762,6 +762,168 @@ describe('SelectionController', () => { }); }); + describe('when selection changes via selectionChanged callback', () => { + it('should update both selectionController and itemsController selection states when selecting single card', () => { + const cardData = { id: 1, value: 'test' }; + const { + selectionController, + itemsController, + } = setup({ + keyExpr: 'id', + dataSource: [cardData], + selection: { + mode: 'multiple', + }, + }); + + // Set up the spy before calling the method + const setSelectionStateSpy = jest.spyOn(itemsController, 'setSelectionState'); + + // Mock the selectionChanged private method call with test data + const selectionChangedEvent = { + addedItemKeys: [1], + removedItemKeys: [], + selectedItemKeys: [1], + selectedItems: [cardData], + }; + + // Call the private method directly + // @ts-expect-error - accessing private method + selectionController.selectionChanged(selectionChangedEvent); + + // Verify that both controllers were updated + expect(selectionController.getSelectedCardKeys()).toEqual([1]); + + // Check that the itemsController was updated with the same keys + expect(setSelectionStateSpy).toHaveBeenCalledWith([1]); + + // Verify the UI would show correct selection state + const items = itemsController.items.peek(); + expect(items[0].isSelected).toBe(true); + }); + + it('should update both selectionController and itemsController when selecting multiple cards', () => { + const cardsData = [ + { id: 1, value: 'test1' }, + { id: 2, value: 'test2' }, + { id: 3, value: 'test3' }, + ]; + const { + selectionController, + itemsController, + } = setup({ + keyExpr: 'id', + dataSource: cardsData, + selection: { + mode: 'multiple', + }, + }); + + // Set up the spy before calling the method + const setSelectionStateSpy = jest.spyOn(itemsController, 'setSelectionState'); + + // Mock the selectionChanged private method call with test data + const selectionChangedEvent = { + addedItemKeys: [1, 3], + removedItemKeys: [], + selectedItemKeys: [1, 3], + selectedItems: [cardsData[0], cardsData[2]], + }; + + // Call the private method directly + // @ts-expect-error - accessing private method + selectionController.selectionChanged(selectionChangedEvent); + + // Verify that both controllers were updated + expect(selectionController.getSelectedCardKeys()).toEqual([1, 3]); + + // Check that the itemsController was updated with the same keys + expect(setSelectionStateSpy).toHaveBeenCalledWith([1, 3]); + + // Verify the UI would show correct selection state + const items = itemsController.items.peek(); + expect(items[0].isSelected).toBe(true); + expect(items[1].isSelected).toBe(false); + expect(items[2].isSelected).toBe(true); + }); + + it('should update both selectionController and itemsController when deselecting cards', () => { + const cardsData = [ + { id: 1, value: 'test1' }, + { id: 2, value: 'test2' }, + { id: 3, value: 'test3' }, + ]; + const { + selectionController, + itemsController, + } = setup({ + keyExpr: 'id', + dataSource: cardsData, + selection: { + mode: 'multiple', + }, + selectedCardKeys: [1, 2, 3], + }); + + // Initially all cards should be selected + expect(itemsController.items.peek()[0].isSelected).toBe(true); + expect(itemsController.items.peek()[1].isSelected).toBe(true); + expect(itemsController.items.peek()[2].isSelected).toBe(true); + + // Set up the spy before calling the method + const setSelectionStateSpy = jest.spyOn(itemsController, 'setSelectionState'); + + // Mock the selectionChanged event when deselecting card #2 + const selectionChangedEvent = { + addedItemKeys: [], + removedItemKeys: [2], + selectedItemKeys: [1, 3], + selectedItems: [cardsData[0], cardsData[2]], + }; + + // Call the private method directly + // @ts-expect-error - accessing private method + selectionController.selectionChanged(selectionChangedEvent); + + // Verify that both controllers were updated + expect(selectionController.getSelectedCardKeys()).toEqual([1, 3]); + + // Check that the itemsController was updated with the same keys + expect(setSelectionStateSpy).toHaveBeenCalledWith([1, 3]); + + // Verify the UI would show correct selection state + const items = itemsController.items.peek(); + expect(items[0].isSelected).toBe(true); + expect(items[1].isSelected).toBe(false); + expect(items[2].isSelected).toBe(true); + }); + + it('should throw error E1042 if keyExpr is missing and selectionChanged', () => { + const cardData = { id: 1, value: 'test' }; + const { + selectionController, + } = setup({ + dataSource: [cardData], + selection: { + mode: 'multiple', + }, + }); + + // Mock the selectionChanged private method call with test data + const selectionChangedEvent = { + addedItemKeys: [1], + removedItemKeys: [], + selectedItemKeys: [1], + selectedItems: [cardData], + }; + + expect(() => { + // @ts-expect-error - accessing private method + selectionController.selectionChanged(selectionChangedEvent); + }).toThrowError('E1042'); + }); + }); + describe('when selecting all cards', () => { it('should be called', () => { const selectionChangedMockFn = jest.fn(); 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 44cbe24bf255..b93415802b9f 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 @@ -6,6 +6,7 @@ import messageLocalization from '@js/localization/message'; import type { ReadonlySignal } from '@preact/signals-core'; import { computed, effect, signal } from '@preact/signals-core'; import { DataController } from '@ts/grids/new/grid_core/data_controller/index'; +import { OptionsValidationController } from '@ts/grids/new/grid_core/options_validation/index'; import { ShowCheckBoxesMode } from '@ts/grids/new/grid_core/selection/const'; import Selection from '@ts/ui/selection/m_selection'; @@ -25,10 +26,23 @@ export class SelectionController { DataController, ItemsController, ToolbarController, + OptionsValidationController, ] as const; private readonly selectedCardKeys = this.options.twoWay('selectedCardKeys'); + // Note: moved option validation logic to computed to make it execute before other effects + private readonly normalizedSelectedCardKeys = computed(() => { + const selectedCardKeys = this.selectedCardKeys.value; + const isSelectionEnabled = this.selectionOption.value.mode !== SelectionMode.None; + + if (isSelectionEnabled && Array.isArray(selectedCardKeys) && selectedCardKeys.length) { + this.optionsValidationController.validateKeyExpr(); + } + + return this.selectedCardKeys.value; + }); + private readonly selectionOption: ReadonlySignal = this.options.oneWay('selection'); private readonly selectionHelper: ReadonlySignal; @@ -101,6 +115,7 @@ export class SelectionController { private readonly dataController: DataController, private readonly itemsController: ItemsController, private readonly toolbarController: ToolbarController, + private readonly optionsValidationController: OptionsValidationController, ) { this.selectionHelper = computed(() => { const dataSource = this.dataController.dataSource.value; @@ -119,7 +134,7 @@ export class SelectionController { }); effect(() => { - const selectedCardKeys = this.selectedCardKeys.value; + const selectedCardKeys = this.normalizedSelectedCardKeys.value; const selectionOption = this.selectionOption.value; if (selectionOption.mode !== SelectionMode.None) { this.itemsController.setSelectionState(selectedCardKeys); @@ -144,7 +159,7 @@ export class SelectionController { effect(() => { // eslint-disable-next-line @typescript-eslint/no-unused-expressions this.dataController.items.value; - this.updateSelectionToolbarButtons(this.selectedCardKeys.value); + this.updateSelectionToolbarButtons(this.normalizedSelectedCardKeys.value); }); } @@ -210,10 +225,13 @@ export class SelectionController { // eslint-disable-next-line @typescript-eslint/no-explicit-any private selectionChanged(e: any): void { if (e.addedItemKeys.length || e.removedItemKeys.length) { + this.optionsValidationController.validateKeyExpr(); + const onSelectionChanged = this.onSelectionChanged.peek(); const eventArgs = this.getSelectionEventArgs(e); this.selectedCardKeys.value = [...e.selectedItemKeys]; + // @ts-expect-error onSelectionChanged?.(eventArgs); } @@ -315,7 +333,7 @@ export class SelectionController { } public isCardSelected(key: Key): boolean { - const selectedCardKeys = this.selectedCardKeys.peek(); + const selectedCardKeys = this.normalizedSelectedCardKeys.peek(); return selectedCardKeys.includes(key); } @@ -354,7 +372,7 @@ export class SelectionController { } public getSelectedCardKeys(): Key[] { - return this.selectedCardKeys.peek(); + return this.normalizedSelectedCardKeys.peek(); } private toggleSelectionCheckBoxes(): void {