From 7b0eecb7f97837c7d90ccc31e091486aacfb7793 Mon Sep 17 00:00:00 2001 From: Anna Shakhova <68295572+anna-shakhova@users.noreply.github.com> Date: Wed, 2 Sep 2026 14:01:52 +0000 Subject: [PATCH] Grids: encapsulate FilterSyncController's cross-module surface (#35009) (cherry picked from commit 87e40d06d0b6e9ccfe75ce7a278424d06a38375f) --- .../grid_core/columns_controller/types.ts | 7 +- .../grids/grid_core/data_controller/types.ts | 14 + .../filter_sync/__tests__/utils.test.ts | 450 ++++++++++++++++++ .../filter_sync_column_headers_view.ts | 35 ++ .../extenders/filter_sync_data_controller.ts | 12 +- .../filter_sync/filter_sync_module.ts | 5 +- .../filter_sync/m_filter_custom_operations.ts | 5 +- .../grid_core/filter_sync/m_filter_sync.ts | 316 ++++-------- .../grids/grid_core/filter_sync/types.ts | 15 + .../grids/grid_core/filter_sync/utils.ts | 205 +++++++- .../js/__internal/grids/grid_core/m_types.ts | 3 + .../state_storing/state_storing_controller.ts | 11 +- .../grids/grid_core/state_storing/types.ts | 5 +- 13 files changed, 846 insertions(+), 237 deletions(-) create mode 100644 packages/devextreme/js/__internal/grids/grid_core/filter_sync/__tests__/utils.test.ts create mode 100644 packages/devextreme/js/__internal/grids/grid_core/filter_sync/extenders/filter_sync_column_headers_view.ts create mode 100644 packages/devextreme/js/__internal/grids/grid_core/filter_sync/types.ts diff --git a/packages/devextreme/js/__internal/grids/grid_core/columns_controller/types.ts b/packages/devextreme/js/__internal/grids/grid_core/columns_controller/types.ts index dd25e14b8867..fdf2a70f43eb 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/columns_controller/types.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/columns_controller/types.ts @@ -1,6 +1,8 @@ import type { ColumnAIOptions, ColumnBase } from '@js/common/grids'; -import type { COLUMN_CHOOSER_LOCATION, GROUP_LOCATION, HEADERS_LOCATION } from './const'; +import type { + COLUMN_CHOOSER_LOCATION, GROUP_LOCATION, HEADERS_LOCATION, USER_STATE_FIELD_NAMES, +} from './const'; export type DropLocationNames = typeof GROUP_LOCATION | typeof COLUMN_CHOOSER_LOCATION @@ -13,6 +15,8 @@ export type ColumnIndex = number | { export type FilterField = Omit & { filterOperations?: string[] | null }; +export type ColumnUserState = Pick; + export type AddedColumn = string | (Column & { columns?: (Column | string)[] }); export interface InternalColumnOptions { @@ -23,6 +27,7 @@ export interface InternalColumnOptions { groupIndex?: number; type?: string; defaultFilterOperations?: string[]; + defaultFilterOperation?: string; visibleWidth?: string | number; hidingPriority?: number; ai?: ColumnAIOptions; diff --git a/packages/devextreme/js/__internal/grids/grid_core/data_controller/types.ts b/packages/devextreme/js/__internal/grids/grid_core/data_controller/types.ts index 6c4a84adf29a..5f6e9efcece3 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/data_controller/types.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/data_controller/types.ts @@ -233,3 +233,17 @@ export type DataFilter = DataFilterExpression | MatchNothingFilter | null | undefined; + +/** Arrays for `anyof`/`noneof` and `between`; nested when a header filter groups by interval. */ +export type FilterValueOperand = ScalarFilterValue | FilterValueOperand[]; + +/** Operation is a plain `string`: `filterBuilder.customOperations` is user-extensible. */ +export type FilterValueCondition = [string, FilterValueOperand] + | [string, string, FilterValueOperand]; + +/** Mirrors `DataFilterExpression`, but over column identifiers (`dataField ?? name`). */ +export type FilterValueExpression = FilterValueCondition + | ['!', FilterValueExpression] + | [FilterValueExpression, ...(FilterCombiner | FilterValueExpression)[]]; + +export type FilterValue = FilterValueExpression | null | undefined; diff --git a/packages/devextreme/js/__internal/grids/grid_core/filter_sync/__tests__/utils.test.ts b/packages/devextreme/js/__internal/grids/grid_core/filter_sync/__tests__/utils.test.ts new file mode 100644 index 000000000000..3a2f863fe628 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/grid_core/filter_sync/__tests__/utils.test.ts @@ -0,0 +1,450 @@ +import { describe, expect, it } from '@jest/globals'; +import type { + FilterValueCondition, + FilterValueOperand, +} from '@ts/grids/grid_core/data_controller/types'; + +import type { FilterSyncColumn } from '../types'; +import { + checkForErrors, + getColumnIdentifier, + getConditionFromFilterRow, + getConditionFromHeaderFilter, + getFilterRowOptionsFromCondition, + getHeaderFilterFromCondition, +} from '../utils'; + +const condition = ( + operation: string, + value: FilterValueOperand, +): FilterValueCondition => ['field', operation, value]; + +const EMPTY_FILTER_ROW_STATE = { + filterValue: undefined, + selectedFilterOperation: undefined, + bufferedFilterValue: undefined, + bufferedSelectedFilterOperation: undefined, +}; + +const EMPTY_HEADER_FILTER_STATE = { + filterType: 'include', + filterValues: undefined, +}; + +describe('getColumnIdentifier', () => { + it('returns the name when both the name and the data field are specified', () => { + expect(getColumnIdentifier({ name: 'columnName', dataField: 'columnDataField' })) + .toBe('columnName'); + }); + + it('returns the data field when there is no name', () => { + expect(getColumnIdentifier({ dataField: 'columnDataField' })).toBe('columnDataField'); + }); + + it('falls back to the data field when the name is an empty string', () => { + expect(getColumnIdentifier({ name: '', dataField: 'columnDataField' })) + .toBe('columnDataField'); + }); + + it('returns undefined when there is neither a name nor a data field', () => { + expect(getColumnIdentifier({})).toBeUndefined(); + }); + + it('returns undefined when the name is an empty string and there is no data field', () => { + expect(getColumnIdentifier({ name: '' })).toBeUndefined(); + }); +}); + +describe('checkForErrors', () => { + it('throws for a filterable column without a name and a data field', () => { + expect(() => checkForErrors([{ allowFiltering: true, caption: 'Column caption' }])) + .toThrow(/E1049/); + }); + + it('names the column caption in the error', () => { + expect(() => checkForErrors([{ allowFiltering: true, caption: 'Column caption' }])) + .toThrow(/Column caption/); + }); + + it('throws for a filterable column whose name is an empty string', () => { + expect(() => checkForErrors([{ allowFiltering: true, name: '' }])).toThrow(/E1049/); + }); + + it('does not throw when the column is not filterable', () => { + expect(() => checkForErrors([{ caption: 'Column caption' }])).not.toThrow(); + }); + + it('does not throw when the filterable column has a name', () => { + expect(() => checkForErrors([{ allowFiltering: true, name: 'columnName' }])).not.toThrow(); + }); + + it('does not throw when the filterable column has a data field', () => { + expect(() => checkForErrors([{ allowFiltering: true, dataField: 'columnDataField' }])) + .not.toThrow(); + }); + + it('checks every column, not only the first one', () => { + expect(() => checkForErrors([ + { allowFiltering: true, dataField: 'columnDataField' }, + { allowFiltering: true, caption: 'Column caption' }, + ])).toThrow(/E1049/); + }); + + it('does not throw for an empty column list', () => { + expect(() => checkForErrors([])).not.toThrow(); + }); +}); + +describe('getConditionFromFilterRow', () => { + it('returns null when the filter value is undefined', () => { + expect(getConditionFromFilterRow({ dataField: 'field' })).toBeNull(); + }); + + it('returns null when the filter value is null', () => { + expect(getConditionFromFilterRow({ dataField: 'field', filterValue: null })).toBeNull(); + }); + + it('builds a condition for a zero filter value', () => { + expect(getConditionFromFilterRow({ + dataField: 'field', + dataType: 'number', + filterValue: 0, + })).toEqual(['field', '=', 0]); + }); + + it('builds a condition for an empty string filter value', () => { + expect(getConditionFromFilterRow({ + dataField: 'field', + dataType: 'string', + filterValue: '', + })).toEqual(['field', 'contains', '']); + }); + + it('builds a condition for a false filter value', () => { + expect(getConditionFromFilterRow({ + dataField: 'field', + dataType: 'boolean', + filterValue: false, + })).toEqual(['field', '=', false]); + }); + + it('prefers the selected filter operation', () => { + expect(getConditionFromFilterRow({ + dataField: 'field', + filterValue: 1, + selectedFilterOperation: '>', + defaultFilterOperation: '<', + })).toEqual(['field', '>', 1]); + }); + + it('falls back to the default filter operation', () => { + expect(getConditionFromFilterRow({ + dataField: 'field', + filterValue: 1, + defaultFilterOperation: '<', + })).toEqual(['field', '<', 1]); + }); + + it('falls back to the first allowed filter operation', () => { + expect(getConditionFromFilterRow({ + dataField: 'field', + dataType: 'number', + filterValue: 1, + filterOperations: ['>', '<'], + })).toEqual(['field', '>', 1]); + }); + + it('falls back to the data type default operation for a number column', () => { + expect(getConditionFromFilterRow({ + dataField: 'field', + dataType: 'number', + filterValue: 1, + })).toEqual(['field', '=', 1]); + }); + + it('falls back to the data type default operation for a string column', () => { + expect(getConditionFromFilterRow({ + dataField: 'field', + dataType: 'string', + filterValue: 'text', + })).toEqual(['field', 'contains', 'text']); + }); + + it('uses the name as the condition field', () => { + expect(getConditionFromFilterRow({ + name: 'columnName', + dataField: 'columnDataField', + dataType: 'number', + filterValue: 1, + })).toEqual(['columnName', '=', 1]); + }); +}); + +describe('getConditionFromHeaderFilter', () => { + it('returns null when there are no filter values', () => { + expect(getConditionFromHeaderFilter({ dataField: 'field' })).toBeNull(); + }); + + it('builds an anyof condition for an empty filter value list', () => { + expect(getConditionFromHeaderFilter({ dataField: 'field', filterValues: [] })) + .toEqual(['field', 'anyof', []]); + }); + + it('builds an equals condition for a single value', () => { + expect(getConditionFromHeaderFilter({ dataField: 'field', filterValues: [1] })) + .toEqual(['field', '=', 1]); + }); + + it('builds a not equals condition for a single excluded value', () => { + expect(getConditionFromHeaderFilter({ + dataField: 'field', + filterValues: [1], + filterType: 'exclude', + })).toEqual(['field', '<>', 1]); + }); + + it('builds an anyof condition for several values', () => { + expect(getConditionFromHeaderFilter({ dataField: 'field', filterValues: [1, 2] })) + .toEqual(['field', 'anyof', [1, 2]]); + }); + + it('builds a noneof condition for several excluded values', () => { + expect(getConditionFromHeaderFilter({ + dataField: 'field', + filterValues: [1, 2], + filterType: 'exclude', + })).toEqual(['field', 'noneof', [1, 2]]); + }); + + it('builds an anyof condition when the single value is itself a list', () => { + expect(getConditionFromHeaderFilter({ dataField: 'field', filterValues: [[2020, 1]] })) + .toEqual(['field', 'anyof', [[2020, 1]]]); + }); + + it('builds an anyof condition for a single value on a column with a group interval', () => { + expect(getConditionFromHeaderFilter({ + dataField: 'field', + dataType: 'number', + filterValues: [1], + headerFilter: { groupInterval: 10 }, + })).toEqual(['field', 'anyof', [1]]); + }); + + it('builds an anyof condition for a single value on a column with its own data source', () => { + expect(getConditionFromHeaderFilter({ + dataField: 'field', + dataType: 'number', + filterValues: [1], + headerFilter: { dataSource: [10, 20] }, + })).toEqual(['field', 'anyof', [1]]); + }); + + it('builds an anyof condition for a single value on a date column', () => { + expect(getConditionFromHeaderFilter({ + dataField: 'field', + dataType: 'date', + filterValues: ['2020/01/01'], + })).toEqual(['field', 'anyof', ['2020/01/01']]); + }); + + it('builds an equals condition for the lone null value despite a group interval', () => { + expect(getConditionFromHeaderFilter({ + dataField: 'field', + dataType: 'number', + filterValues: [null], + headerFilter: { groupInterval: 10 }, + })).toEqual(['field', '=', null]); + }); + + it('builds an equals condition for the lone null value on a date column', () => { + expect(getConditionFromHeaderFilter({ + dataField: 'field', + dataType: 'date', + filterValues: [null], + })).toEqual(['field', '=', null]); + }); +}); + +describe('getHeaderFilterFromCondition', () => { + const numberColumn: FilterSyncColumn = { dataField: 'field', dataType: 'number' }; + const columnWithGroupInterval: FilterSyncColumn = { + dataField: 'field', + dataType: 'number', + headerFilter: { groupInterval: 10 }, + }; + + it('returns the empty state for a missing condition', () => { + expect(getHeaderFilterFromCondition(null, numberColumn)) + .toStrictEqual(EMPTY_HEADER_FILTER_STATE); + }); + + it('includes the value of an equals condition', () => { + expect(getHeaderFilterFromCondition(condition('=', 1), numberColumn)).toStrictEqual({ + filterType: 'include', + filterValues: [1], + }); + }); + + it('includes the values of an anyof condition', () => { + expect(getHeaderFilterFromCondition(condition('anyof', [1, 2]), numberColumn)).toStrictEqual({ + filterType: 'include', + filterValues: [1, 2], + }); + }); + + it('excludes the value of a not equals condition', () => { + expect(getHeaderFilterFromCondition(condition('<>', 1), numberColumn)).toStrictEqual({ + filterType: 'exclude', + filterValues: [1], + }); + }); + + it('excludes the values of a noneof condition', () => { + expect(getHeaderFilterFromCondition(condition('noneof', [1, 2]), numberColumn)).toStrictEqual({ + filterType: 'exclude', + filterValues: [1, 2], + }); + }); + + it('returns the empty state for an operation the header filter cannot show', () => { + expect(getHeaderFilterFromCondition(condition('>', 1), numberColumn)) + .toStrictEqual(EMPTY_HEADER_FILTER_STATE); + }); + + it('returns the empty state for a single value on a column with a group interval', () => { + expect(getHeaderFilterFromCondition(condition('=', 1), columnWithGroupInterval)) + .toStrictEqual(EMPTY_HEADER_FILTER_STATE); + }); + + it('keeps a value list on a column with a group interval', () => { + expect(getHeaderFilterFromCondition(condition('anyof', [1, 2]), columnWithGroupInterval)) + .toStrictEqual({ + filterType: 'include', + filterValues: [1, 2], + }); + }); + + it('keeps the lone null value on a column with a group interval', () => { + expect(getHeaderFilterFromCondition(condition('=', null), { + ...columnWithGroupInterval, + filterValues: [null], + })).toStrictEqual({ + filterType: 'include', + filterValues: [null], + }); + }); +}); + +describe('getFilterRowOptionsFromCondition', () => { + it('clears the filter row for a missing condition', () => { + expect(getFilterRowOptionsFromCondition(null, { dataField: 'field' })) + .toStrictEqual(EMPTY_FILTER_ROW_STATE); + }); + + it('applies a built-in operation', () => { + expect(getFilterRowOptionsFromCondition(condition('=', 1), { dataField: 'field' })) + .toStrictEqual({ + filterValue: 1, + selectedFilterOperation: '=', + bufferedFilterValue: undefined, + bufferedSelectedFilterOperation: undefined, + }); + }); + + it('applies the between operation with its value range', () => { + expect(getFilterRowOptionsFromCondition(condition('between', [1, 2]), { dataField: 'field' })) + .toStrictEqual({ + filterValue: [1, 2], + selectedFilterOperation: 'between', + bufferedFilterValue: undefined, + bufferedSelectedFilterOperation: undefined, + }); + }); + + it('clears the filter row for an operation it cannot show', () => { + expect(getFilterRowOptionsFromCondition(condition('anyof', [1]), { dataField: 'field' })) + .toStrictEqual(EMPTY_FILTER_ROW_STATE); + }); + + it('clears the filter row when the operation is not allowed for the column', () => { + expect(getFilterRowOptionsFromCondition(condition('=', 1), { + dataField: 'field', + filterOperations: ['contains'], + })).toStrictEqual(EMPTY_FILTER_ROW_STATE); + }); + + it('falls back to the default filter operations when there are no filter operations', () => { + expect(getFilterRowOptionsFromCondition(condition('=', 1), { + dataField: 'field', + defaultFilterOperations: ['contains'], + })).toStrictEqual(EMPTY_FILTER_ROW_STATE); + }); + + it('applies a disallowed operation when it is the default operation of the column', () => { + expect(getFilterRowOptionsFromCondition(condition('=', 1), { + dataField: 'field', + filterOperations: ['contains'], + defaultFilterOperation: '=', + })).toStrictEqual({ + filterValue: 1, + selectedFilterOperation: undefined, + bufferedFilterValue: undefined, + bufferedSelectedFilterOperation: undefined, + }); + }); + + it('leaves the operation unset when it is the implicit default operation', () => { + expect(getFilterRowOptionsFromCondition(condition('=', 1), { + dataField: 'field', + defaultFilterOperation: '=', + })).toStrictEqual({ + filterValue: 1, + selectedFilterOperation: undefined, + bufferedFilterValue: undefined, + bufferedSelectedFilterOperation: undefined, + }); + }); + + it('keeps the operation when the column already selected the default operation', () => { + expect(getFilterRowOptionsFromCondition(condition('=', 1), { + dataField: 'field', + defaultFilterOperation: '=', + selectedFilterOperation: '=', + })).toStrictEqual({ + filterValue: 1, + selectedFilterOperation: '=', + bufferedFilterValue: undefined, + bufferedSelectedFilterOperation: undefined, + }); + }); + + it('clears the filter row for a null value', () => { + expect(getFilterRowOptionsFromCondition(condition('=', null), { dataField: 'field' })) + .toStrictEqual(EMPTY_FILTER_ROW_STATE); + }); + + it('clears the filter row for an empty string value', () => { + expect(getFilterRowOptionsFromCondition(condition('contains', ''), { dataField: 'field' })) + .toStrictEqual(EMPTY_FILTER_ROW_STATE); + }); + + it('applies a zero value', () => { + expect(getFilterRowOptionsFromCondition(condition('=', 0), { dataField: 'field' })) + .toStrictEqual({ + filterValue: 0, + selectedFilterOperation: '=', + bufferedFilterValue: undefined, + bufferedSelectedFilterOperation: undefined, + }); + }); + + it('applies a false value', () => { + expect(getFilterRowOptionsFromCondition(condition('=', false), { dataField: 'field' })) + .toStrictEqual({ + filterValue: false, + selectedFilterOperation: '=', + bufferedFilterValue: undefined, + bufferedSelectedFilterOperation: undefined, + }); + }); +}); diff --git a/packages/devextreme/js/__internal/grids/grid_core/filter_sync/extenders/filter_sync_column_headers_view.ts b/packages/devextreme/js/__internal/grids/grid_core/filter_sync/extenders/filter_sync_column_headers_view.ts new file mode 100644 index 000000000000..c55044878687 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/grid_core/filter_sync/extenders/filter_sync_column_headers_view.ts @@ -0,0 +1,35 @@ +import { filterHasField } from '@ts/filter_builder/m_utils'; +import type { ColumnHeadersView } from '@ts/grids/grid_core/column_headers/m_column_headers'; +import type { DataController } from '@ts/grids/grid_core/data_controller/data_controller'; +import type { ModuleType, OptionChanged } from '@ts/grids/grid_core/m_types'; + +import { getColumnIdentifier } from '../utils'; +import type { FilterSyncDataControllerExtension } from './filter_sync_data_controller'; + +export const filterSyncColumnHeadersViewExtender = ( + Base: ModuleType, +): ModuleType => class ColumnHeadersViewFilterSyncExtender extends Base { + declare protected _dataController: DataController & FilterSyncDataControllerExtension; + + public optionChanged(args: OptionChanged): void { + if (args.name === 'filterValue') { + // @ts-expect-error introduced in HeaderFilter extender + this._updateHeaderFilterIndicators(); + } else { + super.optionChanged(args); + } + } + + private _isHeaderFilterEmpty(column): boolean { + if (this._dataController.isFilterSyncActive()) { + return !filterHasField(this.option('filterValue'), getColumnIdentifier(column)); + } + + // @ts-expect-error introduced in HeaderFilter extender + return super._isHeaderFilterEmpty(column) as boolean; + } + + private _needUpdateFilterIndicators(): boolean { + return !this._dataController.isFilterSyncActive(); + } +}; 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 ef344b75661a..f55b1c37f880 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 @@ -14,9 +14,15 @@ import { getColumnIdentifier } from '@ts/grids/grid_core/filter_sync/utils'; import type { ModuleType, OptionChanged } from '@ts/grids/grid_core/m_types'; import gridCoreUtils from '@ts/grids/grid_core/m_utils'; +export interface FilterSyncDataControllerExtension { + isFilterSyncActive: () => boolean | undefined; +} + export const filterSyncDataControllerExtender = ( Base: ModuleType, -): ModuleType => class FilterSyncDataControllerExtender extends Base { +): ModuleType< + DataController & FilterSyncDataControllerExtension +> => class FilterSyncDataControllerExtender extends Base { private filterSyncController!: FilterSyncController; public init(): void { @@ -60,7 +66,7 @@ export const filterSyncDataControllerExtender = ( } } - private isFilterSyncActive(): boolean | undefined { + public isFilterSyncActive(): boolean | undefined { const filterSyncEnabledValue = this.option('filterSyncEnabled'); return filterSyncEnabledValue === 'auto' ? this.option('filterPanel.visible') : filterSyncEnabledValue; } @@ -137,7 +143,7 @@ export const filterSyncDataControllerExtender = ( } if (needSyncFilterRow) { - this.filterSyncController.syncFilterRow(column, column.filterValue); + this.filterSyncController.syncFilterRow(column); } } 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 e8eadd7e2574..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 @@ -1,7 +1,8 @@ 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 { columnHeadersView, FilterSyncController } from './m_filter_sync'; +import { FilterSyncController } from './m_filter_sync'; export const filterSyncModule = { defaultOptions(): Pick { @@ -18,7 +19,7 @@ export const filterSyncModule = { data: filterSyncDataControllerExtender, }, views: { - columnHeadersView, + columnHeadersView: filterSyncColumnHeadersViewExtender, }, }, }; diff --git a/packages/devextreme/js/__internal/grids/grid_core/filter_sync/m_filter_custom_operations.ts b/packages/devextreme/js/__internal/grids/grid_core/filter_sync/m_filter_custom_operations.ts index 9a64587d5845..68504f0821a7 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/filter_sync/m_filter_custom_operations.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/filter_sync/m_filter_custom_operations.ts @@ -3,6 +3,7 @@ import { DataSource } from '@js/common/data/data_source/data_source'; import $ from '@js/core/renderer'; import { Deferred } from '@js/core/utils/deferred'; import { extend } from '@js/core/utils/extend'; +import type { CustomOperation } from '@js/ui/filter_builder'; import errors from '@js/ui/widget/ui.errors'; import { getFilterExpression, isCondition, isGroup, renderValueText, @@ -134,7 +135,7 @@ function baseOperation(grid) { }; } -export function anyOf(grid) { +export function anyOf(grid): CustomOperation { return extend(baseOperation(grid), { name: 'anyof', icon: 'selectall', @@ -142,7 +143,7 @@ export function anyOf(grid) { }); } -export function noneOf(grid) { +export function noneOf(grid): CustomOperation { const baseOp = baseOperation(grid); return extend({}, baseOp, { calculateFilterExpression(filterValue, field, fields) { 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 59a20a305e88..426b8c33f8ff 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,176 +1,54 @@ -/* eslint-disable max-classes-per-file */ import { isDefined } from '@js/core/utils/type'; import type { CustomOperation } from '@js/ui/filter_builder'; -import filterUtils from '@js/ui/shared/filtering'; -import errors from '@js/ui/widget/ui.errors'; import { addItem, - filterHasField, - getDefaultOperation, getMatchedConditions, getNormalizedFilter, - removeFieldConditionsFromFilter, - syncFilters, } from '@ts/filter_builder/m_utils'; -import type { ColumnHeadersView } from '@ts/grids/grid_core/column_headers/m_column_headers'; import type { ColumnsController } from '@ts/grids/grid_core/columns_controller/m_columns_controller'; +import type { + Column, ColumnUserState, FilterField, +} from '@ts/grids/grid_core/columns_controller/types'; import type { DataController } from '@ts/grids/grid_core/data_controller/data_controller'; -import { - FILTER_ROW_OPERATIONS, - FILTER_TYPES_EXCLUDE, - FILTER_TYPES_INCLUDE, -} from '@ts/grids/grid_core/filter_sync/const'; -import { getColumnIdentifier } from '@ts/grids/grid_core/filter_sync/utils'; +import type { FilterValue, FilterValueCondition } from '@ts/grids/grid_core/data_controller/types'; import modules from '@ts/grids/grid_core/m_modules'; -import type { ModuleType } from '@ts/grids/grid_core/m_types'; +import type { + FilterSyncDataControllerExtension, +} from './extenders/filter_sync_data_controller'; import { anyOf, noneOf } from './m_filter_custom_operations'; - -function checkForErrors(columns) { - columns.forEach((column) => { - const identifier = getColumnIdentifier(column); - // @ts-expect-error - if (!isDefined(identifier) && column.allowFiltering) throw new errors.Error('E1049', column.caption); - }); -} - -const getEmptyFilterValues = function () { - return { - filterType: FILTER_TYPES_INCLUDE, - filterValues: undefined, - }; -}; - -const canSyncHeaderFilterWithFilterRow = function (column) { - const filterValues = column.filterValues || []; - return (!filterUtils.getGroupInterval(column) && !(column.headerFilter && column.headerFilter.dataSource)) - || (filterValues.length === 1 && filterValues[0] === null); -}; - -const getHeaderFilterFromCondition = function (headerFilterCondition, column) { - if (!headerFilterCondition) { - return getEmptyFilterValues(); - } - - let filterType; - const selectedFilterOperation = headerFilterCondition[1]; - const value = headerFilterCondition[2]; - const hasArrayValue = Array.isArray(value); - - if (!hasArrayValue) { - if (!canSyncHeaderFilterWithFilterRow(column)) { - return getEmptyFilterValues(); - } - } - - switch (selectedFilterOperation) { - case 'anyof': - case '=': - filterType = FILTER_TYPES_INCLUDE; - break; - case 'noneof': - case '<>': - filterType = FILTER_TYPES_EXCLUDE; - break; - default: return getEmptyFilterValues(); - } - - return { - filterType, - filterValues: hasArrayValue ? value : [value], - }; -}; - -const getConditionFromFilterRow = function (column) { - const value = column.filterValue; - if (isDefined(value)) { - const operation = column.selectedFilterOperation || column.defaultFilterOperation || getDefaultOperation(column); - const filter = [getColumnIdentifier(column), operation, column.filterValue]; - return filter; - } - return null; -}; - -const getConditionFromHeaderFilter = function (column) { - let selectedOperation; - let value; - const { filterValues } = column; - - if (!filterValues) return null; - - if (filterValues.length === 1 && ( - canSyncHeaderFilterWithFilterRow(column) - && !Array.isArray(filterValues[0]) - )) { - selectedOperation = column.filterType === FILTER_TYPES_EXCLUDE ? '<>' : '='; - // eslint-disable-next-line prefer-destructuring - value = filterValues[0]; - } else { - selectedOperation = column.filterType === FILTER_TYPES_EXCLUDE ? 'noneof' : 'anyof'; - value = filterValues; - } - return [getColumnIdentifier(column), selectedOperation, value]; -}; - -const updateHeaderFilterCondition = function (columnsController, column, headerFilterCondition) { - const headerFilter = getHeaderFilterFromCondition(headerFilterCondition, column); - columnsController.columnOption(getColumnIdentifier(column), headerFilter); -}; - -const updateFilterRowCondition = function (columnsController, column, condition) { - let filterRowOptions; - let selectedFilterOperation = condition?.[1]; - const filterValue = condition?.[2]; - const filterOperations = column.filterOperations || column.defaultFilterOperations; - - const selectedOperationExists = !filterOperations || filterOperations.includes(selectedFilterOperation); - const defaultOperationSelected = selectedFilterOperation === column.defaultFilterOperation; - const builtInOperationSelected = FILTER_ROW_OPERATIONS.includes(selectedFilterOperation); - const filterValueNotNullOrEmpty = filterValue !== null && filterValue !== ''; - - if ((selectedOperationExists || defaultOperationSelected) && builtInOperationSelected && filterValueNotNullOrEmpty) { - if (defaultOperationSelected && !isDefined(column.selectedFilterOperation)) { - selectedFilterOperation = column.selectedFilterOperation; - } - filterRowOptions = { - filterValue, - selectedFilterOperation, - bufferedFilterValue: undefined, - bufferedSelectedFilterOperation: undefined, - }; - } else { - filterRowOptions = { - filterValue: undefined, - selectedFilterOperation: undefined, - bufferedFilterValue: undefined, - bufferedSelectedFilterOperation: undefined, - }; - } - columnsController.columnOption(getColumnIdentifier(column), filterRowOptions); -}; +import { + checkForErrors, + getColumnIdentifier, + getConditionFromFilterRow, + getConditionFromHeaderFilter, + getFilterRowOptionsFromCondition, + getFilterValueWithFilterRow, + getFilterValueWithHeaderFilter, + getHeaderFilterFromCondition, +} from './utils'; export class FilterSyncController extends modules.Controller { private skipSyncColumnOptions = false; - private _dataController!: DataController; + private dataController!: DataController & FilterSyncDataControllerExtension; - private _columnsController!: ColumnsController; + private columnsController!: ColumnsController; - public init() { - this._dataController = this.getController('data'); - this._columnsController = this.getController('columns'); + public init(): void { + this.dataController = this.getController('data') as DataController & FilterSyncDataControllerExtension; + this.columnsController = this.getController('columns'); - // @ts-expect-error - if (this._dataController.isFilterSyncActive()) { - if (this._columnsController.isAllDataTypesDefined()) { - this._initSync(); + if (this.dataController.isFilterSyncActive()) { + if (this.columnsController.isAllDataTypesDefined()) { + this.initSync(); } else { - this._dataController.dataSourceChanged.add(() => this._initSync()); + this.dataController.dataSourceChanged.add(() => this.initSync()); } } } - public publicMethods() { + public publicMethods(): string[] { return ['getCustomFilterOperations']; } @@ -187,112 +65,116 @@ export class FilterSyncController extends modules.Controller { } } - public syncFilterValue() { - const columns = this._columnsController.getFilteringColumns(); + public syncFilterValue(): void { + const columns = this.columnsController.getFilteringColumns(); + const filterValue = this.option('filterValue') ?? null; this.withColumnOptionsSync(() => { columns.forEach((column) => { - const filterConditions = getMatchedConditions(this.option('filterValue'), getColumnIdentifier(column)); + const filterConditions = getMatchedConditions( + filterValue, + getColumnIdentifier(column), + ) as FilterValueCondition[]; + if (filterConditions.length === 1) { const filterCondition = filterConditions[0]; - updateHeaderFilterCondition(this._columnsController, column, filterCondition); - updateFilterRowCondition(this._columnsController, column, filterCondition); + + this.updateHeaderFilterCondition(column, filterCondition); + this.updateFilterRowCondition(column, filterCondition); } else { - isDefined(column.filterValues) && updateHeaderFilterCondition(this._columnsController, column, null); - isDefined(column.filterValue) && updateFilterRowCondition(this._columnsController, column, null); + if (isDefined(column.filterValues)) { + this.updateHeaderFilterCondition(column, null); + } + + if (isDefined(column.filterValue)) { + this.updateFilterRowCondition(column, null); + } } }); }); } - private _initSync() { - const columns = this._columnsController.getColumns(); - const pageIndex = this._dataController.pageIndex(); + private updateHeaderFilterCondition( + column: FilterField, + headerFilterCondition: FilterValueCondition | null, + ): void { + const headerFilter = getHeaderFilterFromCondition(headerFilterCondition, column); + + this.columnsController.columnOption(getColumnIdentifier(column), headerFilter); + } + + private updateFilterRowCondition( + column: FilterField, + condition: FilterValueCondition | null, + ): void { + const filterRowOptions = getFilterRowOptionsFromCondition(condition, column); + + this.columnsController.columnOption(getColumnIdentifier(column), filterRowOptions); + } + + private initSync(): void { + const columns = this.columnsController.getColumns(); + const pageIndex = this.dataController.pageIndex(); checkForErrors(columns); + if (!this.option('filterValue')) { - const filteringColumns = this._columnsController.getFilteringColumns(); + const filteringColumns = this.columnsController.getFilteringColumns(); const filterValue = this.getFilterValueFromColumns(filteringColumns); this._silentOption('filterValue', filterValue); } - this.syncFilterValue(); - this._dataController.pageIndex(pageIndex); - } - - private _getSyncFilterRow(filterValue, column) { - const filter = getConditionFromFilterRow(column); - if (isDefined(filter)) { - return syncFilters(filterValue, filter); - } - return removeFieldConditionsFromFilter(filterValue, getColumnIdentifier(column)); - } + this.syncFilterValue(); - private _getSyncHeaderFilter(filterValue, column) { - const filter = getConditionFromHeaderFilter(column); - if (filter) { - return syncFilters(filterValue, filter); - } - return removeFieldConditionsFromFilter(filterValue, getColumnIdentifier(column)); + this.dataController.pageIndex(pageIndex); } - private getFilterValueFromColumns(columns) { - // @ts-expect-error - if (!this._dataController.isFilterSyncActive()) { + public getFilterValueFromColumns( + columns: ColumnUserState[] | undefined, + ): FilterValue { + if (!this.dataController.isFilterSyncActive()) { return null; } const filterValue = ['and']; - columns && columns.forEach((column) => { + columns?.forEach((column) => { const headerFilter = getConditionFromHeaderFilter(column); - const filterRow = getConditionFromFilterRow(column); + if (headerFilter) { + addItem(headerFilter, filterValue); + } - headerFilter && addItem(headerFilter, filterValue); - filterRow && addItem(filterRow, filterValue); + const filterRow = getConditionFromFilterRow(column); + if (filterRow) { + addItem(filterRow, filterValue); + } }); - return getNormalizedFilter(filterValue); + + return getNormalizedFilter(filterValue) as FilterValue; } - // eslint-disable-next-line @typescript-eslint/no-unused-vars - public syncFilterRow(column, filterValue?) { - this.option('filterValue', this._getSyncFilterRow(this.option('filterValue'), column)); + public syncFilterRow(column: Column): void { + const filterValue = this.option('filterValue'); + const syncedFilterValue = getFilterValueWithFilterRow(filterValue, column); + + this.option('filterValue', syncedFilterValue); } - public syncHeaderFilter(column) { - this.option('filterValue', this._getSyncHeaderFilter(this.option('filterValue'), column)); + public syncHeaderFilter(column: Column): void { + const filterValue = this.option('filterValue'); + const syncedFilterValue = getFilterValueWithHeaderFilter(filterValue, column); + + 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)].concat(filterBuilderCustomOperations) as CustomOperation[]; + return [ + anyOf(this.component), + noneOf(this.component), + ...filterBuilderCustomOperations, + ]; } } - -export const columnHeadersView = (Base: ModuleType) => class ColumnHeadersViewFilterSyncExtender extends Base { - public optionChanged(args) { - if (args.name === 'filterValue') { - // @ts-expect-error - this._updateHeaderFilterIndicators(); - } else { - super.optionChanged(args); - } - } - - private _isHeaderFilterEmpty(column) { - // @ts-expect-error - if (this._dataController.isFilterSyncActive()) { - return !filterHasField(this.option('filterValue'), getColumnIdentifier(column)); - } - - // @ts-expect-error - return super._isHeaderFilterEmpty(column); - } - - private _needUpdateFilterIndicators() { - // @ts-expect-error - return !this._dataController.isFilterSyncActive(); - } -}; diff --git a/packages/devextreme/js/__internal/grids/grid_core/filter_sync/types.ts b/packages/devextreme/js/__internal/grids/grid_core/filter_sync/types.ts new file mode 100644 index 000000000000..62830b824f44 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/grid_core/filter_sync/types.ts @@ -0,0 +1,15 @@ +import type { Column, FilterField } from '@ts/grids/grid_core/columns_controller/types'; + +/** + * Column-like shape used by filter sync. Compatible with + * `Column`, `FilterField`, and persisted `ColumnUserState`. + */ +export type FilterSyncColumn = Partial; + +/** The column options a header filter is expressed through. */ +export type HeaderFilterState = Pick; + +/** The column options a filter row is expressed through. */ +export type FilterRowState = Pick; diff --git a/packages/devextreme/js/__internal/grids/grid_core/filter_sync/utils.ts b/packages/devextreme/js/__internal/grids/grid_core/filter_sync/utils.ts index 130295bd4b10..6a7e68056905 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/filter_sync/utils.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/filter_sync/utils.ts @@ -1,6 +1,203 @@ -import type { FilterField } from '@ts/grids/grid_core/columns_controller/types'; +import { isDefined } from '@js/core/utils/type'; +import filterUtils from '@js/ui/shared/filtering'; +import errors from '@js/ui/widget/ui.errors'; +import { + getDefaultOperation, + removeFieldConditionsFromFilter, + syncFilters, +} from '@ts/filter_builder/m_utils'; +import type { Column } from '@ts/grids/grid_core/columns_controller/types'; +import type { FilterValue, FilterValueCondition } from '@ts/grids/grid_core/data_controller/types'; -export function getColumnIdentifier(column: FilterField): string | undefined { +import { FILTER_ROW_OPERATIONS, FILTER_TYPES_EXCLUDE, FILTER_TYPES_INCLUDE } from './const'; +import type { + FilterRowState, + FilterSyncColumn, + HeaderFilterState, +} from './types'; + +const getFilterTypeByOperation = (operation: unknown): Column['filterType'] | undefined => { + switch (operation) { + case 'anyof': + case '=': + return FILTER_TYPES_INCLUDE; + case 'noneof': + case '<>': + return FILTER_TYPES_EXCLUDE; + default: + return undefined; + } +}; + +export const getColumnIdentifier = ( + column: FilterSyncColumn, // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - return column.name || column.dataField; -} +): string | undefined => column.name || column.dataField; + +const canSyncHeaderFilterWithFilterRow = (column: FilterSyncColumn): boolean => { + const { filterValues } = column; + const isOnlyNullFilterValue = filterValues?.length === 1 && filterValues[0] === null; + + if (isOnlyNullFilterValue) { + return true; + } + + const hasGroupInterval = Boolean(filterUtils.getGroupInterval(column)); + const hasOwnDataSource = Boolean(column.headerFilter?.dataSource); + + return !hasGroupInterval && !hasOwnDataSource; +}; + +export const checkForErrors = (columns: FilterSyncColumn[]): void => { + columns.forEach((column) => { + if (!isDefined(getColumnIdentifier(column)) && column.allowFiltering) { + // @ts-expect-error `errors.Error` is not declared as a constructor + throw new errors.Error('E1049', column.caption); + } + }); +}; + +export const getConditionFromFilterRow = ( + column: FilterSyncColumn, +): FilterValueCondition | null => { + const { filterValue } = column; + + if (!isDefined(filterValue)) { + return null; + } + + const operation: string = column.selectedFilterOperation + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + || column.defaultFilterOperation + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + || getDefaultOperation(column); + + return [getColumnIdentifier(column) as string, operation, filterValue]; +}; + +export const getConditionFromHeaderFilter = ( + column: FilterSyncColumn, +): FilterValueCondition | null => { + const { filterValues } = column; + + if (!filterValues) { + return null; + } + + const field = getColumnIdentifier(column) as string; + const isExcluded = column.filterType === FILTER_TYPES_EXCLUDE; + const isSingleValue = filterValues.length === 1 + && canSyncHeaderFilterWithFilterRow(column) + && !Array.isArray(filterValues[0]); + + if (isSingleValue) { + return [field, isExcluded ? '<>' : '=', filterValues[0]]; + } + + return [field, isExcluded ? 'noneof' : 'anyof', filterValues]; +}; + +const getEmptyFilterValues = (): HeaderFilterState => ({ + filterType: FILTER_TYPES_INCLUDE, + filterValues: undefined, +}); + +// A short `[field, value]` condition clears the column state instead of syncing it. +// Building the data filter rewrites it in place into `[field, '=', value]` first, so it +// only reaches here when that filter is skipped, e.g. `filterPanel.filterEnabled: false`. +export const getHeaderFilterFromCondition = ( + condition: FilterValueCondition | null, + column: FilterSyncColumn, +): HeaderFilterState => { + if (!condition) { + return getEmptyFilterValues(); + } + + const value = condition[2]; + const hasArrayValue = Array.isArray(value); + + if (!hasArrayValue && !canSyncHeaderFilterWithFilterRow(column)) { + return getEmptyFilterValues(); + } + + const filterType = getFilterTypeByOperation(condition[1]); + + if (!filterType) { + return getEmptyFilterValues(); + } + + return { + filterType, + filterValues: hasArrayValue ? value : [value], + }; +}; + +// A short `[field, value]` condition clears the column state instead of syncing it. +// Building the data filter rewrites it in place into `[field, '=', value]` first, so it +// only reaches here when that filter is skipped, e.g. `filterPanel.filterEnabled: false`. +export const getFilterRowOptionsFromCondition = ( + condition: FilterValueCondition | null, + column: FilterSyncColumn, +): FilterRowState => { + const operation = condition?.[1] as Column['selectedFilterOperation']; + const filterValue = condition?.[2]; + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const filterOperations = column.filterOperations || column.defaultFilterOperations; + + const selectedOperationExists = !filterOperations + || filterOperations.includes(operation as string); + const isDefaultOperation = operation === column.defaultFilterOperation; + const builtInOperationSelected = FILTER_ROW_OPERATIONS.includes(operation as string); + const filterValueNotNullOrEmpty = filterValue !== null && filterValue !== ''; + const canApplyCondition = (selectedOperationExists || isDefaultOperation) + && builtInOperationSelected + && filterValueNotNullOrEmpty; + + if (!canApplyCondition) { + return { + filterValue: undefined, + selectedFilterOperation: undefined, + bufferedFilterValue: undefined, + bufferedSelectedFilterOperation: undefined, + }; + } + + const isImplicitDefaultOperation = isDefaultOperation + && !isDefined(column.selectedFilterOperation); + const selectedFilterOperation = isImplicitDefaultOperation + ? column.selectedFilterOperation + : operation; + + return { + filterValue, + selectedFilterOperation, + bufferedFilterValue: undefined, + bufferedSelectedFilterOperation: undefined, + }; +}; + +export const getFilterValueWithFilterRow = ( + filterValue: FilterValue, + column: FilterSyncColumn, +): FilterValue => { + const condition = getConditionFromFilterRow(column); + + if (isDefined(condition)) { + return syncFilters(filterValue, condition) as FilterValue; + } + + return removeFieldConditionsFromFilter(filterValue, getColumnIdentifier(column)) as FilterValue; +}; + +export const getFilterValueWithHeaderFilter = ( + filterValue: FilterValue, + column: FilterSyncColumn, +): FilterValue => { + const condition = getConditionFromHeaderFilter(column); + + if (condition) { + return syncFilters(filterValue, condition) as FilterValue; + } + + return removeFieldConditionsFromFilter(filterValue, getColumnIdentifier(column)) as FilterValue; +}; 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 5ccda355ef4c..662de74a6193 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/m_types.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/m_types.ts @@ -11,6 +11,7 @@ import type { Properties as DataGridOptions, Scrolling as DataGridScrolling } fr import type { Properties as TreeListdOptions, Scrolling as TreeListScrolling } from '@js/ui/tree_list'; import type Widget from '@js/ui/widget/ui.widget'; +import type { FilterValue } from './data_controller/types'; import type { EditingController } from './editing/m_editing'; import type { ModuleItem } from './m_modules'; @@ -143,6 +144,8 @@ export interface InternalGridOptions extends GridBaseOptions { const getFilterValue = (that, state: GridState): InternalGridOptions['filterValue'] => { // TODO: getController - const filterSyncController = that.getController('filterSync'); + const filterSyncController: FilterSyncController | undefined = that.getController('filterSync'); + if (!filterSyncController) { return null; } @@ -79,13 +81,14 @@ const getFilterValue = (that, state: GridState): InternalGridOptions['filterValu return state.filterValue; } - const filterValueFromColumns = filterSyncController.getFilterValueFromColumns?.(state.columns); - if (filterValueFromColumns?.length > 0) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-return + const filterValueFromColumns = filterSyncController.getFilterValueFromColumns(state.columns); + + if (filterValueFromColumns?.length) { return filterValueFromColumns; } const columns = that.getController('columns').getColumns(); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return return that._initialFilterValue ?? filterSyncController.getFilterValueFromColumns(columns); }; diff --git a/packages/devextreme/js/__internal/grids/grid_core/state_storing/types.ts b/packages/devextreme/js/__internal/grids/grid_core/state_storing/types.ts index 337d2136754b..600b4c24a5de 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/state_storing/types.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/state_storing/types.ts @@ -1,14 +1,11 @@ import type { PagerPageSize } from '@js/common/grids'; -import type { USER_STATE_FIELD_NAMES } from '../columns_controller/const'; -import type { Column } from '../columns_controller/types'; +import type { ColumnUserState } from '../columns_controller/types'; import type { UserState } from '../data_controller/types'; import type { InternalGridOptions } from '../m_types'; export type PersistentState = Record; -export type ColumnUserState = Pick; - /** * State persisted by DataGrid and TreeList. */