diff --git a/e2e/testcafe-devextreme/tests/cardView/columnChooser/functional.ts b/e2e/testcafe-devextreme/tests/cardView/columnChooser/functional.ts index 1ae3e6c20196..e28773ae4dd4 100644 --- a/e2e/testcafe-devextreme/tests/cardView/columnChooser/functional.ts +++ b/e2e/testcafe-devextreme/tests/cardView/columnChooser/functional.ts @@ -1,6 +1,7 @@ import CardView from 'devextreme-testcafe-models/cardView'; import url from '../../../helpers/getPageUrl'; import { createWidget } from '../../../helpers/createWidget'; +import { getCardFieldCaptions } from '../helpers/cardUtils'; fixture`CardView - ColumnChooser.Functional` .page(url(__dirname, '../../container.html')); @@ -184,3 +185,65 @@ test('ColumnChooser should receive and render custom texts', async (t) => { }).after(async (t) => { await t.eval(() => location.reload()); }); + +test('cards should update when column is hidden via column chooser (select mode) (T1324855)', async (t) => { + const cardView = new CardView('#container'); + + const initialCaptions = await getCardFieldCaptions(t, cardView, 3); + await t.expect(initialCaptions).eql(['A', 'B', 'C']); + + await cardView.apiShowColumnChooser(); + + await t.click(cardView.getColumnChooser().getCheckbox(0)); + + const captionsAfterHide = await getCardFieldCaptions(t, cardView, 2); + await t.expect(captionsAfterHide).eql(['B', 'C']); + + await t.click(cardView.getColumnChooser().getCheckbox(0)); + + const captionsAfterShow = await getCardFieldCaptions(t, cardView, 3); + await t.expect(captionsAfterShow).eql(['A', 'B', 'C']); +}).before(async () => createWidget('dxCardView', { + dataSource: [ + { a: 1, b: 2, c: 3 }, + ], + columns: ['a', 'b', 'c'], + columnChooser: { + enabled: true, + mode: 'select', + }, +})); + +test('cards should update when column is hidden via column chooser (dragAndDrop mode) (T1324855)', async (t) => { + const cardView = new CardView('#container'); + + const initialCaptions = await getCardFieldCaptions(t, cardView, 3); + await t.expect(initialCaptions).eql(['A', 'B', 'C']); + + await cardView.apiShowColumnChooser(); + + await t.dragToElement( + cardView.getHeaderPanel().getHeaderItem(0).element, + cardView.getColumnChooser().content, + ); + + const captionsAfterHide = await getCardFieldCaptions(t, cardView, 2); + await t.expect(captionsAfterHide).eql(['B', 'C']); + + await t.dragToElement( + cardView.getColumnChooser().getColumn(0), + cardView.getHeaderPanel().element, + ); + + const captionsAfterShow = await getCardFieldCaptions(t, cardView, 3); + await t.expect(captionsAfterShow).eql(['A', 'B', 'C']); +}).before(async () => createWidget('dxCardView', { + dataSource: [ + { a: 1, b: 2, c: 3 }, + ], + columns: ['a', 'b', 'c'], + columnChooser: { + enabled: true, + mode: 'dragAndDrop', + }, +})); diff --git a/e2e/testcafe-devextreme/tests/cardView/columnSortable/functional.ts b/e2e/testcafe-devextreme/tests/cardView/columnSortable/functional.ts index 2cfc77359a29..bcdd5b04266f 100644 --- a/e2e/testcafe-devextreme/tests/cardView/columnSortable/functional.ts +++ b/e2e/testcafe-devextreme/tests/cardView/columnSortable/functional.ts @@ -3,6 +3,7 @@ import TreeView from 'devextreme-testcafe-models/treeView'; import { Selector } from 'testcafe'; import url from '../../../helpers/getPageUrl'; import { createWidget } from '../../../helpers/createWidget'; +import { getCardFieldCaptions } from '../helpers/cardUtils'; import { arrayMoveToGap, dragToColumnChooser, @@ -305,3 +306,38 @@ test('drag from columnChooser to headerPanel: when allowReordering: false', asyn mode: 'dragAndDrop', }, })); + +test('cards should update when columns are reordered (T1324855)', async (t) => { + const cardView = new CardView('#container'); + + const initialCaptions = await getCardFieldCaptions(t, cardView, 3); + await t.expect(initialCaptions).eql(['A', 'B', 'C']); + + const headerPanel = cardView.getHeaderPanel(); + const firstHeader = headerPanel.getHeaderItem(0).element; + const secondHeader = headerPanel.getHeaderItem(1).element; + + await t.dragToElement(firstHeader, secondHeader, { + destinationOffsetX: -5, + destinationOffsetY: -20, + speed: 0.5, + }); + + // Wait for headers to update after drag + await t.expect(cardView.getHeaders().getHeaderItemNth(0).element.innerText).notEql('A'); + + const headerCaptions: string[] = []; + const headersCount = await cardView.getHeaders().getHeaderItemsElements().count; + for (let i = 0; i < headersCount; i += 1) { + headerCaptions.push(await cardView.getHeaders().getHeaderItemNth(i).element.innerText); + } + + const cardCaptions = await getCardFieldCaptions(t, cardView, headersCount); + await t.expect(cardCaptions).eql(headerCaptions); +}).before(async () => createWidget('dxCardView', { + dataSource: [ + { a: 1, b: 2, c: 3 }, + ], + columns: ['a', 'b', 'c'], + allowColumnReordering: true, +})); diff --git a/e2e/testcafe-devextreme/tests/cardView/helpers/cardUtils.ts b/e2e/testcafe-devextreme/tests/cardView/helpers/cardUtils.ts new file mode 100644 index 000000000000..58f08e347842 --- /dev/null +++ b/e2e/testcafe-devextreme/tests/cardView/helpers/cardUtils.ts @@ -0,0 +1,17 @@ +import CardView from 'devextreme-testcafe-models/cardView'; + +const getCardFieldCaptions = async ( + t: TestController, + cardView: CardView, + expectedCount: number, + cardIndex = 0, +): Promise => { + const card = cardView.getCard(cardIndex); + const captions = await card.getCaptions(); + + await t.expect(captions.length).eql(expectedCount); + + return captions; +}; + +export { getCardFieldCaptions }; diff --git a/packages/devextreme/js/__internal/grids/new/card_view/widget.test.ts b/packages/devextreme/js/__internal/grids/new/card_view/widget.test.ts index 913d92bd58ea..56e266cd680f 100644 --- a/packages/devextreme/js/__internal/grids/new/card_view/widget.test.ts +++ b/packages/devextreme/js/__internal/grids/new/card_view/widget.test.ts @@ -224,3 +224,37 @@ describe('absence of multiple re-render', () => { }); }); }); + +describe('reactivity to column option changes', () => { + const dataSource = [ + { id: 1, name: 'Audi' }, + { id: 2, name: 'BMW' }, + ]; + + const columns = [ + { dataField: 'id', caption: 'ID' }, + { dataField: 'name', caption: 'Name' }, + ]; + + it('should not cause extra re-render when sort/filter options change', () => { + const cardTemplate = jest.fn(); + + const container = document.createElement('div'); + const cardView = new CardView(container, { + keyExpr: 'id', + dataSource, + columns, + cardTemplate, + sorting: { + mode: 'single', + }, + } as CardViewOptions); + + cardTemplate.mockClear(); + cardView.columnOption('name', 'sortOrder', 'asc'); + + // Should be called dataSource.length times (once per card for data update), + // not dataSource.length * 2 (which would indicate extra re-render). + expect(cardTemplate).toBeCalledTimes(dataSource.length); + }); +}); 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 7e97481c4450..f1b41f71f580 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 @@ -5,8 +5,11 @@ import { ColumnsController } from '@ts/grids/new/grid_core/columns_controller/co import { DataController } from '@ts/grids/new/grid_core/data_controller/data_controller'; import { SearchController } from '@ts/grids/new/grid_core/search/index'; -import type { CardInfo, Column, FieldInfo } from '../columns_controller/types'; +import type { + CardInfo, Column, FieldInfo, +} from '../columns_controller/types'; import type { DataObject, Key } from '../data_controller/types'; +import { getColumnLayoutKey } from './utils'; export class ItemsController { private readonly selectedCardKeys = signal([]); @@ -19,12 +22,22 @@ export class ItemsController { public readonly additionalItems = signal([]); + private readonly visibleColumnsLayout = computed( + () => JSON.stringify( + this.columnsController.visibleColumns.value + .map(getColumnLayoutKey), + ), + ); + public readonly items = computed( () => { // 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-expressions this.searchController.highlightTextOptions.value; + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + this.visibleColumnsLayout.value; + return this.dataController.items.value.map( (item, itemIndex) => this.createCardInfo( item, diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/items_controller/utils.ts b/packages/devextreme/js/__internal/grids/new/grid_core/items_controller/utils.ts new file mode 100644 index 000000000000..fcb06a7b8d14 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/new/grid_core/items_controller/utils.ts @@ -0,0 +1,19 @@ +import type { VisibleColumn } from '../columns_controller/types'; + +// NOTE: Column properties that are excluded from the layout key because they +// don't directly affect card rendering. Data changes caused by sort/filter +// arrive separately via dataController.items, so tracking these properties +// in visibleColumnsLayout would cause redundant re-renders (T1306983, T1309423). +const NON_LAYOUT_COLUMN_KEYS: ReadonlySet = new Set([ + 'sortOrder', + 'sortIndex', + 'filterValues', + 'filterType', +]); + +export const getColumnLayoutKey = (column: VisibleColumn): string => { + const entries = Object.entries(column) + .filter(([key]) => !NON_LAYOUT_COLUMN_KEYS.has(key)); + + return JSON.stringify(entries); +}; diff --git a/packages/testcafe-models/cardView/card.ts b/packages/testcafe-models/cardView/card.ts index 0abe506135e4..e3624971398d 100644 --- a/packages/testcafe-models/cardView/card.ts +++ b/packages/testcafe-models/cardView/card.ts @@ -49,4 +49,17 @@ export default class Card { getHighlightedTexts(): Selector { return this.element.find(`.${CLASS.fieldValue}__${CLASS.highlightedState}`); } + + async getCaptions(): Promise { + const captionElements = this.element.find(`.${CLASS.fieldCaption}`); + const count = await captionElements.count; + const captions: string[] = []; + + for (let i = 0; i < count; i += 1) { + const caption = await captionElements.nth(i).innerText; + captions.push(caption.replace(/:$/, '')); + } + + return captions; + } }