diff --git a/packages/devextreme/js/__internal/grids/grid_core/__tests__/utils.test.ts b/packages/devextreme/js/__internal/grids/grid_core/__tests__/utils.test.ts new file mode 100644 index 000000000000..83ad32871d01 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/grid_core/__tests__/utils.test.ts @@ -0,0 +1,508 @@ +import { + afterEach, + describe, + expect, + it, + jest, +} from '@jest/globals'; +import type { dxElementWrapper } from '@js/core/renderer'; +import $ from '@js/core/renderer'; +import { Deferred } from '@js/core/utils/deferred'; +import { variableWrapper } from '@ts/core/utils/variable_wrapper'; +import type { NormalizedDataSourceOptions } from '@ts/data/data_source/types'; +import type { Column } from '@ts/grids/grid_core/columns_controller/types'; +import type DataSourceAdapter from '@ts/grids/grid_core/data_source_adapter/data_source_adapter'; +import type { DataFilter } from '@ts/grids/grid_core/filter/types'; +import type { WrappedLookupDataSource } from '@ts/grids/grid_core/types'; + +import gridCoreUtils from '../m_utils'; + +interface CellRect { + left: number; + top: number; + width: number; + height: number; +} + +interface CreatedPoint { + index: number; + columnIndex: number; + x: number; + y: number; + item?: Element; + isLeftBoundary?: boolean; + isRightBoundary?: boolean; +} + +interface DoneCallbackHolder { + done: (callback: (value: T) => void) => unknown; +} + +const CELLS_CLASS = 'test-cells'; + +const LTR_CELLS: CellRect[] = [ + { + left: 0, top: 10, width: 50, height: 20, + }, + { + left: 50, top: 10, width: 60, height: 20, + }, + { + left: 110, top: 10, width: 40, height: 20, + }, +]; + +const RTL_CELLS: CellRect[] = [ + { + left: 100, top: 10, width: 50, height: 20, + }, + { + left: 40, top: 10, width: 60, height: 20, + }, + { + left: 0, top: 10, width: 40, height: 20, + }, +]; + +const LOOKUP_ITEMS = [ + { id: 1, text: 'One' }, + { id: 2, text: 'Two' }, + { id: 3, text: 'Three' }, +]; + +const createCells = (rects: CellRect[], direction = 'ltr'): dxElementWrapper => { + const $container = $('
').addClass(CELLS_CLASS).appendTo(document.body); + + rects.forEach(({ + left, top, width, height, + }) => { + const cell = $('
').css('direction', direction).appendTo($container).get(0) as HTMLElement; + const rect = { + left, + top, + width, + height, + x: left, + y: top, + right: left + width, + bottom: top + height, + toJSON: (): void => {}, + } as DOMRect; + + cell.getBoundingClientRect = (): DOMRect => rect; + cell.getClientRects = (): DOMRectList => [rect] as unknown as DOMRectList; + }); + + return $container.children(); +}; + +const notCreated = (): boolean => false; + +const createPointCollector = (): { + points: CreatedPoint[]; + pointCreated: (point: CreatedPoint) => boolean; +} => { + const points: CreatedPoint[] = []; + + return { + points, + pointCreated: (point: CreatedPoint): boolean => { + points.push({ ...point }); + return false; + }, + }; +}; + +const toPromise = (deferred: DoneCallbackHolder): Promise => new Promise((resolve) => { + deferred.done(resolve); +}); + +const calculateCellValue = (data: { categoryId?: number }): unknown => data.categoryId; + +const createLookupColumn = (overrides: Record = {}): Column => ({ + dataField: 'categoryId', + calculateCellValue, + defaultCalculateCellValue: calculateCellValue, + lookup: { dataSource: LOOKUP_ITEMS, valueExpr: 'id', displayExpr: 'text' }, + ...overrides, +}) as unknown as Column; + +const createDataSourceAdapter = ( + groupItems: { key: unknown }[], + groupPaging = false, +): { + dataSourceAdapter: DataSourceAdapter; + customLoad: jest.Mock<(options: unknown) => unknown>; +} => { + const customLoad = jest.fn<(options: unknown) => unknown>( + () => Deferred().resolve({ data: groupItems }), + ); + const dataSourceAdapter = { + remoteOperations: () => ({ groupPaging }), + customLoader: { load: customLoad }, + } as unknown as DataSourceAdapter; + + return { dataSourceAdapter, customLoad }; +}; + +describe('getPointsByColumns', () => { + afterEach(() => { + $(`.${CELLS_CLASS}`).remove(); + }); + + describe('when the columns are in a row', () => { + it('should create a point at the left edge of each column and at the right edge of the last one', () => { + const points = gridCoreUtils.getPointsByColumns(createCells(LTR_CELLS), notCreated); + + expect(points).toEqual([ + { + index: 0, columnIndex: 0, x: 0, y: 10, + }, + { + index: 1, columnIndex: 1, x: 50, y: 10, + }, + { + index: 2, columnIndex: 2, x: 110, y: 10, + }, + { + index: 3, columnIndex: 3, x: 150, y: 10, + }, + ]); + }); + + it('should pass the column element of each point to pointCreated', () => { + const cells = createCells(LTR_CELLS); + const { points, pointCreated } = createPointCollector(); + + gridCoreUtils.getPointsByColumns(cells, pointCreated); + + expect(points.map((point) => point.item)).toEqual([ + cells.get(0), cells.get(1), cells.get(2), cells.get(2), + ]); + expect(points[3].item).toBe(cells.get(2)); + }); + + it('should start the indexes from startColumnIndex', () => { + const points = gridCoreUtils.getPointsByColumns( + createCells(LTR_CELLS), + notCreated, + false, + 2, + ); + + expect(points.map((point) => [point.index, point.columnIndex])).toEqual([ + [2, 2], [3, 3], [4, 4], [5, 5], + ]); + }); + + it('should skip the points for which pointCreated returns true', () => { + const points = gridCoreUtils.getPointsByColumns( + createCells(LTR_CELLS), + (point: CreatedPoint) => point.index === 1, + ); + + expect(points.map((point) => point.index)).toEqual([0, 2, 3]); + }); + + it('should use the top of the previous column when it is higher', () => { + const points = gridCoreUtils.getPointsByColumns(createCells([ + { + left: 0, top: 10, width: 50, height: 20, + }, + { + left: 50, top: 15, width: 60, height: 20, + }, + ]), notCreated); + + expect(points.map((point) => point.y)).toEqual([10, 10, 15]); + }); + }); + + describe('when the grid is in RTL mode', () => { + it('should create a point at the right edge of each column and at the left edge of the last one', () => { + const points = gridCoreUtils.getPointsByColumns(createCells(RTL_CELLS, 'rtl'), notCreated); + + expect(points.map((point) => point.x)).toEqual([150, 100, 40, 0]); + }); + }); + + describe('when the points are vertical', () => { + it('should create a point at the top of each row and at the bottom of the last one', () => { + const points = gridCoreUtils.getPointsByColumns(createCells([ + { + left: 0, top: 0, width: 50, height: 20, + }, + { + left: 0, top: 20, width: 50, height: 20, + }, + { + left: 0, top: 40, width: 50, height: 20, + }, + ]), notCreated, true); + + expect(points.map((point) => [point.x, point.y])).toEqual([ + [0, 0], [0, 20], [0, 40], [0, 60], + ]); + }); + }); + + describe('when there is a gap between columns and needToCheckPrevPoint is set', () => { + it('should add a right boundary point after the previous column and mark the next point as a left boundary', () => { + const cells = createCells([ + { + left: 0, top: 10, width: 50, height: 20, + }, + { + left: 80, top: 10, width: 50, height: 20, + }, + ]); + const { points, pointCreated } = createPointCollector(); + + const result = gridCoreUtils.getPointsByColumns(cells, pointCreated, false, 0, true); + + expect(result.map((point) => [point.index, point.x])).toEqual([ + [0, 0], [1, 50], [1, 80], [2, 130], + ]); + const boundaries = points.map( + (point) => [point.x, point.isLeftBoundary, point.isRightBoundary], + ); + + expect(boundaries).toEqual([ + [0, undefined, undefined], + [50, undefined, true], + [80, true, undefined], + [130, undefined, undefined], + ]); + expect(points[1].item).toBe(cells.get(0)); + expect(points[2].item).toBe(cells.get(1)); + expect(points[3].item).toBe(cells.get(1)); + }); + + it('should swap the boundaries in RTL mode', () => { + const cells = createCells([ + { + left: 80, top: 10, width: 50, height: 20, + }, + { + left: 0, top: 10, width: 50, height: 20, + }, + ], 'rtl'); + const { points, pointCreated } = createPointCollector(); + + gridCoreUtils.getPointsByColumns(cells, pointCreated, false, 0, true); + + const boundaries = points.map( + (point) => [point.x, point.isLeftBoundary, point.isRightBoundary], + ); + + expect(boundaries).toEqual([ + [130, undefined, undefined], + [80, true, undefined], + [50, undefined, true], + [0, undefined, undefined], + ]); + }); + }); + + describe('when there are no columns', () => { + it('should create a single point without an element at the origin', () => { + const { points, pointCreated } = createPointCollector(); + + const result = gridCoreUtils.getPointsByColumns(createCells([]), pointCreated); + + expect(result).toEqual([{ + index: 0, columnIndex: 0, x: 0, y: 0, + }]); + expect(points[0].item).toBeUndefined(); + }); + }); +}); + +describe('normalizeLookupDataSource', () => { + describe('when the lookup has items', () => { + it('should use the items instead of the data source', async () => { + const options = gridCoreUtils.normalizeLookupDataSource({ + items: LOOKUP_ITEMS, + dataSource: [{ id: 9, text: 'Nine' }], + }); + + await expect(toPromise(options.store.load())).resolves.toEqual(LOOKUP_ITEMS); + }); + }); + + describe('when the lookup has a data source array', () => { + it('should use the array as the store data', async () => { + const options = gridCoreUtils.normalizeLookupDataSource({ dataSource: LOOKUP_ITEMS }); + + await expect(toPromise(options.store.load())).resolves.toEqual(LOOKUP_ITEMS); + }); + }); + + describe('when the data source is a function', () => { + it('should call it with an empty object and use its result', async () => { + const dataSource = jest.fn<(options: object) => typeof LOOKUP_ITEMS>(() => LOOKUP_ITEMS); + + const options = gridCoreUtils.normalizeLookupDataSource({ dataSource }); + + expect(dataSource).toHaveBeenCalledWith({}); + await expect(toPromise(options.store.load())).resolves.toEqual(LOOKUP_ITEMS); + }); + }); + + describe('when the data source function is a wrapped variable', () => { + afterEach(() => { + variableWrapper.resetInjection(); + }); + + it('should not call it', () => { + const dataSource = jest.fn<(options: object) => typeof LOOKUP_ITEMS>(() => LOOKUP_ITEMS); + variableWrapper.inject({ isWrapped: (value: unknown) => value === dataSource }); + + gridCoreUtils.normalizeLookupDataSource({ dataSource }); + + expect(dataSource).not.toHaveBeenCalled(); + }); + }); +}); + +describe('getWrappedLookupDataSource', () => { + describe('when there is no data source adapter', () => { + it('should return an empty array', () => { + const lookupDataSource = gridCoreUtils.getWrappedLookupDataSource( + createLookupColumn(), + null, + null, + ); + + expect(lookupDataSource).toEqual([]); + }); + }); + + describe('when the column calculates its own cell value', () => { + it('should return the lookup data source as it is', async () => { + const { dataSourceAdapter, customLoad } = createDataSourceAdapter([{ key: 1 }]); + const column = createLookupColumn({ calculateCellValue: () => 1 }); + + const lookupDataSource = gridCoreUtils.getWrappedLookupDataSource( + column, + dataSourceAdapter, + null, + ) as NormalizedDataSourceOptions; + + expect(lookupDataSource).not.toHaveProperty('byKey'); + await expect(toPromise(lookupDataSource.store.load())).resolves.toEqual(LOOKUP_ITEMS); + expect(customLoad).not.toHaveBeenCalled(); + }); + }); + + describe('when the lookup items are loaded', () => { + it('should load only the items whose keys are used in the grid', async () => { + const { dataSourceAdapter, customLoad } = createDataSourceAdapter([{ key: 1 }, { key: 3 }]); + const filter: DataFilter = ['categoryId', '>', 0]; + + const lookupDataSource = gridCoreUtils.getWrappedLookupDataSource( + createLookupColumn(), + dataSourceAdapter, + filter, + ) as WrappedLookupDataSource; + + await expect(toPromise(lookupDataSource.load({}))).resolves.toEqual([ + { id: 1, text: 'One' }, + { id: 3, text: 'Three' }, + ]); + expect(customLoad).toHaveBeenCalledWith({ + filter, + group: [{ selector: 'categoryId', isExpanded: false }], + take: undefined, + skip: undefined, + }); + }); + + it('should group by the display field too when it is set', async () => { + const { dataSourceAdapter, customLoad } = createDataSourceAdapter([{ key: 1 }]); + + const lookupDataSource = gridCoreUtils.getWrappedLookupDataSource( + createLookupColumn({ displayField: 'categoryName' }), + dataSourceAdapter, + null, + ) as WrappedLookupDataSource; + await toPromise(lookupDataSource.load({})); + + expect(customLoad).toHaveBeenCalledWith(expect.objectContaining({ + group: [ + { selector: 'categoryId', isExpanded: true }, + { selector: 'categoryName', isExpanded: false }, + ], + })); + }); + + it('should resolve an empty array when no keys are used', async () => { + const { dataSourceAdapter } = createDataSourceAdapter([]); + + const lookupDataSource = gridCoreUtils.getWrappedLookupDataSource( + createLookupColumn(), + dataSourceAdapter, + null, + ) as WrappedLookupDataSource; + + await expect(toPromise(lookupDataSource.load({}))).resolves.toEqual([]); + }); + }); + + describe('when the items are loaded again without group paging', () => { + it('should take the keys from the cache and slice them', async () => { + const { dataSourceAdapter, customLoad } = createDataSourceAdapter([{ key: 1 }, { key: 3 }]); + + const lookupDataSource = gridCoreUtils.getWrappedLookupDataSource( + createLookupColumn(), + dataSourceAdapter, + null, + ) as WrappedLookupDataSource; + await toPromise(lookupDataSource.load({})); + const items = await toPromise(lookupDataSource.load({ skip: 1, take: 1 })); + + expect(items).toEqual([{ id: 3, text: 'Three' }]); + expect(customLoad).toHaveBeenCalledTimes(1); + }); + }); + + describe('when the items are loaded again with group paging', () => { + it('should pass the page to the loader and use the cache for the same page only', async () => { + const { dataSourceAdapter, customLoad } = createDataSourceAdapter( + [{ key: 1 }, { key: 3 }], + true, + ); + + const lookupDataSource = gridCoreUtils.getWrappedLookupDataSource( + createLookupColumn(), + dataSourceAdapter, + null, + ) as WrappedLookupDataSource; + await toPromise(lookupDataSource.load({ skip: 0, take: 2 })); + await toPromise(lookupDataSource.load({ skip: 0, take: 2 })); + + expect(customLoad).toHaveBeenCalledTimes(1); + expect(customLoad).toHaveBeenCalledWith(expect.objectContaining({ skip: 0, take: 2 })); + + await toPromise(lookupDataSource.load({ skip: 2, take: 2 })); + + expect(customLoad).toHaveBeenCalledTimes(2); + expect(customLoad).toHaveBeenLastCalledWith(expect.objectContaining({ skip: 2, take: 2 })); + }); + }); + + describe('when an item is requested by key', () => { + it('should resolve the item when its key is used in the grid', async () => { + const { dataSourceAdapter } = createDataSourceAdapter([{ key: 1 }, { key: 3 }]); + + const lookupDataSource = gridCoreUtils.getWrappedLookupDataSource( + createLookupColumn(), + dataSourceAdapter, + null, + ) as WrappedLookupDataSource; + + await expect(lookupDataSource.byKey(3)).resolves.toEqual({ id: 3, text: 'Three' }); + await expect(lookupDataSource.byKey(2)).resolves.toBeUndefined(); + }); + }); +}); 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 851c82ff8a93..c85d1ef39abb 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 @@ -4,6 +4,8 @@ import type { dxElementWrapper } from '@js/core/renderer'; import type { Callback } from '@js/core/utils/callbacks'; import type { DeferredObj } from '@js/core/utils/deferred'; import type { Properties as DataGridProperties } from '@js/ui/data_grid'; +import type { StoreLoadOptions } from '@ts/data/data_source/types'; +import type { SortingInfo } from '@ts/data/utils'; import type { RawItemData } from '@ts/grids/grid_core/data_source_adapter/types'; import type { DataFilter } from '../filter/types'; @@ -154,14 +156,29 @@ export interface ColumnDataSourceParameter { isExpanded?: boolean; } +export type SortParameters = StoreLoadOptions['sort'] + | ColumnDataSourceParameter[] + | SortingInfo[] + | null; + +export type GroupParameters = StoreLoadOptions['group'] + | ColumnDataSourceParameter[] + | SortingInfo[] + | null; + export interface ColumnsDataSourceParameters { - sorting: unknown; - grouping: unknown; + sorting: SortParameters; + grouping: GroupParameters; filtering?: DataFilter; } export type GroupColumn = Column & { groupIndex: number }; +export type DisplayValueColumn = Pick< + Column, + 'displayValueMap' | 'calculateDisplayValue' | 'type' | 'calculateGroupValue' +> & { lookup?: Pick, 'calculateCellValue'> }; + export interface ColumnsChanges { changeTypes: { sorting?: boolean; diff --git a/packages/devextreme/js/__internal/grids/grid_core/header_filter/__tests__/utils.test.ts b/packages/devextreme/js/__internal/grids/grid_core/header_filter/__tests__/utils.test.ts index 8beb0d1be88f..519eab026c7c 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/header_filter/__tests__/utils.test.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/header_filter/__tests__/utils.test.ts @@ -1,8 +1,14 @@ -import { describe, expect, it } from '@jest/globals'; +import { + describe, + expect, + it, + jest, +} from '@jest/globals'; import filterUtils from '@js/ui/shared/filtering'; -import gridCoreUtils from '../../m_utils'; import { getFormatOptions } from '../m_header_filter'; +import type { HeaderFilterGroupSelector } from '../types'; +import { getHeaderFilterGroupParameters } from '../utils'; describe('getGroupInterval', () => { describe('headerFilter.groupInterval normalization', () => { @@ -66,26 +72,30 @@ describe('getGroupInterval', () => { }); }); +type HeaderFilterColumn = Parameters[0]; + +const asHeaderFilterColumn = (column: object): HeaderFilterColumn => column as HeaderFilterColumn; + describe('getHeaderFilterGroupParameters', () => { it('builds one remote group parameter per level, expanding all but the last', () => { - const column = { + const column = asHeaderFilterColumn({ dataField: 'Price', dataType: 'number', headerFilter: { groupInterval: [100, 10] }, - }; + }); - expect(gridCoreUtils.getHeaderFilterGroupParameters(column, true)).toEqual([ + expect(getHeaderFilterGroupParameters(column, true)).toEqual([ { selector: 'Price', groupInterval: 100, isExpanded: true }, { selector: 'Price', groupInterval: 10, isExpanded: false }, ]); }); it('supports an arbitrary number of levels', () => { - const column = { + const column = asHeaderFilterColumn({ dataField: 'Population', dataType: 'number', headerFilter: { groupInterval: [100000, 10000, 1000] }, - }; + }); - expect(gridCoreUtils.getHeaderFilterGroupParameters(column, true)).toEqual([ + expect(getHeaderFilterGroupParameters(column, true)).toEqual([ { selector: 'Population', groupInterval: 100000, isExpanded: true }, { selector: 'Population', groupInterval: 10000, isExpanded: true }, { selector: 'Population', groupInterval: 1000, isExpanded: false }, @@ -93,20 +103,76 @@ describe('getHeaderFilterGroupParameters', () => { }); it('builds a local interval selector per level that buckets the cell value', () => { - const column = { + const column = asHeaderFilterColumn({ dataField: 'Price', dataType: 'number', headerFilter: { groupInterval: [100, 10] }, - calculateCellValue: (data) => data.Price, - }; + calculateCellValue: (data: { Price: number }) => data.Price, + }); - const params = gridCoreUtils.getHeaderFilterGroupParameters(column); + const params = getHeaderFilterGroupParameters( + column, + ) as HeaderFilterGroupSelector[]; expect(params).toHaveLength(2); // 753 -> floor(753 / 100) * 100 on the outer level, floor(753 / 10) * 10 on the inner level expect(params[0]({ Price: 753 })).toBe(700); expect(params[1]({ Price: 753 })).toBe(750); }); + + describe('when the column has no group interval', () => { + it('should group remotely by the data field', () => { + const column = asHeaderFilterColumn({ dataField: 'Name', dataType: 'string' }); + + expect(getHeaderFilterGroupParameters(column, true)).toEqual([ + { selector: 'Name', isExpanded: false }, + ]); + }); + + it('should group remotely by the column name when there is no data field', () => { + const column = asHeaderFilterColumn({ name: 'nameColumn', dataType: 'string' }); + + expect(getHeaderFilterGroupParameters(column, true)).toEqual([ + { selector: 'nameColumn', isExpanded: false }, + ]); + }); + + it('should return a local selector that turns empty values into null', () => { + const column = asHeaderFilterColumn({ + dataField: 'Name', + dataType: 'string', + calculateCellValue: (data: { Name?: unknown }) => data.Name, + }); + + const selector = getHeaderFilterGroupParameters( + column, + ) as HeaderFilterGroupSelector; + + expect(selector({ Name: 'a' })).toBe('a'); + expect(selector({ Name: 0 })).toBe(0); + expect(selector({ Name: '' })).toBeNull(); + expect(selector({})).toBeNull(); + }); + + it('should pair the local selector with the bound sorting method', () => { + const sortingMethod = jest.fn((value1: number, value2: number) => value1 - value2); + const column = asHeaderFilterColumn({ + dataField: 'Name', + dataType: 'string', + calculateCellValue: (data: { Name?: unknown }) => data.Name, + sortingMethod, + }); + + const [descriptor] = getHeaderFilterGroupParameters(column) as { + selector: HeaderFilterGroupSelector; + compare: (value1: unknown, value2: unknown) => number; + }[]; + + expect(descriptor.selector({ Name: '' })).toBeNull(); + expect(descriptor.compare(3, 1)).toBe(2); + expect(sortingMethod.mock.contexts[0]).toBe(column); + }); + }); }); describe('getFormatOptions', () => { 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 5618e3192fca..ea1992f1f0c9 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 @@ -30,7 +30,11 @@ import { HeaderFilterView, updateHeaderFilterItemSelectionState, } from './m_header_filter_core'; -import { allowHeaderFiltering, createHeaderFilterExpressions } from './utils'; +import { + allowHeaderFiltering, + createHeaderFilterExpressions, + getHeaderFilterGroupParameters, +} from './utils'; const DATE_INTERVAL_FORMATS = { year(value) { @@ -83,7 +87,7 @@ function ungroupUTCDates(items, dateParts?, dates?) { export function convertDataFromUTCToLocal(data, column) { const dates = ungroupUTCDates(data); const query = dataQuery(dates); - const group = gridCoreUtils.getHeaderFilterGroupParameters({ + const group = getHeaderFilterGroupParameters({ ...column, calculateCellValue: (date) => date, }); @@ -251,7 +255,7 @@ export class HeaderFilterController extends Modules.ViewController { public getDataSource(column) { const dataSourceAdapter = this.dataSourceController.getAdapter(); const remoteGrouping = this.dataSourceController.remoteOperations().grouping; - const group = gridCoreUtils.getHeaderFilterGroupParameters(column, remoteGrouping); + const group = getHeaderFilterGroupParameters(column, remoteGrouping); const headerFilterDataSource = column.headerFilter?.dataSource; const headerFilterOptions = this.option('headerFilter'); let isLookup = false; diff --git a/packages/devextreme/js/__internal/grids/grid_core/header_filter/types.ts b/packages/devextreme/js/__internal/grids/grid_core/header_filter/types.ts new file mode 100644 index 000000000000..757d7edb541d --- /dev/null +++ b/packages/devextreme/js/__internal/grids/grid_core/header_filter/types.ts @@ -0,0 +1,14 @@ +export type EmptyDateValue = '' | false; + +export type HeaderFilterGroupSelector = (data: unknown) => unknown; + +export interface HeaderFilterGroupDescriptor { + selector: string | HeaderFilterGroupSelector | undefined; + groupInterval?: string | number; + isExpanded?: boolean; + compare?: (value1: unknown, value2: unknown) => number; +} + +export type HeaderFilterGroupItem = HeaderFilterGroupDescriptor | HeaderFilterGroupSelector; + +export type HeaderFilterGroup = HeaderFilterGroupSelector | HeaderFilterGroupItem[]; diff --git a/packages/devextreme/js/__internal/grids/grid_core/header_filter/utils.ts b/packages/devextreme/js/__internal/grids/grid_core/header_filter/utils.ts index a0ee2a525761..e92eb9e51bc7 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/header_filter/utils.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/header_filter/utils.ts @@ -1,8 +1,14 @@ +import type { HeaderFilterGroupInterval } from '@js/common/grids'; +import { each } from '@js/core/utils/iterator'; +import { isDefined, isString } from '@js/core/utils/type'; +import sharedFiltering from '@js/ui/shared/filtering'; import type { Column } from '@ts/grids/grid_core/columns_controller/types'; import type { DataFilter } from '@ts/grids/grid_core/filter/types'; import { combineFilters, isColumnExcluded } from '@ts/grids/grid_core/filter/utils'; import gridCoreUtils from '@ts/grids/grid_core/m_utils'; +import type { EmptyDateValue, HeaderFilterGroup, HeaderFilterGroupItem } from './types'; + export const allowHeaderFiltering = ( column: Column, ): boolean | undefined => column.allowHeaderFiltering ?? column.allowFiltering; @@ -62,3 +68,92 @@ export const createHeaderFilterExpressions = ( && hasHeaderFilterValues(column) && !isColumnExcluded(column, excludedColumn)) .map(createHeaderFilterExpression); + +const DATE_INTERVAL_SELECTORS = { + year(value: Date | EmptyDateValue): number | EmptyDateValue { + return value && value.getFullYear(); + }, + month(value: Date | EmptyDateValue): number | EmptyDateValue { + return value && (value.getMonth() + 1); + }, + day(value: Date | EmptyDateValue): number | EmptyDateValue { + return value && value.getDate(); + }, + quarter(value: Date | EmptyDateValue): number | EmptyDateValue { + return value && (Math.floor(value.getMonth() / 3) + 1); + }, + hour(value: Date | EmptyDateValue): number | EmptyDateValue { + return value && value.getHours(); + }, + minute(value: Date | EmptyDateValue): number | EmptyDateValue { + return value && value.getMinutes(); + }, + second(value: Date | EmptyDateValue): number | EmptyDateValue { + return value && value.getSeconds(); + }, +}; + +const getIntervalSelector = function getIntervalSelector( + this: Column & Required>, + interval: HeaderFilterGroupInterval | number, + data: unknown, +): number | EmptyDateValue | null | undefined { + const value = this.calculateCellValue(data); + + if (!isDefined(value)) { + return null; + } + + if (gridCoreUtils.isDateType(this.dataType) && isString(interval)) { + return DATE_INTERVAL_SELECTORS[interval](value); + } + + if (this.dataType === 'number') { + const groupInterval = Number(interval); + return Math.floor(Number(value) / groupInterval) * groupInterval; + } + + return undefined; +}; + +export function getHeaderFilterGroupParameters( + column: Column & Required>, + remoteGrouping?: boolean, +): HeaderFilterGroup { + const dataField = column.dataField || column.name; + const groupInterval = sharedFiltering.getGroupInterval(column); + + if (groupInterval) { + const result: HeaderFilterGroupItem[] = []; + + each(groupInterval, (index, interval) => { + result.push(remoteGrouping ? { + selector: dataField, + groupInterval: interval, + isExpanded: index < groupInterval.length - 1, + // @ts-expect-error getGroupInterval() types interval names and numbers as string[] + } : getIntervalSelector.bind(column, interval)); + }); + + return result; + } + + if (remoteGrouping) { + return [{ selector: dataField, isExpanded: false }]; + } + + const selector = (data: unknown): unknown => { + const cellValue = column.calculateCellValue(data); + + if (cellValue === undefined || cellValue === '') { + return null; + } + return cellValue; + }; + + if (column.sortingMethod) { + return [{ selector, compare: column.sortingMethod.bind(column) }]; + } + + return selector; +} diff --git a/packages/devextreme/js/__internal/grids/grid_core/m_utils.ts b/packages/devextreme/js/__internal/grids/grid_core/m_utils.ts index e907c9f36246..50a485efe27d 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/m_utils.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/m_utils.ts @@ -2,14 +2,18 @@ import eventsEngine from '@js/common/core/events/core/events_engine'; import dateLocalization from '@js/common/core/localization/date'; +import type { GroupDescriptor } from '@js/common/data'; import DataSource from '@js/common/data/data_source'; import { normalizeDataSourceOptions } from '@js/common/data/data_source/utils'; import { normalizeSortingInfo as normalizeSortingInfoUtility } from '@js/common/data/utils'; +import { data as elementData } from '@js/core/element_data'; +import type { dxElementWrapper } from '@js/core/renderer'; import $ from '@js/core/renderer'; +import type { BrowserInfo } from '@js/core/utils/browser'; import { equalByValue } from '@js/core/utils/common'; +import type { DeferredObj } from '@js/core/utils/deferred'; import { Deferred, when } from '@js/core/utils/deferred'; import { extend } from '@js/core/utils/extend'; -import { each } from '@js/core/utils/iterator'; import { getBoundingRect } from '@js/core/utils/position'; import { getHeight, getInnerWidth, getOuterWidth } from '@js/core/utils/size'; import { format } from '@js/core/utils/string'; @@ -19,16 +23,43 @@ import { getWindow } from '@js/core/utils/window'; import formatHelper from '@js/format_helper'; import type { Format } from '@js/localization'; import LoadPanel from '@js/ui/load_panel'; -import sharedFiltering from '@js/ui/shared/filtering'; import { getGlobalFormatByDataType } from '@ts/core/global_format_config'; import { isNumeric } from '@ts/core/utils/m_type'; -import type { Column } from '@ts/grids/grid_core/columns_controller/types'; -import type { ColumnPoint, SelectionRange } from '@ts/grids/grid_core/types'; +import type { NormalizedDataSourceOptions, StoreLoadOptions } from '@ts/data/data_source/types'; +import type { SortingInfo } from '@ts/data/utils'; +import type { + Column, + ColumnsChanges, + DisplayValueColumn, + GroupParameters, + SortParameters, + TemplateModel, +} from '@ts/grids/grid_core/columns_controller/types'; +import type { + ColumnPoint, + ColumnPointProps, + ExpandCellTemplate, + ExpandCellTemplateOptions, + FormatOptions, + LoadPanelPosition, + LookupDataSource, + OptionsReader, + RowKey, + SelectionRange, + SummaryTextItem, + TextSelectionElement, + WidgetElementData, + WrappedLookupDataSource, +} from '@ts/grids/grid_core/types'; import { AI_COLUMN_NAME } from './ai_column/const'; import type DataSourceAdapter from './data_source_adapter/data_source_adapter'; +import type { RawItemData } from './data_source_adapter/types'; +import type { DataFilter } from './filter/types'; import { combineFilters } from './filter/utils'; +import type { ModuleItem } from './modules/modules'; import { isEqualSelectors, isSelectorEqualWithCallback } from './utils/index'; +import type { ColumnWidth } from './views/types'; const BASE_LOAD_PANEL_Z_INDEX = 1000; @@ -42,29 +73,6 @@ const SCROLLING_MODE_VIRTUAL = 'virtual'; const LEGACY_SCROLLING_MODE = 'scrolling.legacyMode'; const SCROLLING_MODE_OPTION = 'scrolling.mode'; const ROW_RENDERING_MODE_OPTION = 'scrolling.rowRenderingMode'; -const DATE_INTERVAL_SELECTORS = { - year(value) { - return value && value.getFullYear(); - }, - month(value) { - return value && (value.getMonth() + 1); - }, - day(value) { - return value && value.getDate(); - }, - quarter(value) { - return value && (Math.floor(value.getMonth() / 3) + 1); - }, - hour(value) { - return value && value.getHours(); - }, - minute(value) { - return value && value.getMinutes(); - }, - second(value) { - return value && value.getSeconds(); - }, -}; const DEFAULT_COLUMN_WIDTH = 50; @@ -72,21 +80,6 @@ export function isDateType(dataType: string | undefined): boolean { return dataType === 'date' || dataType === 'datetime'; } -const getIntervalSelector = function () { - const data = arguments[1]; - const value = this.calculateCellValue(data); - - if (!isDefined(value)) { - return null; - } if (isDateType(this.dataType)) { - const nameIntervalSelector = arguments[0]; - return DATE_INTERVAL_SELECTORS[nameIntervalSelector](value); - } if (this.dataType === 'number') { - const groupInterval = arguments[0]; - return Math.floor(Number(value) / groupInterval) * groupInterval; - } -}; - const getGlobalFormat = (dataType: string): Format | undefined => { const globalFormat = getGlobalFormatByDataType(dataType); @@ -104,27 +97,39 @@ const getGlobalFormat = (dataType: string): Format | undefined => { : globalFormat; }; -const setEmptyText = function ($container) { +const setEmptyText = ($container: dxElementWrapper): void => { $container.get(0).textContent = '\u00A0'; }; -const normalizeSortingInfo = function (sort) { - sort = sort || []; - const result = normalizeSortingInfoUtility(sort); +const normalizeSortingInfo = (sort: SortParameters | GroupParameters): SortingInfo[] => { + const sortItems = sort || []; + const result = normalizeSortingInfoUtility(sortItems); - for (let i = 0; i < sort.length; i++) { - if (sort && sort[i] && sort[i].isExpanded !== undefined) { - result[i].isExpanded = sort[i].isExpanded; - } - if (sort && sort[i] && sort[i].groupInterval !== undefined) { - result[i].groupInterval = sort[i].groupInterval; + if (!Array.isArray(sortItems)) { + return result; + } + + for (let i = 0; i < sortItems.length; i += 1) { + const item = sortItems[i]; + + if (item && typeof item === 'object') { + const { isExpanded, groupInterval }: Partial = item; + + if (isExpanded !== undefined) { + result[i].isExpanded = isExpanded; + } + if (groupInterval !== undefined) { + result[i].groupInterval = groupInterval; + } } } return result; }; -const formatValue = function (value, options) { - const valueText = formatHelper.format(value, options.format) || (value && value.toString()) || ''; +const formatValue = (value: unknown, options: FormatOptions): string => { + // @ts-expect-error typings of format() and of this || chain expect a primitive value + // eslint-disable-next-line @typescript-eslint/no-base-to-string -- the value can be of any type + const valueText: string = formatHelper.format(value, options.format) || (value && value.toString()) || ''; const formatObject = { value, valueText: options.getDisplayFormat ? options.getDisplayFormat(valueText) : valueText, @@ -132,27 +137,36 @@ const formatValue = function (value, options) { groupInterval: options.groupInterval, }; - return options.customizeText ? options.customizeText.call(options, formatObject) : formatObject.valueText; + return options.customizeText + ? options.customizeText.call(options, formatObject) + : formatObject.valueText; }; -const getSummaryText = function (summaryItem, summaryTexts) { +const getSummaryText = ( + summaryItem: SummaryTextItem, + summaryTexts: Record, +): string => { const displayFormat = summaryItem.displayFormat || (summaryItem.columnCaption && summaryTexts[`${summaryItem.summaryType}OtherColumn`]) || summaryTexts[summaryItem.summaryType]; return formatValue(summaryItem.value, { format: summaryItem.valueFormat, getDisplayFormat(valueText) { - return displayFormat ? format(displayFormat, valueText, summaryItem.columnCaption) : valueText; + return displayFormat + ? format(displayFormat, valueText, summaryItem.columnCaption) + : valueText; }, customizeText: summaryItem.customizeText, }); }; -const getWidgetInstance = function ($element) { - const editorData = $element?.data(); +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- callers use their editor's API +const getWidgetInstance = ($element: dxElementWrapper | undefined): any => { + const element = $element?.get(0); + const editorData: WidgetElementData | undefined = element && elementData(element); const dxComponents = editorData?.dxComponents; const widgetName = dxComponents?.[0]; - return widgetName && editorData[widgetName]; + return widgetName && editorData?.[widgetName]; }; const createPoint = (options: T): ColumnPoint => ({ @@ -162,7 +176,11 @@ const createPoint = (options: T): ColumnPoint => ({ y: options.y, }); -const addPointIfNeed = (points: ColumnPoint[], pointProps: T, pointCreated: (point: T) => boolean): void => { +const addPointIfNeed = ( + points: ColumnPoint[], + pointProps: T, + pointCreated: (point: T) => boolean, +): void => { let notCreatePoint = false; if (pointCreated) { @@ -176,6 +194,20 @@ const addPointIfNeed = (points: ColumnPoint[], pointProp } }; +const markBoundaryPoints = ( + point: ColumnPointProps, + prevPoint: ColumnPointProps, + rtlEnabled: boolean, +): void => { + if (rtlEnabled) { + point.isRightBoundary = true; + prevPoint.isLeftBoundary = true; + } else { + point.isLeftBoundary = true; + prevPoint.isRightBoundary = true; + } +}; + const getColumnWidths = (columns: Column[]): number[] => columns .map((column) => { // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing @@ -184,16 +216,16 @@ const getColumnWidths = (columns: Column[]): number[] => columns return isNumeric(width) ? parseFloat(width as string) : DEFAULT_COLUMN_WIDTH; }); -function normalizeGroupingLoadOptions(group) { - if (!Array.isArray(group)) { - group = [group]; - } +function normalizeGroupingLoadOptions( + group: GroupDescriptor | GroupDescriptor[], +): GroupDescriptor[] { + const groups = Array.isArray(group) ? group : [group]; - return group.map((item, i) => { + return groups.map((item, i) => { if (isString(item)) { return { selector: item, - isExpanded: i < group.length - 1, + isExpanded: i < groups.length - 1, }; } @@ -201,19 +233,31 @@ function normalizeGroupingLoadOptions(group) { }); } +const getLookupDataSourceOptions = (lookup: NonNullable): unknown => { + if (lookup.items) { + return lookup.items; + } + + const { dataSource } = lookup; + + return isFunction(dataSource) && !variableWrapper.isWrapped(dataSource) + ? dataSource({}) + : dataSource; +}; + export default { - renderNoDataText($element) { - const that = this; - $element = $element || this.element(); + renderNoDataText($container?: dxElementWrapper): void { + const $element = $container || this.element(); if (!$element) { return; } - const noDataClass = that.addWidgetPrefix(NO_DATA_CLASS); + const noDataClass = this.addWidgetPrefix(NO_DATA_CLASS); let noDataElement = $element.find(`.${noDataClass}`).last(); const isVisible = this._dataController.isEmpty(); - const isDefaultLoading = this._dataController.isLoading() && !this._dataController.isCustomLoading?.(); + const isDefaultLoading = this._dataController.isLoading() + && !this._dataController.isCustomLoading?.(); if (!noDataElement.length) { noDataElement = $('') @@ -227,19 +271,22 @@ export default { if (isVisible && !isDefaultLoading) { noDataElement .removeClass('dx-hidden') - .text(that._getNoDataText()); + .text(this._getNoDataText()); } else { noDataElement .addClass('dx-hidden'); } }, - renderLoadPanel($element, $container, isLocalStore) { - const that = this; - let loadPanelOptions; - - that._loadPanel && that._loadPanel.$element().remove(); - loadPanelOptions = that.option('loadPanel'); + renderLoadPanel( + $element: dxElementWrapper, + $container: dxElementWrapper, + isLocalStore?: boolean, + ): void { + if (this._loadPanel) { + this._loadPanel.$element().remove(); + } + let loadPanelOptions = this.option('loadPanel'); if (loadPanelOptions && (loadPanelOptions.enabled === 'auto' ? !isLocalStore : loadPanelOptions.enabled)) { loadPanelOptions = extend({ @@ -249,13 +296,13 @@ export default { zIndex: BASE_LOAD_PANEL_Z_INDEX, }, loadPanelOptions); - that._loadPanel = that._createComponent($('
').appendTo($container), LoadPanel, loadPanelOptions); + this._loadPanel = this._createComponent($('
').appendTo($container), LoadPanel, loadPanelOptions); } else { - that._loadPanel = null; + this._loadPanel = null; } }, - calculateLoadPanelPosition($element) { + calculateLoadPanelPosition($element: dxElementWrapper | undefined): LoadPanelPosition { const $window = $(getWindow()); if (getHeight($element) > getHeight($window)) { return { @@ -267,13 +314,17 @@ export default { return { of: $element }; }, - getIndexByKey(key, items, keyName?) { + getIndexByKey( + key: RowKey, + items: unknown[] | null | undefined, + keyName?: string | string[] | null, + ): number { let index = -1; if (key !== undefined && Array.isArray(items)) { - keyName = arguments.length <= 2 ? 'key' : keyName; - for (let i = 0; i < items.length; i++) { - const item = isDefined(keyName) ? items[i][keyName] : items[i]; + const keyField = arguments.length <= 2 ? 'key' : keyName; + for (let i = 0; i < items.length; i += 1) { + const item = isDefined(keyField) ? (items[i] as RawItemData)[String(keyField)] : items[i]; if (equalByValue(key, item)) { index = i; @@ -285,12 +336,12 @@ export default { return index; }, - checkChanges(changes, changeNames) { + checkChanges(changes: ColumnsChanges['optionNames'], changeNames: string[]): number | boolean { let changesWithChangeNamesCount = 0; - for (let i = 0; i < changeNames.length; i++) { - if (changes[changeNames[i]]) { - changesWithChangeNamesCount++; + for (const changeName of changeNames) { + if (changes[changeName]) { + changesWithChangeNamesCount += 1; } } @@ -299,7 +350,10 @@ export default { formatValue, - getFormatOptionsByColumn(column, target) { + getFormatOptionsByColumn( + column: Column & Pick, + target: string, + ): FormatOptions { return { format: column.format, getDisplayFormat: column.getDisplayFormat, @@ -310,11 +364,18 @@ export default { }; }, - getDisplayValue(column, value, data, rowType?) { - if (column.displayValueMap && column.displayValueMap[value] !== undefined) { - return column.displayValueMap[value]; + getDisplayValue( + column: DisplayValueColumn, + value: unknown, + data: RawItemData | null | undefined, + rowType?: string, + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- callers use it as their own type + ): any { + if (column.displayValueMap?.[String(value)] !== undefined) { + return column.displayValueMap[String(value)]; } if (column.calculateDisplayValue && data && rowType !== 'group') { + // @ts-expect-error after columnOption it can still be a string, and the call throws return column.calculateDisplayValue(data); } @@ -323,18 +384,22 @@ export default { && (rowType !== 'group' || (!column.calculateGroupValue && !column.calculateDisplayValue)); if (isCalculatedFromLookup) { + // @ts-expect-error the lookup is checked above and gets calculateCellValue on init return column.lookup.calculateCellValue(value); } return value; }, - getGroupRowSummaryText(summaryItems, summaryTexts) { + getGroupRowSummaryText( + summaryItems: SummaryTextItem[], + summaryTexts: Record | undefined, + ): string { let result = '('; - for (let i = 0; i < summaryItems.length; i++) { + for (let i = 0; i < summaryItems.length; i += 1) { const summaryItem = summaryItems[i]; - result += (i > 0 ? ', ' : '') + getSummaryText(summaryItem, summaryTexts); + result += (i > 0 ? ', ' : '') + getSummaryText(summaryItem, summaryTexts ?? {}); } // eslint-disable-next-line no-return-assign return result += ')'; @@ -344,7 +409,7 @@ export default { normalizeSortingInfo, - getFormatByDataType(dataType) { + getFormatByDataType(dataType: string | undefined): Format | undefined { switch (dataType) { case 'date': return getGlobalFormat('date') || 'shortDate'; @@ -355,75 +420,59 @@ export default { } }, - getHeaderFilterGroupParameters(column, remoteGrouping?) { - let result: any = []; - const dataField = column.dataField || column.name; - const groupInterval = sharedFiltering.getGroupInterval(column); - - if (groupInterval) { - each(groupInterval, (index, interval) => { - result.push(remoteGrouping ? { - selector: dataField, - groupInterval: interval, - isExpanded: index < groupInterval.length - 1, - // @ts-ignore - } : getIntervalSelector.bind(column, interval)); - }); - - return result; - } - - if (remoteGrouping) { - result = [{ selector: dataField, isExpanded: false }]; - } else { - result = function (data) { - let result = column.calculateCellValue(data); - - if (result === undefined || result === '') { - result = null; - } - return result; - }; - - if (column.sortingMethod) { - result = [{ selector: result, compare: column.sortingMethod.bind(column) }]; - } - } + equalSortParameters( + sortParameters1: SortParameters | GroupParameters, + sortParameters2: SortParameters | GroupParameters, + ignoreIsExpanded?: boolean, + ): boolean { + const sortInfo1 = normalizeSortingInfo(sortParameters1); + const sortInfo2 = normalizeSortingInfo(sortParameters2); - return result; - }, - - equalSortParameters(sortParameters1, sortParameters2, ignoreIsExpanded?) { - sortParameters1 = normalizeSortingInfo(sortParameters1); - sortParameters2 = normalizeSortingInfo(sortParameters2); - - if (Array.isArray(sortParameters1) && Array.isArray(sortParameters2)) { - if (sortParameters1.length !== sortParameters2.length) { + if (Array.isArray(sortInfo1) && Array.isArray(sortInfo2)) { + if (sortInfo1.length !== sortInfo2.length) { return false; } - for (let i = 0; i < sortParameters1.length; i++) { - if (!isEqualSelectors(sortParameters1[i].selector, sortParameters2[i].selector) || sortParameters1[i].desc !== sortParameters2[i].desc || sortParameters1[i].groupInterval !== sortParameters2[i].groupInterval || (!ignoreIsExpanded && Boolean(sortParameters1[i].isExpanded) !== Boolean(sortParameters2[i].isExpanded))) { + for (let i = 0; i < sortInfo1.length; i += 1) { + const sortItem1 = sortInfo1[i]; + const sortItem2 = sortInfo2[i]; + const isSameSelector = isEqualSelectors(sortItem1.selector, sortItem2.selector); + const isSameSortOrder = sortItem1.desc === sortItem2.desc; + const isSameGroupInterval = sortItem1.groupInterval === sortItem2.groupInterval; + const isSameExpandState = Boolean(sortItem1.isExpanded) === Boolean(sortItem2.isExpanded); + + if ( + !isSameSelector + || !isSameSortOrder + || !isSameGroupInterval + || (!ignoreIsExpanded && !isSameExpandState) + ) { return false; } } return true; } - return (!sortParameters1 || !sortParameters1.length) === (!sortParameters2 || !sortParameters2.length); + return !sortInfo1?.length === !sortInfo2?.length; }, - getPointsByColumns(items, pointCreated: (point: T) => boolean, isVertical = false, startColumnIndex = 0, needToCheckPrevPoint = false): ColumnPoint[] { + getPointsByColumns( + items: dxElementWrapper, + pointCreated: (point: ColumnPointProps) => boolean, + isVertical = false, + startColumnIndex = 0, + needToCheckPrevPoint = false, + ): ColumnPoint[] { const result: ColumnPoint[] = []; const cellsLength: number = items.length; - let $item; let offset: { left: number; top: number } = { left: 0, top: 0 }; let itemRect: { width: number; height: number } = { width: 0, height: 0 }; let columnIndex = startColumnIndex; - let rtlEnabled; + let rtlEnabled = false; - for (let i = 0; i <= cellsLength; i++) { + for (let i = 0; i <= cellsLength; i += 1) { if (i < cellsLength) { - $item = items.eq(i); + const $item = items.eq(i); + // @ts-expect-error offset() of a wrapper with an element is always defined offset = $item.offset(); itemRect = getBoundingRect($item.get(0)); rtlEnabled = $item.css('direction') === 'rtl'; @@ -432,15 +481,16 @@ export default { const offsetRight = offset.left + itemRect.width; const offsetBottom = offset.top + itemRect.height; - const pointProps: any = { + const pointProps: ColumnPointProps = { index: columnIndex, columnIndex, - item: $item?.get(0), + item: items[Math.min(i, cellsLength - 1)], x: !isVertical && rtlEnabled !== (i === cellsLength) ? offsetRight : offset.left, y: isVertical && i === cellsLength ? offsetBottom : offset.top, }; if (!isVertical && i > 0) { + // @ts-expect-error offset() of a wrapper with an element is always defined const prevItemOffset: { left: number; top: number } = items.eq(i - 1).offset(); const { width: prevItemWidth }: { width: number } = getBoundingRect(items[i - 1]); const prevItemOffsetX = rtlEnabled @@ -452,37 +502,35 @@ export default { } if (needToCheckPrevPoint && Math.round(prevItemOffsetX) !== Math.round(pointProps.x)) { - const prevPointProps: any = { + const prevPointProps: ColumnPointProps = { ...pointProps, item: items[i - 1], x: prevItemOffsetX, }; - if (rtlEnabled) { - pointProps.isRightBoundary = true; - prevPointProps.isLeftBoundary = true; - } else { - pointProps.isLeftBoundary = true; - prevPointProps.isRightBoundary = true; - } - + markBoundaryPoints(pointProps, prevPointProps, rtlEnabled); addPointIfNeed(result, prevPointProps, pointCreated); } } addPointIfNeed(result, pointProps, pointCreated); - columnIndex++; + columnIndex += 1; } return result; }, - getExpandCellTemplate() { + getExpandCellTemplate(): ExpandCellTemplate { return { allowRenderToDetachedContainer: true, - render(container, options) { + render(container: dxElementWrapper, model: TemplateModel): void { + const options = model as ExpandCellTemplateOptions; const $container = $(container); - if (isDefined(options.value) && !(options.data && options.data.isContinuation) && !options.row.isNewRow) { + if ( + isDefined(options.value) + && !options.data?.isContinuation + && !options.row.isNewRow + ) { const rowsView = options.component.getView('rowsView'); $container .addClass(DATAGRID_EXPAND_CLASS) @@ -492,7 +540,11 @@ export default { .addClass(options.value ? DATAGRID_GROUP_OPENED_CLASS : DATAGRID_GROUP_CLOSED_CLASS) .appendTo($container); - rowsView.setAria('label', options.value ? rowsView.localize('dxDataGrid-ariaCollapse') : rowsView.localize('dxDataGrid-ariaExpand'), $container); + rowsView.setAria( + 'label', + options.value ? rowsView.localize('dxDataGrid-ariaCollapse') : rowsView.localize('dxDataGrid-ariaExpand'), + $container, + ); } else { setEmptyText($container); } @@ -504,12 +556,16 @@ export default { isDateType, - getSelectionRange(focusedElement): SelectionRange { + getSelectionRange(focusedElement: TextSelectionElement | null | undefined): SelectionRange { try { if (focusedElement) { return { - selectionStart: isNumeric(focusedElement.selectionStart) ? focusedElement.selectionStart : -1, - selectionEnd: isNumeric(focusedElement.selectionEnd) ? focusedElement.selectionEnd : -1, + selectionStart: isNumeric(focusedElement.selectionStart) + ? focusedElement.selectionStart as number + : -1, + selectionEnd: isNumeric(focusedElement.selectionEnd) + ? focusedElement.selectionEnd as number + : -1, }; } } catch (e) { /* empty */ } @@ -520,23 +576,33 @@ export default { }; }, - setSelectionRange(focusedElement, selectionRange: SelectionRange): void { + setSelectionRange( + focusedElement: TextSelectionElement | null | undefined, + selectionRange: SelectionRange, + ): void { try { - if (focusedElement && focusedElement.setSelectionRange && selectionRange.selectionStart >= 0 && selectionRange.selectionEnd >= 0) { - focusedElement.setSelectionRange(selectionRange.selectionStart, selectionRange.selectionEnd); + if ( + focusedElement?.setSelectionRange + && selectionRange.selectionStart >= 0 + && selectionRange.selectionEnd >= 0 + ) { + focusedElement.setSelectionRange( + selectionRange.selectionStart, + selectionRange.selectionEnd, + ); } } catch (e) { /* empty */ } }, - focusAndSelectElement(component, $element) { + focusAndSelectElement(component: ModuleItem, $element: dxElementWrapper): void { const isFocused = $element.is(':focus'); // @ts-expect-error eventsEngine.trigger($element, 'focus'); const isSelectTextOnEditingStart = component.option('editing.selectTextOnEditStart'); - const element = $element.get(0); if (!isFocused && isSelectTextOnEditingStart && $element.is('.dx-texteditor-input') && !$element.is('[readonly]')) { + const element = $element.get(0) as HTMLInputElement | HTMLTextAreaElement; const editor = getWidgetInstance($element.closest('.dx-texteditor')); when(editor && editor._loadItemDeferred).done(() => { @@ -547,11 +613,13 @@ export default { getWidgetInstance, - getLastResizableColumnIndex(columns, resultWidths?) { - const hasResizableColumns = columns.some((column) => column && !column.command && !column.fixed && column.allowResizing !== false); - let lastColumnIndex; + getLastResizableColumnIndex(columns: Column[], resultWidths?: ColumnWidth[]): number { + const hasResizableColumns = columns.some( + (column) => column && !column.command && !column.fixed && column.allowResizing !== false, + ); + let lastColumnIndex = columns.length - 1; - for (lastColumnIndex = columns.length - 1; columns[lastColumnIndex]; lastColumnIndex--) { + for (; columns[lastColumnIndex]; lastColumnIndex -= 1) { const column = columns[lastColumnIndex]; const width = resultWidths && resultWidths[lastColumnIndex]; const allowResizing = !hasResizableColumns || column.allowResizing !== false; @@ -564,8 +632,11 @@ export default { return lastColumnIndex; }, - isElementInCurrentGrid(controller, $element) { - if ($element && $element.length) { + isElementInCurrentGrid( + controller: ModuleItem, + $element: dxElementWrapper | null | undefined, + ): boolean { + if ($element?.length) { const $grid = $element.closest(`.${controller.getWidgetContainerClass()}`).parent(); return $grid.is(controller.component.$element()); @@ -573,7 +644,7 @@ export default { return false; }, - isVirtualRowRendering(that) { + isVirtualRowRendering(that: OptionsReader): boolean { const rowRenderingMode = that.option(ROW_RENDERING_MODE_OPTION); const isVirtualMode = that.option(SCROLLING_MODE_OPTION) === SCROLLING_MODE_VIRTUAL; const isAppendMode = that.option(SCROLLING_MODE_OPTION) === SCROLLING_MODE_INFINITE; @@ -585,17 +656,17 @@ export default { return rowRenderingMode === SCROLLING_MODE_VIRTUAL; }, - getPixelRatio(window) { + getPixelRatio(window: Window): number { return window.devicePixelRatio || 1; }, /// #DEBUG - _setPixelRatioFn(value) { + _setPixelRatioFn(value: (window: Window) => number): void { this.getPixelRatio = value; }, /// #ENDDEBUG - getContentHeightLimit(browser) { + getContentHeightLimit(browser: BrowserInfo): number { if (browser.mozilla) { return 8000000; } @@ -603,25 +674,24 @@ export default { return 15000000 / this.getPixelRatio(getWindow()); }, - normalizeLookupDataSource(lookup) { - let lookupDataSourceOptions; - if (lookup.items) { - lookupDataSourceOptions = lookup.items; - } else { - lookupDataSourceOptions = lookup.dataSource; - if (isFunction(lookupDataSourceOptions) && !variableWrapper.isWrapped(lookupDataSourceOptions)) { - lookupDataSourceOptions = lookupDataSourceOptions({}); - } - } - return normalizeDataSourceOptions(lookupDataSourceOptions); + normalizeLookupDataSource(lookup: NonNullable): NormalizedDataSourceOptions { + return normalizeDataSourceOptions(getLookupDataSourceOptions(lookup)); }, - getWrappedLookupDataSource(column, dataSourceAdapter: DataSourceAdapter | null | undefined, filter) { - if (!dataSourceAdapter) { + getWrappedLookupDataSource( + column: Column, + dataSourceAdapter: DataSourceAdapter | null | undefined, + filter: DataFilter, + ): LookupDataSource { + const { lookup } = column; + + if (!dataSourceAdapter || !lookup) { return []; } - const lookupDataSourceOptions = this.normalizeLookupDataSource(column.lookup); + const lookupDataSourceOptions: NormalizedDataSourceOptions = this.normalizeLookupDataSource( + lookup, + ); if (column.calculateCellValue !== column.defaultCalculateCellValue) { return lookupDataSourceOptions; @@ -629,52 +699,51 @@ export default { const hasGroupPaging = dataSourceAdapter.remoteOperations().groupPaging; const hasLookupOptimization = column.displayField && isString(column.displayField); + const cache: { items?: RawItemData[]; skip?: number; take?: number } = {}; - let cachedUniqueRelevantItems; - let previousTake; - let previousSkip; - - const sliceItems = (items, loadOptions) => { + const sliceItems = (items: RawItemData[], loadOptions: StoreLoadOptions): RawItemData[] => { const start = loadOptions.skip ?? 0; const end = loadOptions.take ? start + loadOptions.take : items.length; return items.slice(start, end); }; - const loadUniqueRelevantItems = (loadOptions) => { + const loadUniqueRelevantItems = ( + loadOptions: StoreLoadOptions, + ): DeferredObj => { const group = normalizeGroupingLoadOptions( + // @ts-expect-error a bound lookup column has dataField, and displayField is checked above hasLookupOptimization ? [column.dataField, column.displayField] : column.dataField, ); // @ts-expect-error const d = new Deferred(); - const canUseCache = cachedUniqueRelevantItems && ( - !hasGroupPaging - || (loadOptions.skip === previousSkip && loadOptions.take === previousTake) - ); + const isSamePage = !hasGroupPaging + || (loadOptions.skip === cache.skip && loadOptions.take === cache.take); - if (canUseCache) { - d.resolve(sliceItems(cachedUniqueRelevantItems, loadOptions)); + if (cache.items && isSamePage) { + d.resolve(sliceItems(cache.items, loadOptions)); } else { - previousSkip = loadOptions.skip; - previousTake = loadOptions.take; + cache.skip = loadOptions.skip; + cache.take = loadOptions.take; dataSourceAdapter.customLoader.load({ filter, group, take: hasGroupPaging ? loadOptions.take : undefined, skip: hasGroupPaging ? loadOptions.skip : undefined, }).done(({ data }) => { - cachedUniqueRelevantItems = data; + cache.items = data; d.resolve(hasGroupPaging ? data : sliceItems(data, loadOptions)); }).fail(d.fail); } + // eslint-disable-next-line @typescript-eslint/no-unsafe-return -- untyped new Deferred() return d; }; - const lookupDataSource = { + const lookupDataSource: WrappedLookupDataSource = { ...lookupDataSourceOptions, __dataGridSourceFilter: filter, - load: (loadOptions) => { + load: (loadOptions: StoreLoadOptions): DeferredObj => { // @ts-expect-error const d = new Deferred(); loadUniqueRelevantItems(loadOptions).done((items) => { @@ -683,9 +752,9 @@ export default { return; } - const filter = combineFilters( + const keysFilter = combineFilters( items.flatMap((data) => data.key).map((key) => [ - column.lookup.valueExpr, key, + lookup.valueExpr, key, ]), 'or', ); @@ -693,7 +762,7 @@ export default { const newDataSource = new DataSource({ ...lookupDataSourceOptions, ...loadOptions, - filter: combineFilters([filter, loadOptions.filter], 'and'), + filter: combineFilters([keysFilter, loadOptions.filter], 'and'), paginate: false, // pagination is included to filter }); @@ -702,13 +771,14 @@ export default { .done(d.resolve) .fail(d.fail); }).fail(d.fail); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return -- untyped new Deferred() return d; }, - key: column.lookup.valueExpr, - byKey(key) { - const d = Deferred(); + key: lookup.valueExpr, + byKey(key: RowKey): Promise { + const d = Deferred(); this.load({ - filter: [column.lookup.valueExpr, '=', key], + filter: [lookup.valueExpr, '=', key], }).done((arr) => { d.resolve(arr[0]); }); @@ -760,7 +830,10 @@ export default { logSpecificDeprecatedWarningIfNeed(columns); }, - getComponentBorderWidth(that, $rowsViewElement) { + getComponentBorderWidth( + that: ModuleItem, + $rowsViewElement: dxElementWrapper | undefined, + ): number { const borderWidth = that.option('showBorders') ? Math.ceil(getOuterWidth($rowsViewElement) - getInnerWidth($rowsViewElement)) : 0; @@ -768,7 +841,7 @@ export default { return borderWidth; }, - isCustomCommandColumn(columns, commandColumn): boolean { + isCustomCommandColumn(columns: Column[], commandColumn: Column): boolean { const customCommandColumns = columns .filter((column) => column.type === commandColumn.type); diff --git a/packages/devextreme/js/__internal/grids/grid_core/modules/modules.ts b/packages/devextreme/js/__internal/grids/grid_core/modules/modules.ts index 44822c8bcc78..2babb00d367e 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/modules/modules.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/modules/modules.ts @@ -130,7 +130,7 @@ export class ModuleItem { return this.component._setOptionWithoutOptionChange(name, value); } - protected localize(name: string): string { + public localize(name: string): string { const optionCache = this.component._optionCache; if (optionCache) { diff --git a/packages/devextreme/js/__internal/grids/grid_core/types.ts b/packages/devextreme/js/__internal/grids/grid_core/types.ts index 9ada0edb8d41..065ee2f4abd4 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/types.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/types.ts @@ -1,16 +1,23 @@ /* eslint-disable spellcheck/spell-checker */ import type { - GridBase, GridBaseOptions, SelectionBase, + ColumnCustomizeTextArg, GridBase, GridBaseOptions, SelectionBase, } from '@js/common/grids'; import type { PropertyType } from '@js/core'; import type { Component } from '@js/core/component'; import type { dxElementWrapper } from '@js/core/renderer'; import type { DeferredObj } from '@js/core/utils/deferred'; -import type { Properties as DataGridOptions, Scrolling as DataGridScrolling } from '@js/ui/data_grid'; +import type { Format } from '@js/localization'; +import type { + Properties as DataGridOptions, + Scrolling as DataGridScrolling, + SummaryTotalItem, +} from '@js/ui/data_grid'; import type { Properties as TreeListdOptions, Scrolling as TreeListScrolling } from '@js/ui/tree_list'; import type Widget from '@js/ui/widget/ui.widget'; import type { IntegrationOptions } from '@ts/core/template_manager'; import type { ActionConfig } from '@ts/core/widget/component'; +import type { NormalizedDataSourceOptions, StoreLoadOptions } from '@ts/data/data_source/types'; +import type { TemplateModel } from '@ts/grids/grid_core/columns_controller/types'; import type { FilterValue } from '@ts/grids/grid_core/filter/types'; import type { ModuleItem } from '@ts/grids/grid_core/modules/modules'; @@ -33,6 +40,68 @@ export interface SelectionRange { selectionEnd: number; } +export interface FormatOptions { + format?: Format; + getDisplayFormat?: (valueText: string) => string; + customizeText?: (cellInfo: ColumnCustomizeTextArg & { valueText: string }) => string; + target?: string; + groupInterval?: string | number; + trueText?: string; + falseText?: string; +} + +/** @architectureLeak data_grid/summary: core renders summary texts in group rows */ +export interface SummaryTextItem extends Pick { + summaryType: string; + value?: unknown; + columnCaption?: string; +} + +export interface LoadPanelPosition { + of: dxElementWrapper | undefined; + boundary?: dxElementWrapper; + collision?: 'fit'; +} + +export type WidgetElementData = Record & { dxComponents?: string[] }; + +export interface ColumnPointProps extends ColumnPoint { + item: Element | undefined; + isLeftBoundary?: boolean; + isRightBoundary?: boolean; +} + +export interface ExpandCellTemplateOptions { + value?: unknown; + data?: { isContinuation?: boolean }; + row: { isNewRow?: boolean }; + component: InternalGrid; +} + +export interface ExpandCellTemplate { + allowRenderToDetachedContainer: boolean; + render: (container: dxElementWrapper, model: TemplateModel) => void; +} + +export type TextSelectionElement = Element & { + selectionStart?: number | null; + selectionEnd?: number | null; + setSelectionRange?: (start: number, end: number) => void; +}; + +export interface OptionsReader { + option: (name: string) => unknown; +} + +export type WrappedLookupDataSource = NormalizedDataSourceOptions & { + __dataGridSourceFilter: unknown; + load: (loadOptions: StoreLoadOptions) => DeferredObj; + key: string | undefined; + byKey: (key: RowKey) => Promise; +}; + +export type LookupDataSource = never[] | NormalizedDataSourceOptions | WrappedLookupDataSource; + type OptionsMethod = (() => TOptions) & ((options: TOptions) => void) & ( diff --git a/packages/devextreme/js/__internal/grids/grid_core/views/m_rows_view.ts b/packages/devextreme/js/__internal/grids/grid_core/views/m_rows_view.ts index 248410a9baee..01a1de98f22b 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/views/m_rows_view.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/views/m_rows_view.ts @@ -41,7 +41,7 @@ import type { FocusController } from '@ts/grids/grid_core/focus/m_focus'; import type { KeyboardNavigationController, } from '@ts/grids/grid_core/keyboard_navigation/m_keyboard_navigation'; -import type { OptionChanged } from '@ts/grids/grid_core/types'; +import type { OptionChanged, SummaryTextItem } from '@ts/grids/grid_core/types'; import type { ValidatingController } from '@ts/grids/grid_core/validating/m_validating'; import type { ResizingController } from '@ts/grids/grid_core/views/grid_view'; import Scrollable from '@ts/ui/scroll_view/scrollable'; @@ -239,7 +239,7 @@ export class RowsView extends ColumnsView { const container = $container.get(0); if (options.summaryItems?.length) { - text += ` ${gridCoreUtils.getGroupRowSummaryText(options.summaryItems, summaryTexts)}`; + text += ` ${gridCoreUtils.getGroupRowSummaryText(options.summaryItems as SummaryTextItem[], summaryTexts)}`; } if (data) { diff --git a/packages/devextreme/js/__internal/grids/grid_core/views/utils.ts b/packages/devextreme/js/__internal/grids/grid_core/views/utils.ts index 558ffbcafddc..dc16289586f6 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/views/utils.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/views/utils.ts @@ -11,7 +11,7 @@ export const getCellText = ( displayValue: unknown, ): string => ( columnHasValue(column) - ? gridCoreUtils.formatValue(displayValue, column) as string + ? gridCoreUtils.formatValue(displayValue, column) : '' ); diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/filtering/header_filter/legacy_header_filter.ts b/packages/devextreme/js/__internal/grids/new/grid_core/filtering/header_filter/legacy_header_filter.ts index 1072316e25ba..669a83d63fa8 100644 --- a/packages/devextreme/js/__internal/grids/new/grid_core/filtering/header_filter/legacy_header_filter.ts +++ b/packages/devextreme/js/__internal/grids/new/grid_core/filtering/header_filter/legacy_header_filter.ts @@ -23,6 +23,7 @@ import { isUTCFormat, } from '@ts/grids/grid_core/header_filter/m_header_filter'; import { updateHeaderFilterItemSelectionState } from '@ts/grids/grid_core/header_filter/m_header_filter_core'; +import { getHeaderFilterGroupParameters } from '@ts/grids/grid_core/header_filter/utils'; import gridCoreUtils from '@ts/grids/grid_core/m_utils'; import type { Column } from '@ts/grids/new/grid_core/columns_controller/types'; @@ -153,7 +154,7 @@ export const getDataSourceOptions = ( const { grouping: localGrouping } = storeLoadAdapter.getLocalLoadOperations(); const remoteGrouping = !localGrouping; - const group = gridCoreUtils.getHeaderFilterGroupParameters(column, remoteGrouping); + const group = getHeaderFilterGroupParameters(column, remoteGrouping); const headerFilterDataSource = column.headerFilter?.dataSource; const options: any = {}; diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets.dataGrid/headerFilter.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets.dataGrid/headerFilter.tests.js index 96cce6af831c..3bbd3a6edd9e 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets.dataGrid/headerFilter.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets.dataGrid/headerFilter.tests.js @@ -1,7 +1,6 @@ import 'fluent_blue_light.css!'; import 'ui/data_grid'; -import gridCoreUtils from '__internal/grids/grid_core/m_utils'; import $ from 'jquery'; import ArrayStore from 'common/data/array_store'; @@ -9,7 +8,7 @@ import ODataStore from 'common/data/odata/store'; import devices from '__internal/core/devices'; import { DataSource } from 'common/data/data_source/data_source'; import { isFunction } from 'core/utils/type'; -import { invertFilterExpression } from '__internal/grids/grid_core/header_filter/utils'; +import { getHeaderFilterGroupParameters, invertFilterExpression } from '__internal/grids/grid_core/header_filter/utils'; import dragEvents from 'common/core/events/drag'; import { setupDataGridModules, MockDataController, MockColumnsController } from '../../helpers/dataGridMocks.js'; import viewPortUtils from 'core/utils/view_port'; @@ -4272,7 +4271,7 @@ QUnit.module('Header Filter with real columnsController', { const dataSourceOptions = this.headerFilterController.getDataSource(column); // act - const group = gridCoreUtils.getHeaderFilterGroupParameters(column, remoteOperations); + const group = getHeaderFilterGroupParameters(column, remoteOperations); dataSourceOptions.load({ group