diff --git a/packages/devextreme/build/pure-getters-guard.test.ts b/packages/devextreme/build/pure-getters-guard.test.ts new file mode 100644 index 000000000000..e8f06ee6c6fc --- /dev/null +++ b/packages/devextreme/build/pure-getters-guard.test.ts @@ -0,0 +1,114 @@ +/** + * Guard against minifiers deleting signal dependency subscriptions (T1334012) + * + * `@preact/signals-core` subscribes as a *side effect* of reading `.value`, so a read whose result + * is never used can still be load-bearing. Terser's `compress.pure_getters` asserts that no getter + * has side effects and deletes such reads, silently unsubscribing the reaction. + * + * This is not an exotic opt-in: Angular CLI's legacy webpack builder enables + * `pure_getters` by default via `buildOptimizer` + * + * Every module is minified twice and the two outputs are compared. Any `.value` read + * that survives without `pure_getters` but disappears with it is a lost subscription. + */ +import { transformSync } from '@babel/core'; +import * as fs from 'fs'; +import * as path from 'path'; + +const terser = require(require.resolve('terser', { + paths: [path.dirname(require.resolve('terser-webpack-plugin'))], +})); + +const GRIDS_NEW = path.join(__dirname, '../js/__internal/grids/new'); + +/** + * A read of `.value`, with its receiver chain so failures name the culprit. + * + * Assignment targets (`item.value = x`) are excluded, comparisons (`item.value === x`) kept. + * Without that exclusion Terser collapsing an if/else into a single ternary assignment looks + * exactly like a deleted read — `filtering/header_filter/legacy_header_filter.ts` does this and + * reported a false positive. + */ +const VALUE_READ = /[\w$.]*\.value\b(?!\s*=(?!=))/g; + +/** + * `.tsx` is excluded: only `@babel/plugin-transform-typescript` is available here, with no JSX + * syntax plugin, so view files cannot be parsed. No known subscription lives in a view, but this + * is a real coverage gap — widen the filter if a JSX parser is ever added. + */ +function listModules(dir: string): string[] { + return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const entryPath = path.join(dir, entry.name); + + if (entry.isDirectory()) { + return listModules(entryPath); + } + + const isTestSupport = /\.(test|mock|test_utils)\./.test(entry.name); + return entry.name.endsWith('.ts') && !isTestSupport ? [entryPath] : []; + }); +} + +const MODULES = listModules(GRIDS_NEW); + +function stripTypes(absPath: string): string { + const result = transformSync(fs.readFileSync(absPath, 'utf8'), { + filename: absPath, + babelrc: false, + configFile: false, + plugins: ['@babel/plugin-transform-typescript'], + }); + + return result!.code!; +} + +async function minify(absPath: string, pureGetters: boolean): Promise { + const result = await terser.minify(stripTypes(absPath), { + module: true, + // Keeps property names and locals readable, so a lost read can be named in the failure. + // It is `compress`, not `mangle`, that deletes reads — this does not weaken the guard. + mangle: false, + compress: { pure_getters: pureGetters }, + }); + + return result.code as string; +} + +/** Multiset difference: reads present in `before` that `after` no longer has. */ +function lostReads(before: string, after: string): string[] { + const remaining = after.match(VALUE_READ) ?? []; + + return (before.match(VALUE_READ) ?? []).filter((read) => { + const at = remaining.indexOf(read); + if (at === -1) { + return true; + } + remaining.splice(at, 1); + return false; + }); +} + +describe('compress.pure_getters must not drop signal subscriptions (T1334012)', () => { + // Guards the harness itself: a module with no `.value` reads passes trivially, so a broken + // directory walk or an empty transform would turn the whole suite green. + it('finds a corpus of modules that actually read .value', async () => { + expect(MODULES.length).toBeGreaterThan(0); + + const totalReads = ( + await Promise.all( + MODULES.map(async (m) => ((await minify(m, false)).match(VALUE_READ) ?? []).length), + ) + ).reduce((total, n) => total + n, 0); + + expect(totalReads).toBeGreaterThan(0); + }); + + it.each(MODULES.map((m) => [path.relative(GRIDS_NEW, m), m]))( + '%s', + async (_name: string, absPath: string) => { + const [before, after] = await Promise.all([minify(absPath, false), minify(absPath, true)]); + + expect(lostReads(before, after)).toEqual([]); + }, + ); +}); diff --git a/packages/devextreme/js/__internal/core/state_manager/dev/index.ts b/packages/devextreme/js/__internal/core/state_manager/dev/index.ts index b4d5f7de788b..da1435659b2b 100644 --- a/packages/devextreme/js/__internal/core/state_manager/dev/index.ts +++ b/packages/devextreme/js/__internal/core/state_manager/dev/index.ts @@ -7,6 +7,7 @@ export { computed, effect, signal, + track, // eslint-disable-next-line spellcheck/spell-checker untracked, } from './reactive_primitives/index'; diff --git a/packages/devextreme/js/__internal/core/state_manager/dev/reactive_primitives/index.ts b/packages/devextreme/js/__internal/core/state_manager/dev/reactive_primitives/index.ts index 54c83a6de24c..6f2eed23c51f 100644 --- a/packages/devextreme/js/__internal/core/state_manager/dev/reactive_primitives/index.ts +++ b/packages/devextreme/js/__internal/core/state_manager/dev/reactive_primitives/index.ts @@ -61,3 +61,5 @@ export function untracked(fn: UntrackedFunction): T { // eslint-disable-next-line spellcheck/spell-checker return Reactive.untracked(fn); } + +export { track } from '../../prod/reactive_primitives/index'; diff --git a/packages/devextreme/js/__internal/core/state_manager/prod/index.ts b/packages/devextreme/js/__internal/core/state_manager/prod/index.ts index d9a8adf6df33..db8aed96f8e8 100644 --- a/packages/devextreme/js/__internal/core/state_manager/prod/index.ts +++ b/packages/devextreme/js/__internal/core/state_manager/prod/index.ts @@ -9,6 +9,7 @@ export { computed, effect, signal, + track, // eslint-disable-next-line spellcheck/spell-checker untracked, } from './reactive_primitives/index'; diff --git a/packages/devextreme/js/__internal/core/state_manager/prod/reactive_primitives/index.ts b/packages/devextreme/js/__internal/core/state_manager/prod/reactive_primitives/index.ts index 718195a9c3e9..576049ddc7db 100644 --- a/packages/devextreme/js/__internal/core/state_manager/prod/reactive_primitives/index.ts +++ b/packages/devextreme/js/__internal/core/state_manager/prod/reactive_primitives/index.ts @@ -37,3 +37,23 @@ export function untracked(fn: UntrackedFunction): T { // eslint-disable-next-line spellcheck/spell-checker return SignalsCore.untracked(fn); } + +const trackSink: { last: unknown } = { last: undefined }; + +/** + * Registers `values` as dependencies of the enclosing computed/effect without using them. + * + * A standalone `signal.value;` read is load-bearing - it IS the subscription - + * but minifiers with `compress.pure_getters` delete it. A property assignment survives, + * because `pure_getters` makes no claim about setters (T1334012). + * + * The sink is cleared before returning: holding on to it would keep whatever was tracked last - + * often a whole item array - strongly referenced until the next `track` call anywhere. + */ +export function track(...values: unknown[]): void { + for (const value of values) { + trackSink.last = value; + } + + trackSink.last = undefined; +} diff --git a/packages/devextreme/js/__internal/core/state_manager/reactive_primitives.test.ts b/packages/devextreme/js/__internal/core/state_manager/reactive_primitives.test.ts index 214542800ca3..34b93c413eeb 100644 --- a/packages/devextreme/js/__internal/core/state_manager/reactive_primitives.test.ts +++ b/packages/devextreme/js/__internal/core/state_manager/reactive_primitives.test.ts @@ -77,5 +77,49 @@ describe('Reactive wrapper', () => { untrackedDispose(); }); + + it('track subscribes the enclosing effect to its arguments', () => { + const first = ReactiveModule.signal(1); + const second = ReactiveModule.signal(1); + + let effectRunCount = 0; + + const dispose = ReactiveModule.effect(() => { + ReactiveModule.track(first.value, second.value); + effectRunCount += 1; + }); + + expect(effectRunCount).toBe(1); + + first.value = 2; + expect(effectRunCount).toBe(2); + + second.value = 2; + expect(effectRunCount).toBe(3); + + dispose(); + + first.value = 3; + expect(effectRunCount).toBe(3); + }); + + it('track subscribes the enclosing computed to its arguments', () => { + const trackedSignal = ReactiveModule.signal(1); + + let computedRunCount = 0; + + const testComputed = ReactiveModule.computed(() => { + ReactiveModule.track(trackedSignal.value); + computedRunCount += 1; + return 'result'; + }); + + expect(testComputed.value).toBe('result'); + expect(computedRunCount).toBe(1); + + trackedSignal.value = 2; + expect(testComputed.value).toBe('result'); + expect(computedRunCount).toBe(2); + }); }); }); diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/accessibility/controller.test.ts b/packages/devextreme/js/__internal/grids/new/grid_core/accessibility/controller.test.ts new file mode 100644 index 000000000000..fb1986bf3691 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/new/grid_core/accessibility/controller.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from '@jest/globals'; + +import { getContext } from '../di.test_utils'; +import type { Options } from '../options'; +import { OptionsControllerMock } from '../options_controller/options_controller.mock'; +import { AccessibilityController } from './controller'; + +const setup = (config: Options = {}) => { + const context = getContext(config); + + return { + optionsController: context.get(OptionsControllerMock), + accessibilityController: context.get(AccessibilityController), + }; +}; + +describe('AccessibilityController', () => { + describe('componentStatus', () => { + // The status is announced only after the description changes, so the effect tracking the + // description is the only thing that can ever take the status out of its initial empty state. + it('should report the description after a column is hidden', () => { + const { optionsController, accessibilityController } = setup({ + dataSource: [{ a: 'a_0', b: 'b_0' }], + columns: ['a', 'b'], + }); + + expect(accessibilityController.componentStatus.value).toBe(''); + + optionsController.option('columns', ['a']); + + expect(accessibilityController.componentStatus.value) + .toBe(accessibilityController.componentDescription.peek()); + expect(accessibilityController.componentStatus.value).not.toBe(''); + }); + }); +}); diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/accessibility/controller.ts b/packages/devextreme/js/__internal/grids/new/grid_core/accessibility/controller.ts index 7d71a505890a..f920e0d4c6b8 100644 --- a/packages/devextreme/js/__internal/grids/new/grid_core/accessibility/controller.ts +++ b/packages/devextreme/js/__internal/grids/new/grid_core/accessibility/controller.ts @@ -1,5 +1,7 @@ import messageLocalization from '@js/localization/message'; -import { computed, effect, signal } from '@ts/core/state_manager/index'; +import { + computed, effect, signal, track, +} from '@ts/core/state_manager/index'; import { ColumnsController } from '../columns_controller/columns_controller'; import { DataController } from '../data_controller/index'; @@ -38,8 +40,7 @@ export class AccessibilityController { effect(() => { // TODO: First Render refactor - // eslint-disable-next-line @typescript-eslint/no-unused-expressions - this.componentDescription.value; + track(this.componentDescription.value); if (!firstRender) { this.firstRender.value = false; diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/accessibility/render.test.ts b/packages/devextreme/js/__internal/grids/new/grid_core/accessibility/render.test.ts index 3d6e41e27e46..d62354c620e3 100644 --- a/packages/devextreme/js/__internal/grids/new/grid_core/accessibility/render.test.ts +++ b/packages/devextreme/js/__internal/grids/new/grid_core/accessibility/render.test.ts @@ -62,6 +62,26 @@ describe('Accessibility attributes', () => { }); }); + describe('Status container', () => { + it('should announce the description after it changes', () => { + const cardView = setup({ + dataSource: [ + { A: 'A_0', B: 'B_0' }, + { A: 'A_1', B: 'B_1' }, + ], + columns: ['A', 'B'], + }); + + const statusContainer = rootQuerySelector(SELECTORS.statusContainer); + expect(statusContainer?.textContent).toBe('Card view with 2 cards. Each card has 2 fields'); + + cardView.columnOption('B', 'visible', false); + rerender(); + + expect(statusContainer?.textContent).toBe('Card view with 2 cards. Each card has 1 fields'); + }); + }); + describe('Header panel', () => { it('should be represented as menubar', () => { setup({ diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/data_controller/data_controller.test.ts b/packages/devextreme/js/__internal/grids/new/grid_core/data_controller/data_controller.test.ts index bcfd8a3b12f0..06199c4fee69 100644 --- a/packages/devextreme/js/__internal/grids/new/grid_core/data_controller/data_controller.test.ts +++ b/packages/devextreme/js/__internal/grids/new/grid_core/data_controller/data_controller.test.ts @@ -101,6 +101,30 @@ describe('DataController', () => { }); }); + describe('remoteOperations', () => { + it('should reload the store when the option is changed at runtime', async () => { + const loadSpy = jest.fn(() => generateData(10)); + const { optionsController, dataController } = setup({ + dataSource: new CustomStore({ + load: loadSpy, + }), + remoteOperations: { + filtering: false, sorting: false, paging: false, + }, + }); + await dataController.waitLoaded(); + + expect(loadSpy).toHaveBeenCalledTimes(1); + + optionsController.option('remoteOperations', { + filtering: true, sorting: false, paging: false, + }); + await dataController.waitLoaded(); + + expect(loadSpy).toHaveBeenCalledTimes(2); + }); + }); + describe('regressions', () => { it('should work good with odata store', async () => { const sendRequestSpy = jest.spyOn(ajax, 'sendRequest').mockImplementation((params: any) => { 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 2e6382acd56b..db19a9726d56 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 @@ -4,7 +4,9 @@ import ArrayStore from '@js/common/data/array_store'; import { Deferred } from '@js/core/utils/deferred'; import { isDefined, isPlainObject } from '@js/core/utils/type'; import type { ReadonlySignal } from '@ts/core/state_manager/index'; -import { computed, effect, signal } from '@ts/core/state_manager/index'; +import { + computed, effect, signal, track, +} from '@ts/core/state_manager/index'; import { equalByValue } from '@ts/core/utils/m_common'; import type { PromiseWithResolvers } from '@ts/core/utils/promise'; import { createPromise } from '@ts/core/utils/promise'; @@ -230,8 +232,7 @@ export class DataController { effect( () => { - // eslint-disable-next-line @typescript-eslint/no-unused-expressions - this.normalizedRemoteOptions.value; + track(this.normalizedRemoteOptions.value); if (this.dataSource.peek().isLoaded()) { // eslint-disable-next-line @typescript-eslint/no-floating-promises 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 6a3142339263..9dcf3fa06309 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 @@ -148,4 +148,41 @@ describe('ItemsController', () => { expect(itemsController.items.peek()).toMatchSnapshot(); }); }); + + // `items` reads columns and highlight options non-reactively, so the subscriptions are separate. + // Each test changes only one of those signals, so a recompute proves the subscription. + describe('items dependency tracking', () => { + it('should recompute when a column becomes hidden', () => { + const { options, itemsController } = setup({ + keyExpr: 'id', + dataSource: [{ id: 1, a: 'my a value', b: 'my b value' }], + columns: ['a', 'b'], + }); + + expect(itemsController.items.value[0].fields.map((field) => field.column.dataField)) + .toEqual(['a', 'b']); + + options.option('columns', ['a', { dataField: 'b', visible: false }]); + + expect(itemsController.items.value[0].fields.map((field) => field.column.dataField)) + .toEqual(['a']); + }); + + it('should recompute when highlighting options change', () => { + const { options, itemsController } = setup({ + keyExpr: 'id', + dataSource: [{ id: 1, a: 'ABC' }], + columns: ['a'], + searchPanel: { text: 'abc', highlightCaseSensitive: false }, + }); + + expect(itemsController.items.value[0].fields[0].highlightedText).not.toBeNull(); + + // Feeds highlightTextOptions only. The search filter is built from searchPanel.text, + // columns and searchVisibleColumnsOnly, so the data set does not change. + options.option('searchPanel.highlightCaseSensitive', true); + + expect(itemsController.items.value[0].fields[0].highlightedText).toBeNull(); + }); + }); }); 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 f1b41f71f580..ca912926d0fc 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,6 +1,6 @@ import { equalByValue } from '@js/core/utils/common'; import formatHelper from '@js/format_helper'; -import { computed, signal } from '@ts/core/state_manager/index'; +import { computed, signal, track } from '@ts/core/state_manager/index'; 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/index'; @@ -29,19 +29,24 @@ export class ItemsController { ), ); + // Re-emits only when visibleColumnsLayout changes, which excludes sort/filter properties, + // so `items` still does not recompute on those (T1306983, T1309423). + private readonly layoutColumns = computed( + () => { + track(this.visibleColumnsLayout.value); + + return this.columnsController.visibleColumns.peek(); + }, + ); + 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; + const columns = this.layoutColumns.value; return this.dataController.items.value.map( (item, itemIndex) => this.createCardInfo( item, - this.columnsController.visibleColumns.peek(), + columns, itemIndex, this.selectedCardKeys.value, ), diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/items_controller/utils.test.ts b/packages/devextreme/js/__internal/grids/new/grid_core/items_controller/utils.test.ts new file mode 100644 index 000000000000..3a8c85a6990b --- /dev/null +++ b/packages/devextreme/js/__internal/grids/new/grid_core/items_controller/utils.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from '@jest/globals'; + +import type { VisibleColumn } from '../columns_controller/types'; +import { getColumnLayoutKey } from './utils'; + +const column = (overrides: Partial = {}): VisibleColumn => ({ + dataField: 'a', + caption: 'A', + visible: true, + ...overrides, +} as VisibleColumn); + +describe('getColumnLayoutKey', () => { + it('should change when a layout-affecting property changes', () => { + expect(getColumnLayoutKey(column({ visible: true }))) + .not.toBe(getColumnLayoutKey(column({ visible: false }))); + }); + + // These drive re-sorting and re-filtering, which reach the cards through + // dataController.items instead. Including them in the key would recompute + // the cards a second time for the same user action (T1306983, T1309423). + it.each(['sortOrder', 'sortIndex', 'filterValues', 'filterType'])( + 'should ignore %s', + (property) => { + const before = getColumnLayoutKey(column()); + const after = getColumnLayoutKey(column({ [property]: 'changed' })); + + expect(after).toBe(before); + }, + ); + + it('should distinguish columns that differ only in a non-excluded property', () => { + expect(getColumnLayoutKey(column({ caption: 'A' }))) + .not.toBe(getColumnLayoutKey(column({ caption: 'B' }))); + }); +}); diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/options_controller/options_controller_base.test.ts b/packages/devextreme/js/__internal/grids/new/grid_core/options_controller/options_controller_base.test.ts index 64e919251133..be569b4cacc5 100644 --- a/packages/devextreme/js/__internal/grids/new/grid_core/options_controller/options_controller_base.test.ts +++ b/packages/devextreme/js/__internal/grids/new/grid_core/options_controller/options_controller_base.test.ts @@ -829,6 +829,35 @@ describe('oneWayWithChanges', () => { }); }); +describe('action', () => { + interface ActionOptions { + onTest?: () => void; + } + + // The action returned by _createActionByOption binds the handler on its first call and + // caches it, so a new action has to be created when the option changes. + it('should call the handler assigned after the action was already executed', () => { + const initialHandler = jest.fn(); + const newHandler = jest.fn(); + const { component, optionsController } = setup( + { onTest: initialHandler }, + {}, + ); + + const action = optionsController.action('onTest'); + + action.value?.({}); + + expect(initialHandler).toHaveBeenCalledTimes(1); + + component.option('onTest', newHandler); + action.value?.({}); + + expect(newHandler).toHaveBeenCalledTimes(1); + expect(initialHandler).toHaveBeenCalledTimes(1); + }); +}); + describe('notifyColumnOptionChanged', () => { it('should not update the internal state', () => { const publicOptions = { columns: [{ visible: true }] }; diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/options_controller/options_controller_base.ts b/packages/devextreme/js/__internal/grids/new/grid_core/options_controller/options_controller_base.ts index 509b3943a06b..7d0802e2a6c7 100644 --- a/packages/devextreme/js/__internal/grids/new/grid_core/options_controller/options_controller_base.ts +++ b/packages/devextreme/js/__internal/grids/new/grid_core/options_controller/options_controller_base.ts @@ -5,7 +5,9 @@ import { Component } from '@js/core/component'; import { getPathParts } from '@js/core/utils/data'; import type { ChangedOptionInfo } from '@js/events'; import type { ReadonlySignal, Signal } from '@ts/core/state_manager/index'; -import { computed, effect, signal } from '@ts/core/state_manager/index'; +import { + computed, effect, signal, track, +} from '@ts/core/state_manager/index'; import { extend } from '@ts/core/utils/m_extend'; import type { ComponentType } from 'inferno'; @@ -219,8 +221,7 @@ export class OptionsController< const actionOption = this.oneWay(name); return computed( () => { - // eslint-disable-next-line @typescript-eslint/no-unused-expressions - actionOption.value; + track(actionOption.value); // @ts-expect-error return this.component._createActionByOption(name) as any; }, diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/search/controller.ts b/packages/devextreme/js/__internal/grids/new/grid_core/search/controller.ts index 02b3d706d5ce..ff2c81100c0e 100644 --- a/packages/devextreme/js/__internal/grids/new/grid_core/search/controller.ts +++ b/packages/devextreme/js/__internal/grids/new/grid_core/search/controller.ts @@ -51,7 +51,7 @@ export class SearchController { text: string, ): HighlightedTextItem[] | null => splitHighlightedText( text, - this.highlightTextOptions.peek(), + this.highlightTextOptions.value, ); public readonly updateSearchText = (text: string): void => { 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 042eeff4b9cf..db8f856868ff 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 @@ -7,6 +7,7 @@ import { getContext } from '../di.test_utils'; import { ItemsController } from '../items_controller/items_controller'; import type { Options } from '../options'; import { OptionsControllerMock } from '../options_controller/options_controller.mock'; +import { ToolbarController } from '../toolbar/controller'; import { SelectionController } from './controller'; const setup = (config: Options = {}) => { @@ -23,6 +24,7 @@ const setup = (config: Options = {}) => { selectionController: context.get(SelectionController), itemsController: context.get(ItemsController), dataController: context.get(DataController), + toolbarController: context.get(ToolbarController), }; }; @@ -530,6 +532,55 @@ describe('SelectionController', () => { }); }); + // Toolbar buttons + + describe('selection toolbar buttons', () => { + const setupOnePageSelectAll = () => { + const result = setup({ + keyExpr: 'id', + dataSource: [ + { id: 1, value: 'test1' }, + { id: 2, value: 'test2' }, + { id: 3, value: 'test3' }, + { id: 4, value: 'test4' }, + ], + paging: { + enabled: true, + pageSize: 2, + }, + selection: { + mode: 'multiple', + allowSelectAll: true, + selectAllMode: 'page', + }, + selectedCardKeys: [1, 2], + }); + + const getButtonDisabled = (name: string): unknown => result.toolbarController.items.value + .find((item) => item.name === name) + ?.options + ?.disabled; + + return { ...result, getButtonDisabled }; + }; + + // The buttons are derived from the selection helper's internal, non-signal state, so the + // effect updating them depends on the data only through dataController.items / isLoaded. + it('should be updated when the displayed data changes and the selection does not', () => { + const { optionsController, getButtonDisabled } = setupOnePageSelectAll(); + + // The whole first page is selected + expect(getButtonDisabled('selectAllButton')).toBe(true); + expect(getButtonDisabled('clearSelectionButton')).toBe(false); + + optionsController.option('paging.pageIndex', 1); + + // Nothing on the second page is selected, while selectedCardKeys is unchanged + expect(getButtonDisabled('selectAllButton')).toBe(false); + expect(getButtonDisabled('clearSelectionButton')).toBe(true); + }); + }); + describe('needToHiddenCheckBoxes', () => { describe('when the selection mode is equal to \'multiple\' and the showCheckBoxesMode is equal to \'onClick\'', () => { it('should return true', () => { 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 98ef90b6d003..be567cba7033 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,7 +4,9 @@ import type { DeferredObj } from '@js/core/utils/deferred'; import messageLocalization from '@js/localization/message'; import type { ReadonlySignal } from '@ts/core/state_manager/index'; -import { computed, effect, signal } from '@ts/core/state_manager/index'; +import { + computed, effect, signal, track, +} from '@ts/core/state_manager/index'; 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'; @@ -167,10 +169,10 @@ export class SelectionController { }); effect(() => { - // eslint-disable-next-line @typescript-eslint/no-unused-expressions - this.dataController.items.value; - // eslint-disable-next-line @typescript-eslint/no-unused-expressions - this.dataController.isLoaded.value; + track( + this.dataController.items.value, + this.dataController.isLoaded.value, + ); this.updateSelectionToolbarButtons(this.normalizedSelectedCardKeys.value); }); }