Skip to content

Commit 94009e3

Browse files
haksurwdevfx
andauthored
CardView: fix selection (throw error if needed) (oqSw9T3c) (DevExpress#29873)
Signed-off-by: haksur <[email protected]> Co-authored-by: wdevfx <[email protected]>
1 parent a8b5bad commit 94009e3

File tree

7 files changed

+391
-4
lines changed

7 files changed

+391
-4
lines changed

packages/devextreme/js/__internal/grids/new/grid_core/di.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { HeaderFilterViewController } from './filtering/header_filter/view_contr
2121
import * as FilterControllerModule from './filtering/index';
2222
import { ItemsController } from './items_controller/items_controller';
2323
import { KeyboardNavigationController } from './keyboard_navigation/index';
24+
import { OptionsValidationController } from './options_validation/index';
2425
import { PagerView } from './pager/view';
2526
import { SearchController } from './search/controller';
2627
import { SearchView } from './search/view';
@@ -62,4 +63,5 @@ export function register(diContext: DIContext): void {
6263
diContext.register(GetAppliedFilterVisitor);
6364
diContext.register(FilterCustomOperationsVisitor);
6465
diContext.register(KeyboardNavigationController);
66+
diContext.register(OptionsValidationController);
6567
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { isDefined } from '@js/core/utils/type';
2+
import { DataController } from '@ts/grids/new/grid_core/data_controller/index';
3+
4+
import { throwError } from './utils';
5+
6+
export class OptionsValidationController {
7+
public static dependencies = [
8+
DataController,
9+
] as const;
10+
11+
constructor(
12+
private readonly dataController: DataController,
13+
) {
14+
}
15+
16+
public validateKeyExpr(): void {
17+
const keyExpr = this.dataController.dataSource.peek().key();
18+
19+
if (!isDefined(keyExpr)) {
20+
throwError('E1042', 'CardView');
21+
}
22+
}
23+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { OptionsValidationController } from './controller';
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import errors from '@js/ui/widget/ui.errors';
2+
3+
export const throwError = (errorCode?: string, message?: string): void => {
4+
throw errors.Error(errorCode, message);
5+
};
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
/* eslint-disable spellcheck/spell-checker, @stylistic/max-len */
2+
import {
3+
afterEach, beforeEach, describe, expect, it, jest,
4+
} from '@jest/globals';
5+
import $ from '@js/core/renderer';
6+
import CardView from '@ts/grids/new/card_view/widget';
7+
import type { Options as GridCoreOptions } from '@ts/grids/new/grid_core/options';
8+
import { throwError } from '@ts/grids/new/grid_core/options_validation/utils';
9+
import { rerender } from 'inferno';
10+
11+
const SELECTORS = {
12+
cardView: '.dx-cardview',
13+
card: '.dx-cardview-card',
14+
cardCheckbox: '.dx-checkbox-container',
15+
selectAllButton: '[aria-label="Select all"]',
16+
};
17+
18+
const setup = (options: GridCoreOptions = {}): CardView => {
19+
const container = document.createElement('div');
20+
const { body } = document;
21+
body.append(container);
22+
23+
const cardView = new CardView(container, options);
24+
rerender();
25+
return cardView;
26+
};
27+
28+
const getCardElements = (): NodeListOf<Element> => document.querySelectorAll(SELECTORS.card);
29+
30+
const getCardCheckboxes = (): NodeListOf<Element> => document.querySelectorAll(SELECTORS.cardCheckbox);
31+
32+
const getSelectAllButton = (): Element | null => document.querySelector(SELECTORS.selectAllButton);
33+
34+
const checkError = (): void => expect(throwError).toHaveBeenCalledWith('E1042', 'CardView');
35+
36+
jest.mock('@ts/grids/new/grid_core/options_validation/utils', () => ({
37+
throwError: jest.fn().mockImplementation(() => ({})),
38+
}));
39+
40+
describe('when keyExpr is missing', () => {
41+
afterEach(() => {
42+
const cardView = document.querySelector(SELECTORS.cardView);
43+
// @ts-expect-error bad typed renderer
44+
$(cardView ?? undefined as any)?.dxCardView('dispose');
45+
document.body.innerHTML = '';
46+
});
47+
48+
beforeEach(() => {
49+
jest.clearAllMocks();
50+
});
51+
52+
describe('selection mode single', () => {
53+
it('shouldn\'t throw E1042 on initial startup', () => {
54+
setup({
55+
dataSource: [{ value: 'test1' }, { value: 'test2' }],
56+
selection: {
57+
mode: 'single',
58+
},
59+
});
60+
61+
const cardElements = getCardElements();
62+
expect(cardElements.length).toEqual(2);
63+
});
64+
65+
it('should throw E1042 error on card click selection', () => {
66+
setup({
67+
dataSource: [{ value: 'test1' }, { value: 'test2' }],
68+
selection: {
69+
mode: 'single',
70+
},
71+
});
72+
73+
const cardElements = getCardElements();
74+
cardElements[0].dispatchEvent(new MouseEvent('click'));
75+
76+
checkError();
77+
});
78+
79+
it('should throw E1042 error on initial selectedCardKeys', () => {
80+
setup({
81+
dataSource: [{ value: 'test1' }, { value: 'test2' }],
82+
selection: {
83+
mode: 'single',
84+
},
85+
selectedCardKeys: [0],
86+
});
87+
88+
checkError();
89+
});
90+
91+
it('should throw E1042 error on runtime selectedCardKeys update', () => {
92+
const cardView = setup({
93+
dataSource: [{ value: 'test1' }, { value: 'test2' }],
94+
selection: {
95+
mode: 'single',
96+
},
97+
});
98+
99+
cardView.instance().option('selectedCardKeys', [1]);
100+
101+
checkError();
102+
});
103+
});
104+
105+
describe('selection mode multiple', () => {
106+
it('shouldn\'t throw E1042 on initial startup', () => {
107+
setup({
108+
dataSource: [{ value: 'test1' }, { value: 'test2' }],
109+
selection: {
110+
mode: 'multiple',
111+
},
112+
});
113+
114+
const cardElements = getCardElements();
115+
expect(cardElements.length).toEqual(2);
116+
});
117+
118+
it('should throw E1042 error on checkbox click selection', () => {
119+
setup({
120+
dataSource: [{ value: 'test1' }, { value: 'test2' }],
121+
selection: {
122+
mode: 'multiple',
123+
showCheckBoxesMode: 'always',
124+
},
125+
});
126+
127+
const cardCheckboxes = getCardCheckboxes();
128+
cardCheckboxes[0].dispatchEvent(new MouseEvent('click', { bubbles: true }));
129+
130+
checkError();
131+
});
132+
133+
it('should throw E1042 error on selectAll toolbar button click', () => {
134+
setup({
135+
dataSource: [{ value: 'test1' }, { value: 'test2' }],
136+
selection: {
137+
mode: 'multiple',
138+
showCheckBoxesMode: 'always',
139+
allowSelectAll: true,
140+
},
141+
});
142+
143+
const selectAllButton = getSelectAllButton();
144+
selectAllButton?.dispatchEvent(new MouseEvent('click'));
145+
146+
checkError();
147+
});
148+
149+
it('should throw E1042 error on initial selectedCardKeys', () => {
150+
setup({
151+
dataSource: [{ value: 'test1' }, { value: 'test2' }],
152+
selection: {
153+
mode: 'multiple',
154+
showCheckBoxesMode: 'always',
155+
},
156+
selectedCardKeys: [0, 1],
157+
});
158+
159+
checkError();
160+
});
161+
162+
it('should throw E1042 error on runtime selectedCardKeys update', () => {
163+
const cardView = setup({
164+
dataSource: [{ value: 'test1' }, { value: 'test2' }],
165+
selection: {
166+
mode: 'multiple',
167+
showCheckBoxesMode: 'always',
168+
},
169+
});
170+
171+
cardView.instance().option('selectedCardKeys', [1]);
172+
173+
checkError();
174+
});
175+
});
176+
});

packages/devextreme/js/__internal/grids/new/grid_core/selection/controller.test.ts

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -762,6 +762,168 @@ describe('SelectionController', () => {
762762
});
763763
});
764764

765+
describe('when selection changes via selectionChanged callback', () => {
766+
it('should update both selectionController and itemsController selection states when selecting single card', () => {
767+
const cardData = { id: 1, value: 'test' };
768+
const {
769+
selectionController,
770+
itemsController,
771+
} = setup({
772+
keyExpr: 'id',
773+
dataSource: [cardData],
774+
selection: {
775+
mode: 'multiple',
776+
},
777+
});
778+
779+
// Set up the spy before calling the method
780+
const setSelectionStateSpy = jest.spyOn(itemsController, 'setSelectionState');
781+
782+
// Mock the selectionChanged private method call with test data
783+
const selectionChangedEvent = {
784+
addedItemKeys: [1],
785+
removedItemKeys: [],
786+
selectedItemKeys: [1],
787+
selectedItems: [cardData],
788+
};
789+
790+
// Call the private method directly
791+
// @ts-expect-error - accessing private method
792+
selectionController.selectionChanged(selectionChangedEvent);
793+
794+
// Verify that both controllers were updated
795+
expect(selectionController.getSelectedCardKeys()).toEqual([1]);
796+
797+
// Check that the itemsController was updated with the same keys
798+
expect(setSelectionStateSpy).toHaveBeenCalledWith([1]);
799+
800+
// Verify the UI would show correct selection state
801+
const items = itemsController.items.peek();
802+
expect(items[0].isSelected).toBe(true);
803+
});
804+
805+
it('should update both selectionController and itemsController when selecting multiple cards', () => {
806+
const cardsData = [
807+
{ id: 1, value: 'test1' },
808+
{ id: 2, value: 'test2' },
809+
{ id: 3, value: 'test3' },
810+
];
811+
const {
812+
selectionController,
813+
itemsController,
814+
} = setup({
815+
keyExpr: 'id',
816+
dataSource: cardsData,
817+
selection: {
818+
mode: 'multiple',
819+
},
820+
});
821+
822+
// Set up the spy before calling the method
823+
const setSelectionStateSpy = jest.spyOn(itemsController, 'setSelectionState');
824+
825+
// Mock the selectionChanged private method call with test data
826+
const selectionChangedEvent = {
827+
addedItemKeys: [1, 3],
828+
removedItemKeys: [],
829+
selectedItemKeys: [1, 3],
830+
selectedItems: [cardsData[0], cardsData[2]],
831+
};
832+
833+
// Call the private method directly
834+
// @ts-expect-error - accessing private method
835+
selectionController.selectionChanged(selectionChangedEvent);
836+
837+
// Verify that both controllers were updated
838+
expect(selectionController.getSelectedCardKeys()).toEqual([1, 3]);
839+
840+
// Check that the itemsController was updated with the same keys
841+
expect(setSelectionStateSpy).toHaveBeenCalledWith([1, 3]);
842+
843+
// Verify the UI would show correct selection state
844+
const items = itemsController.items.peek();
845+
expect(items[0].isSelected).toBe(true);
846+
expect(items[1].isSelected).toBe(false);
847+
expect(items[2].isSelected).toBe(true);
848+
});
849+
850+
it('should update both selectionController and itemsController when deselecting cards', () => {
851+
const cardsData = [
852+
{ id: 1, value: 'test1' },
853+
{ id: 2, value: 'test2' },
854+
{ id: 3, value: 'test3' },
855+
];
856+
const {
857+
selectionController,
858+
itemsController,
859+
} = setup({
860+
keyExpr: 'id',
861+
dataSource: cardsData,
862+
selection: {
863+
mode: 'multiple',
864+
},
865+
selectedCardKeys: [1, 2, 3],
866+
});
867+
868+
// Initially all cards should be selected
869+
expect(itemsController.items.peek()[0].isSelected).toBe(true);
870+
expect(itemsController.items.peek()[1].isSelected).toBe(true);
871+
expect(itemsController.items.peek()[2].isSelected).toBe(true);
872+
873+
// Set up the spy before calling the method
874+
const setSelectionStateSpy = jest.spyOn(itemsController, 'setSelectionState');
875+
876+
// Mock the selectionChanged event when deselecting card #2
877+
const selectionChangedEvent = {
878+
addedItemKeys: [],
879+
removedItemKeys: [2],
880+
selectedItemKeys: [1, 3],
881+
selectedItems: [cardsData[0], cardsData[2]],
882+
};
883+
884+
// Call the private method directly
885+
// @ts-expect-error - accessing private method
886+
selectionController.selectionChanged(selectionChangedEvent);
887+
888+
// Verify that both controllers were updated
889+
expect(selectionController.getSelectedCardKeys()).toEqual([1, 3]);
890+
891+
// Check that the itemsController was updated with the same keys
892+
expect(setSelectionStateSpy).toHaveBeenCalledWith([1, 3]);
893+
894+
// Verify the UI would show correct selection state
895+
const items = itemsController.items.peek();
896+
expect(items[0].isSelected).toBe(true);
897+
expect(items[1].isSelected).toBe(false);
898+
expect(items[2].isSelected).toBe(true);
899+
});
900+
901+
it('should throw error E1042 if keyExpr is missing and selectionChanged', () => {
902+
const cardData = { id: 1, value: 'test' };
903+
const {
904+
selectionController,
905+
} = setup({
906+
dataSource: [cardData],
907+
selection: {
908+
mode: 'multiple',
909+
},
910+
});
911+
912+
// Mock the selectionChanged private method call with test data
913+
const selectionChangedEvent = {
914+
addedItemKeys: [1],
915+
removedItemKeys: [],
916+
selectedItemKeys: [1],
917+
selectedItems: [cardData],
918+
};
919+
920+
expect(() => {
921+
// @ts-expect-error - accessing private method
922+
selectionController.selectionChanged(selectionChangedEvent);
923+
}).toThrowError('E1042');
924+
});
925+
});
926+
765927
describe('when selecting all cards', () => {
766928
it('should be called', () => {
767929
const selectionChangedMockFn = jest.fn();

0 commit comments

Comments
 (0)