From dc04e3709ab4f41d41f2130056e48f4c3cf0978d Mon Sep 17 00:00:00 2001 From: "anna.shakhova" <68295572+anna-shakhova@users.noreply.github.com> Date: Thu, 3 Sep 2026 14:29:23 +0200 Subject: [PATCH 1/6] Grids: introduce dataSourceController --- .../grids/data_grid/m_widget_base.ts | 2 + .../module_not_extended/data_source.ts | 5 + ...data_source_controller.integration.test.ts | 96 +++++++++ .../__tests__/data_source_controller.test.ts | 191 ++++++++++++++++++ .../data_source/data_source_controller.ts | 50 +++++ .../data_source/data_source_module.ts | 7 + .../js/__internal/grids/grid_core/m_types.ts | 1 + .../grids/tree_list/m_widget_base.ts | 2 + .../module_not_extended/data_source.ts | 5 + 9 files changed, 359 insertions(+) create mode 100644 packages/devextreme/js/__internal/grids/data_grid/module_not_extended/data_source.ts create mode 100644 packages/devextreme/js/__internal/grids/grid_core/data_source/__tests__/data_source_controller.integration.test.ts create mode 100644 packages/devextreme/js/__internal/grids/grid_core/data_source/__tests__/data_source_controller.test.ts create mode 100644 packages/devextreme/js/__internal/grids/grid_core/data_source/data_source_controller.ts create mode 100644 packages/devextreme/js/__internal/grids/grid_core/data_source/data_source_module.ts create mode 100644 packages/devextreme/js/__internal/grids/tree_list/module_not_extended/data_source.ts diff --git a/packages/devextreme/js/__internal/grids/data_grid/m_widget_base.ts b/packages/devextreme/js/__internal/grids/data_grid/m_widget_base.ts index 1710af383abd..47e25fa88151 100644 --- a/packages/devextreme/js/__internal/grids/data_grid/m_widget_base.ts +++ b/packages/devextreme/js/__internal/grids/data_grid/m_widget_base.ts @@ -1,5 +1,6 @@ import './module_not_extended/column_headers'; import './m_columns_controller'; +import './module_not_extended/data_source'; import './m_data_controller'; import './module_not_extended/sorting'; import './module_not_extended/rows'; @@ -24,6 +25,7 @@ import gridCore from './m_core'; const DATAGRID_DEPRECATED_TEMPLATE_WARNING = 'Specifying grid templates with the jQuery selector name is now deprecated. Use the DOM Node or the jQuery object that references this selector instead.'; gridCore.registerModulesOrder([ + 'dataSource', 'stateStoring', 'columns', 'selection', diff --git a/packages/devextreme/js/__internal/grids/data_grid/module_not_extended/data_source.ts b/packages/devextreme/js/__internal/grids/data_grid/module_not_extended/data_source.ts new file mode 100644 index 000000000000..360d96e2d4bb --- /dev/null +++ b/packages/devextreme/js/__internal/grids/data_grid/module_not_extended/data_source.ts @@ -0,0 +1,5 @@ +import { dataSourceModule } from '@ts/grids/grid_core/data_source/data_source_module'; + +import core from '../m_core'; + +core.registerModule('dataSource', dataSourceModule); diff --git a/packages/devextreme/js/__internal/grids/grid_core/data_source/__tests__/data_source_controller.integration.test.ts b/packages/devextreme/js/__internal/grids/grid_core/data_source/__tests__/data_source_controller.integration.test.ts new file mode 100644 index 000000000000..011220a042f4 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/grid_core/data_source/__tests__/data_source_controller.integration.test.ts @@ -0,0 +1,96 @@ +import { + afterEach, + beforeEach, + describe, + expect, + it, + jest, +} from '@jest/globals'; +import type { dxElementWrapper } from '@js/core/renderer'; +import $ from '@js/core/renderer'; +import type { Properties as TreeListProperties } from '@js/ui/tree_list'; +import TreeList from '@js/ui/tree_list'; +import { + afterTest, + beforeTest, + createDataGrid, +} from '@ts/grids/grid_core/__tests__/__mock__/helpers/utils'; +import type { Controllers, InternalGrid } from '@ts/grids/grid_core/m_types'; + +import { DataSourceController } from '../data_source_controller'; + +const TREELIST_CONTAINER_ID = 'treeListContainer'; + +const DATA = [ + { id: 1, parentId: 0, value: 'a' }, + { id: 2, parentId: 1, value: 'b' }, +]; + +interface TreeListInstance extends TreeList { + getController: (name: T) => Controllers[T]; +} + +const createTreeList = ( + options: TreeListProperties = {}, +): { $container: dxElementWrapper; instance: TreeListInstance } => { + const $container = $('
') + .attr('id', TREELIST_CONTAINER_ID) + .appendTo(document.body); + + const instance = new TreeList( + $container.get(0) as HTMLDivElement, + { keyExpr: 'id', parentIdExpr: 'parentId', ...options }, + ) as TreeListInstance; + + jest.runAllTimers(); + + return { $container, instance }; +}; + +const disposeTreeList = ($container: dxElementWrapper): void => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (($container as any).dxTreeList('instance') as TreeList | undefined)?.dispose(); + $container.remove(); +}; + +const getControllerNames = (instance: unknown): string[] => Object + .keys((instance as InternalGrid)._controllers); + +describe('dataSource module registration', () => { + beforeEach(beforeTest); + afterEach(afterTest); + + it('is reachable from DataGrid', async () => { + const { instance } = await createDataGrid({ dataSource: DATA }); + + expect(instance.getController('dataSource')).toBeInstanceOf(DataSourceController); + }); + + it('is reachable from TreeList', () => { + const { $container, instance } = createTreeList({ dataSource: DATA }); + + try { + expect(instance.getController('dataSource')).toBeInstanceOf(DataSourceController); + } finally { + disposeTreeList($container); + } + }); + + it('holds no adapter yet, because DataController does not wire it', async () => { + const { instance } = await createDataGrid({ dataSource: DATA }); + + expect(instance.getController('dataSource').hasAdapter()).toBe(false); + }); + + it('leaves the getDataSource public method on DataController', async () => { + const { instance } = await createDataGrid({ dataSource: DATA }); + + expect(instance.getDataSource()).toBe(instance.getController('data').getDataSource()); + }); + + it('sits at the bottom of the controller order', async () => { + const { instance } = await createDataGrid({ dataSource: DATA }); + + expect(getControllerNames(instance)[0]).toBe('dataSource'); + }); +}); diff --git a/packages/devextreme/js/__internal/grids/grid_core/data_source/__tests__/data_source_controller.test.ts b/packages/devextreme/js/__internal/grids/grid_core/data_source/__tests__/data_source_controller.test.ts new file mode 100644 index 000000000000..827284da260e --- /dev/null +++ b/packages/devextreme/js/__internal/grids/grid_core/data_source/__tests__/data_source_controller.test.ts @@ -0,0 +1,191 @@ +import { + describe, + expect, + it, + jest, +} from '@jest/globals'; +import type Store from '@ts/data/abstract_store'; +import type { StoreKey } from '@ts/data/abstract_store'; +import type { DataSource } from '@ts/data/data_source/data_source'; +import type DataSourceAdapter from '@ts/grids/grid_core/data_source_adapter/m_data_source_adapter'; +import type { + RawItemData, RemoteOperationsOptions, +} from '@ts/grids/grid_core/data_source_adapter/types'; +import type { InternalGrid } from '@ts/grids/grid_core/m_types'; + +import { DataSourceController } from '../data_source_controller'; + +interface AdapterStub { + _dataSource: DataSource; + store: jest.Mock<() => Store | undefined>; + key: jest.Mock<() => StoreKey | undefined>; + remoteOperations: jest.Mock<() => RemoteOperationsOptions>; + getDataIndexGetter: jest.Mock<() => (data: RawItemData) => number>; + dispose: jest.Mock<() => void>; +} + +const createAdapter = (marker: string): AdapterStub => ({ + _dataSource: { marker } as unknown as DataSource, + store: jest.fn(() => ({ marker } as unknown as Store)), + key: jest.fn(() => marker as StoreKey), + remoteOperations: jest.fn(() => ({ filtering: true } as RemoteOperationsOptions)), + getDataIndexGetter: jest.fn(() => (): number => 0), + dispose: jest.fn(), +}); + +const asAdapter = (stub: AdapterStub): DataSourceAdapter => stub as unknown as DataSourceAdapter; + +const createController = (): DataSourceController => { + const component = { + _optionCache: {}, + _controllers: {}, + option: jest.fn(), + }; + + return new DataSourceController(component as unknown as InternalGrid); +}; + +const withAdapter = (marker = 'first'): { + controller: DataSourceController; + adapter: AdapterStub; +} => { + const controller = createController(); + const adapter = createAdapter(marker); + + controller.setAdapter(asAdapter(adapter)); + + return { controller, adapter }; +}; + +describe('DataSourceController', () => { + describe('with no adapter', () => { + it('has no adapter right after construction, without init()', () => { + const controller = createController(); + + expect(controller.hasAdapter()).toBe(false); + expect(controller.getAdapter()).toBeNull(); + }); + + it('returns null from getDataSource', () => { + expect(createController().getDataSource()).toBeNull(); + }); + + it('returns undefined from store and key', () => { + const controller = createController(); + + expect(controller.store()).toBeUndefined(); + expect(controller.key()).toBeUndefined(); + }); + + it('returns undefined from getDataIndexGetter', () => { + expect(createController().getDataIndexGetter()).toBeUndefined(); + }); + + it('returns an empty object from remoteOperations, so callers can enumerate it', () => { + const controller = createController(); + + expect(controller.remoteOperations()).toEqual({}); + expect(Object.keys(controller.remoteOperations())).toEqual([]); + }); + }); + + describe('with an adapter', () => { + it('reports the adapter as present and hands back the same object', () => { + const { controller, adapter } = withAdapter(); + + expect(controller.hasAdapter()).toBe(true); + expect(controller.getAdapter()).toBe(asAdapter(adapter)); + }); + + it('delegates store to the adapter once per call', () => { + const { controller, adapter } = withAdapter(); + + expect(controller.store()).toBe(adapter.store.mock.results[0]?.value); + expect(adapter.store).toHaveBeenCalledTimes(1); + }); + + it('delegates key to the adapter', () => { + const { controller, adapter } = withAdapter(); + + expect(controller.key()).toBe('first'); + expect(adapter.key).toHaveBeenCalledTimes(1); + }); + + it('returns the adapter remoteOperations object as-is', () => { + const { controller, adapter } = withAdapter(); + + expect(controller.remoteOperations()).toEqual({ filtering: true }); + expect(adapter.remoteOperations).toHaveBeenCalledTimes(1); + }); + + it('delegates getDataIndexGetter to the adapter', () => { + const { controller, adapter } = withAdapter(); + const getter = controller.getDataIndexGetter(); + + expect(getter).toBe(adapter.getDataIndexGetter.mock.results[0]?.value); + expect(adapter.getDataIndexGetter).toHaveBeenCalledTimes(1); + }); + + it('returns the inner DataSource from getDataSource, not the adapter', () => { + const { controller, adapter } = withAdapter(); + + expect(controller.getDataSource()).toBe(adapter._dataSource); + expect(controller.getDataSource()).not.toBe(asAdapter(adapter)); + }); + }); + + describe('replacing the adapter', () => { + it('follows the new adapter after a replacement', () => { + const { controller, adapter: first } = withAdapter(); + const second = createAdapter('second'); + + controller.setAdapter(asAdapter(second)); + + expect(controller.getAdapter()).toBe(asAdapter(second)); + expect(controller.key()).toBe('second'); + expect(controller.getDataSource()).toBe(second._dataSource); + expect(first.key).not.toHaveBeenCalled(); + }); + + it('returns to the absent state after setAdapter(null)', () => { + const { controller } = withAdapter(); + + controller.setAdapter(null); + + expect(controller.hasAdapter()).toBe(false); + expect(controller.getAdapter()).toBeNull(); + expect(controller.getDataSource()).toBeNull(); + expect(controller.store()).toBeUndefined(); + expect(controller.key()).toBeUndefined(); + expect(controller.getDataIndexGetter()).toBeUndefined(); + expect(controller.remoteOperations()).toEqual({}); + }); + + it('does not dispose the adapter it lets go of', () => { + const { controller, adapter } = withAdapter(); + + controller.setAdapter(null); + + expect(adapter.dispose).not.toHaveBeenCalled(); + }); + }); + + describe('layering', () => { + it('reads no other controller', () => { + const { controller, adapter } = withAdapter(); + const getController = jest.spyOn(controller, 'getController'); + + controller.setAdapter(asAdapter(adapter)); + controller.hasAdapter(); + controller.getAdapter(); + controller.getDataSource(); + controller.store(); + controller.key(); + controller.remoteOperations(); + controller.getDataIndexGetter(); + controller.setAdapter(null); + + expect(getController).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/devextreme/js/__internal/grids/grid_core/data_source/data_source_controller.ts b/packages/devextreme/js/__internal/grids/grid_core/data_source/data_source_controller.ts new file mode 100644 index 000000000000..75a4816f2e2f --- /dev/null +++ b/packages/devextreme/js/__internal/grids/grid_core/data_source/data_source_controller.ts @@ -0,0 +1,50 @@ +import type Store from '@ts/data/abstract_store'; +import type { StoreKey } from '@ts/data/abstract_store'; +import type { DataSource } from '@ts/data/data_source/data_source'; +import type DataSourceAdapter from '@ts/grids/grid_core/data_source_adapter/m_data_source_adapter'; +import type { + RawItemData, RemoteOperationsOptions, +} from '@ts/grids/grid_core/data_source_adapter/types'; +import modules from '@ts/grids/grid_core/m_modules'; + +export class DataSourceController extends modules.Controller { + // DataController owns the adapter's lifecycle, so it is absent before the first + // dataSource assignment and again after a reset. + private adapter: DataSourceAdapter | null = null; + + public setAdapter(adapter: DataSourceAdapter | null): void { + this.adapter = adapter; + } + + public hasAdapter(): boolean { + return this.adapter !== null; + } + + /** + * Escape hatch for callers that need the adapter object itself rather than + * a delegated read. Temporary — it reopens the boundary this controller draws. + */ + public getAdapter(): DataSourceAdapter | null { + return this.adapter; + } + + public getDataSource(): DataSource | null { + return this.adapter?._dataSource ?? null; + } + + public store(): Store | undefined { + return this.adapter?.store(); + } + + public key(): StoreKey | undefined { + return this.adapter?.key(); + } + + public remoteOperations(): RemoteOperationsOptions { + return this.adapter?.remoteOperations() ?? {}; + } + + public getDataIndexGetter(): ((data: RawItemData) => number) | undefined { + return this.adapter?.getDataIndexGetter(); + } +} diff --git a/packages/devextreme/js/__internal/grids/grid_core/data_source/data_source_module.ts b/packages/devextreme/js/__internal/grids/grid_core/data_source/data_source_module.ts new file mode 100644 index 000000000000..df5c39eddbc1 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/grid_core/data_source/data_source_module.ts @@ -0,0 +1,7 @@ +import { DataSourceController } from './data_source_controller'; + +export const dataSourceModule = { + controllers: { + dataSource: DataSourceController, + }, +}; diff --git a/packages/devextreme/js/__internal/grids/grid_core/m_types.ts b/packages/devextreme/js/__internal/grids/grid_core/m_types.ts index 662de74a6193..d056690762f3 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/m_types.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/m_types.ts @@ -203,6 +203,7 @@ export interface Controllers { columnsResizer: import('./columns_resizing_reordering/m_columns_resizing_reordering').ColumnsResizerViewController; contextMenu: import('./context_menu/m_context_menu').ContextMenuController; data: import('./data_controller/data_controller').DataController; + dataSource: import('./data_source/data_source_controller').DataSourceController; draggingHeader: import('./columns_resizing_reordering/m_columns_resizing_reordering').DraggingHeaderViewController; // todo: export is dataGrid-only controller editing: import('./editing/m_editing').EditingController; diff --git a/packages/devextreme/js/__internal/grids/tree_list/m_widget_base.ts b/packages/devextreme/js/__internal/grids/tree_list/m_widget_base.ts index c933bd272e7e..04ca0636b4b9 100644 --- a/packages/devextreme/js/__internal/grids/tree_list/m_widget_base.ts +++ b/packages/devextreme/js/__internal/grids/tree_list/m_widget_base.ts @@ -1,5 +1,6 @@ import './module_not_extended/column_headers'; import './m_columns_controller'; +import './module_not_extended/data_source'; import './data_controller/m_data_controller'; import './module_not_extended/sorting'; import './rows/m_rows'; @@ -19,6 +20,7 @@ import treeListCore from './m_core'; const TREELIST_CLASS = 'dx-treelist'; treeListCore.registerModulesOrder([ + 'dataSource', 'stateStoring', 'columns', 'selection', diff --git a/packages/devextreme/js/__internal/grids/tree_list/module_not_extended/data_source.ts b/packages/devextreme/js/__internal/grids/tree_list/module_not_extended/data_source.ts new file mode 100644 index 000000000000..546ea70d7d95 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/tree_list/module_not_extended/data_source.ts @@ -0,0 +1,5 @@ +import { dataSourceModule } from '@ts/grids/grid_core/data_source/data_source_module'; + +import treeListCore from '../m_core'; + +treeListCore.registerModule('dataSource', dataSourceModule); From 86bce1c76c1868228b905e6e38b4c588b87111be Mon Sep 17 00:00:00 2001 From: "anna.shakhova" <68295572+anna-shakhova@users.noreply.github.com> Date: Thu, 3 Sep 2026 15:59:30 +0200 Subject: [PATCH 2/6] Grids: mirror the data source adapter into dataSourceController --- .../data_controller/data_controller.ts | 5 + ...data_source_controller.integration.test.ts | 145 +++++++++++++++++- .../testing/helpers/gridBaseMocks.js | 6 +- 3 files changed, 149 insertions(+), 7 deletions(-) diff --git a/packages/devextreme/js/__internal/grids/grid_core/data_controller/data_controller.ts b/packages/devextreme/js/__internal/grids/grid_core/data_controller/data_controller.ts index 5b58e6902297..2a91bc6d1f83 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/data_controller/data_controller.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/data_controller/data_controller.ts @@ -15,6 +15,7 @@ import type Store from '@ts/data/abstract_store'; import type { DataSource } from '@ts/data/data_source/data_source'; import type { ChangingEvent } from '@ts/data/data_source/types'; import type { Column, ColumnsChanges } from '@ts/grids/grid_core/columns_controller/types'; +import type { DataSourceController } from '@ts/grids/grid_core/data_source/data_source_controller'; import type DataSourceAdapter from '@ts/grids/grid_core/data_source_adapter/m_data_source_adapter'; import type { ChangedEvent, DataSourceAdapterProvider, LoadOperation, OperationTypes, RawItemData, @@ -126,6 +127,8 @@ export class DataController extends modules.Controller { public rowIndicesChanged!: Callback<[RowIndexCorrection]>; + protected dataSourceController!: DataSourceController; + // TODO public controller public _columnsController!: Controllers['columns']; @@ -140,6 +143,7 @@ export class DataController extends modules.Controller { public init(): void { this._items = []; this._cachedProcessedItems = null; + this.dataSourceController = this.getController('dataSource'); this._columnsController = this.getController('columns'); this._isPaging = false; @@ -1403,6 +1407,7 @@ export class DataController extends modules.Controller { : null; this._dataSource = dataSourceAdapter; + this.dataSourceController.setAdapter(dataSourceAdapter); if (dataSourceAdapter) { this._isLoading = !dataSourceAdapter.isLoaded(); diff --git a/packages/devextreme/js/__internal/grids/grid_core/data_source/__tests__/data_source_controller.integration.test.ts b/packages/devextreme/js/__internal/grids/grid_core/data_source/__tests__/data_source_controller.integration.test.ts index 011220a042f4..58bab12cb42c 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/data_source/__tests__/data_source_controller.integration.test.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/data_source/__tests__/data_source_controller.integration.test.ts @@ -14,6 +14,7 @@ import { afterTest, beforeTest, createDataGrid, + flushAsync, } from '@ts/grids/grid_core/__tests__/__mock__/helpers/utils'; import type { Controllers, InternalGrid } from '@ts/grids/grid_core/m_types'; @@ -26,6 +27,10 @@ const DATA = [ { id: 2, parentId: 1, value: 'b' }, ]; +const OTHER_DATA = [ + { id: 3, parentId: 0, value: 'c' }, +]; + interface TreeListInstance extends TreeList { getController: (name: T) => Controllers[T]; } @@ -76,12 +81,6 @@ describe('dataSource module registration', () => { } }); - it('holds no adapter yet, because DataController does not wire it', async () => { - const { instance } = await createDataGrid({ dataSource: DATA }); - - expect(instance.getController('dataSource').hasAdapter()).toBe(false); - }); - it('leaves the getDataSource public method on DataController', async () => { const { instance } = await createDataGrid({ dataSource: DATA }); @@ -94,3 +93,137 @@ describe('dataSource module registration', () => { expect(getControllerNames(instance)[0]).toBe('dataSource'); }); }); + +describe('dataSource controller holds the adapter', () => { + beforeEach(beforeTest); + afterEach(afterTest); + + it('holds the same adapter object as DataController', async () => { + const { instance } = await createDataGrid({ dataSource: DATA }); + const dataSourceController = instance.getController('dataSource'); + const adapter = instance.getController('data')._dataSource; + + expect(adapter).toBeTruthy(); + expect(dataSourceController.hasAdapter()).toBe(true); + expect(dataSourceController.getAdapter()).toBe(adapter); + }); + + it('follows the rebuilt adapter when the dataSource option changes', async () => { + const { instance } = await createDataGrid({ dataSource: DATA }); + const dataSourceController = instance.getController('dataSource'); + const dataController = instance.getController('data'); + const firstAdapter = dataSourceController.getAdapter(); + + instance.option('dataSource', OTHER_DATA); + await flushAsync(); + + expect(dataSourceController.getAdapter()).not.toBe(firstAdapter); + expect(dataSourceController.getAdapter()).toBe(dataController._dataSource); + }); + + it('releases the adapter when the dataSource option is cleared', async () => { + const { instance } = await createDataGrid({ dataSource: DATA }); + const dataSourceController = instance.getController('dataSource'); + + instance.option('dataSource', undefined); + await flushAsync(); + + expect(instance.getController('data')._dataSource).toBeNull(); + expect(dataSourceController.hasAdapter()).toBe(false); + expect(dataSourceController.getAdapter()).toBeNull(); + expect(dataSourceController.getDataSource()).toBeNull(); + expect(dataSourceController.store()).toBeUndefined(); + }); + + it('recovers after the dataSource option is set again', async () => { + const { instance } = await createDataGrid({ dataSource: DATA }); + const dataSourceController = instance.getController('dataSource'); + + instance.option('dataSource', undefined); + await flushAsync(); + instance.option('dataSource', OTHER_DATA); + await flushAsync(); + + expect(dataSourceController.hasAdapter()).toBe(true); + expect(dataSourceController.getAdapter()).toBe(instance.getController('data')._dataSource); + }); + + it('still holds the same adapter after a refresh', async () => { + const { instance } = await createDataGrid({ dataSource: DATA }); + const dataSourceController = instance.getController('dataSource'); + + const refreshed = instance.refresh(); + await flushAsync(); + await refreshed; + + expect(dataSourceController.hasAdapter()).toBe(true); + expect(dataSourceController.getAdapter()).toBe(instance.getController('data')._dataSource); + }); + + it('releases the adapter on dispose', async () => { + const { $container, instance } = await createDataGrid({ dataSource: DATA }); + const dataSourceController = instance.getController('dataSource'); + const dataController = instance.getController('data'); + + instance.dispose(); + $container.remove(); + + expect(dataController._dataSource).toBeNull(); + expect(dataSourceController.hasAdapter()).toBe(false); + }); + + it('holds the adapter in TreeList too', () => { + const { $container, instance } = createTreeList({ dataSource: DATA }); + + try { + const dataSourceController = instance.getController('dataSource'); + + expect(dataSourceController.hasAdapter()).toBe(true); + expect(dataSourceController.getAdapter()).toBe(instance.getController('data')._dataSource); + } finally { + disposeTreeList($container); + } + }); +}); + +describe('dataSource controller reads delegate to the adapter', () => { + beforeEach(beforeTest); + afterEach(afterTest); + + it('delegates store', async () => { + const { instance } = await createDataGrid({ dataSource: DATA }); + + expect(instance.getController('dataSource').store()) + .toBe(instance.getController('data').store()); + }); + + it('delegates key', async () => { + const { instance } = await createDataGrid({ dataSource: DATA }); + + expect(instance.getController('dataSource').key()).toBe('id'); + }); + + it('unwraps one hop for getDataSource', async () => { + const { instance } = await createDataGrid({ dataSource: DATA }); + const dataSourceController = instance.getController('dataSource'); + + expect(dataSourceController.getDataSource()) + .toBe(instance.getController('data').getDataSource()); + expect(dataSourceController.getDataSource()) + .not.toBe(dataSourceController.getAdapter()); + }); + + it('delegates remoteOperations instead of falling back to an empty object', async () => { + const { instance } = await createDataGrid({ dataSource: DATA }); + const dataSourceController = instance.getController('dataSource'); + const adapter = dataSourceController.getAdapter(); + + expect(dataSourceController.remoteOperations()).toBe(adapter?.remoteOperations()); + }); + + it('delegates getDataIndexGetter', async () => { + const { instance } = await createDataGrid({ dataSource: DATA }); + + expect(typeof instance.getController('dataSource').getDataIndexGetter()).toBe('function'); + }); +}); diff --git a/packages/devextreme/testing/helpers/gridBaseMocks.js b/packages/devextreme/testing/helpers/gridBaseMocks.js index 05441eea0baa..27c1041868bd 100644 --- a/packages/devextreme/testing/helpers/gridBaseMocks.js +++ b/packages/devextreme/testing/helpers/gridBaseMocks.js @@ -994,11 +994,15 @@ module.exports = function($, gridCore, columnResizingReordering, domUtils, commo _subscribeToEvents(rootElement) { } }; + // The dataSource controller is a leaf that the data controller resolves in init(), + // so it is always included rather than listed by every caller. + const ALWAYS_INCLUDED_MODULES = ['dataSource']; + exports['setup' + nameWidget + 'Modules'] = function(that, moduleNames, options) { const modules = []; $.each(gridCore.modules, function() { - if($.inArray(this.name, moduleNames) !== -1) { + if($.inArray(this.name, moduleNames) !== -1 || $.inArray(this.name, ALWAYS_INCLUDED_MODULES) !== -1) { modules.push(this); } }); From 87a9154348f103f714162024a3db0f4552e371c3 Mon Sep 17 00:00:00 2001 From: "anna.shakhova" <68295572+anna-shakhova@users.noreply.github.com> Date: Fri, 4 Sep 2026 10:03:57 +0200 Subject: [PATCH 3/6] Grids: split setDataSource to create and dispose branches --- .../data_controller/data_controller.ts | 37 +++++++++---------- .../dataController.tests.js | 2 +- .../dataController.tests.js | 2 +- 3 files changed, 19 insertions(+), 22 deletions(-) diff --git a/packages/devextreme/js/__internal/grids/grid_core/data_controller/data_controller.ts b/packages/devextreme/js/__internal/grids/grid_core/data_controller/data_controller.ts index 2a91bc6d1f83..006bfb1a7411 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/data_controller/data_controller.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/data_controller/data_controller.ts @@ -1393,30 +1393,18 @@ export class DataController extends modules.Controller { dataSourceAdapter.pushed.remove(this.dataPushedHandlerProxy); } - private setDataSource(dataSource: DataSource | null): void { - const oldDataSource = this._dataSource; - - if (!dataSource && oldDataSource) { - oldDataSource.cancelAll(); - this.unsubscribeFromDataSource(oldDataSource); - oldDataSource.dispose(this.isSharedDataSource); - } - - const dataSourceAdapter = dataSource - ? this._createDataSourceAdapter(dataSource) - : null; + private setDataSource(dataSource: DataSource): void { + const dataSourceAdapter = this._createDataSourceAdapter(dataSource); this._dataSource = dataSourceAdapter; this.dataSourceController.setAdapter(dataSourceAdapter); - if (dataSourceAdapter) { - this._isLoading = !dataSourceAdapter.isLoaded(); - this._needApplyFilter = true; - this._isAllDataTypesDefined = this._columnsController.isAllDataTypesDefined(); + this._isLoading = !dataSourceAdapter.isLoaded(); + this._needApplyFilter = true; + this._isAllDataTypesDefined = this._columnsController.isAllDataTypesDefined(); - this.changed.add(this.fireDataSourceChanged); - this.subscribeToDataSource(dataSourceAdapter); - } + this.changed.add(this.fireDataSourceChanged); + this.subscribeToDataSource(dataSourceAdapter); } /** @@ -1655,7 +1643,16 @@ export class DataController extends modules.Controller { } protected _disposeDataSource(): void { - this.setDataSource(null); + const oldDataSource = this._dataSource; + + if (oldDataSource) { + oldDataSource.cancelAll(); + this.unsubscribeFromDataSource(oldDataSource); + oldDataSource.dispose(this.isSharedDataSource); + } + + this._dataSource = null; + this.dataSourceController.setAdapter(null); } public dispose(): void { diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets.dataGrid/dataController.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets.dataGrid/dataController.tests.js index c2fc39385b72..bbad2d4a95d0 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets.dataGrid/dataController.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets.dataGrid/dataController.tests.js @@ -383,7 +383,7 @@ QUnit.module('Initialization', { beforeEach: setupModule, afterEach: teardownMod dataSource.load(); // act - this.dataController.setDataSource(null); + this.dataController._disposeDataSource(); // assert assert.strictEqual(loadingChangedSpy.callCount, 2, 'loadingChanged call count'); diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets.treeList/dataController.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets.treeList/dataController.tests.js index ee7a4c5f2ac6..5f2f56fb3699 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets.treeList/dataController.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets.treeList/dataController.tests.js @@ -707,7 +707,7 @@ QUnit.module('Initialization', { beforeEach: setupModule, afterEach: teardownMod QUnit.test('There are no exceptions on getting node when hasn\'t datasource', function(assert) { // arrange - this.dataController.setDataSource(undefined); + this.dataController._disposeDataSource(); // act, assert assert.equal(this.getNodeByKey(1), undefined, 'no exceptions'); From e60a3257a571730845ad400d0d06c7d85cbcdea9 Mon Sep 17 00:00:00 2001 From: "anna.shakhova" <68295572+anna-shakhova@users.noreply.github.com> Date: Fri, 4 Sep 2026 13:57:41 +0200 Subject: [PATCH 4/6] Grids: move createAdapter to dataSourceController --- .../data_source/data_source_controller.ts | 9 ++ .../data_source/data_source_module.ts | 9 ++ .../grids/data_grid/m_data_controller.ts | 6 - .../grids/data_grid/m_widget_base.ts | 2 +- .../module_not_extended/data_source.ts | 5 - .../data_controller/data_controller.ts | 17 +-- ...data_source_controller.integration.test.ts | 26 ++++ .../__tests__/data_source_controller.test.ts | 117 ++++++++++++++++-- .../data_source/data_source_controller.ts | 18 ++- .../data_source/data_source_module.ts | 7 -- .../data_controller/m_data_controller.ts | 6 - .../data_source/data_source_controller.ts | 9 ++ .../data_source/data_source_module.ts | 9 ++ .../grids/tree_list/m_widget_base.ts | 2 +- .../module_not_extended/data_source.ts | 5 - .../dataSource.tests.js | 2 +- 16 files changed, 194 insertions(+), 55 deletions(-) create mode 100644 packages/devextreme/js/__internal/grids/data_grid/data_source/data_source_controller.ts create mode 100644 packages/devextreme/js/__internal/grids/data_grid/data_source/data_source_module.ts delete mode 100644 packages/devextreme/js/__internal/grids/data_grid/module_not_extended/data_source.ts delete mode 100644 packages/devextreme/js/__internal/grids/grid_core/data_source/data_source_module.ts create mode 100644 packages/devextreme/js/__internal/grids/tree_list/data_source/data_source_controller.ts create mode 100644 packages/devextreme/js/__internal/grids/tree_list/data_source/data_source_module.ts delete mode 100644 packages/devextreme/js/__internal/grids/tree_list/module_not_extended/data_source.ts diff --git a/packages/devextreme/js/__internal/grids/data_grid/data_source/data_source_controller.ts b/packages/devextreme/js/__internal/grids/data_grid/data_source/data_source_controller.ts new file mode 100644 index 000000000000..7d2ce108bdff --- /dev/null +++ b/packages/devextreme/js/__internal/grids/data_grid/data_source/data_source_controller.ts @@ -0,0 +1,9 @@ +import dataSourceAdapterProvider from '@ts/grids/data_grid/m_data_source_adapter'; +import { DataSourceController } from '@ts/grids/grid_core/data_source/data_source_controller'; +import type { DataSourceAdapterProvider } from '@ts/grids/grid_core/data_source_adapter/types'; + +export class DataGridDataSourceController extends DataSourceController { + protected getAdapterProvider(): DataSourceAdapterProvider { + return dataSourceAdapterProvider; + } +} diff --git a/packages/devextreme/js/__internal/grids/data_grid/data_source/data_source_module.ts b/packages/devextreme/js/__internal/grids/data_grid/data_source/data_source_module.ts new file mode 100644 index 000000000000..87d5621798c0 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/data_grid/data_source/data_source_module.ts @@ -0,0 +1,9 @@ +import gridCore from '@ts/grids/data_grid/m_core'; + +import { DataGridDataSourceController } from './data_source_controller'; + +gridCore.registerModule('dataSource', { + controllers: { + dataSource: DataGridDataSourceController, + }, +}); diff --git a/packages/devextreme/js/__internal/grids/data_grid/m_data_controller.ts b/packages/devextreme/js/__internal/grids/data_grid/m_data_controller.ts index e6632d7df28f..6c8dae17494f 100644 --- a/packages/devextreme/js/__internal/grids/data_grid/m_data_controller.ts +++ b/packages/devextreme/js/__internal/grids/data_grid/m_data_controller.ts @@ -1,15 +1,9 @@ import errors from '@js/ui/widget/ui.errors'; import { DataController, dataControllerModule } from '@ts/grids/grid_core/data_controller/data_controller'; -import type { DataSourceAdapterProvider } from '../grid_core/data_source_adapter/types'; import gridCore from './m_core'; -import dataSourceAdapterProvider from './m_data_source_adapter'; class DataGridDataController extends DataController { - protected _getDataSourceAdapterProvider(): DataSourceAdapterProvider { - return dataSourceAdapterProvider; - } - protected _getSpecificDataSourceOption() { const dataSource = this.option('dataSource'); diff --git a/packages/devextreme/js/__internal/grids/data_grid/m_widget_base.ts b/packages/devextreme/js/__internal/grids/data_grid/m_widget_base.ts index 47e25fa88151..82ad21b6b782 100644 --- a/packages/devextreme/js/__internal/grids/data_grid/m_widget_base.ts +++ b/packages/devextreme/js/__internal/grids/data_grid/m_widget_base.ts @@ -1,6 +1,6 @@ import './module_not_extended/column_headers'; import './m_columns_controller'; -import './module_not_extended/data_source'; +import './data_source/data_source_module'; import './m_data_controller'; import './module_not_extended/sorting'; import './module_not_extended/rows'; diff --git a/packages/devextreme/js/__internal/grids/data_grid/module_not_extended/data_source.ts b/packages/devextreme/js/__internal/grids/data_grid/module_not_extended/data_source.ts deleted file mode 100644 index 360d96e2d4bb..000000000000 --- a/packages/devextreme/js/__internal/grids/data_grid/module_not_extended/data_source.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { dataSourceModule } from '@ts/grids/grid_core/data_source/data_source_module'; - -import core from '../m_core'; - -core.registerModule('dataSource', dataSourceModule); diff --git a/packages/devextreme/js/__internal/grids/grid_core/data_controller/data_controller.ts b/packages/devextreme/js/__internal/grids/grid_core/data_controller/data_controller.ts index 006bfb1a7411..d6f6bb643c72 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/data_controller/data_controller.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/data_controller/data_controller.ts @@ -18,7 +18,7 @@ import type { Column, ColumnsChanges } from '@ts/grids/grid_core/columns_control import type { DataSourceController } from '@ts/grids/grid_core/data_source/data_source_controller'; import type DataSourceAdapter from '@ts/grids/grid_core/data_source_adapter/m_data_source_adapter'; import type { - ChangedEvent, DataSourceAdapterProvider, LoadOperation, OperationTypes, RawItemData, + ChangedEvent, LoadOperation, OperationTypes, RawItemData, } from '@ts/grids/grid_core/data_source_adapter/types'; import { isLocalStore } from '@ts/grids/grid_core/data_source_adapter/utils/store'; import modules from '@ts/grids/grid_core/m_modules'; @@ -1363,18 +1363,6 @@ export class DataController extends modules.Controller { this.dataSourceChanged.fire(); }; - protected _getDataSourceAdapterProvider(): DataSourceAdapterProvider { - throw new Error('Method not implemented.'); - } - - protected _createDataSourceAdapter(dataSource: DataSource): DataSourceAdapter { - const dataSourceAdapterProvider = this._getDataSourceAdapterProvider(); - const dataSourceAdapter = dataSourceAdapterProvider.create(this.component); - - dataSourceAdapter.init(dataSource); - return dataSourceAdapter; - } - private subscribeToDataSource(dataSourceAdapter: DataSourceAdapter): void { dataSourceAdapter.changed.add(this.dataChangedHandlerProxy); dataSourceAdapter.loadingChanged.add(this.loadingChangedHandler); @@ -1394,10 +1382,9 @@ export class DataController extends modules.Controller { } private setDataSource(dataSource: DataSource): void { - const dataSourceAdapter = this._createDataSourceAdapter(dataSource); + const dataSourceAdapter = this.dataSourceController.createAdapter(dataSource); this._dataSource = dataSourceAdapter; - this.dataSourceController.setAdapter(dataSourceAdapter); this._isLoading = !dataSourceAdapter.isLoaded(); this._needApplyFilter = true; diff --git a/packages/devextreme/js/__internal/grids/grid_core/data_source/__tests__/data_source_controller.integration.test.ts b/packages/devextreme/js/__internal/grids/grid_core/data_source/__tests__/data_source_controller.integration.test.ts index 58bab12cb42c..0a3dff04aca9 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/data_source/__tests__/data_source_controller.integration.test.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/data_source/__tests__/data_source_controller.integration.test.ts @@ -227,3 +227,29 @@ describe('dataSource controller reads delegate to the adapter', () => { expect(typeof instance.getController('dataSource').getDataIndexGetter()).toBe('function'); }); }); + +describe('dataSource controller resolves its own component adapter provider', () => { + beforeEach(beforeTest); + afterEach(afterTest); + + it('builds a DataGrid adapter in DataGrid', async () => { + const { instance } = await createDataGrid({ dataSource: DATA }); + const adapter = instance.getController('dataSource').getAdapter(); + + expect(adapter).toBeTruthy(); + expect('forEachNode' in (adapter as object)).toBe(false); + }); + + it('builds a TreeList adapter in TreeList', () => { + const { $container, instance } = createTreeList({ dataSource: DATA }); + + try { + const adapter = instance.getController('dataSource').getAdapter(); + + expect(adapter).toBeTruthy(); + expect('forEachNode' in (adapter as object)).toBe(true); + } finally { + disposeTreeList($container); + } + }); +}); diff --git a/packages/devextreme/js/__internal/grids/grid_core/data_source/__tests__/data_source_controller.test.ts b/packages/devextreme/js/__internal/grids/grid_core/data_source/__tests__/data_source_controller.test.ts index 827284da260e..0ad4663794f0 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/data_source/__tests__/data_source_controller.test.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/data_source/__tests__/data_source_controller.test.ts @@ -9,7 +9,7 @@ import type { StoreKey } from '@ts/data/abstract_store'; import type { DataSource } from '@ts/data/data_source/data_source'; import type DataSourceAdapter from '@ts/grids/grid_core/data_source_adapter/m_data_source_adapter'; import type { - RawItemData, RemoteOperationsOptions, + DataSourceAdapterProvider, RawItemData, RemoteOperationsOptions, } from '@ts/grids/grid_core/data_source_adapter/types'; import type { InternalGrid } from '@ts/grids/grid_core/m_types'; @@ -22,27 +22,76 @@ interface AdapterStub { remoteOperations: jest.Mock<() => RemoteOperationsOptions>; getDataIndexGetter: jest.Mock<() => (data: RawItemData) => number>; dispose: jest.Mock<() => void>; + init: jest.Mock<(dataSource: DataSource) => void>; } -const createAdapter = (marker: string): AdapterStub => ({ +interface ProviderStub { + create: jest.Mock<(component: InternalGrid) => DataSourceAdapter>; + extend: jest.Mock<() => void>; +} + +const createAdapterStub = (marker: string): AdapterStub => ({ _dataSource: { marker } as unknown as DataSource, store: jest.fn(() => ({ marker } as unknown as Store)), key: jest.fn(() => marker as StoreKey), remoteOperations: jest.fn(() => ({ filtering: true } as RemoteOperationsOptions)), getDataIndexGetter: jest.fn(() => (): number => 0), dispose: jest.fn(), + init: jest.fn(), }); const asAdapter = (stub: AdapterStub): DataSourceAdapter => stub as unknown as DataSourceAdapter; -const createController = (): DataSourceController => { +class TestDataSourceController extends DataSourceController { + public providerStub!: DataSourceAdapterProvider; + + protected getAdapterProvider(): DataSourceAdapterProvider { + return this.providerStub; + } +} + +const createControllerWith = (): { + controller: DataSourceController; + component: InternalGrid; +} => { + const component = { + _optionCache: {}, + _controllers: {}, + option: jest.fn(), + } as unknown as InternalGrid; + + return { controller: new DataSourceController(component), component }; +}; + +const createController = (): DataSourceController => createControllerWith().controller; + +const createProviderStub = (adapter: AdapterStub): ProviderStub => ({ + create: jest.fn(() => asAdapter(adapter)), + extend: jest.fn(), +}); + +const asProvider = ( + stub: ProviderStub, +): DataSourceAdapterProvider => stub as unknown as DataSourceAdapterProvider; + +const SOURCE = { marker: 'source' } as unknown as DataSource; + +const withProvider = (adapter: AdapterStub): { + controller: TestDataSourceController; + component: InternalGrid; + provider: ProviderStub; +} => { const component = { _optionCache: {}, _controllers: {}, option: jest.fn(), - }; + } as unknown as InternalGrid; + const controller = new TestDataSourceController(component); + const provider = createProviderStub(adapter); + + controller.providerStub = asProvider(provider); - return new DataSourceController(component as unknown as InternalGrid); + return { controller, component, provider }; }; const withAdapter = (marker = 'first'): { @@ -50,7 +99,7 @@ const withAdapter = (marker = 'first'): { adapter: AdapterStub; } => { const controller = createController(); - const adapter = createAdapter(marker); + const adapter = createAdapterStub(marker); controller.setAdapter(asAdapter(adapter)); @@ -137,7 +186,7 @@ describe('DataSourceController', () => { describe('replacing the adapter', () => { it('follows the new adapter after a replacement', () => { const { controller, adapter: first } = withAdapter(); - const second = createAdapter('second'); + const second = createAdapterStub('second'); controller.setAdapter(asAdapter(second)); @@ -188,4 +237,58 @@ describe('DataSourceController', () => { expect(getController).not.toHaveBeenCalled(); }); }); + + describe('createAdapter', () => { + it('builds the adapter through the provider, passing the component', () => { + const { controller, component, provider } = withProvider(createAdapterStub('built')); + + controller.createAdapter(SOURCE); + + expect(provider.create).toHaveBeenCalledTimes(1); + expect(provider.create).toHaveBeenCalledWith(component); + }); + + it('initialises the adapter with the given data source', () => { + const adapter = createAdapterStub('built'); + + withProvider(adapter).controller.createAdapter(SOURCE); + + expect(adapter.init).toHaveBeenCalledTimes(1); + expect(adapter.init).toHaveBeenCalledWith(SOURCE); + }); + + it('returns the adapter the provider produced', () => { + const adapter = createAdapterStub('built'); + + const result = withProvider(adapter).controller.createAdapter(SOURCE); + + expect(result).toBe(asAdapter(adapter)); + }); + + it('stores the adapter it built', () => { + const adapter = createAdapterStub('built'); + const { controller } = withProvider(adapter); + + controller.createAdapter(SOURCE); + + expect(controller.hasAdapter()).toBe(true); + expect(controller.getAdapter()).toBe(asAdapter(adapter)); + }); + + it('replaces a previously held adapter without disposing it', () => { + const second = createAdapterStub('second'); + const { controller } = withProvider(second); + const first = createAdapterStub('first'); + + controller.setAdapter(asAdapter(first)); + controller.createAdapter(SOURCE); + + expect(controller.getAdapter()).toBe(asAdapter(second)); + expect(first.dispose).not.toHaveBeenCalled(); + }); + + it('throws on the base class, where no component has supplied a provider', () => { + expect(() => createController().createAdapter(SOURCE)).toThrow('Method not implemented.'); + }); + }); }); diff --git a/packages/devextreme/js/__internal/grids/grid_core/data_source/data_source_controller.ts b/packages/devextreme/js/__internal/grids/grid_core/data_source/data_source_controller.ts index 75a4816f2e2f..137d3da50f93 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/data_source/data_source_controller.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/data_source/data_source_controller.ts @@ -3,7 +3,7 @@ import type { StoreKey } from '@ts/data/abstract_store'; import type { DataSource } from '@ts/data/data_source/data_source'; import type DataSourceAdapter from '@ts/grids/grid_core/data_source_adapter/m_data_source_adapter'; import type { - RawItemData, RemoteOperationsOptions, + DataSourceAdapterProvider, RawItemData, RemoteOperationsOptions, } from '@ts/grids/grid_core/data_source_adapter/types'; import modules from '@ts/grids/grid_core/m_modules'; @@ -16,6 +16,22 @@ export class DataSourceController extends modules.Controller { this.adapter = adapter; } + /** + * @extended: DataGrid's and TreeList's data_source_controller + */ + protected getAdapterProvider(): DataSourceAdapterProvider { + throw new Error('Method not implemented.'); + } + + public createAdapter(dataSource: DataSource): DataSourceAdapter { + const adapter = this.getAdapterProvider().create(this.component); + + adapter.init(dataSource); + this.setAdapter(adapter); + + return adapter; + } + public hasAdapter(): boolean { return this.adapter !== null; } diff --git a/packages/devextreme/js/__internal/grids/grid_core/data_source/data_source_module.ts b/packages/devextreme/js/__internal/grids/grid_core/data_source/data_source_module.ts deleted file mode 100644 index df5c39eddbc1..000000000000 --- a/packages/devextreme/js/__internal/grids/grid_core/data_source/data_source_module.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { DataSourceController } from './data_source_controller'; - -export const dataSourceModule = { - controllers: { - dataSource: DataSourceController, - }, -}; diff --git a/packages/devextreme/js/__internal/grids/tree_list/data_controller/m_data_controller.ts b/packages/devextreme/js/__internal/grids/tree_list/data_controller/m_data_controller.ts index e87ee434b1e8..309f50bd4481 100644 --- a/packages/devextreme/js/__internal/grids/tree_list/data_controller/m_data_controller.ts +++ b/packages/devextreme/js/__internal/grids/tree_list/data_controller/m_data_controller.ts @@ -2,11 +2,9 @@ import { equalByValue } from '@js/core/utils/common'; import { Deferred } from '@js/core/utils/deferred'; import { extend } from '@js/core/utils/extend'; import { DataController, dataControllerModule } from '@ts/grids/grid_core/data_controller/data_controller'; -import type { DataSourceAdapterProvider } from '@ts/grids/grid_core/data_source_adapter/types'; import type { RowKey } from '@ts/grids/grid_core/m_types'; import type { DataSourceAdapterTreeList } from '../data_source_adapter/m_data_source_adapter'; -import dataSourceAdapterProvider from '../data_source_adapter/m_data_source_adapter'; import treeListCore from '../m_core'; export class TreeListDataController extends DataController { @@ -16,10 +14,6 @@ export class TreeListDataController extends DataController { return this._dataSource ?? undefined; } - protected _getDataSourceAdapterProvider(): DataSourceAdapterProvider { - return dataSourceAdapterProvider; - } - private _getNodeLevel(node) { let level = -1; while (node.parent) { diff --git a/packages/devextreme/js/__internal/grids/tree_list/data_source/data_source_controller.ts b/packages/devextreme/js/__internal/grids/tree_list/data_source/data_source_controller.ts new file mode 100644 index 000000000000..70f64d785c20 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/tree_list/data_source/data_source_controller.ts @@ -0,0 +1,9 @@ +import { DataSourceController } from '@ts/grids/grid_core/data_source/data_source_controller'; +import type { DataSourceAdapterProvider } from '@ts/grids/grid_core/data_source_adapter/types'; +import dataSourceAdapterProvider from '@ts/grids/tree_list/data_source_adapter/m_data_source_adapter'; + +export class TreeListDataSourceController extends DataSourceController { + protected getAdapterProvider(): DataSourceAdapterProvider { + return dataSourceAdapterProvider; + } +} diff --git a/packages/devextreme/js/__internal/grids/tree_list/data_source/data_source_module.ts b/packages/devextreme/js/__internal/grids/tree_list/data_source/data_source_module.ts new file mode 100644 index 000000000000..d3b5072248af --- /dev/null +++ b/packages/devextreme/js/__internal/grids/tree_list/data_source/data_source_module.ts @@ -0,0 +1,9 @@ +import treeListCore from '@ts/grids/tree_list/m_core'; + +import { TreeListDataSourceController } from './data_source_controller'; + +treeListCore.registerModule('dataSource', { + controllers: { + dataSource: TreeListDataSourceController, + }, +}); diff --git a/packages/devextreme/js/__internal/grids/tree_list/m_widget_base.ts b/packages/devextreme/js/__internal/grids/tree_list/m_widget_base.ts index 04ca0636b4b9..41ccc3606941 100644 --- a/packages/devextreme/js/__internal/grids/tree_list/m_widget_base.ts +++ b/packages/devextreme/js/__internal/grids/tree_list/m_widget_base.ts @@ -1,6 +1,6 @@ import './module_not_extended/column_headers'; import './m_columns_controller'; -import './module_not_extended/data_source'; +import './data_source/data_source_module'; import './data_controller/m_data_controller'; import './module_not_extended/sorting'; import './rows/m_rows'; diff --git a/packages/devextreme/js/__internal/grids/tree_list/module_not_extended/data_source.ts b/packages/devextreme/js/__internal/grids/tree_list/module_not_extended/data_source.ts deleted file mode 100644 index 546ea70d7d95..000000000000 --- a/packages/devextreme/js/__internal/grids/tree_list/module_not_extended/data_source.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { dataSourceModule } from '@ts/grids/grid_core/data_source/data_source_module'; - -import treeListCore from '../m_core'; - -treeListCore.registerModule('dataSource', dataSourceModule); diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets.dataGrid/dataSource.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets.dataGrid/dataSource.tests.js index d83fa3a157c7..eccc30888fb8 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets.dataGrid/dataSource.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets.dataGrid/dataSource.tests.js @@ -31,7 +31,7 @@ const createDataSource = function(options) { setupDataGridModules(dataGridStub, ['data', 'columns']); - const dataSourceAdapter = dataGridStub.dataController._createDataSourceAdapter(dataSource); + const dataSourceAdapter = dataGridStub.dataSourceController.createAdapter(dataSource); const origItems = dataSourceAdapter.items; const processItems = function(items) { From 0eddf577289f8b3f513d8929f808e658289025bc Mon Sep 17 00:00:00 2001 From: "anna.shakhova" <68295572+anna-shakhova@users.noreply.github.com> Date: Fri, 4 Sep 2026 14:23:46 +0200 Subject: [PATCH 5/6] Grids: move createDataSource to dataSourceController --- .../data_source/data_source_controller.ts | 11 ++ .../grids/data_grid/m_data_controller.ts | 25 --- .../grids/data_grid/m_widget_base.ts | 2 +- .../module_not_extended/data_controller.ts | 5 + .../data_controller.data_source.test.ts | 65 -------- .../data_controller/data_controller.ts | 48 +----- ...data_source_controller.integration.test.ts | 77 +++++++++ .../__tests__/data_source_controller.test.ts | 155 ++++++++++++++++++ .../data_source/data_source_controller.ts | 49 ++++++ .../dataController.tests.js | 2 +- 10 files changed, 303 insertions(+), 136 deletions(-) delete mode 100644 packages/devextreme/js/__internal/grids/data_grid/m_data_controller.ts create mode 100644 packages/devextreme/js/__internal/grids/data_grid/module_not_extended/data_controller.ts delete mode 100644 packages/devextreme/js/__internal/grids/grid_core/data_controller/__tests__/data_controller.data_source.test.ts diff --git a/packages/devextreme/js/__internal/grids/data_grid/data_source/data_source_controller.ts b/packages/devextreme/js/__internal/grids/data_grid/data_source/data_source_controller.ts index 7d2ce108bdff..c978b138692e 100644 --- a/packages/devextreme/js/__internal/grids/data_grid/data_source/data_source_controller.ts +++ b/packages/devextreme/js/__internal/grids/data_grid/data_source/data_source_controller.ts @@ -1,3 +1,4 @@ +import errors from '@js/ui/widget/ui.errors'; import dataSourceAdapterProvider from '@ts/grids/data_grid/m_data_source_adapter'; import { DataSourceController } from '@ts/grids/grid_core/data_source/data_source_controller'; import type { DataSourceAdapterProvider } from '@ts/grids/grid_core/data_source_adapter/types'; @@ -6,4 +7,14 @@ export class DataGridDataSourceController extends DataSourceController { protected getAdapterProvider(): DataSourceAdapterProvider { return dataSourceAdapterProvider; } + + protected getSpecificDataSourceOption(): unknown { + const dataSource = this.option('dataSource'); + + if (dataSource && !Array.isArray(dataSource) && this.option('keyExpr')) { + errors.log('W1011'); + } + + return super.getSpecificDataSourceOption(); + } } diff --git a/packages/devextreme/js/__internal/grids/data_grid/m_data_controller.ts b/packages/devextreme/js/__internal/grids/data_grid/m_data_controller.ts deleted file mode 100644 index 6c8dae17494f..000000000000 --- a/packages/devextreme/js/__internal/grids/data_grid/m_data_controller.ts +++ /dev/null @@ -1,25 +0,0 @@ -import errors from '@js/ui/widget/ui.errors'; -import { DataController, dataControllerModule } from '@ts/grids/grid_core/data_controller/data_controller'; - -import gridCore from './m_core'; - -class DataGridDataController extends DataController { - protected _getSpecificDataSourceOption() { - const dataSource = this.option('dataSource'); - - if (dataSource && !Array.isArray(dataSource) && this.option('keyExpr')) { - errors.log('W1011'); - } - - return super._getSpecificDataSourceOption(); - } -} - -export { DataGridDataController as DataController }; - -gridCore.registerModule('data', { - defaultOptions: dataControllerModule.defaultOptions, - controllers: { - data: DataGridDataController, - }, -}); diff --git a/packages/devextreme/js/__internal/grids/data_grid/m_widget_base.ts b/packages/devextreme/js/__internal/grids/data_grid/m_widget_base.ts index 82ad21b6b782..546d0b54c089 100644 --- a/packages/devextreme/js/__internal/grids/data_grid/m_widget_base.ts +++ b/packages/devextreme/js/__internal/grids/data_grid/m_widget_base.ts @@ -1,7 +1,7 @@ import './module_not_extended/column_headers'; import './m_columns_controller'; import './data_source/data_source_module'; -import './m_data_controller'; +import './module_not_extended/data_controller'; import './module_not_extended/sorting'; import './module_not_extended/rows'; import './module_not_extended/context_menu'; diff --git a/packages/devextreme/js/__internal/grids/data_grid/module_not_extended/data_controller.ts b/packages/devextreme/js/__internal/grids/data_grid/module_not_extended/data_controller.ts new file mode 100644 index 000000000000..5965240a4e70 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/data_grid/module_not_extended/data_controller.ts @@ -0,0 +1,5 @@ +import { dataControllerModule } from '@ts/grids/grid_core/data_controller/data_controller'; + +import gridCore from '../m_core'; + +gridCore.registerModule('data', dataControllerModule); diff --git a/packages/devextreme/js/__internal/grids/grid_core/data_controller/__tests__/data_controller.data_source.test.ts b/packages/devextreme/js/__internal/grids/grid_core/data_controller/__tests__/data_controller.data_source.test.ts deleted file mode 100644 index 0d64fec2e747..000000000000 --- a/packages/devextreme/js/__internal/grids/grid_core/data_controller/__tests__/data_controller.data_source.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { - afterEach, - beforeEach, - describe, - expect, - it, -} from '@jest/globals'; -import DataSource from '@js/data/data_source'; -import type { DataGridInstance } from '@ts/grids/grid_core/__tests__/__mock__/helpers/utils'; -import { - afterTest, - beforeTest, - createDataGrid, - flushAsync, -} from '@ts/grids/grid_core/__tests__/__mock__/helpers/utils'; - -import { DataController } from '../data_controller'; - -declare class ExposedDataController extends DataController { - public isSharedDataSource?: boolean; -} - -const DATA = [ - { id: 1, value: 'a' }, - { id: 2, value: 'b' }, -]; - -const getIsSharedDataSource = (instance: DataGridInstance): boolean | undefined => { - const dataController = instance.getController('data') as unknown as ExposedDataController; - - return dataController.isSharedDataSource; -}; - -describe('DataController data source', () => { - beforeEach(beforeTest); - afterEach(afterTest); - - describe('isSharedDataSource', () => { - it('should be true when a DataSource instance is passed', async () => { - const sharedDataSource = new DataSource({ store: DATA, key: 'id' }); - - const { instance } = await createDataGrid({ dataSource: sharedDataSource }); - - expect(getIsSharedDataSource(instance)).toBe(true); - }); - - it('should be false when a plain array is passed', async () => { - const { instance } = await createDataGrid({ dataSource: DATA }); - - expect(getIsSharedDataSource(instance)).toBe(false); - }); - - it('should reset to false after switching from a shared DataSource to a plain array', async () => { - const sharedDataSource = new DataSource({ store: DATA, key: 'id' }); - - const { instance } = await createDataGrid({ dataSource: sharedDataSource }); - expect(getIsSharedDataSource(instance)).toBe(true); - - instance.option('dataSource', DATA); - await flushAsync(); - - expect(getIsSharedDataSource(instance)).toBe(false); - }); - }); -}); diff --git a/packages/devextreme/js/__internal/grids/grid_core/data_controller/data_controller.ts b/packages/devextreme/js/__internal/grids/grid_core/data_controller/data_controller.ts index d6f6bb643c72..011e0dfbfeef 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/data_controller/data_controller.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/data_controller/data_controller.ts @@ -1,11 +1,8 @@ -import { DataSource as DataSourceClass } from '@js/common/data/data_source/data_source'; -import { normalizeDataSourceOptions } from '@js/common/data/data_source/utils'; import type { Callback } from '@js/core/utils/callbacks'; import { deferRender } from '@js/core/utils/common'; import { logger } from '@js/core/utils/console'; import type { DeferredObj } from '@js/core/utils/deferred'; import { Deferred, when } from '@js/core/utils/deferred'; -import { extend } from '@js/core/utils/extend'; import { isDefined } from '@js/core/utils/type'; import type { StoreChange } from '@js/data/store'; import errors from '@js/ui/widget/ui.errors'; @@ -73,8 +70,6 @@ import { generateRowValues } from './utils/row_values'; export class DataController extends modules.Controller { public _dataSource?: DataSourceAdapter | null; - protected isSharedDataSource?: boolean; - protected _items!: ProcessedItem[]; private _cachedProcessedItems!: ProcessedItem[] | null; @@ -647,22 +642,6 @@ export class DataController extends modules.Controller { }); } - protected _getSpecificDataSourceOption(): unknown { - const dataSource = this.option('dataSource'); - - if (Array.isArray(dataSource)) { - return { - store: { - type: 'array', - data: dataSource, - key: this.option('keyExpr'), - }, - }; - } - - return dataSource; - } - /** * @extended: state_storing, virtual_scrolling */ @@ -676,7 +655,9 @@ export class DataController extends modules.Controller { protected _initDataSource(): void { const hadDataSource = !!this._dataSource; - const dataSource = this.recreateDataSource(); + this._disposeDataSource(); + + const dataSource = this.dataSourceController.createDataSource(); this._useSortingGroupingFromColumns = true; this._cachedProcessedItems = null; @@ -690,27 +671,6 @@ export class DataController extends modules.Controller { } } - private recreateDataSource(): DataSource | undefined { - const dataSourceOptions = this._getSpecificDataSourceOption(); - - this._disposeDataSource(); - - if (!dataSourceOptions) { - this.isSharedDataSource = false; - return undefined; - } - - if (dataSourceOptions instanceof DataSourceClass) { - this.isSharedDataSource = true; - return dataSourceOptions as unknown as DataSource; - } - - this.isSharedDataSource = false; - return new DataSourceClass( - extend(true, {}, normalizeDataSourceOptions(dataSourceOptions, {})), - ) as unknown as DataSource; - } - /** * @extended: selection, virtual_scrolling */ @@ -1635,7 +1595,7 @@ export class DataController extends modules.Controller { if (oldDataSource) { oldDataSource.cancelAll(); this.unsubscribeFromDataSource(oldDataSource); - oldDataSource.dispose(this.isSharedDataSource); + oldDataSource.dispose(this.dataSourceController.isSharedDataSource()); } this._dataSource = null; diff --git a/packages/devextreme/js/__internal/grids/grid_core/data_source/__tests__/data_source_controller.integration.test.ts b/packages/devextreme/js/__internal/grids/grid_core/data_source/__tests__/data_source_controller.integration.test.ts index 0a3dff04aca9..5f9d5c5f4f63 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/data_source/__tests__/data_source_controller.integration.test.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/data_source/__tests__/data_source_controller.integration.test.ts @@ -8,8 +8,10 @@ import { } from '@jest/globals'; import type { dxElementWrapper } from '@js/core/renderer'; import $ from '@js/core/renderer'; +import DataSourceClass from '@js/data/data_source'; import type { Properties as TreeListProperties } from '@js/ui/tree_list'; import TreeList from '@js/ui/tree_list'; +import errors from '@js/ui/widget/ui.errors'; import { afterTest, beforeTest, @@ -253,3 +255,78 @@ describe('dataSource controller resolves its own component adapter provider', () } }); }); + +describe('dataSource controller owns the dataSource option reading', () => { + beforeEach(beforeTest); + afterEach(afterTest); + + it('warns W1011 in DataGrid when keyExpr is combined with a non-array dataSource', async () => { + const log = jest.spyOn(errors, 'log').mockImplementation(() => {}); + + try { + await createDataGrid({ dataSource: { store: { type: 'array', data: DATA } }, keyExpr: 'id' }); + + expect(log).toHaveBeenCalledWith('W1011'); + } finally { + log.mockRestore(); + } + }); + + it('does not warn W1011 in DataGrid for an array dataSource', async () => { + const log = jest.spyOn(errors, 'log').mockImplementation(() => {}); + + try { + await createDataGrid({ dataSource: DATA, keyExpr: 'id' }); + + expect(log).not.toHaveBeenCalledWith('W1011'); + } finally { + log.mockRestore(); + } + }); + + it('does not warn W1011 in TreeList, where the override does not apply', () => { + const log = jest.spyOn(errors, 'log').mockImplementation(() => {}); + const { $container } = createTreeList({ + dataSource: { store: { type: 'array', data: DATA } }, + keyExpr: 'id', + }); + + try { + expect(log).not.toHaveBeenCalledWith('W1011'); + } finally { + log.mockRestore(); + disposeTreeList($container); + } + }); + + it('builds a DataSource from the array option and keys it by keyExpr', async () => { + const { instance } = await createDataGrid({ dataSource: DATA, keyExpr: 'id' }); + + expect(instance.getController('dataSource').key()).toBe('id'); + }); + + it('reports a passed DataSource instance as shared', async () => { + const shared = new DataSourceClass({ store: DATA, key: 'id' }); + const { instance } = await createDataGrid({ dataSource: shared }); + + expect(instance.getController('dataSource').isSharedDataSource()).toBe(true); + }); + + it('reports an array option as not shared', async () => { + const { instance } = await createDataGrid({ dataSource: DATA, keyExpr: 'id' }); + + expect(instance.getController('dataSource').isSharedDataSource()).toBe(false); + }); + + it('clears the shared flag when the option switches from a DataSource to an array', async () => { + const shared = new DataSourceClass({ store: DATA, key: 'id' }); + const { instance } = await createDataGrid({ dataSource: shared }); + + expect(instance.getController('dataSource').isSharedDataSource()).toBe(true); + + instance.option('dataSource', OTHER_DATA); + await flushAsync(); + + expect(instance.getController('dataSource').isSharedDataSource()).toBe(false); + }); +}); diff --git a/packages/devextreme/js/__internal/grids/grid_core/data_source/__tests__/data_source_controller.test.ts b/packages/devextreme/js/__internal/grids/grid_core/data_source/__tests__/data_source_controller.test.ts index 0ad4663794f0..dfa8e318d453 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/data_source/__tests__/data_source_controller.test.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/data_source/__tests__/data_source_controller.test.ts @@ -4,6 +4,7 @@ import { it, jest, } from '@jest/globals'; +import { DataSource as DataSourceClass } from '@js/common/data/data_source/data_source'; import type Store from '@ts/data/abstract_store'; import type { StoreKey } from '@ts/data/abstract_store'; import type { DataSource } from '@ts/data/data_source/data_source'; @@ -48,6 +49,10 @@ class TestDataSourceController extends DataSourceController { protected getAdapterProvider(): DataSourceAdapterProvider { return this.providerStub; } + + public readSpecificDataSourceOption(): unknown { + return this.getSpecificDataSourceOption(); + } } const createControllerWith = (): { @@ -94,6 +99,15 @@ const withProvider = (adapter: AdapterStub): { return { controller, component, provider }; }; +const withOptions = (options: Record): TestDataSourceController => { + const component = { + _controllers: {}, + option: jest.fn((name?: string) => (name === undefined ? options : options[name])), + } as unknown as InternalGrid; + + return new TestDataSourceController(component); +}; + const withAdapter = (marker = 'first'): { controller: DataSourceController; adapter: AdapterStub; @@ -291,4 +305,145 @@ describe('DataSourceController', () => { expect(() => createController().createAdapter(SOURCE)).toThrow('Method not implemented.'); }); }); + + describe('getSpecificDataSourceOption', () => { + it('wraps an array option into an array store, keyed by keyExpr', () => { + const data = [{ id: 1 }]; + + const result = withOptions({ dataSource: data, keyExpr: 'id' }) + .readSpecificDataSourceOption(); + + expect(result).toEqual({ store: { type: 'array', data, key: 'id' } }); + }); + + it('keeps the caller array by reference, leaving the copy to createDataSource', () => { + const data = [{ id: 1 }]; + + const result = withOptions({ dataSource: data, keyExpr: 'id' }) + .readSpecificDataSourceOption() as { store: { data: unknown } }; + + expect(result.store.data).toBe(data); + }); + + it('passes a non-array option straight through', () => { + const config = { store: { type: 'odata', url: 'x' } }; + + const result = withOptions({ dataSource: config }).readSpecificDataSourceOption(); + + expect(result).toBe(config); + }); + + it('returns the unset option as-is, so createDataSource can see it is absent', () => { + expect(withOptions({}).readSpecificDataSourceOption()).toBeUndefined(); + }); + + it('does not throw on the base class, unlike getAdapterProvider', () => { + expect(() => withOptions({}).readSpecificDataSourceOption()).not.toThrow(); + }); + }); + + describe('createDataSource', () => { + it('returns undefined when the dataSource option is absent', () => { + const controller = withOptions({}); + + expect(controller.createDataSource()).toBeUndefined(); + }); + + it('reports not-shared when the dataSource option is absent', () => { + const controller = withOptions({}); + + controller.createDataSource(); + + expect(controller.isSharedDataSource()).toBe(false); + }); + + it('builds a DataSource from a plain array', () => { + const controller = withOptions({ dataSource: [{ id: 1 }], keyExpr: 'id' }); + + expect(controller.createDataSource()).toBeInstanceOf(DataSourceClass); + }); + + it('keys the built DataSource by keyExpr', () => { + const controller = withOptions({ dataSource: [{ id: 1 }], keyExpr: 'id' }); + + expect(controller.createDataSource()?.key()).toBe('id'); + }); + + it('reports not-shared for an array, so disposal may destroy what it built', () => { + const controller = withOptions({ dataSource: [{ id: 1 }], keyExpr: 'id' }); + + controller.createDataSource(); + + expect(controller.isSharedDataSource()).toBe(false); + }); + + it('builds a DataSource from a store config', () => { + const controller = withOptions({ dataSource: { store: { type: 'array', data: [] } } }); + + expect(controller.createDataSource()).toBeInstanceOf(DataSourceClass); + }); + + it('builds a fresh DataSource on every call', () => { + const controller = withOptions({ dataSource: [{ id: 1 }], keyExpr: 'id' }); + + expect(controller.createDataSource()).not.toBe(controller.createDataSource()); + }); + + it('hands back the very DataSource the caller passed, without rebuilding it', () => { + const shared = new DataSourceClass({ store: [{ id: 1 }], key: 'id' }); + const controller = withOptions({ dataSource: shared }); + + expect(controller.createDataSource()).toBe(shared); + + shared.dispose(); + }); + + it('reports shared for a DataSource instance, so disposal spares it', () => { + const shared = new DataSourceClass({ store: [{ id: 1 }], key: 'id' }); + const controller = withOptions({ dataSource: shared }); + + controller.createDataSource(); + + expect(controller.isSharedDataSource()).toBe(true); + + shared.dispose(); + }); + + it('clears the shared flag when the option moves from a DataSource to an array', () => { + const shared = new DataSourceClass({ store: [{ id: 1 }], key: 'id' }); + const options: Record = { dataSource: shared, keyExpr: 'id' }; + const controller = withOptions(options); + + controller.createDataSource(); + options.dataSource = [{ id: 2 }]; + controller.createDataSource(); + + expect(controller.isSharedDataSource()).toBe(false); + + shared.dispose(); + }); + + it('reports not-shared before anything has been created', () => { + expect(withOptions({}).isSharedDataSource()).toBe(false); + }); + + it('leaves the held adapter alone — creating a source is not creating an adapter', () => { + const controller = withOptions({ dataSource: [{ id: 1 }], keyExpr: 'id' }); + + controller.createDataSource(); + + expect(controller.hasAdapter()).toBe(false); + }); + + it('reads no other controller', () => { + const controller = withOptions({ dataSource: [{ id: 1 }], keyExpr: 'id' }); + const getController = jest.spyOn(controller, 'getController'); + + controller.createDataSource(); + controller.readSpecificDataSourceOption(); + controller.isSharedDataSource(); + + expect(getController).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/devextreme/js/__internal/grids/grid_core/data_source/data_source_controller.ts b/packages/devextreme/js/__internal/grids/grid_core/data_source/data_source_controller.ts index 137d3da50f93..efdc8d8434b0 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/data_source/data_source_controller.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/data_source/data_source_controller.ts @@ -1,3 +1,6 @@ +import { DataSource as DataSourceClass } from '@js/common/data/data_source/data_source'; +import { normalizeDataSourceOptions } from '@js/common/data/data_source/utils'; +import { extend } from '@js/core/utils/extend'; import type Store from '@ts/data/abstract_store'; import type { StoreKey } from '@ts/data/abstract_store'; import type { DataSource } from '@ts/data/data_source/data_source'; @@ -12,10 +15,56 @@ export class DataSourceController extends modules.Controller { // dataSource assignment and again after a reset. private adapter: DataSourceAdapter | null = null; + private isShared = false; + public setAdapter(adapter: DataSourceAdapter | null): void { this.adapter = adapter; } + /** + * @extended: DataGrid's data_source_controller + */ + protected getSpecificDataSourceOption(): unknown { + const dataSource = this.option('dataSource'); + + if (Array.isArray(dataSource)) { + return { + store: { + type: 'array', + data: dataSource, + key: this.option('keyExpr'), + }, + }; + } + + return dataSource; + } + + public createDataSource(): DataSource | undefined { + const dataSourceOptions = this.getSpecificDataSourceOption(); + + if (!dataSourceOptions) { + this.isShared = false; + return undefined; + } + + if (dataSourceOptions instanceof DataSourceClass) { + this.isShared = true; + return dataSourceOptions as unknown as DataSource; + } + + this.isShared = false; + return new DataSourceClass( + extend(true, {}, normalizeDataSourceOptions(dataSourceOptions, {})), + ) as unknown as DataSource; + } + + // Read back by DataController only until disposal moves here too, at which point + // the flag stops leaving this class. + public isSharedDataSource(): boolean { + return this.isShared; + } + /** * @extended: DataGrid's and TreeList's data_source_controller */ diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets.dataGrid/dataController.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets.dataGrid/dataController.tests.js index bbad2d4a95d0..f6d335ff9e01 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets.dataGrid/dataController.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets.dataGrid/dataController.tests.js @@ -7746,7 +7746,7 @@ QUnit.module('Filtering', { remoteOperations: { filtering: true } }); - this.dataController.isSharedDataSource = true; + this.dataSourceController.isShared = true; this.dataController.setDataSource(this.dataSource); let loadingCount = 0; From 5326ae833391aad5554cb27056fa2a403a1103ae Mon Sep 17 00:00:00 2001 From: "anna.shakhova" <68295572+anna-shakhova@users.noreply.github.com> Date: Fri, 4 Sep 2026 15:00:44 +0200 Subject: [PATCH 6/6] Grids: move disposeAdapter to dataSourceController --- .../data_controller/data_controller.ts | 4 +- ...data_source_controller.integration.test.ts | 57 +++++++-- .../__tests__/data_source_controller.test.ts | 118 ++++++++++++------ .../data_source/data_source_controller.ts | 24 ++-- .../dataController.tests.js | 1 - 5 files changed, 137 insertions(+), 67 deletions(-) diff --git a/packages/devextreme/js/__internal/grids/grid_core/data_controller/data_controller.ts b/packages/devextreme/js/__internal/grids/grid_core/data_controller/data_controller.ts index 011e0dfbfeef..439b28289ea6 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/data_controller/data_controller.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/data_controller/data_controller.ts @@ -1593,13 +1593,13 @@ export class DataController extends modules.Controller { const oldDataSource = this._dataSource; if (oldDataSource) { + // Before unsubscribing: cancelling in-flight loads still notifies this controller. oldDataSource.cancelAll(); this.unsubscribeFromDataSource(oldDataSource); - oldDataSource.dispose(this.dataSourceController.isSharedDataSource()); } this._dataSource = null; - this.dataSourceController.setAdapter(null); + this.dataSourceController.disposeAdapter(); } public dispose(): void { diff --git a/packages/devextreme/js/__internal/grids/grid_core/data_source/__tests__/data_source_controller.integration.test.ts b/packages/devextreme/js/__internal/grids/grid_core/data_source/__tests__/data_source_controller.integration.test.ts index 5f9d5c5f4f63..cfae192b7095 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/data_source/__tests__/data_source_controller.integration.test.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/data_source/__tests__/data_source_controller.integration.test.ts @@ -304,29 +304,66 @@ describe('dataSource controller owns the dataSource option reading', () => { expect(instance.getController('dataSource').key()).toBe('id'); }); +}); + +describe('dataSource controller owns adapter disposal', () => { + beforeEach(beforeTest); + afterEach(afterTest); - it('reports a passed DataSource instance as shared', async () => { + it('spares a DataSource the caller still owns when the grid is disposed', async () => { const shared = new DataSourceClass({ store: DATA, key: 'id' }); - const { instance } = await createDataGrid({ dataSource: shared }); + const dispose = jest.spyOn(shared, 'dispose'); + const { $container, instance } = await createDataGrid({ dataSource: shared }); - expect(instance.getController('dataSource').isSharedDataSource()).toBe(true); + instance.dispose(); + // afterTest reads the component off #gridContainer, so a disposed one must not linger. + $container.remove(); + + expect(dispose).not.toHaveBeenCalled(); + + dispose.mockRestore(); + shared.dispose(); }); - it('reports an array option as not shared', async () => { - const { instance } = await createDataGrid({ dataSource: DATA, keyExpr: 'id' }); + it('destroys a DataSource it built itself when the grid is disposed', async () => { + const { $container, instance } = await createDataGrid({ dataSource: DATA, keyExpr: 'id' }); + const built = instance.getController('dataSource').getDataSource(); + + if (!built) { + throw new Error('expected the controller to have built a DataSource'); + } + + const dispose = jest.spyOn(built, 'dispose'); - expect(instance.getController('dataSource').isSharedDataSource()).toBe(false); + instance.dispose(); + $container.remove(); + + expect(dispose).toHaveBeenCalledTimes(1); + + dispose.mockRestore(); }); - it('clears the shared flag when the option switches from a DataSource to an array', async () => { + it('spares a shared DataSource when the dataSource option is replaced', async () => { const shared = new DataSourceClass({ store: DATA, key: 'id' }); + const dispose = jest.spyOn(shared, 'dispose'); const { instance } = await createDataGrid({ dataSource: shared }); - expect(instance.getController('dataSource').isSharedDataSource()).toBe(true); - instance.option('dataSource', OTHER_DATA); await flushAsync(); - expect(instance.getController('dataSource').isSharedDataSource()).toBe(false); + expect(dispose).not.toHaveBeenCalled(); + + dispose.mockRestore(); + shared.dispose(); + }); + + it('leaves DataController and the controller agreeing that the adapter is gone', async () => { + const { instance } = await createDataGrid({ dataSource: DATA, keyExpr: 'id' }); + + instance.option('dataSource', undefined); + await flushAsync(); + + expect(instance.getController('dataSource').hasAdapter()).toBe(false); + expect(instance.getController('dataSource').getAdapter()).toBeNull(); }); }); diff --git a/packages/devextreme/js/__internal/grids/grid_core/data_source/__tests__/data_source_controller.test.ts b/packages/devextreme/js/__internal/grids/grid_core/data_source/__tests__/data_source_controller.test.ts index dfa8e318d453..d64d70ec46ab 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/data_source/__tests__/data_source_controller.test.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/data_source/__tests__/data_source_controller.test.ts @@ -22,11 +22,12 @@ interface AdapterStub { key: jest.Mock<() => StoreKey | undefined>; remoteOperations: jest.Mock<() => RemoteOperationsOptions>; getDataIndexGetter: jest.Mock<() => (data: RawItemData) => number>; - dispose: jest.Mock<() => void>; + dispose: jest.Mock<(isShared?: boolean) => void>; init: jest.Mock<(dataSource: DataSource) => void>; } interface ProviderStub { + nextAdapter: AdapterStub; create: jest.Mock<(component: InternalGrid) => DataSourceAdapter>; extend: jest.Mock<() => void>; } @@ -70,10 +71,15 @@ const createControllerWith = (): { const createController = (): DataSourceController => createControllerWith().controller; -const createProviderStub = (adapter: AdapterStub): ProviderStub => ({ - create: jest.fn(() => asAdapter(adapter)), - extend: jest.fn(), -}); +const createProviderStub = (adapter: AdapterStub): ProviderStub => { + const stub: ProviderStub = { + nextAdapter: adapter, + create: jest.fn(() => asAdapter(stub.nextAdapter)), + extend: jest.fn(), + }; + + return stub; +}; const asProvider = ( stub: ProviderStub, @@ -108,16 +114,30 @@ const withOptions = (options: Record): TestDataSourceController return new TestDataSourceController(component); }; +// isShared is private and read only by disposal, so that is where it becomes observable. +const flagHandedToAdapter = ( + controller: TestDataSourceController, +): boolean | undefined => { + const probe = createAdapterStub('probe'); + + controller.providerStub = asProvider(createProviderStub(probe)); + controller.createAdapter(SOURCE); + controller.disposeAdapter(); + + return probe.dispose.mock.calls[0]?.[0]; +}; + const withAdapter = (marker = 'first'): { - controller: DataSourceController; + controller: TestDataSourceController; adapter: AdapterStub; + provider: ProviderStub; } => { - const controller = createController(); const adapter = createAdapterStub(marker); + const { controller, provider } = withProvider(adapter); - controller.setAdapter(asAdapter(adapter)); + controller.createAdapter(SOURCE); - return { controller, adapter }; + return { controller, adapter, provider }; }; describe('DataSourceController', () => { @@ -199,10 +219,11 @@ describe('DataSourceController', () => { describe('replacing the adapter', () => { it('follows the new adapter after a replacement', () => { - const { controller, adapter: first } = withAdapter(); + const { controller, adapter: first, provider } = withAdapter(); const second = createAdapterStub('second'); - controller.setAdapter(asAdapter(second)); + provider.nextAdapter = second; + controller.createAdapter(SOURCE); expect(controller.getAdapter()).toBe(asAdapter(second)); expect(controller.key()).toBe('second'); @@ -210,10 +231,10 @@ describe('DataSourceController', () => { expect(first.key).not.toHaveBeenCalled(); }); - it('returns to the absent state after setAdapter(null)', () => { + it('returns to the absent state after disposeAdapter', () => { const { controller } = withAdapter(); - controller.setAdapter(null); + controller.disposeAdapter(); expect(controller.hasAdapter()).toBe(false); expect(controller.getAdapter()).toBeNull(); @@ -223,22 +244,13 @@ describe('DataSourceController', () => { expect(controller.getDataIndexGetter()).toBeUndefined(); expect(controller.remoteOperations()).toEqual({}); }); - - it('does not dispose the adapter it lets go of', () => { - const { controller, adapter } = withAdapter(); - - controller.setAdapter(null); - - expect(adapter.dispose).not.toHaveBeenCalled(); - }); }); describe('layering', () => { it('reads no other controller', () => { - const { controller, adapter } = withAdapter(); + const { controller } = withAdapter(); const getController = jest.spyOn(controller, 'getController'); - controller.setAdapter(asAdapter(adapter)); controller.hasAdapter(); controller.getAdapter(); controller.getDataSource(); @@ -246,7 +258,7 @@ describe('DataSourceController', () => { controller.key(); controller.remoteOperations(); controller.getDataIndexGetter(); - controller.setAdapter(null); + controller.disposeAdapter(); expect(getController).not.toHaveBeenCalled(); }); @@ -290,11 +302,12 @@ describe('DataSourceController', () => { }); it('replaces a previously held adapter without disposing it', () => { - const second = createAdapterStub('second'); - const { controller } = withProvider(second); const first = createAdapterStub('first'); + const { controller, provider } = withProvider(first); + const second = createAdapterStub('second'); - controller.setAdapter(asAdapter(first)); + controller.createAdapter(SOURCE); + provider.nextAdapter = second; controller.createAdapter(SOURCE); expect(controller.getAdapter()).toBe(asAdapter(second)); @@ -349,12 +362,12 @@ describe('DataSourceController', () => { expect(controller.createDataSource()).toBeUndefined(); }); - it('reports not-shared when the dataSource option is absent', () => { + it('leaves the source not-shared when the dataSource option is absent', () => { const controller = withOptions({}); controller.createDataSource(); - expect(controller.isSharedDataSource()).toBe(false); + expect(flagHandedToAdapter(controller)).toBe(false); }); it('builds a DataSource from a plain array', () => { @@ -369,12 +382,12 @@ describe('DataSourceController', () => { expect(controller.createDataSource()?.key()).toBe('id'); }); - it('reports not-shared for an array, so disposal may destroy what it built', () => { + it('marks what it built not-shared, so disposal may destroy it', () => { const controller = withOptions({ dataSource: [{ id: 1 }], keyExpr: 'id' }); controller.createDataSource(); - expect(controller.isSharedDataSource()).toBe(false); + expect(flagHandedToAdapter(controller)).toBe(false); }); it('builds a DataSource from a store config', () => { @@ -398,13 +411,13 @@ describe('DataSourceController', () => { shared.dispose(); }); - it('reports shared for a DataSource instance, so disposal spares it', () => { + it('marks a caller-owned DataSource shared, so disposal spares it', () => { const shared = new DataSourceClass({ store: [{ id: 1 }], key: 'id' }); const controller = withOptions({ dataSource: shared }); controller.createDataSource(); - expect(controller.isSharedDataSource()).toBe(true); + expect(flagHandedToAdapter(controller)).toBe(true); shared.dispose(); }); @@ -418,13 +431,13 @@ describe('DataSourceController', () => { options.dataSource = [{ id: 2 }]; controller.createDataSource(); - expect(controller.isSharedDataSource()).toBe(false); + expect(flagHandedToAdapter(controller)).toBe(false); shared.dispose(); }); - it('reports not-shared before anything has been created', () => { - expect(withOptions({}).isSharedDataSource()).toBe(false); + it('starts out not-shared, before anything has been created', () => { + expect(flagHandedToAdapter(withOptions({}))).toBe(false); }); it('leaves the held adapter alone — creating a source is not creating an adapter', () => { @@ -441,7 +454,38 @@ describe('DataSourceController', () => { controller.createDataSource(); controller.readSpecificDataSourceOption(); - controller.isSharedDataSource(); + + expect(getController).not.toHaveBeenCalled(); + }); + }); + + describe('disposeAdapter', () => { + it('disposes the adapter it holds', () => { + const { controller, adapter } = withAdapter(); + + controller.disposeAdapter(); + + expect(adapter.dispose).toHaveBeenCalledTimes(1); + }); + + it('does nothing when there is no adapter to dispose', () => { + expect(() => createController().disposeAdapter()).not.toThrow(); + }); + + it('disposes only once across repeated calls', () => { + const { controller, adapter } = withAdapter(); + + controller.disposeAdapter(); + controller.disposeAdapter(); + + expect(adapter.dispose).toHaveBeenCalledTimes(1); + }); + + it('reads no other controller', () => { + const { controller } = withAdapter(); + const getController = jest.spyOn(controller, 'getController'); + + controller.disposeAdapter(); expect(getController).not.toHaveBeenCalled(); }); diff --git a/packages/devextreme/js/__internal/grids/grid_core/data_source/data_source_controller.ts b/packages/devextreme/js/__internal/grids/grid_core/data_source/data_source_controller.ts index efdc8d8434b0..7a70ce031ba9 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/data_source/data_source_controller.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/data_source/data_source_controller.ts @@ -11,16 +11,11 @@ import type { import modules from '@ts/grids/grid_core/m_modules'; export class DataSourceController extends modules.Controller { - // DataController owns the adapter's lifecycle, so it is absent before the first - // dataSource assignment and again after a reset. + // Absent before the first dataSource assignment and again after a reset. private adapter: DataSourceAdapter | null = null; private isShared = false; - public setAdapter(adapter: DataSourceAdapter | null): void { - this.adapter = adapter; - } - /** * @extended: DataGrid's data_source_controller */ @@ -59,10 +54,8 @@ export class DataSourceController extends modules.Controller { ) as unknown as DataSource; } - // Read back by DataController only until disposal moves here too, at which point - // the flag stops leaving this class. - public isSharedDataSource(): boolean { - return this.isShared; + public getDataSource(): DataSource | null { + return this.adapter?._dataSource ?? null; } /** @@ -76,7 +69,7 @@ export class DataSourceController extends modules.Controller { const adapter = this.getAdapterProvider().create(this.component); adapter.init(dataSource); - this.setAdapter(adapter); + this.adapter = adapter; return adapter; } @@ -85,16 +78,13 @@ export class DataSourceController extends modules.Controller { return this.adapter !== null; } - /** - * Escape hatch for callers that need the adapter object itself rather than - * a delegated read. Temporary — it reopens the boundary this controller draws. - */ public getAdapter(): DataSourceAdapter | null { return this.adapter; } - public getDataSource(): DataSource | null { - return this.adapter?._dataSource ?? null; + public disposeAdapter(): void { + this.adapter?.dispose(this.isShared); + this.adapter = null; } public store(): Store | undefined { diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets.dataGrid/dataController.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets.dataGrid/dataController.tests.js index f6d335ff9e01..4b78e7d2de73 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets.dataGrid/dataController.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets.dataGrid/dataController.tests.js @@ -7746,7 +7746,6 @@ QUnit.module('Filtering', { remoteOperations: { filtering: true } }); - this.dataSourceController.isShared = true; this.dataController.setDataSource(this.dataSource); let loadingCount = 0;