From adb36af9e6116dc2ad6e1739d0f5048a5d9ca776 Mon Sep 17 00:00:00 2001 From: Alyar <> Date: Mon, 23 Mar 2026 14:31:41 +0400 Subject: [PATCH 1/4] fix(cardview): update cards when columns are hidden or reordered (T1324855) - Add visibleColumnsLayout computed to track column identity/order changes without subscribing to sort/filter metadata updates - Read visibleColumns via untracked() to avoid extra re-renders - Add e2e tests for column chooser (select & dragAndDrop modes) and reordering - Extract getCardFieldCaptions helper to shared cardUtils --- .../cardView/columnChooser/functional.ts | 63 +++++++++++++++++++ .../cardView/columnSortable/functional.ts | 33 ++++++++++ .../tests/cardView/helpers/cardUtils.ts | 26 ++++++++ .../items_controller/items_controller.ts | 11 ++++ 4 files changed, 133 insertions(+) create mode 100644 e2e/testcafe-devextreme/tests/cardView/helpers/cardUtils.ts 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..5547d70b2986 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,35 @@ 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, + }); + + 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..b4eeac362fd5 --- /dev/null +++ b/e2e/testcafe-devextreme/tests/cardView/helpers/cardUtils.ts @@ -0,0 +1,26 @@ +import CardView from 'devextreme-testcafe-models/cardView'; + +const FIELD_CAPTION_SELECTOR = '.dx-cardview-field-caption'; + +const getCardFieldCaptions = async ( + t: TestController, + cardView: CardView, + expectedCount: number, + cardIndex = 0, +): Promise => { + const card = cardView.getCard(cardIndex); + const captionElements = card.element.find(FIELD_CAPTION_SELECTOR); + + await t.expect(captionElements.count).eql(expectedCount); + + const captions: string[] = []; + + for (let i = 0; i < expectedCount; i += 1) { + const caption = await captionElements.nth(i).innerText; + captions.push(caption.replace(/:$/, '')); + } + + return captions; +}; + +export { getCardFieldCaptions }; 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..a20b16ce868b 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 @@ -19,12 +19,23 @@ export class ItemsController { public readonly additionalItems = signal([]); + // NOTE: Tracks only identity and order of visible columns (not sort/filter metadata). + // Prevents extra re-renders when only sort or filter changes on the same set of columns. + private readonly visibleColumnsLayout = computed( + () => this.columnsController.visibleColumns.value + .map((column) => column.name) + .join(','), + ); + 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, From 715c2c54db0b263fc1152706fe376789d0a725e3 Mon Sep 17 00:00:00 2001 From: Alyar <> Date: Mon, 23 Mar 2026 15:06:02 +0400 Subject: [PATCH 2/4] refactor(cardview): track all layout-affecting column properties in items computed - Replace name-only layout key with exclusion-based approach: only sortOrder, sortIndex, filterValues, filterType are excluded from the column layout key (they trigger data reload separately) - Any new column property automatically triggers items recalculation - Add unit tests for caption, format, alignment reactivity --- .../grids/new/card_view/widget.test.ts | 85 +++++++++++++++++++ .../items_controller/items_controller.ts | 30 +++++-- 2 files changed, 110 insertions(+), 5 deletions(-) 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..d4ff06f2e181 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,88 @@ 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 re-render cards when column caption changes', () => { + const cardTemplate = jest.fn(); + + const container = document.createElement('div'); + const cardView = new CardView(container, { + keyExpr: 'id', + dataSource, + columns, + cardTemplate, + } as CardViewOptions); + + cardTemplate.mockClear(); + cardView.columnOption('name', 'caption', 'Vehicle'); + + expect(cardTemplate).toBeCalledTimes(dataSource.length); + }); + + it('should re-render cards when column format changes', () => { + const cardTemplate = jest.fn(); + + const container = document.createElement('div'); + const cardView = new CardView(container, { + keyExpr: 'id', + dataSource, + columns, + cardTemplate, + } as CardViewOptions); + + cardTemplate.mockClear(); + cardView.columnOption('id', 'format', 'currency'); + + expect(cardTemplate).toBeCalledTimes(dataSource.length); + }); + + it('should re-render cards when column alignment changes', () => { + const cardTemplate = jest.fn(); + + const container = document.createElement('div'); + const cardView = new CardView(container, { + keyExpr: 'id', + dataSource, + columns, + cardTemplate, + } as CardViewOptions); + + cardTemplate.mockClear(); + cardView.columnOption('name', 'alignment', 'right'); + + expect(cardTemplate).toBeCalledTimes(dataSource.length); + }); + + it('should not re-render cards when sort/filter options change without data 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 from column metadata). + 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 a20b16ce868b..c99514f22f69 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,9 +5,28 @@ 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, VisibleColumn, +} from '../columns_controller/types'; import type { DataObject, Key } from '../data_controller/types'; +// NOTE: Column properties that do NOT affect card rendering. +// Changes to these properties trigger data reload via dataController.items, +// so subscribing to them in visibleColumns causes extra re-renders (T1306983, T1309423). +const NON_LAYOUT_COLUMN_KEYS: ReadonlySet = new Set([ + 'sortOrder', + 'sortIndex', + 'filterValues', + 'filterType', +]); + +const getColumnLayoutKey = (column: VisibleColumn): string => { + const entries = Object.entries(column) + .filter(([key]) => !NON_LAYOUT_COLUMN_KEYS.has(key)); + + return JSON.stringify(entries); +}; + export class ItemsController { private readonly selectedCardKeys = signal([]); @@ -19,12 +38,13 @@ export class ItemsController { public readonly additionalItems = signal([]); - // NOTE: Tracks only identity and order of visible columns (not sort/filter metadata). - // Prevents extra re-renders when only sort or filter changes on the same set of columns. + // NOTE: Tracks column properties that affect card rendering. + // Sort/filter properties are excluded — data changes from sort/filter arrive + // separately via dataController.items, so subscribing to them causes extra re-renders. private readonly visibleColumnsLayout = computed( () => this.columnsController.visibleColumns.value - .map((column) => column.name) - .join(','), + .map(getColumnLayoutKey) + .join(';'), ); public readonly items = computed( From a3b3f593f40b5c9cf3765138d0a865627164978d Mon Sep 17 00:00:00 2001 From: Alyar <> Date: Mon, 23 Mar 2026 15:21:49 +0400 Subject: [PATCH 3/4] fix: add auto-wait assertion in reordering e2e test to prevent flakiness --- .../tests/cardView/columnSortable/functional.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/e2e/testcafe-devextreme/tests/cardView/columnSortable/functional.ts b/e2e/testcafe-devextreme/tests/cardView/columnSortable/functional.ts index 5547d70b2986..bcdd5b04266f 100644 --- a/e2e/testcafe-devextreme/tests/cardView/columnSortable/functional.ts +++ b/e2e/testcafe-devextreme/tests/cardView/columnSortable/functional.ts @@ -323,6 +323,9 @@ test('cards should update when columns are reordered (T1324855)', async (t) => { 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) { From 0a337caa60261fc7fa9e48fbb0c3da7213700355 Mon Sep 17 00:00:00 2001 From: Alyar <> Date: Mon, 23 Mar 2026 16:03:33 +0400 Subject: [PATCH 4/4] fix: use JSON.stringify instead of join to avoid delimiter collision in layout key --- .../new/grid_core/items_controller/items_controller.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) 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 c99514f22f69..51d8b9d75e2c 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 @@ -42,9 +42,10 @@ export class ItemsController { // Sort/filter properties are excluded — data changes from sort/filter arrive // separately via dataController.items, so subscribing to them causes extra re-renders. private readonly visibleColumnsLayout = computed( - () => this.columnsController.visibleColumns.value - .map(getColumnLayoutKey) - .join(';'), + () => JSON.stringify( + this.columnsController.visibleColumns.value + .map(getColumnLayoutKey), + ), ); public readonly items = computed(