From b395341cf715e2c7aeb6b7838922b0aedf10da83 Mon Sep 17 00:00:00 2001 From: Alyar <> Date: Mon, 14 Sep 2026 06:25:05 +0400 Subject: [PATCH] Grids - FilterController: Build the additional filter from filter sources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the chain of FilterController extenders with a list of filter sources. Every module that contributes to the data filter — the filter row, the header filter, the search panel and the filter sync — now implements `getFilterExpressions` on its own controller, and FilterController asks the sources in a fixed order instead of being extended by each of them. Along the way the suppression windows of the filter sync become explicit: `suspendColumnSources` covers the whole "column option -> filterValue" step, because column options are updated in a batch and the column sources still report the previous filter until the batch ends, while `withColumnOptionsSync` covers only the mirroring back into the column options. --- .../ai_assistant/commands/filtering.ts | 2 +- ..._controller.suspend_column_sources.test.ts | 102 +++++++++++ .../grids/grid_core/filter/const.ts | 1 + .../grid_core/filter/filter_controller.ts | 57 +++++- .../grids/grid_core/filter/types.ts | 15 ++ ...r_builder.custom_filter_operations.test.ts | 76 ++++++++ .../filter_builder.filter_expressions.test.ts | 117 ++++++++++++ ...ilter_builder.filter_source_active.test.ts | 77 ++++++++ .../filter_builder/m_filter_builder.ts | 59 +++++- .../grid_core/filter_panel/m_filter_panel.ts | 12 +- .../grid_core/filter_row/m_filter_row.ts | 42 ++--- .../filter_sync.additional_filter.test.ts} | 0 .../filter_sync.column_filter_changes.test.ts | 173 ++++++++++++++++++ .../filter_sync.column_options_sync.test.ts | 103 +++++++++++ .../extenders/filter_sync_data_controller.ts | 2 +- .../filter_sync_filter_controller.ts | 56 ------ .../filter_sync/filter_sync_module.ts | 2 - .../grid_core/filter_sync/m_filter_sync.ts | 27 +-- .../header_filter/m_header_filter.ts | 45 ++--- .../js/__internal/grids/grid_core/m_types.ts | 1 + .../grids/grid_core/search/m_search.ts | 49 +++-- .../js/__internal/grids/new/grid_core/di.ts | 3 +- .../compatibility.ts | 4 +- .../filtering/filter_builder/index.ts | 1 + .../grid_core/filtering/filter_sync/index.ts | 1 - .../__internal/grids/new/grid_core/widget.ts | 5 +- .../grids/new/grid_core/widget_mock.ts | 6 +- .../filterPanel.tests.js | 12 +- .../filterSync.tests.js | 2 +- .../keyboardNavigation.accessibility.tests.js | 2 +- .../stateStoring.tests.js | 2 +- 31 files changed, 858 insertions(+), 198 deletions(-) create mode 100644 packages/devextreme/js/__internal/grids/grid_core/filter/__tests__/filter_controller.suspend_column_sources.test.ts create mode 100644 packages/devextreme/js/__internal/grids/grid_core/filter/const.ts create mode 100644 packages/devextreme/js/__internal/grids/grid_core/filter_builder/__tests__/filter_builder.custom_filter_operations.test.ts create mode 100644 packages/devextreme/js/__internal/grids/grid_core/filter_builder/__tests__/filter_builder.filter_expressions.test.ts create mode 100644 packages/devextreme/js/__internal/grids/grid_core/filter_builder/__tests__/filter_builder.filter_source_active.test.ts rename packages/devextreme/js/__internal/grids/grid_core/filter_sync/{extenders/__tests__/filter_sync_filter_controller.additional_filter.test.ts => __tests__/filter_sync.additional_filter.test.ts} (100%) create mode 100644 packages/devextreme/js/__internal/grids/grid_core/filter_sync/__tests__/filter_sync.column_filter_changes.test.ts create mode 100644 packages/devextreme/js/__internal/grids/grid_core/filter_sync/__tests__/filter_sync.column_options_sync.test.ts delete mode 100644 packages/devextreme/js/__internal/grids/grid_core/filter_sync/extenders/filter_sync_filter_controller.ts rename packages/devextreme/js/__internal/grids/new/grid_core/filtering/{filter_sync => filter_builder}/compatibility.ts (78%) create mode 100644 packages/devextreme/js/__internal/grids/new/grid_core/filtering/filter_builder/index.ts diff --git a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/filtering.ts b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/filtering.ts index 98d35f76ea90..d9282b2d45c0 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/filtering.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/filtering.ts @@ -146,7 +146,7 @@ const getFilterSuccessMessage = async ( filterValue: FilterExprArray, ): Promise => { try { - const customOperations = component.getController('filterSync').getCustomFilterOperations(); + const customOperations = component.getController('filterBuilder').getCustomFilterOperations(); const filterText: string = await when( component.getView('filterPanelView').getFilterText(filterValue, customOperations), ); diff --git a/packages/devextreme/js/__internal/grids/grid_core/filter/__tests__/filter_controller.suspend_column_sources.test.ts b/packages/devextreme/js/__internal/grids/grid_core/filter/__tests__/filter_controller.suspend_column_sources.test.ts new file mode 100644 index 000000000000..2652f2235ce2 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/grid_core/filter/__tests__/filter_controller.suspend_column_sources.test.ts @@ -0,0 +1,102 @@ +import { + afterEach, + beforeEach, + describe, + expect, + it, +} from '@jest/globals'; +import type { Properties as DataGridProperties } from '@js/ui/data_grid'; +import type { DataGridInstance } from '@ts/grids/grid_core/__tests__/__mock__/helpers/utils'; +import { + afterTest, + beforeTest, + createDataGrid, + toPlainFilter, +} from '@ts/grids/grid_core/__tests__/__mock__/helpers/utils'; + +const DATA = [ + { id: 1, name: 'Alex' }, + { id: 2, name: 'Dan' }, +]; + +const GRID_OPTIONS: DataGridProperties = { + dataSource: DATA, + filterPanel: { visible: true }, + filterSyncEnabled: true, + columns: ['name'], +}; + +// A column filter is only visible to the column sources while `filterValue` stays empty, +// and the sync keeps those two in step. Setting the filter with the sync suppressed is +// the one way to reach that state. +const createGridWithFilterRowValue = async (): Promise => { + const { instance } = await createDataGrid(GRID_OPTIONS); + + instance.getController('filterSync').withColumnOptionsSync(() => { + instance.columnOption('name', 'filterValue', 'Alex'); + }); + + return instance; +}; + +describe('FilterController.suspendColumnSources', () => { + beforeEach(beforeTest); + afterEach(afterTest); + + describe('when a callback runs', () => { + it('should skip the column sources and resume them afterwards', async () => { + const instance = await createGridWithFilterRowValue(); + const filterController = instance.getController('filter'); + + filterController.suspendColumnSources(() => { + expect(filterController.getAdditionalFilter()).toBeUndefined(); + }); + + expect(toPlainFilter(filterController.getAdditionalFilter())).toEqual(['name', 'contains', 'Alex']); + }); + + it('should return the callback result', async () => { + const instance = await createGridWithFilterRowValue(); + const filterController = instance.getController('filter'); + + expect(filterController.suspendColumnSources(() => 'result')).toBe('result'); + }); + }); + + describe('when a nested call completes', () => { + it('should keep the column sources suspended for the outer call', async () => { + const instance = await createGridWithFilterRowValue(); + const filterController = instance.getController('filter'); + + filterController.suspendColumnSources(() => { + filterController.suspendColumnSources(() => undefined); + + expect(filterController.getAdditionalFilter()).toBeUndefined(); + }); + + expect(toPlainFilter(filterController.getAdditionalFilter())).toEqual(['name', 'contains', 'Alex']); + }); + }); + + describe('when the callback throws', () => { + it('should rethrow the error', async () => { + const instance = await createGridWithFilterRowValue(); + const filterController = instance.getController('filter'); + + expect(() => filterController.suspendColumnSources(() => { + throw new Error('callback failed'); + })).toThrow('callback failed'); + }); + + it('should resume the column sources', async () => { + const instance = await createGridWithFilterRowValue(); + const filterController = instance.getController('filter'); + + expect(() => filterController.suspendColumnSources(() => { + throw new Error('callback failed'); + })).toThrow('callback failed'); + + expect(toPlainFilter(filterController.getAdditionalFilter())).toEqual(['name', 'contains', 'Alex']); + }); + }); +}); diff --git a/packages/devextreme/js/__internal/grids/grid_core/filter/const.ts b/packages/devextreme/js/__internal/grids/grid_core/filter/const.ts new file mode 100644 index 000000000000..f39f974eaad2 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/grid_core/filter/const.ts @@ -0,0 +1 @@ +export const SOURCE_ORDER = ['applyFilter', 'headerFilter', 'searchPanel', 'filterBuilder'] as const; diff --git a/packages/devextreme/js/__internal/grids/grid_core/filter/filter_controller.ts b/packages/devextreme/js/__internal/grids/grid_core/filter/filter_controller.ts index fb33814b809a..768f6e829c42 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/filter/filter_controller.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/filter/filter_controller.ts @@ -1,12 +1,16 @@ import type { LangParams } from '@js/common/data'; import config from '@js/core/config'; import { extend } from '@js/core/utils/extend'; -import { isFunction, isString } from '@js/core/utils/type'; +import { isDefined, isFunction, isString } from '@js/core/utils/type'; import type { Column } from '@ts/grids/grid_core/columns_controller/types'; import type { DataFilter } from '@ts/grids/grid_core/filter/types'; import modules from '@ts/grids/grid_core/m_modules'; import type { Controllers } from '@ts/grids/grid_core/m_types'; +import { SOURCE_ORDER } from './const'; +import type { FilterSourceContext } from './types'; +import { combineFilters } from './utils'; + type TaggedFilter = unknown[] & { columnIndex?: number; filterValue?: unknown; @@ -18,27 +22,43 @@ export class FilterController extends modules.Controller { protected dataSourceController!: Controllers['dataSource']; + private columnSourcesSuspended = false; + public init(): void { this.columnsController = this.getController('columns'); this.dataSourceController = this.getController('dataSource'); } - public isFilterSyncActive(): boolean | undefined { + public isFilterSyncActive(): boolean { const filterSyncEnabled = this.option('filterSyncEnabled'); - return filterSyncEnabled === 'auto' ? this.option('filterPanel.visible') : filterSyncEnabled; + return filterSyncEnabled === 'auto' ? !!this.option('filterPanel.visible') : !!filterSyncEnabled; } - protected getLangParams(): LangParams | undefined { - return this.dataSourceController.getDataSource()?.loadOptions?.()?.langParams; + public suspendColumnSources(callback: () => T): T { + const wasSuspended = this.columnSourcesSuspended; + + this.columnSourcesSuspended = true; + + try { + return callback(); + } finally { + this.columnSourcesSuspended = wasSuspended; + } } - /** - * @extended: filter_row, filter_sync, header_filter, search - */ - // eslint-disable-next-line @typescript-eslint/no-unused-vars public getAdditionalFilter(excludedColumn?: Column | null): DataFilter { - return null; + const context = this.createSourceContext(excludedColumn); + + return SOURCE_ORDER.reduce((filter, sourceName) => { + const source = this.getController(sourceName); + + if (!source?.isFilterSourceActive(context)) { + return filter; + } + + return combineFilters([filter, ...source.getFilterExpressions(context)]); + }, null); } public normalizeFilterSelectors( @@ -50,6 +70,23 @@ export class FilterController extends modules.Controller { return this.normalizeNode(filter, remoteFiltering, columnIndex, filterValue) as DataFilter; } + private createSourceContext(excludedColumn?: Column | null): FilterSourceContext { + const filterSyncActive = this.isFilterSyncActive(); + + return { + langParams: this.getLangParams(), + excludedColumn: excludedColumn ?? null, + filterSyncActive, + columnSourcesActive: !filterSyncActive + || (!isDefined(this.option('filterValue')) && !this.columnSourcesSuspended), + columnsController: this.columnsController, + }; + } + + private getLangParams(): LangParams | undefined { + return this.dataSourceController?.getDataSource()?.loadOptions?.()?.langParams; + } + private normalizeNode( node: unknown, remoteFiltering: boolean | undefined, diff --git a/packages/devextreme/js/__internal/grids/grid_core/filter/types.ts b/packages/devextreme/js/__internal/grids/grid_core/filter/types.ts index c086a45a74bc..fcb14c162697 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/filter/types.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/filter/types.ts @@ -1,6 +1,11 @@ +import type { LangParams } from '@js/common/data'; import type { SearchOperation } from '@js/common/data.types'; import type { ScalarFilterValue } from '@js/common/grids'; +import type { Column } from '@ts/grids/grid_core/columns_controller/types'; import type { RawItemData } from '@ts/grids/grid_core/data_source_adapter/types'; +import type { Controllers } from '@ts/grids/grid_core/m_types'; + +import type { SOURCE_ORDER } from './const'; export type FilterCombiner = 'and' | 'or'; @@ -47,3 +52,13 @@ export type FilterValueExpression = FilterValueCondition | [FilterValueExpression, ...(FilterCombiner | FilterValueExpression)[]]; export type FilterValue = FilterValueExpression | null | undefined; + +export interface FilterSourceContext { + readonly langParams: LangParams | undefined; + readonly excludedColumn: Column | null; + readonly filterSyncActive: boolean; + readonly columnSourcesActive: boolean; + readonly columnsController: Controllers['columns']; +} + +export type FilterSourceName = typeof SOURCE_ORDER[number]; diff --git a/packages/devextreme/js/__internal/grids/grid_core/filter_builder/__tests__/filter_builder.custom_filter_operations.test.ts b/packages/devextreme/js/__internal/grids/grid_core/filter_builder/__tests__/filter_builder.custom_filter_operations.test.ts new file mode 100644 index 000000000000..04bf31d5515d --- /dev/null +++ b/packages/devextreme/js/__internal/grids/grid_core/filter_builder/__tests__/filter_builder.custom_filter_operations.test.ts @@ -0,0 +1,76 @@ +import { + afterEach, + beforeEach, + describe, + expect, + it, +} from '@jest/globals'; +import type { CustomOperation } from '@js/ui/filter_builder'; +import type { DataGridInstance } from '@ts/grids/grid_core/__tests__/__mock__/helpers/utils'; +import { + afterTest, + beforeTest, + createDataGrid, +} from '@ts/grids/grid_core/__tests__/__mock__/helpers/utils'; + +const DATA = [ + { id: 1, name: 'Alex', age: 15 }, + { id: 2, name: 'Dan', age: 20 }, +]; + +const CUSTOM_OPERATION: CustomOperation = { + name: 'isEven', + caption: 'Is even', + dataTypes: ['number'], + hasValue: false, + calculateFilterExpression: () => [['age', '%', 2], '=', 0], +}; + +const createGrid = ( + customOperations?: CustomOperation[], +): Promise<{ instance: DataGridInstance }> => createDataGrid({ + dataSource: DATA, + columns: ['name', 'age'], + filterPanel: { visible: true }, + filterBuilder: customOperations ? { customOperations } : {}, +}); + +const getOperationNames = (operations: CustomOperation[]): (string | undefined)[] => operations + .map((operation) => operation.name); + +describe('FilterBuilderController.getCustomFilterOperations', () => { + beforeEach(beforeTest); + afterEach(afterTest); + + describe('when no custom operation is specified', () => { + it('should return the built-in operations only', async () => { + const { instance } = await createGrid(); + + const operations = instance.getController('filterBuilder').getCustomFilterOperations(); + + expect(getOperationNames(operations)).toEqual(['anyof', 'noneof']); + }); + }); + + describe('when filterBuilder.customOperations is specified', () => { + it('should add them after the built-in operations', async () => { + const { instance } = await createGrid([CUSTOM_OPERATION]); + + const operations = instance.getController('filterBuilder').getCustomFilterOperations(); + + expect(getOperationNames(operations)).toEqual(['anyof', 'noneof', 'isEven']); + }); + }); + + describe('when the method is called on the component', () => { + it('should be available as a public method', async () => { + const { instance } = await createGrid([CUSTOM_OPERATION]); + + const operations = (instance as unknown as { + getCustomFilterOperations: () => CustomOperation[]; + }).getCustomFilterOperations(); + + expect(getOperationNames(operations)).toEqual(['anyof', 'noneof', 'isEven']); + }); + }); +}); diff --git a/packages/devextreme/js/__internal/grids/grid_core/filter_builder/__tests__/filter_builder.filter_expressions.test.ts b/packages/devextreme/js/__internal/grids/grid_core/filter_builder/__tests__/filter_builder.filter_expressions.test.ts new file mode 100644 index 000000000000..ac9b8a20d1bb --- /dev/null +++ b/packages/devextreme/js/__internal/grids/grid_core/filter_builder/__tests__/filter_builder.filter_expressions.test.ts @@ -0,0 +1,117 @@ +import { + afterEach, + beforeEach, + describe, + expect, + it, +} from '@jest/globals'; +import type { Properties as DataGridProperties } from '@js/ui/data_grid'; +import type { DataGridInstance } from '@ts/grids/grid_core/__tests__/__mock__/helpers/utils'; +import { + afterTest, + beforeTest, + createDataGrid, + toPlainFilter, +} from '@ts/grids/grid_core/__tests__/__mock__/helpers/utils'; +import type { Column } from '@ts/grids/grid_core/columns_controller/types'; +import type { FilterSourceContext } from '@ts/grids/grid_core/filter/types'; + +const DATA = [ + { id: 1, name: 'Alex', age: 15 }, + { id: 2, name: 'Dan', age: 20 }, +]; + +const createGrid = (options: DataGridProperties): Promise<{ + instance: DataGridInstance; +}> => createDataGrid({ + dataSource: DATA, + columns: ['name', 'age'], + filterPanel: { visible: true }, + ...options, +}); + +const getFilterExpressions = ( + instance: DataGridInstance, + contextOptions: Partial = {}, +): unknown => { + const context: FilterSourceContext = { + langParams: undefined, + excludedColumn: null, + filterSyncActive: true, + columnSourcesActive: true, + columnsController: instance.getController('columns'), + ...contextOptions, + }; + + return toPlainFilter(instance.getController('filterBuilder').getFilterExpressions(context)); +}; + +describe('FilterBuilderController.getFilterExpressions', () => { + beforeEach(beforeTest); + afterEach(afterTest); + + describe('when the filter value is set', () => { + it('should build a single expression from it', async () => { + const { instance } = await createGrid({ + filterValue: [['name', '=', 'Alex'], 'and', ['age', '=', 15]], + }); + + expect(getFilterExpressions(instance)).toEqual([[ + ['name', '=', 'Alex'], + 'and', + ['age', '=', 15], + ]]); + }); + }); + + describe('when there is no filter value', () => { + it('should build no expression at all', async () => { + const { instance } = await createGrid({}); + + expect(getFilterExpressions(instance)).toEqual([]); + }); + }); + + describe('when a custom operation is used', () => { + it('should build the expression through that operation', async () => { + const { instance } = await createGrid({ + filterValue: ['name', 'anyof', ['Alex', 'Dan']], + }); + + expect(getFilterExpressions(instance)).toEqual([[ + ['name', '=', 'Alex'], + 'or', + ['name', '=', 'Dan'], + ]]); + }); + }); + + describe('when a column is excluded and filter sync is active', () => { + it('should drop the conditions of that column', async () => { + const { instance } = await createGrid({ + filterValue: [['name', '=', 'Alex'], 'and', ['age', '=', 15]], + }); + + expect(getFilterExpressions(instance, { + excludedColumn: instance.columnOption('name') as Column, + })).toEqual([['age', '=', 15]]); + }); + }); + + describe('when a column is excluded and filter sync is not active', () => { + it('should keep the conditions of that column', async () => { + const { instance } = await createGrid({ + filterValue: [['name', '=', 'Alex'], 'and', ['age', '=', 15]], + }); + + expect(getFilterExpressions(instance, { + excludedColumn: instance.columnOption('name') as Column, + filterSyncActive: false, + })).toEqual([[ + ['name', '=', 'Alex'], + 'and', + ['age', '=', 15], + ]]); + }); + }); +}); diff --git a/packages/devextreme/js/__internal/grids/grid_core/filter_builder/__tests__/filter_builder.filter_source_active.test.ts b/packages/devextreme/js/__internal/grids/grid_core/filter_builder/__tests__/filter_builder.filter_source_active.test.ts new file mode 100644 index 000000000000..3fb61e66e303 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/grid_core/filter_builder/__tests__/filter_builder.filter_source_active.test.ts @@ -0,0 +1,77 @@ +import { + afterEach, + beforeEach, + describe, + expect, + it, +} from '@jest/globals'; +import type { Properties as DataGridProperties } from '@js/ui/data_grid'; +import type { DataGridInstance } from '@ts/grids/grid_core/__tests__/__mock__/helpers/utils'; +import { + afterTest, + beforeTest, + createDataGrid, +} from '@ts/grids/grid_core/__tests__/__mock__/helpers/utils'; +import type { FilterSourceContext } from '@ts/grids/grid_core/filter/types'; + +const DATA = [ + { id: 1, name: 'Alex', age: 15 }, + { id: 2, name: 'Dan', age: 20 }, +]; + +const createGrid = (options: DataGridProperties): Promise<{ + instance: DataGridInstance; +}> => createDataGrid({ + dataSource: DATA, + columns: ['name', 'age'], + filterPanel: { visible: true }, + ...options, +}); + +const isFilterSourceActive = (instance: DataGridInstance): boolean => { + const context: FilterSourceContext = { + langParams: undefined, + excludedColumn: null, + filterSyncActive: true, + columnSourcesActive: true, + columnsController: instance.getController('columns'), + }; + + return instance.getController('filterBuilder').isFilterSourceActive(context); +}; + +describe('FilterBuilderController.isFilterSourceActive', () => { + beforeEach(beforeTest); + afterEach(afterTest); + + describe('when the columns allow filtering', () => { + it('should take part in the filter', async () => { + const { instance } = await createGrid({}); + + expect(isFilterSourceActive(instance)).toBe(true); + }); + }); + + describe('when no column allows filtering', () => { + it('should not take part in the filter', async () => { + const { instance } = await createGrid({ + columns: [ + { dataField: 'name', allowFiltering: false, allowHeaderFiltering: false }, + { dataField: 'age', allowFiltering: false, allowHeaderFiltering: false }, + ], + }); + + expect(isFilterSourceActive(instance)).toBe(false); + }); + }); + + describe('when filterPanel.filterEnabled is false', () => { + it('should not take part in the filter', async () => { + const { instance } = await createGrid({ + filterPanel: { visible: true, filterEnabled: false }, + }); + + expect(isFilterSourceActive(instance)).toBe(false); + }); + }); +}); diff --git a/packages/devextreme/js/__internal/grids/grid_core/filter_builder/m_filter_builder.ts b/packages/devextreme/js/__internal/grids/grid_core/filter_builder/m_filter_builder.ts index 2ddf463f93eb..ec97b5eb9d24 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/filter_builder/m_filter_builder.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/filter_builder/m_filter_builder.ts @@ -1,14 +1,21 @@ +/* eslint-disable max-classes-per-file */ import messageLocalization from '@js/common/core/localization/message'; import $ from '@js/core/renderer'; import { extend } from '@js/core/utils/extend'; +import { isDefined } from '@js/core/utils/type'; +import type { CustomOperation } from '@js/ui/filter_builder'; import FilterBuilder from '@js/ui/filter_builder'; import Popup from '@js/ui/popup/ui.popup'; import ScrollView from '@js/ui/scroll_view'; import { restoreFocus } from '@js/ui/shared/accessibility'; +import { getFilterExpression, removeFieldConditionsFromFilter } from '@ts/filter_builder/m_utils'; import type { ColumnsController } from '@ts/grids/grid_core/columns_controller/m_columns_controller'; -import type { FilterSyncController } from '@ts/grids/grid_core/filter_sync/m_filter_sync'; import modules from '@ts/grids/grid_core/m_modules'; +import type { DataFilter, FilterSourceContext } from '../filter/types'; +import { anyOf, noneOf } from '../filter_sync/m_filter_custom_operations'; +import { getColumnIdentifier } from '../filter_sync/utils'; + export class FilterBuilderView extends modules.View { private _filterBuilderPopup: any; @@ -16,12 +23,12 @@ export class FilterBuilderView extends modules.View { private _columnsController!: ColumnsController; - private _filterSyncController!: FilterSyncController; + private filterBuilderController?: FilterBuilderController; public init() { super.init(); this._columnsController = this.getController('columns'); - this._filterSyncController = this.getController('filterSync'); + this.filterBuilderController = this.getController('filterBuilder'); } public optionChanged(args) { @@ -90,7 +97,7 @@ export class FilterBuilderView extends modules.View { value: this.option('filterValue'), fields: this._columnsController.getFilteringColumns(), }, this.option('filterBuilder'), { - customOperations: this._filterSyncController.getCustomFilterOperations(), + customOperations: this.filterBuilderController?.getCustomFilterOperations(), })); this._createComponent($contentElement, ScrollView, { direction: 'both' }); @@ -127,6 +134,47 @@ export class FilterBuilderView extends modules.View { } } +export class FilterBuilderController extends modules.Controller { + public publicMethods(): string[] { + return ['getCustomFilterOperations']; + } + + public isFilterSourceActive({ columnsController }: FilterSourceContext): boolean { + return !!columnsController.getFilteringColumns()?.length + && this.option('filterPanel.filterEnabled') !== false; + } + + public getFilterExpressions( + { + excludedColumn, + columnsController, + filterSyncActive, + }: FilterSourceContext, + ): DataFilter[] { + const currentFilterValue = this.option('filterValue'); + const shouldExcludeColumn = filterSyncActive && isDefined(excludedColumn); + const filterValue = shouldExcludeColumn + ? removeFieldConditionsFromFilter(currentFilterValue, getColumnIdentifier(excludedColumn)) + : currentFilterValue; + const columns = columnsController.getFilteringColumns(); + const customOperations = this.getCustomFilterOperations(); + const filterExpression: DataFilter = getFilterExpression(filterValue, columns, customOperations, 'filterBuilder'); + + return filterExpression ? [filterExpression] : []; + } + + // Override in the private API WA [T1232532] + public getCustomFilterOperations(): CustomOperation[] { + const filterBuilderCustomOperations = this.option('filterBuilder.customOperations') ?? []; + + return [ + anyOf(this.component), + noneOf(this.component), + ...filterBuilderCustomOperations, + ]; + } +} + export const filterBuilderModule = { defaultOptions() { return { @@ -157,6 +205,9 @@ export const filterBuilderModule = { filterBuilderPopup: {}, }; }, + controllers: { + filterBuilder: FilterBuilderController, + }, views: { filterBuilderView: FilterBuilderView, }, diff --git a/packages/devextreme/js/__internal/grids/grid_core/filter_panel/m_filter_panel.ts b/packages/devextreme/js/__internal/grids/grid_core/filter_panel/m_filter_panel.ts index 7b472e65d6c6..25824e150fe0 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/filter_panel/m_filter_panel.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/filter_panel/m_filter_panel.ts @@ -15,12 +15,13 @@ import { import type { ColumnsController } from '@ts/grids/grid_core/columns_controller/m_columns_controller'; import type { DataController } from '@ts/grids/grid_core/data_controller/data_controller'; import type { DataSourceController } from '@ts/grids/grid_core/data_source/data_source_controller'; -import type { FilterSyncController } from '@ts/grids/grid_core/filter_sync/m_filter_sync'; import { registerKeyboardAction } from '@ts/grids/grid_core/m_accessibility'; import modules from '@ts/grids/grid_core/m_modules'; import type { ModuleType } from '@ts/grids/grid_core/m_types'; import gridUtils from '@ts/grids/grid_core/m_utils'; +import type { FilterBuilderController } from '../filter_builder/m_filter_builder'; + const FILTER_PANEL_CLASS = 'filter-panel'; const FILTER_PANEL_TEXT_CLASS = `${FILTER_PANEL_CLASS}-text`; const FILTER_PANEL_CHECKBOX_CLASS = `${FILTER_PANEL_CLASS}-checkbox`; @@ -36,7 +37,7 @@ export class FilterPanelView extends modules.View { private dataSourceController!: DataSourceController; - private _filterSyncController!: FilterSyncController; + private filterBuilderController?: FilterBuilderController; private readonly _filterValueBuffer: any; @@ -44,7 +45,7 @@ export class FilterPanelView extends modules.View { this._dataController = this.getController('data'); this.dataSourceController = this.getController('dataSource'); this._columnsController = this.getController('columns'); - this._filterSyncController = this.getController('filterSync'); + this.filterBuilderController = this.getController('filterBuilder'); this._dataController.dataSourceChanged.add(() => this.render()); } @@ -133,7 +134,10 @@ export class FilterPanelView extends modules.View { let filterText; const filterValue = that.option('filterValue'); if (filterValue) { - when(that.getFilterText(filterValue, this._filterSyncController.getCustomFilterOperations())).done((filterText) => { + when(that.getFilterText( + filterValue, + this.filterBuilderController?.getCustomFilterOperations(), + )).done((filterText) => { const customizeText = that.option('filterPanel.customizeText'); if (customizeText) { const customText = customizeText({ diff --git a/packages/devextreme/js/__internal/grids/grid_core/filter_row/m_filter_row.ts b/packages/devextreme/js/__internal/grids/grid_core/filter_row/m_filter_row.ts index 840ade3206e0..02ca14f7248a 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/filter_row/m_filter_row.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/filter_row/m_filter_row.ts @@ -18,9 +18,9 @@ import type { Column } from '@ts/grids/grid_core/columns_controller/types'; import type { ColumnsResizerViewController } from '@ts/grids/grid_core/columns_resizing_reordering/m_columns_resizing_reordering'; import type { DataSourceController } from '@ts/grids/grid_core/data_source/data_source_controller'; import type { EditingController } from '@ts/grids/grid_core/editing/m_editing'; -import type { FilterController } from '@ts/grids/grid_core/filter/filter_controller'; -import type { DataFilter } from '@ts/grids/grid_core/filter/types'; -import { combineFilters } from '@ts/grids/grid_core/filter/utils'; +import type { + DataFilter, FilterSourceContext, +} from '@ts/grids/grid_core/filter/types'; import type { HeaderPanel } from '@ts/grids/grid_core/header_panel/m_header_panel'; import modules from '@ts/grids/grid_core/m_modules'; import type { ModuleType } from '@ts/grids/grid_core/m_types'; @@ -819,28 +819,6 @@ const columnHeadersView = (Base: ModuleType) => class ColumnH } }; -const filterController = ( - Base: ModuleType, -) => class FilterControllerFilterRowExtender extends Base { - private skipCalculateColumnFilters() { - return false; - } - - public getAdditionalFilter(excludedColumn?: Column | null): DataFilter { - if (this.skipCalculateColumnFilters()) { - return super.getAdditionalFilter(excludedColumn); - } - - const columns: Column[] = this.columnsController.getVisibleColumns(null, true); - const filters = [ - super.getAdditionalFilter(excludedColumn), - ...createFilterRowExpressions(columns, excludedColumn ?? null), - ]; - - return combineFilters(filters); - } -}; - export class ApplyFilterViewController extends modules.ViewController { private _headerPanel: any; @@ -850,6 +828,19 @@ export class ApplyFilterViewController extends modules.ViewController { this._columnsController = this.getController('columns'); } + public isFilterSourceActive({ columnSourcesActive }: FilterSourceContext): boolean { + return columnSourcesActive; + } + + public getFilterExpressions({ + excludedColumn, + columnsController, + }: FilterSourceContext): DataFilter[] { + const columns: Column[] = columnsController.getVisibleColumns(null, true); + + return createFilterRowExpressions(columns, excludedColumn); + } + private _getHeaderPanel() { if (!this._headerPanel) { // TODO getView @@ -1049,7 +1040,6 @@ export const filterRowModule = { }, extenders: { controllers: { - filter: filterController, columnsResizer, editing, }, diff --git a/packages/devextreme/js/__internal/grids/grid_core/filter_sync/extenders/__tests__/filter_sync_filter_controller.additional_filter.test.ts b/packages/devextreme/js/__internal/grids/grid_core/filter_sync/__tests__/filter_sync.additional_filter.test.ts similarity index 100% rename from packages/devextreme/js/__internal/grids/grid_core/filter_sync/extenders/__tests__/filter_sync_filter_controller.additional_filter.test.ts rename to packages/devextreme/js/__internal/grids/grid_core/filter_sync/__tests__/filter_sync.additional_filter.test.ts diff --git a/packages/devextreme/js/__internal/grids/grid_core/filter_sync/__tests__/filter_sync.column_filter_changes.test.ts b/packages/devextreme/js/__internal/grids/grid_core/filter_sync/__tests__/filter_sync.column_filter_changes.test.ts new file mode 100644 index 000000000000..9d0252c0afba --- /dev/null +++ b/packages/devextreme/js/__internal/grids/grid_core/filter_sync/__tests__/filter_sync.column_filter_changes.test.ts @@ -0,0 +1,173 @@ +import { + afterEach, + beforeEach, + describe, + expect, + it, + jest, +} from '@jest/globals'; +import { DataSource, type LoadOptions } from '@js/common/data'; +import type { Properties as DataGridProperties } from '@js/ui/data_grid'; +import type { DataGridInstance } from '@ts/grids/grid_core/__tests__/__mock__/helpers/utils'; +import { + afterTest, + beforeTest, + createDataGrid, + flushAsync, + toPlainFilter, +} from '@ts/grids/grid_core/__tests__/__mock__/helpers/utils'; + +const DATA = [ + { id: 1, name: 'Alex', age: 15 }, + { id: 2, name: 'Dan', age: 20 }, +]; + +const GRID_OPTIONS: DataGridProperties = { + dataSource: DATA, + filterPanel: { visible: true }, + filterRow: { visible: true }, + columns: [ + { dataField: 'name', dataType: 'string' }, + { dataField: 'age', dataType: 'number' }, + ], +}; + +const createGrid = async ( + options: DataGridProperties = {}, +): Promise => { + const { instance } = await createDataGrid({ ...GRID_OPTIONS, ...options }); + + return instance; +}; + +const getNames = (instance: DataGridInstance): string[] => instance + .getVisibleRows() + .map((row) => (row.data as { name: string }).name); + +const changeFilterRowValue = (columnIndex: number, value: string): void => { + const input = document + .querySelectorAll('.dx-datagrid-filter-row .dx-texteditor-input') + .item(columnIndex) as HTMLInputElement; + + input.focus(); + input.value = value; + input.dispatchEvent(new Event('change', { bubbles: true })); +}; + +describe('Filter sync when a filter row value changes', () => { + beforeEach(beforeTest); + afterEach(afterTest); + + describe('when a value is entered', () => { + it('should filter the data by the new value', async () => { + const instance = await createGrid(); + + changeFilterRowValue(0, 'Alex'); + await flushAsync(); + + expect(getNames(instance)).toEqual(['Alex']); + }); + + it('should sync the value into filterValue', async () => { + const instance = await createGrid(); + + changeFilterRowValue(0, 'Alex'); + await flushAsync(); + + expect(toPlainFilter(instance.option('filterValue'))).toEqual(['name', 'contains', 'Alex']); + }); + + it('should keep the filter of another column', async () => { + const instance = await createGrid({ + columns: [ + { dataField: 'name', dataType: 'string' }, + { dataField: 'age', dataType: 'number', filterValue: 20 }, + ], + }); + + changeFilterRowValue(0, 'Alex'); + await flushAsync(); + + expect(getNames(instance)).toEqual([]); + expect(toPlainFilter(instance.option('filterValue'))).toEqual([ + ['age', '=', 20], 'and', ['name', 'contains', 'Alex'], + ]); + }); + }); + + describe('when a value is cleared', () => { + it('should show all the rows again', async () => { + const instance = await createGrid({ + columns: [ + { dataField: 'name', dataType: 'string', filterValue: 'Alex' }, + { dataField: 'age', dataType: 'number' }, + ], + }); + + changeFilterRowValue(0, ''); + await flushAsync(); + + expect(getNames(instance)).toEqual(['Alex', 'Dan']); + expect(instance.option('filterValue')).toBeNull(); + }); + }); + + describe('when the data source filters remotely', () => { + it('should request the data once with the new filter', async () => { + const load = jest.fn<(options: LoadOptions) => Promise>( + () => Promise.resolve(DATA), + ); + + await createGrid({ + dataSource: new DataSource({ load, key: 'id' }), + remoteOperations: { filtering: true }, + }); + + load.mockClear(); + changeFilterRowValue(0, 'Alex'); + await flushAsync(); + + expect(load).toHaveBeenCalledTimes(1); + expect(toPlainFilter(load.mock.calls[0][0].filter)).toEqual(['name', 'contains', 'Alex']); + }); + }); + + describe('when filterValue is notified', () => { + it('should report the combined filter of the new value', async () => { + const filters: unknown[] = []; + + await createGrid({ + onOptionChanged: (e) => { + if (e.fullName === 'filterValue') { + filters.push(toPlainFilter(e.component.getCombinedFilter(true))); + } + }, + }); + + changeFilterRowValue(0, 'Alex'); + await flushAsync(); + changeFilterRowValue(0, ''); + await flushAsync(); + + expect(filters).toEqual([['name', 'contains', 'Alex'], undefined]); + }); + + it('should report the combined filter of a value set through the API', async () => { + const filters: unknown[] = []; + const instance = await createGrid({ + onOptionChanged: (e) => { + if (e.fullName === 'filterValue') { + filters.push(toPlainFilter(e.component.getCombinedFilter(true))); + } + }, + }); + + instance.columnOption('age', 'filterValue', 20); + await flushAsync(); + instance.columnOption('age', 'filterValue', null); + await flushAsync(); + + expect(filters).toEqual([['age', '=', 20], undefined]); + }); + }); +}); diff --git a/packages/devextreme/js/__internal/grids/grid_core/filter_sync/__tests__/filter_sync.column_options_sync.test.ts b/packages/devextreme/js/__internal/grids/grid_core/filter_sync/__tests__/filter_sync.column_options_sync.test.ts new file mode 100644 index 000000000000..dbe5ec0304dd --- /dev/null +++ b/packages/devextreme/js/__internal/grids/grid_core/filter_sync/__tests__/filter_sync.column_options_sync.test.ts @@ -0,0 +1,103 @@ +import { + afterEach, + beforeEach, + describe, + expect, + it, +} from '@jest/globals'; +import type { Properties as DataGridProperties } from '@js/ui/data_grid'; +import type { DataGridInstance } from '@ts/grids/grid_core/__tests__/__mock__/helpers/utils'; +import { + afterTest, + beforeTest, + createDataGrid, +} from '@ts/grids/grid_core/__tests__/__mock__/helpers/utils'; + +const DATA = [ + { id: 1, name: 'Alex' }, + { id: 2, name: 'Dan' }, +]; + +const GRID_OPTIONS: DataGridProperties = { + dataSource: DATA, + filterPanel: { visible: true }, + filterSyncEnabled: true, + columns: ['name'], +}; + +const createGrid = async (): Promise => { + const { instance } = await createDataGrid(GRID_OPTIONS); + + return instance; +}; + +describe('FilterSyncController.withColumnOptionsSync', () => { + beforeEach(beforeTest); + afterEach(afterTest); + + describe('when a callback runs', () => { + it('should not sync column options back into filterValue', async () => { + const instance = await createGrid(); + + instance.getController('filterSync').withColumnOptionsSync(() => { + instance.columnOption('name', 'filterValue', 'Alex'); + }); + + expect(instance.option('filterValue')).toBeNull(); + }); + + it('should return the callback result', async () => { + const instance = await createGrid(); + + expect(instance.getController('filterSync').withColumnOptionsSync(() => 'result')).toBe('result'); + }); + + it('should stop syncing afterwards', async () => { + const instance = await createGrid(); + const filterSyncController = instance.getController('filterSync'); + + filterSyncController.withColumnOptionsSync(() => undefined); + + expect(filterSyncController.isSyncingColumnOptions()).toBe(false); + }); + }); + + describe('when a nested call completes', () => { + it('should keep syncing suppressed for the outer call', async () => { + const instance = await createGrid(); + const filterSyncController = instance.getController('filterSync'); + + filterSyncController.withColumnOptionsSync(() => { + filterSyncController.withColumnOptionsSync(() => undefined); + + expect(filterSyncController.isSyncingColumnOptions()).toBe(true); + + instance.columnOption('name', 'filterValue', 'Alex'); + }); + + expect(instance.option('filterValue')).toBeNull(); + expect(filterSyncController.isSyncingColumnOptions()).toBe(false); + }); + }); + + describe('when the callback throws', () => { + it('should rethrow the error', async () => { + const instance = await createGrid(); + + expect(() => instance.getController('filterSync').withColumnOptionsSync(() => { + throw new Error('sync failed'); + })).toThrow('sync failed'); + }); + + it('should stop syncing', async () => { + const instance = await createGrid(); + const filterSyncController = instance.getController('filterSync'); + + expect(() => filterSyncController.withColumnOptionsSync(() => { + throw new Error('sync failed'); + })).toThrow('sync failed'); + + expect(filterSyncController.isSyncingColumnOptions()).toBe(false); + }); + }); +}); diff --git a/packages/devextreme/js/__internal/grids/grid_core/filter_sync/extenders/filter_sync_data_controller.ts b/packages/devextreme/js/__internal/grids/grid_core/filter_sync/extenders/filter_sync_data_controller.ts index 053fee3c9810..01b6a5d27173 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/filter_sync/extenders/filter_sync_data_controller.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/filter_sync/extenders/filter_sync_data_controller.ts @@ -35,7 +35,7 @@ export const filterSyncDataControllerExtender = ( const column: Column = this._columnsController.getColumnByPath(args.fullName); if (column && !this.filterSyncController.isSyncingColumnOptions()) { - this.filterSyncController.withColumnOptionsSync(() => { + this.filterController.suspendColumnSources(() => { this.syncColumnOption( column, this.parseColumnPropertyName(args.fullName), diff --git a/packages/devextreme/js/__internal/grids/grid_core/filter_sync/extenders/filter_sync_filter_controller.ts b/packages/devextreme/js/__internal/grids/grid_core/filter_sync/extenders/filter_sync_filter_controller.ts deleted file mode 100644 index 955ce63cbf45..000000000000 --- a/packages/devextreme/js/__internal/grids/grid_core/filter_sync/extenders/filter_sync_filter_controller.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { isDefined } from '@js/core/utils/type'; -import { - getFilterExpression, - removeFieldConditionsFromFilter, -} from '@ts/filter_builder/m_utils'; -import type { Column } from '@ts/grids/grid_core/columns_controller/types'; -import type { FilterController } from '@ts/grids/grid_core/filter/filter_controller'; -import type { DataFilter } from '@ts/grids/grid_core/filter/types'; -import { combineFilters } from '@ts/grids/grid_core/filter/utils'; -import type { FilterSyncController } from '@ts/grids/grid_core/filter_sync/m_filter_sync'; -import { getColumnIdentifier } from '@ts/grids/grid_core/filter_sync/utils'; -import type { ModuleType } from '@ts/grids/grid_core/m_types'; - -export const filterSyncFilterControllerExtender = ( - Base: ModuleType, -): ModuleType => class FilterControllerFilterSyncExtender extends Base { - private filterSyncController!: FilterSyncController; - - public init(): void { - this.filterSyncController = this.getController('filterSync'); - - super.init(); - } - - private skipCalculateColumnFilters(): boolean | undefined { - const hasFilterValueOrSyncing = isDefined(this.option('filterValue')) - || this.filterSyncController.isSyncingColumnOptions(); - - return hasFilterValueOrSyncing && this.isFilterSyncActive(); - } - - public getAdditionalFilter(excludedColumn?: Column | null): DataFilter { - const columns = this.columnsController.getFilteringColumns(); - const isFilterValueDisabled = this.option('filterPanel.filterEnabled') === false; - - if (!columns?.length || isFilterValueDisabled) { - return super.getAdditionalFilter(excludedColumn); - } - - const filters = [super.getAdditionalFilter(excludedColumn)]; - const currentFilterValue = this.option('filterValue'); - const shouldExcludeColumn = this.isFilterSyncActive() && isDefined(excludedColumn); - const filterValue = shouldExcludeColumn - ? removeFieldConditionsFromFilter(currentFilterValue, getColumnIdentifier(excludedColumn)) - : currentFilterValue; - - const customOperations = this.filterSyncController.getCustomFilterOperations(); - const calculatedFilterValue: DataFilter = getFilterExpression(filterValue, columns, customOperations, 'filterBuilder'); - - if (calculatedFilterValue) { - filters.push(calculatedFilterValue); - } - - return combineFilters(filters); - } -}; diff --git a/packages/devextreme/js/__internal/grids/grid_core/filter_sync/filter_sync_module.ts b/packages/devextreme/js/__internal/grids/grid_core/filter_sync/filter_sync_module.ts index 64b53f5f3a5d..dc3ee7dd096f 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/filter_sync/filter_sync_module.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/filter_sync/filter_sync_module.ts @@ -2,7 +2,6 @@ import type { InternalGridOptions } from '@ts/grids/grid_core/m_types'; import { filterSyncColumnHeadersViewExtender } from './extenders/filter_sync_column_headers_view'; import { filterSyncDataControllerExtender } from './extenders/filter_sync_data_controller'; -import { filterSyncFilterControllerExtender } from './extenders/filter_sync_filter_controller'; import { FilterSyncController } from './m_filter_sync'; export const filterSyncModule = { @@ -18,7 +17,6 @@ export const filterSyncModule = { extenders: { controllers: { data: filterSyncDataControllerExtender, - filter: filterSyncFilterControllerExtender, }, views: { columnHeadersView: filterSyncColumnHeadersViewExtender, diff --git a/packages/devextreme/js/__internal/grids/grid_core/filter_sync/m_filter_sync.ts b/packages/devextreme/js/__internal/grids/grid_core/filter_sync/m_filter_sync.ts index c9224a7cca80..f5196c9713f0 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/filter_sync/m_filter_sync.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/filter_sync/m_filter_sync.ts @@ -1,5 +1,4 @@ import { isDefined } from '@js/core/utils/type'; -import type { CustomOperation } from '@js/ui/filter_builder'; import { addItem, getMatchedConditions, @@ -11,10 +10,11 @@ import type { } from '@ts/grids/grid_core/columns_controller/types'; import type { DataController } from '@ts/grids/grid_core/data_controller/data_controller'; import type { FilterController } from '@ts/grids/grid_core/filter/filter_controller'; -import type { FilterValue, FilterValueCondition } from '@ts/grids/grid_core/filter/types'; +import type { + FilterValue, FilterValueCondition, +} from '@ts/grids/grid_core/filter/types'; import modules from '@ts/grids/grid_core/m_modules'; -import { anyOf, noneOf } from './m_filter_custom_operations'; import { checkForErrors, getColumnIdentifier, @@ -49,20 +49,18 @@ export class FilterSyncController extends modules.Controller { } } - public publicMethods(): string[] { - return ['getCustomFilterOperations']; - } - public isSyncingColumnOptions(): boolean { return this.skipSyncColumnOptions; } public withColumnOptionsSync(sync: () => T): T { + const wasSyncing = this.skipSyncColumnOptions; + this.skipSyncColumnOptions = true; try { - return sync(); + return this.filterController.suspendColumnSources(sync); } finally { - this.skipSyncColumnOptions = false; + this.skipSyncColumnOptions = wasSyncing; } } @@ -167,15 +165,4 @@ export class FilterSyncController extends modules.Controller { this.option('filterValue', syncedFilterValue); } - - // Override in the private API WA [T1232532] - public getCustomFilterOperations(): CustomOperation[] { - const filterBuilderCustomOperations = this.option('filterBuilder.customOperations') ?? []; - - return [ - anyOf(this.component), - noneOf(this.component), - ...filterBuilderCustomOperations, - ]; - } } diff --git a/packages/devextreme/js/__internal/grids/grid_core/header_filter/m_header_filter.ts b/packages/devextreme/js/__internal/grids/grid_core/header_filter/m_header_filter.ts index 7cd8ff06014b..6ca9a356ffe5 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/header_filter/m_header_filter.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/header_filter/m_header_filter.ts @@ -14,12 +14,11 @@ import { isDefined, isFunction, isObject } from '@js/core/utils/type'; import { restoreFocus, saveFocusedElementInfo } from '@js/ui/shared/accessibility'; import filterUtils from '@js/ui/shared/filtering'; import type { ColumnHeadersView } from '@ts/grids/grid_core/column_headers/m_column_headers'; -import type { Column } from '@ts/grids/grid_core/columns_controller/types'; import type { DataController } from '@ts/grids/grid_core/data_controller/data_controller'; import type { DataSourceController } from '@ts/grids/grid_core/data_source/data_source_controller'; -import type { FilterController } from '@ts/grids/grid_core/filter/filter_controller'; -import type { DataFilter } from '@ts/grids/grid_core/filter/types'; -import { combineFilters } from '@ts/grids/grid_core/filter/utils'; +import type { + DataFilter, FilterSourceContext, +} from '@ts/grids/grid_core/filter/types'; import type { HeaderPanel } from '@ts/grids/grid_core/header_panel/m_header_panel'; import Modules from '@ts/grids/grid_core/m_modules'; import type { ModuleType } from '@ts/grids/grid_core/m_types'; @@ -135,6 +134,19 @@ export class HeaderFilterController extends Modules.ViewController { this._headerFilterView = this.getView('headerFilterView'); } + public isFilterSourceActive({ columnSourcesActive }: FilterSourceContext): boolean { + return columnSourcesActive; + } + + public getFilterExpressions({ + excludedColumn, + columnsController, + }: FilterSourceContext): DataFilter[] { + const columns = columnsController.getVisibleColumns(null, true); + + return createHeaderFilterExpressions(columns, excludedColumn); + } + private _updateSelectedState(items, column) { let i = items.length; const isExclude = column.filterType === 'exclude'; @@ -493,28 +505,6 @@ const headerPanel = (Base: ModuleType) => class HeaderPanelHeaderFi } }; -const filterController = ( - Base: ModuleType, -) => class FilterControllerHeaderFilterExtender extends Base { - private skipCalculateColumnFilters() { - return false; - } - - public getAdditionalFilter(excludedColumn?: Column | null): DataFilter { - if (this.skipCalculateColumnFilters()) { - return super.getAdditionalFilter(excludedColumn); - } - - const columns: Column[] = this.columnsController.getVisibleColumns(null, true); - const filters = [ - super.getAdditionalFilter(excludedColumn), - ...createHeaderFilterExpressions(columns, excludedColumn ?? null), - ]; - - return combineFilters(filters); - } -}; - export const headerFilterModule = { defaultOptions() { return { @@ -546,9 +536,6 @@ export const headerFilterModule = { headerFilterView: HeaderFilterView, }, extenders: { - controllers: { - filter: filterController, - }, views: { columnHeadersView, headerPanel, 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 00843df1b336..529353610025 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/m_types.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/m_types.ts @@ -212,6 +212,7 @@ export interface Controllers { export: import('../data_grid/export/m_export').ExportController; filter: import('./filter/filter_controller').FilterController; filterSync: import('./filter_sync/m_filter_sync').FilterSyncController; + filterBuilder: import('./filter_builder/m_filter_builder').FilterBuilderController; focus: import('./focus/m_focus').FocusController; headerFilter: import('./header_filter/m_header_filter').HeaderFilterController; keyboardNavigation: import('./keyboard_navigation/m_keyboard_navigation').KeyboardNavigationController; diff --git a/packages/devextreme/js/__internal/grids/grid_core/search/m_search.ts b/packages/devextreme/js/__internal/grids/grid_core/search/m_search.ts index b35afa27efbc..f8aceba65bdf 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/search/m_search.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/search/m_search.ts @@ -11,9 +11,7 @@ import type { ToolbarItem } from '@ts/grids/new/grid_core/toolbar/types'; import type { DataController } from '../data_controller/data_controller'; import type { UserState } from '../data_controller/types'; -import type { FilterController } from '../filter/filter_controller'; -import type { DataFilter } from '../filter/types'; -import { combineFilters } from '../filter/utils'; +import type { DataFilter, FilterSourceContext } from '../filter/types'; import type { HeaderPanel } from '../header_panel/m_header_panel'; import modules from '../m_modules'; import type { ModuleType, OptionChanged } from '../m_types'; @@ -58,29 +56,6 @@ const dataController = ( } }; -const filterController = ( - base: ModuleType, -) => class FilterControllerSearchExtender extends base { - public getAdditionalFilter(excludedColumn?: Column | null): DataFilter { - const filter = super.getAdditionalFilter(excludedColumn); - const searchFilter = createSearchExpression( - this.getColumnsToSearch(), - this.option('searchPanel.text'), - this.getLangParams(), - ); - - return combineFilters([filter, searchFilter]); - } - - private getColumnsToSearch(): Column[] { - const searchVisibleColumnsOnly = this.option('searchPanel.searchVisibleColumnsOnly'); - - return this.columnsController - .getColumns() - .filter((column: Column) => !searchVisibleColumnsOnly || !!column.visible); - } -}; - type SearchDataControllerExtender = InstanceType>; export class SearchPanelViewController extends modules.ViewController { @@ -101,6 +76,27 @@ export class SearchPanelViewController extends modules.ViewController { } } + public isFilterSourceActive(): boolean { + return true; + } + + public getFilterExpressions({ langParams, columnsController }: FilterSourceContext): DataFilter[] { + const columns = this.getColumnsToSearch(columnsController); + const searchFilter = createSearchExpression(columns, this.option('searchPanel.text'), langParams); + + return searchFilter ? [searchFilter] : []; + } + + private getColumnsToSearch( + columnsController: FilterSourceContext['columnsController'], + ): Column[] { + const searchVisibleColumnsOnly = this.option('searchPanel.searchVisibleColumnsOnly'); + + return columnsController + ?.getColumns() + ?.filter((column: Column) => !searchVisibleColumnsOnly || !!column.visible) ?? []; + } + public optionChanged(args: OptionChanged): void { if (args.name === 'searchPanel') { if (args.fullName === 'searchPanel.text') { @@ -365,7 +361,6 @@ export const searchModule = { extenders: { controllers: { data: dataController, - filter: filterController, }, views: { rowsView, diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/di.ts b/packages/devextreme/js/__internal/grids/new/grid_core/di.ts index 4755ccb7a770..34ac942b362d 100644 --- a/packages/devextreme/js/__internal/grids/new/grid_core/di.ts +++ b/packages/devextreme/js/__internal/grids/new/grid_core/di.ts @@ -11,6 +11,7 @@ import { ConfirmController } from './editing/confirm_controller'; import { EditingController } from './editing/controller'; import { EditPopupView } from './editing/popup/view'; import { ErrorController } from './error_controller/error_controller'; +import { CompatibilityFilterBuilderController } from './filtering/filter_builder/index'; import * as FilterSyncModule from './filtering/filter_sync/index'; import { CompatibilityHeaderFilterController, @@ -52,7 +53,7 @@ export function register(diContext: DIContext): void { diContext.register(HeaderFilterController); diContext.register(HeaderFilterPopupView); diContext.register(FilterSyncModule.FilterSyncController); - diContext.register(FilterSyncModule.CompatibilityFilterSyncController); + diContext.register(CompatibilityFilterBuilderController); diContext.register(CompatibilityHeaderFilterController); diContext.register(ErrorController); diContext.register(EditingController); diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/filtering/filter_sync/compatibility.ts b/packages/devextreme/js/__internal/grids/new/grid_core/filtering/filter_builder/compatibility.ts similarity index 78% rename from packages/devextreme/js/__internal/grids/new/grid_core/filtering/filter_sync/compatibility.ts rename to packages/devextreme/js/__internal/grids/new/grid_core/filtering/filter_builder/compatibility.ts index faf6f6f75c26..edad18881b34 100644 --- a/packages/devextreme/js/__internal/grids/new/grid_core/filtering/filter_sync/compatibility.ts +++ b/packages/devextreme/js/__internal/grids/new/grid_core/filtering/filter_builder/compatibility.ts @@ -1,7 +1,7 @@ import { FilterController } from '../filter_controller'; -import { FilterSyncController } from './controller'; +import { FilterSyncController } from '../filter_sync/controller'; -export class CompatibilityFilterSyncController { +export class CompatibilityFilterBuilderController { public static dependencies = [ FilterController, FilterSyncController, diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/filtering/filter_builder/index.ts b/packages/devextreme/js/__internal/grids/new/grid_core/filtering/filter_builder/index.ts new file mode 100644 index 000000000000..5adf07919550 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/new/grid_core/filtering/filter_builder/index.ts @@ -0,0 +1 @@ +export { CompatibilityFilterBuilderController } from './compatibility'; diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/filtering/filter_sync/index.ts b/packages/devextreme/js/__internal/grids/new/grid_core/filtering/filter_sync/index.ts index 60e16f21857d..5850d1d2a06a 100644 --- a/packages/devextreme/js/__internal/grids/new/grid_core/filtering/filter_sync/index.ts +++ b/packages/devextreme/js/__internal/grids/new/grid_core/filtering/filter_sync/index.ts @@ -1,3 +1,2 @@ -export { CompatibilityFilterSyncController } from './compatibility'; export { FilterSyncController } from './controller'; export { defaultOptions, type Options } from './options'; diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/widget.ts b/packages/devextreme/js/__internal/grids/new/grid_core/widget.ts index 21c38a094c42..bd25524f3b3a 100644 --- a/packages/devextreme/js/__internal/grids/new/grid_core/widget.ts +++ b/packages/devextreme/js/__internal/grids/new/grid_core/widget.ts @@ -21,7 +21,8 @@ import * as di from './di'; import * as EditingModule from './editing/index'; import { EditPopupView } from './editing/popup/view'; import { ErrorController } from './error_controller/error_controller'; -import { CompatibilityFilterSyncController, FilterSyncController } from './filtering/filter_sync/index'; +import { CompatibilityFilterBuilderController } from './filtering/filter_builder/index'; +import { FilterSyncController } from './filtering/filter_sync/index'; import { CompatibilityHeaderFilterController, HeaderFilterController } from './filtering/header_filter/index'; import { HeaderFilterViewController } from './filtering/header_filter/view_controller'; import * as FilterControllerModule from './filtering/index'; @@ -104,7 +105,7 @@ export class GridCoreNewBase< this.diContext.get(CompatibilityDataSourceController), this.diContext.get(CompatibilityColumnsController), this.diContext.get(CompatibilityHeaderFilterController), - this.diContext.get(CompatibilityFilterSyncController), + this.diContext.get(CompatibilityFilterBuilderController), )); } diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/widget_mock.ts b/packages/devextreme/js/__internal/grids/new/grid_core/widget_mock.ts index 6eeca8104fd3..9744192c386e 100644 --- a/packages/devextreme/js/__internal/grids/new/grid_core/widget_mock.ts +++ b/packages/devextreme/js/__internal/grids/new/grid_core/widget_mock.ts @@ -1,7 +1,7 @@ import type { CompatibilityColumnsController } from './columns_controller/compatibility'; import type { CompatibilityDataController } from './data_controller'; import type { CompatibilityDataSourceController } from './data_source/compatibility'; -import type { CompatibilityFilterSyncController } from './filtering/filter_sync/compatibility'; +import type { CompatibilityFilterBuilderController } from './filtering/filter_builder/compatibility'; import type { CompatibilityHeaderFilterController } from './filtering/header_filter/compatibility'; import type { GridCoreNewBase } from './widget'; @@ -13,7 +13,7 @@ export class WidgetMock { dataSource: this.dataSource, columns: this.columns, headerFilter: this.headerFilter, - filterSync: this.filterSync, + filterBuilder: this.filterBuilder, }; constructor( @@ -23,7 +23,7 @@ export class WidgetMock { private readonly dataSource: CompatibilityDataSourceController, private readonly columns: CompatibilityColumnsController, private readonly headerFilter: CompatibilityHeaderFilterController, - private readonly filterSync: CompatibilityFilterSyncController, + private readonly filterBuilder: CompatibilityFilterBuilderController, ) {} public option(...args: unknown[]): unknown { diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets.dataGrid/filterPanel.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets.dataGrid/filterPanel.tests.js index 20aabe75c24d..bcf72f58503b 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets.dataGrid/filterPanel.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets.dataGrid/filterPanel.tests.js @@ -259,7 +259,7 @@ QUnit.module('Filter Panel', { // act assert.expect(3); - this.filterPanelView.getFilterText(filter, this.filterSyncController.getCustomFilterOperations()).done(function(result) { + this.filterPanelView.getFilterText(filter, this.filterBuilderController.getCustomFilterOperations()).done(function(result) { assert.equal(result, '[Field] Is any of(\'Text 1\', \'Text 2\')'); assert.equal(loadingSpy.callCount, 1, 'loadingSpy.callCount'); const loadingFilters = loadingSpy.getCalls().map(i => i.args[0].filter); @@ -297,7 +297,7 @@ QUnit.module('Filter Panel', { // act assert.expect(3); - this.filterPanelView.getFilterText(filter, this.filterSyncController.getCustomFilterOperations()).done(function(result) { + this.filterPanelView.getFilterText(filter, this.filterBuilderController.getCustomFilterOperations()).done(function(result) { assert.equal(result, '[Field] Is any of(\'Text 1\', \'Text 2\')'); assert.equal(loadingSpy.callCount, 1, 'loadingSpy.callCount'); const loadingFilters = loadingSpy.getCalls().map(i => i.args[0].filter); @@ -339,7 +339,7 @@ QUnit.module('Filter Panel', { // act assert.expect(2); - this.filterPanelView.getFilterText(filter, this.filterSyncController.getCustomFilterOperations()).done(function(result) { + this.filterPanelView.getFilterText(filter, this.filterBuilderController.getCustomFilterOperations()).done(function(result) { assert.equal(result, '[Field] Is any of(\'Text 1\', \'Text 2\')'); assert.equal(errors.log.callCount, 0, 'no warnings'); }).always(() => { @@ -381,7 +381,7 @@ QUnit.module('Filter Panel', { // act assert.expect(key ? 2 : 4); - this.filterPanelView.getFilterText(filter, this.filterSyncController.getCustomFilterOperations()).done(function(result) { + this.filterPanelView.getFilterText(filter, this.filterBuilderController.getCustomFilterOperations()).done(function(result) { assert.equal(result, '[Field] Is any of(\'Text 1\', \'Text 2\')'); if(!key) { assert.equal(errors.log.callCount, 2, 'four warnings'); @@ -422,7 +422,7 @@ QUnit.module('Filter Panel', { // act assert.expect(2); - this.filterPanelView.getFilterText(filter, this.filterSyncController.getCustomFilterOperations()).done(function(result) { + this.filterPanelView.getFilterText(filter, this.filterBuilderController.getCustomFilterOperations()).done(function(result) { assert.equal(result, '[Field] Is any of(\'Text 1\', \'Text 2\')'); assert.deepEqual(lookupDataSource[0], { key: 1, text: 'Text 1' }, 'lookup dataSource item is not changed'); }); @@ -460,7 +460,7 @@ QUnit.module('Filter Panel', { // act assert.expect(2); - this.filterPanelView.getFilterText(filter, this.filterSyncController.getCustomFilterOperations()).done(function(result) { + this.filterPanelView.getFilterText(filter, this.filterBuilderController.getCustomFilterOperations()).done(function(result) { assert.equal(result, '[Field] Is any of(\'Text 1\', \'Text 2\')'); assert.equal(spy.callCount, 1); }); diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets.dataGrid/filterSync.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets.dataGrid/filterSync.tests.js index c79faaef52bc..4fdf95c2a7f1 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets.dataGrid/filterSync.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets.dataGrid/filterSync.tests.js @@ -403,7 +403,7 @@ QUnit.module('getCombinedFilter', { beforeEach: function() { this.setupDataGrid = function(options) { this.options = options; - setupDataGridModules(this, ['columns', 'data', 'headerFilter', 'filterRow', 'filterSync'], { + setupDataGridModules(this, ['columns', 'data', 'headerFilter', 'filterRow', 'filterSync', 'filterBuilder'], { initViews: false }); }; diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets.dataGrid/keyboardNavigation.accessibility.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets.dataGrid/keyboardNavigation.accessibility.tests.js index bd25a02825a4..d30201fd4066 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets.dataGrid/keyboardNavigation.accessibility.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets.dataGrid/keyboardNavigation.accessibility.tests.js @@ -76,7 +76,7 @@ QUnit.module('Keyboard navigation accessibility', { }, this.options); setupDataGridModules(this, - ['data', 'columns', 'columnHeaders', 'sorting', 'columnFixing', 'grouping', 'groupPanel', 'headerPanel', 'pager', 'headerFilter', 'filterSync', 'filterPanel', 'filterRow', + ['data', 'columns', 'columnHeaders', 'sorting', 'columnFixing', 'grouping', 'groupPanel', 'headerPanel', 'pager', 'headerFilter', 'filterSync', 'filterPanel', 'filterBuilder', 'filterRow', 'rows', 'editorFactory', 'gridView', 'editing', 'editingRowBased', 'editingFormBased', 'editingCellBased', 'selection', 'focus', 'groupPanelKeyboardNavigation', 'headersKeyboardNavigation', 'keyboardNavigation', 'validating', 'masterDetail'], { initViews: true } ); diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets.dataGrid/stateStoring.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets.dataGrid/stateStoring.tests.js index 1dfba6d7b4ff..2e228ccf194d 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets.dataGrid/stateStoring.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets.dataGrid/stateStoring.tests.js @@ -1774,7 +1774,7 @@ QUnit.module('State Storing for filterPanel', { this.clock = sinon.useFakeTimers(); this.preventOptionChanged = true; this.setupDataGridModules = function(options) { - setupDataGridModules(this, ['data', 'columns', 'rows', 'gridView', 'stateStoring', 'columnHeaders', 'editorFactory', 'editing', 'filterRow', 'headerFilter', 'search', 'pager', 'selection', 'virtualScrolling', 'focus', 'keyboardNavigation', 'filterSync'], { + setupDataGridModules(this, ['data', 'columns', 'rows', 'gridView', 'stateStoring', 'columnHeaders', 'editorFactory', 'editing', 'filterRow', 'headerFilter', 'search', 'pager', 'selection', 'virtualScrolling', 'focus', 'keyboardNavigation', 'filterSync', 'filterBuilder'], { initDefaultOptions: true, initViews: true, options: $.extend({