diff --git a/packages/devextreme/js/__internal/grids/new/card_view/options_controller.mock.ts b/packages/devextreme/js/__internal/grids/new/card_view/options_controller.mock.ts new file mode 100644 index 000000000000..d66ec5d1e7c9 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/new/card_view/options_controller.mock.ts @@ -0,0 +1,14 @@ +import { + OptionsControllerMock as OptionsControllerBaseMock, +} from '@ts/grids/new/grid_core/options_controller/options_controller_base.mock'; + +import type { Options } from './options'; +import { defaultOptions } from './options'; + +export class OptionsControllerMock extends OptionsControllerBaseMock< +Options, typeof defaultOptions +> { + constructor(options: Options) { + super(options, defaultOptions); + } +} diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/columns_controller/__snapshots__/columns_controller.test.ts.snap b/packages/devextreme/js/__internal/grids/new/grid_core/columns_controller/__snapshots__/columns_controller.test.ts.snap new file mode 100644 index 000000000000..da54d5acb102 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/new/grid_core/columns_controller/__snapshots__/columns_controller.test.ts.snap @@ -0,0 +1,98 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ColumnsController columns should contain processed column configs 1`] = ` +[ + { + "alignment": "left", + "allowReordering": true, + "calculateCellValue": [Function], + "calculateDisplayValue": [Function], + "caption": "A", + "dataField": "a", + "dataType": "string", + "falseText": "false", + "name": "a", + "trueText": "true", + "visible": true, + "visibleIndex": 0, + }, + { + "alignment": "left", + "allowReordering": true, + "calculateCellValue": [Function], + "calculateDisplayValue": [Function], + "caption": "B", + "dataField": "b", + "dataType": "string", + "falseText": "false", + "name": "b", + "trueText": "true", + "visible": true, + "visibleIndex": 1, + }, + { + "alignment": "left", + "allowReordering": true, + "calculateCellValue": [Function], + "calculateDisplayValue": [Function], + "caption": "C", + "dataField": "c", + "dataType": "string", + "falseText": "false", + "name": "c", + "trueText": "true", + "visible": false, + "visibleIndex": 2, + }, +] +`; + +exports[`ColumnsController createDataRow should process data object to data row using column configuration 1`] = ` +{ + "cells": [ + { + "column": { + "alignment": "left", + "allowReordering": true, + "calculateCellValue": [Function], + "calculateDisplayValue": [Function], + "caption": "A", + "dataField": "a", + "dataType": "string", + "falseText": "false", + "name": "a", + "trueText": "true", + "visible": true, + "visibleIndex": 0, + }, + "displayValue": "my a value", + "text": "my a value", + "value": "my a value", + }, + { + "column": { + "alignment": "left", + "allowReordering": true, + "calculateCellValue": [Function], + "calculateDisplayValue": [Function], + "caption": "B", + "dataField": "b", + "dataType": "string", + "falseText": "false", + "name": "b", + "trueText": "true", + "visible": true, + "visibleIndex": 1, + }, + "displayValue": "my b value", + "text": "my b value", + "value": "my b value", + }, + ], + "data": { + "a": "my a value", + "b": "my b value", + }, + "key": undefined, +} +`; diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/columns_controller/__snapshots__/options.test.ts.snap b/packages/devextreme/js/__internal/grids/new/grid_core/columns_controller/__snapshots__/options.test.ts.snap new file mode 100644 index 000000000000..472e587f2151 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/new/grid_core/columns_controller/__snapshots__/options.test.ts.snap @@ -0,0 +1,95 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Options columns when given as object should be normalized 1`] = ` +[ + { + "alignment": "left", + "allowReordering": true, + "calculateCellValue": [Function], + "calculateDisplayValue": [Function], + "caption": "A", + "dataField": "a", + "dataType": "string", + "falseText": "false", + "name": "a", + "trueText": "true", + "visible": true, + "visibleIndex": 0, + }, + { + "alignment": "left", + "allowReordering": true, + "calculateCellValue": [Function], + "calculateDisplayValue": [Function], + "caption": "B", + "dataField": "b", + "dataType": "string", + "falseText": "false", + "name": "b", + "trueText": "true", + "visible": true, + "visibleIndex": 1, + }, + { + "alignment": "left", + "allowReordering": true, + "calculateCellValue": [Function], + "calculateDisplayValue": [Function], + "caption": "C", + "dataField": "c", + "dataType": "string", + "falseText": "false", + "name": "c", + "trueText": "true", + "visible": true, + "visibleIndex": 2, + }, +] +`; + +exports[`Options columns when given as string should be normalized 1`] = ` +[ + { + "alignment": "left", + "allowReordering": true, + "calculateCellValue": [Function], + "calculateDisplayValue": [Function], + "caption": "A", + "dataField": "a", + "dataType": "string", + "falseText": "false", + "name": "a", + "trueText": "true", + "visible": true, + "visibleIndex": 0, + }, + { + "alignment": "left", + "allowReordering": true, + "calculateCellValue": [Function], + "calculateDisplayValue": [Function], + "caption": "B", + "dataField": "b", + "dataType": "string", + "falseText": "false", + "name": "b", + "trueText": "true", + "visible": true, + "visibleIndex": 1, + }, + { + "alignment": "left", + "allowReordering": true, + "calculateCellValue": [Function], + "calculateDisplayValue": [Function], + "caption": "C", + "dataField": "c", + "dataType": "string", + "falseText": "false", + "name": "c", + "trueText": "true", + "visible": true, + "visibleIndex": 2, + }, +] +`; diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/columns_controller/columns_controller.test.ts b/packages/devextreme/js/__internal/grids/new/grid_core/columns_controller/columns_controller.test.ts new file mode 100644 index 000000000000..fc86a7139ca5 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/new/grid_core/columns_controller/columns_controller.test.ts @@ -0,0 +1,173 @@ +/* eslint-disable spellcheck/spell-checker */ +import { describe, expect, it } from '@jest/globals'; + +import type { Options } from '../options'; +import { OptionsControllerMock } from '../options_controller/options_controller.mock'; +import { ColumnsController } from './columns_controller'; + +const setup = (config: Options = {}) => { + const options = new OptionsControllerMock(config); + + const columnsController = new ColumnsController(options); + + return { + options, + columnsController, + }; +}; + +describe('ColumnsController', () => { + describe('columns', () => { + it('should contain processed column configs', () => { + const { columnsController } = setup({ + columns: [ + 'a', + { dataField: 'b' }, + { dataField: 'c', visible: false }, + ], + }); + + const columns = columnsController.columns.unreactive_get(); + expect(columns).toMatchSnapshot(); + }); + }); + describe('visibleColumns', () => { + it('should contain visible columns', () => { + const { columnsController } = setup({ + columns: [ + 'a', + { dataField: 'b' }, + { dataField: 'c', visible: false }, + ], + }); + + const visibleColumns = columnsController.visibleColumns.unreactive_get(); + expect(visibleColumns).toHaveLength(2); + expect(visibleColumns[0].name).toBe('a'); + expect(visibleColumns[1].name).toBe('b'); + }); + }); + describe('nonVisibleColumns', () => { + it('should contain non visible columns', () => { + const { columnsController } = setup({ + columns: [ + 'a', + { dataField: 'b' }, + { dataField: 'c', visible: false }, + ], + }); + + const nonVisibleColumns = columnsController.nonVisibleColumns.unreactive_get(); + expect(nonVisibleColumns).toHaveLength(1); + expect(nonVisibleColumns[0].name).toBe('c'); + }); + }); + + describe('createDataRow', () => { + it('should process data object to data row using column configuration', () => { + const { columnsController } = setup({ + columns: [ + 'a', + { dataField: 'b' }, + ], + }); + + const columns = columnsController.columns.unreactive_get(); + const dataObject = { a: 'my a value', b: 'my b value' }; + const dataRow = columnsController.createDataRow(dataObject, columns); + expect(dataRow).toMatchSnapshot(); + }); + }); + + describe('addColumn', () => { + it('should add new column to columns', () => { + const { columnsController } = setup( + { columns: ['a', 'b'] }, + ); + + let columns = columnsController.columns.unreactive_get(); + expect(columns).toHaveLength(2); + expect(columns).toMatchObject([ + { dataField: 'a' }, + { dataField: 'b' }, + ]); + + columnsController.addColumn('c'); + + columns = columnsController.columns.unreactive_get(); + expect(columns).toHaveLength(3); + expect(columns).toMatchObject([ + { dataField: 'a' }, + { dataField: 'b' }, + { dataField: 'c' }, + ]); + }); + }); + + describe('deleteColumn', () => { + it('should remove given column from columns', () => { + const { columnsController } = setup( + { columns: ['a', 'b'] }, + ); + + let columns = columnsController.columns.unreactive_get(); + expect(columns).toHaveLength(2); + expect(columns).toMatchObject([ + { dataField: 'a' }, + { dataField: 'b' }, + ]); + + columnsController.deleteColumn(columns[1]); + + columns = columnsController.columns.unreactive_get(); + expect(columns).toHaveLength(1); + expect(columns).toMatchObject([ + { dataField: 'a' }, + ]); + }); + }); + + describe('columnOption', () => { + it('should update option of given column', () => { + const { columnsController } = setup( + { columns: ['a', 'b'] }, + ); + + let columns = columnsController.columns.unreactive_get(); + expect(columns).toMatchObject([ + { dataField: 'a', visible: true }, + { dataField: 'b', visible: true }, + ]); + + columnsController.columnOption(columns[1], 'visible', false); + + columns = columnsController.columns.unreactive_get(); + expect(columns).toMatchObject([ + { dataField: 'a', visible: true }, + { dataField: 'b', visible: false }, + ]); + }); + + it('should correctly update visibleIndex option for all columns', () => { + const { columnsController } = setup( + { columns: ['a', 'b', 'c'] }, + ); + + let columns = columnsController.columns.unreactive_get(); + expect(columns).toMatchObject([ + { dataField: 'a', visibleIndex: 0 }, + { dataField: 'b', visibleIndex: 1 }, + { dataField: 'c', visibleIndex: 2 }, + ]); + + columnsController.columnOption(columns[2], 'visibleIndex', 0); + + columns = columnsController.columns.unreactive_get(); + expect(columns).toMatchObject([ + { dataField: 'a', visibleIndex: 1 }, + { dataField: 'b', visibleIndex: 2 }, + { dataField: 'c', visibleIndex: 0 }, + ]); + }); + }); +}); diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/columns_controller/columns_controller.ts b/packages/devextreme/js/__internal/grids/new/grid_core/columns_controller/columns_controller.ts new file mode 100644 index 000000000000..3b2649a579ca --- /dev/null +++ b/packages/devextreme/js/__internal/grids/new/grid_core/columns_controller/columns_controller.ts @@ -0,0 +1,138 @@ +/* eslint-disable @typescript-eslint/no-shadow */ +/* eslint-disable spellcheck/spell-checker */ +import formatHelper from '@js/format_helper'; +import type { Subscribable, SubsGets, SubsGetsUpd } from '@ts/core/reactive/index'; +import { + computed, interruptableComputed, +} from '@ts/core/reactive/index'; + +import { OptionsController } from '../options_controller/options_controller'; +import type { ColumnProperties, ColumnSettings, PreNormalizedColumn } from './options'; +import type { Column, DataRow, VisibleColumn } from './types'; +import { + getColumnIndexByName, normalizeColumns, normalizeVisibleIndexes, preNormalizeColumns, +} from './utils'; + +export class ColumnsController { + private readonly columnsConfiguration: Subscribable; + private readonly columnsSettings: SubsGetsUpd; + + public readonly columns: SubsGets; + + public readonly visibleColumns: SubsGets; + + public readonly nonVisibleColumns: SubsGets; + + public readonly allowColumnReordering: Subscribable; + + public static dependencies = [OptionsController] as const; + + constructor( + private readonly options: OptionsController, + ) { + this.columnsConfiguration = this.options.oneWay('columns'); + + this.columnsSettings = interruptableComputed( + (columnsConfiguration) => preNormalizeColumns(columnsConfiguration ?? []), + [ + this.columnsConfiguration, + ], + ); + + this.columns = computed( + (columnsSettings) => normalizeColumns(columnsSettings ?? []), + [ + this.columnsSettings, + ], + ); + + this.visibleColumns = computed( + (columns) => columns + .filter((column): column is VisibleColumn => column.visible) + .sort((a, b) => a.visibleIndex - b.visibleIndex), + [this.columns], + ); + + this.nonVisibleColumns = computed( + (columns) => columns.filter((column) => !column.visible), + [this.columns], + ); + + this.allowColumnReordering = this.options.oneWay('allowColumnReordering'); + } + + public createDataRow(data: unknown, columns: Column[]): DataRow { + return { + cells: columns.map((c) => { + const displayValue = c.calculateDisplayValue(data); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let text = formatHelper.format(displayValue as any, c.format); + + if (c.customizeText) { + text = c.customizeText({ + value: displayValue, + valueText: text, + }); + } + + return { + column: c, + value: c.calculateCellValue(data), + displayValue, + text, + }; + }), + key: undefined, + data, + }; + } + + public addColumn(columnProps: ColumnProperties): void { + this.columnsSettings.updateFunc((columns) => preNormalizeColumns([ + ...columns, + columnProps, + ])); + } + + public deleteColumn(column: Column): void { + this.columnsSettings.updateFunc( + (columns) => columns.filter((c) => c.name !== column.name), + ); + } + + public columnOption( + column: Column, + option: TProp, + value: ColumnSettings[TProp], + ): void { + this.columnsSettings.updateFunc((columns) => { + const index = getColumnIndexByName(columns, column.name); + const newColumns = [...columns]; + + if (columns[index][option] === value) { + return columns; + } + + newColumns[index] = { + ...newColumns[index], + [option]: value, + }; + + const visibleIndexes = normalizeVisibleIndexes( + newColumns.map((c) => c.visibleIndex), + index, + ); + + visibleIndexes.forEach((visibleIndex, i) => { + if (newColumns[i].visibleIndex !== visibleIndex) { + newColumns[i] = { + ...newColumns[i], + visibleIndex, + }; + } + }); + + return newColumns; + }); + } +} diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/columns_controller/index.ts b/packages/devextreme/js/__internal/grids/new/grid_core/columns_controller/index.ts new file mode 100644 index 000000000000..45601c054834 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/new/grid_core/columns_controller/index.ts @@ -0,0 +1,3 @@ +export { ColumnsController } from './columns_controller'; +export { defaultOptions, type Options } from './options'; +export { PublicMethods } from './public_methods'; diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/columns_controller/options.test.ts b/packages/devextreme/js/__internal/grids/new/grid_core/columns_controller/options.test.ts new file mode 100644 index 000000000000..b28b870fddfb --- /dev/null +++ b/packages/devextreme/js/__internal/grids/new/grid_core/columns_controller/options.test.ts @@ -0,0 +1,271 @@ +/* eslint-disable spellcheck/spell-checker */ +import { describe, expect, it } from '@jest/globals'; + +import type { Options } from '../options'; +import { OptionsControllerMock } from '../options_controller/options_controller.mock'; +import { ColumnsController } from './columns_controller'; + +const setup = (config: Options) => { + const options = new OptionsControllerMock(config); + + const columnsController = new ColumnsController(options); + + return { + options, + columnsController, + }; +}; + +describe('Options', () => { + describe('columns', () => { + describe('when given as string', () => { + it('should be normalized', () => { + const { columnsController } = setup({ columns: ['a', 'b', 'c'] }); + const columns = columnsController.columns.unreactive_get(); + + expect(columns).toMatchSnapshot(); + }); + it('should use given string as dataField', () => { + const { columnsController } = setup({ columns: ['a', 'b', 'c'] }); + const columns = columnsController.columns.unreactive_get(); + + expect(columns[0].dataField).toBe('a'); + expect(columns[1].dataField).toBe('b'); + expect(columns[2].dataField).toBe('c'); + }); + it('should be the same as if we passed objects with dataField only', () => { + const { columnsController: columnsController1 } = setup({ + columns: ['a', 'b', 'c'], + }); + const columns1 = columnsController1.columns.unreactive_get(); + + const { columnsController: columnsController2 } = setup({ + columns: [ + { dataField: 'a' }, + { dataField: 'b' }, + { dataField: 'c' }, + ], + }); + const columns2 = columnsController2.columns.unreactive_get(); + + expect(columns1).toEqual(columns2); + }); + }); + describe('when given as object', () => { + it('should be normalized', () => { + const { columnsController } = setup({ + columns: [ + { dataField: 'a' }, + { dataField: 'b' }, + { dataField: 'c' }, + ], + }); + const columns = columnsController.columns.unreactive_get(); + expect(columns).toMatchSnapshot(); + }); + }); + }); + + describe('columns[].visible', () => { + describe('when it is true', () => { + it('should include column to visibleColumns', () => { + const { columnsController } = setup({ + columns: [ + { dataField: 'a', visible: true }, + { dataField: 'b', visible: true }, + ], + }); + + const visibleColumns = columnsController.visibleColumns.unreactive_get(); + expect(visibleColumns).toHaveLength(2); + expect(visibleColumns[0].name).toBe('a'); + expect(visibleColumns[1].name).toBe('b'); + }); + }); + + describe('when it is false', () => { + it('should exclude column from visibleColumns', () => { + const { columnsController } = setup({ + columns: [ + { dataField: 'a', visible: true }, + { dataField: 'b', visible: false }, + ], + }); + + const visibleColumns = columnsController.visibleColumns.unreactive_get(); + expect(visibleColumns).toHaveLength(1); + expect(visibleColumns[0].name).toBe('a'); + }); + }); + }); + + describe('columns[].visibleIndex', () => { + it('should affect order in visibleColumns', () => { + const { columnsController } = setup({ + columns: [ + { dataField: 'a', visibleIndex: 1 }, + { dataField: 'b' }, + ], + }); + const visibleColumns = columnsController.visibleColumns.unreactive_get(); + + expect(visibleColumns).toHaveLength(2); + expect(visibleColumns[0]).toMatchObject({ + name: 'b', + visibleIndex: 0, + }); + expect(visibleColumns[1]).toMatchObject({ + name: 'a', + visibleIndex: 1, + }); + }); + }); + + describe('column[].calculateCellValue', () => { + it('should override value in DataRow', () => { + const { columnsController } = setup({ + columns: [ + { calculateCellValue: (data: any) => `${data.a} ${data.b}` }, + ], + }); + + const dataObject = { a: 'a', b: 'b' }; + const columns = columnsController.columns.unreactive_get(); + const dataRow = columnsController.createDataRow(dataObject, columns); + + expect(dataRow.cells).toHaveLength(1); + expect(dataRow.cells[0].value).toBe('a b'); + }); + + it('should take priority over dataField', () => { + const { columnsController } = setup({ + columns: [ + { + calculateCellValue: (data: any) => `${data.a} ${data.b}`, + dataField: 'a', + }, + ], + }); + + const dataObject = { a: 'a', b: 'b' }; + const columns = columnsController.columns.unreactive_get(); + const dataRow = columnsController.createDataRow(dataObject, columns); + + expect(dataRow.cells).toHaveLength(1); + expect(dataRow.cells[0].value).toBe('a b'); + }); + }); + + describe('column[].calculateDisplayValue', () => { + it('should override displayValue in DataRow', () => { + const { columnsController } = setup({ + columns: [ + { calculateDisplayValue: (data: any) => `${data.a} ${data.b}` }, + ], + }); + + const dataObject = { a: 'a', b: 'b' }; + const columns = columnsController.columns.unreactive_get(); + const dataRow = columnsController.createDataRow(dataObject, columns); + + expect(dataRow.cells).toHaveLength(1); + expect(dataRow.cells[0].displayValue).toBe('a b'); + }); + }); + + describe('column[].customizeText', () => { + it('should override text in DataRow', () => { + const { columnsController } = setup({ + columns: [ + { + dataField: 'a', + customizeText: ({ valueText }) => `aa ${valueText} aa`, + }, + ], + }); + + const dataObject = { a: 'a', b: 'b' }; + const columns = columnsController.columns.unreactive_get(); + const dataRow = columnsController.createDataRow(dataObject, columns); + + expect(dataRow.cells).toHaveLength(1); + expect(dataRow.cells[0].text).toBe('aa a aa'); + }); + }); + + describe('column[].dataField', () => { + it('should determine which value from data will be used', () => { + const { columnsController } = setup({ + columns: [{ dataField: 'a' }, { dataField: 'b' }], + }); + + const dataObject = { a: 'a text', b: 'b text' }; + const columns = columnsController.columns.unreactive_get(); + const dataRow = columnsController.createDataRow(dataObject, columns); + + expect(dataRow.cells).toHaveLength(2); + expect(dataRow.cells[0].text).toBe('a text'); + expect(dataRow.cells[1].text).toBe('b text'); + }); + }); + + describe('column[].dataType', () => { + it('should affect column default settings', () => { + const { columnsController } = setup({ + columns: [ + { dataField: 'a', dataType: 'number' }, + { dataField: 'b', dataType: 'boolean' }, + ], + }); + + const columns = columnsController.columns.unreactive_get(); + + expect(columns).toHaveLength(2); + expect(columns[0].alignment).toMatchInlineSnapshot('"right"'); + expect(columns[1].alignment).toMatchInlineSnapshot('"center"'); + }); + }); + + (['falseText', 'trueText'] as const).forEach((propName) => { + describe(`column[].${propName}`, () => { + it('should be used as text for boolean column', () => { + const { columnsController } = setup({ + columns: [ + { + dataField: 'a', + dataType: 'boolean', + [propName]: `my ${propName} text`, + }, + ], + }); + + const dataObject = { a: propName === 'trueText' }; + const columns = columnsController.columns.unreactive_get(); + const dataRow = columnsController.createDataRow(dataObject, columns); + + expect(dataRow.cells).toHaveLength(1); + expect(dataRow.cells[0].text).toBe(`my ${propName} text`); + }); + }); + }); + + describe('column[].format', () => { + it('should affect dataRow text', () => { + const { columnsController } = setup({ + columns: [ + { + dataField: 'a', + format: 'currency', + }, + ], + }); + + const dataObject = { a: 123 }; + const columns = columnsController.columns.unreactive_get(); + const dataRow = columnsController.createDataRow(dataObject, columns); + + expect(dataRow.cells).toHaveLength(1); + expect(dataRow.cells[0].text).toBe('$123'); + }); + }); +}); diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/columns_controller/options.ts b/packages/devextreme/js/__internal/grids/new/grid_core/columns_controller/options.ts new file mode 100644 index 000000000000..bcbe80dc058c --- /dev/null +++ b/packages/devextreme/js/__internal/grids/new/grid_core/columns_controller/options.ts @@ -0,0 +1,69 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import type { DataType } from '@js/common'; +import messageLocalization from '@js/localization/message'; + +import type { WithRequired } from '../types'; +import type { Column } from './types'; + +export type ColumnSettings = Partial & { + calculateDisplayValue: string | ((this: Column, data: unknown) => unknown); +}>; + +export type PreNormalizedColumn = WithRequired; + +export type ColumnProperties = ColumnSettings | string; + +export const defaultColumnProperties = { + dataType: 'string', + calculateCellValue(data): unknown { + // @ts-expect-error + return data[this.dataField!]; + }, + calculateDisplayValue(data): unknown { + return this.calculateCellValue(data); + }, + alignment: 'left', + visible: true, + allowReordering: true, + trueText: messageLocalization.format('dxDataGrid-trueText'), + falseText: messageLocalization.format('dxDataGrid-falseText'), +} satisfies Partial; + +export const defaultColumnPropertiesByDataType: Record< +DataType, +Exclude +> = { + boolean: { + alignment: 'center', + customizeText({ value }): string { + return value + ? this.trueText + : this.falseText; + }, + }, + string: { + + }, + date: { + + }, + datetime: { + + }, + number: { + alignment: 'right', + }, + object: { + + }, +}; + +export interface Options { + columns?: ColumnProperties[]; + + allowColumnReordering?: boolean; +} + +export const defaultOptions = { + allowColumnReordering: false, +} satisfies Options; diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/columns_controller/public_methods.test.ts b/packages/devextreme/js/__internal/grids/new/grid_core/columns_controller/public_methods.test.ts new file mode 100644 index 000000000000..9ef479fdc33d --- /dev/null +++ b/packages/devextreme/js/__internal/grids/new/grid_core/columns_controller/public_methods.test.ts @@ -0,0 +1,65 @@ +/* eslint-disable spellcheck/spell-checker */ +import { describe, expect, it } from '@jest/globals'; + +import { OptionsControllerMock } from '../options_controller/options_controller.mock'; +import { ColumnsController } from './columns_controller'; +import type { Options } from './options'; +import { PublicMethods } from './public_methods'; + +const setup = (config: Options = {}) => { + const options = new OptionsControllerMock(config); + const columnsController = new ColumnsController(options); + + // @ts-expect-error + const gridCore = new (PublicMethods(class { + protected columnsController = columnsController; + }))(); + + return { + options, + columnsController, + gridCore, + }; +}; + +describe('PublicMethods', () => { + describe('getVisibleColumns', () => { + it('should return visible columns', () => { + const { gridCore } = setup({ + columns: ['a', 'b', { dataField: 'c', visible: false }], + }); + + expect(gridCore.getVisibleColumns()).toMatchObject([ + { name: 'a' }, + { name: 'b' }, + ]); + }); + }); + + describe('addColumn', () => { + // tested in columns_controller.test.ts + }); + + describe('getVisibleColumnIndex', () => { + const { gridCore } = setup({ + columns: [{ dataField: 'a', visible: false }, 'b', 'c'], + }); + + it('should return visible index of visible column', () => { + expect(gridCore.getVisibleColumnIndex('b')).toBe(0); + expect(gridCore.getVisibleColumnIndex('c')).toBe(1); + }); + + it('should return -1 for non-visible colunm', () => { + expect(gridCore.getVisibleColumnIndex('a')).toBe(-1); + }); + }); + + describe('deleteColumn', () => { + // tested in columns_controller.test.ts + }); + + describe('columnOption', () => { + // tested in columns_controller.test.ts + }); +}); diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/columns_controller/public_methods.ts b/packages/devextreme/js/__internal/grids/new/grid_core/columns_controller/public_methods.ts new file mode 100644 index 000000000000..043bddfc20b0 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/new/grid_core/columns_controller/public_methods.ts @@ -0,0 +1,109 @@ +/* eslint-disable max-classes-per-file */ +/* eslint-disable consistent-return */ +/* eslint-disable spellcheck/spell-checker */ +/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ + +import { isObject } from '@js/core/utils/type'; + +import type { Constructor } from '../types'; +import type { GridCoreNewBase } from '../widget'; +import type { ColumnProperties, ColumnSettings } from './options'; +import type { Column } from './types'; +import { getColumnByIndexOrName } from './utils'; + +export function PublicMethods>(GridCore: TBase) { + return class GridCoreWithColumnsController extends GridCore { + public getVisibleColumns(): Column[] { + return this.columnsController.visibleColumns.unreactive_get(); + } + + public addColumn(column: ColumnProperties): void { + this.columnsController.addColumn(column); + } + + public getVisibleColumnIndex(columnNameOrIndex: string | number): number { + const column = getColumnByIndexOrName( + this.columnsController.columns.unreactive_get(), + columnNameOrIndex, + ); + + return this.columnsController.visibleColumns.unreactive_get() + .findIndex( + (c) => c.name === column?.name, + ); + } + + public deleteColumn(columnNameOrIndex: string | number): void { + const column = getColumnByIndexOrName( + this.columnsController.columns.unreactive_get(), + columnNameOrIndex, + ); + + if (!column) { + return; + } + + this.columnsController.deleteColumn(column); + } + + public columnOption( + columnNameOrIndex: string | number, + ): Column; + public columnOption( + columnNameOrIndex: string | number, + options: ColumnSettings, + ): void; + public columnOption( + columnNameOrIndex: string | number, + option: T, + value: ColumnSettings[T] + ): void; + public columnOption( + columnNameOrIndex: string | number, + option: T, + value: ColumnSettings[T] + ): void; + public columnOption( + columnNameOrIndex: string | number, + option: T + ): Column[T]; + public columnOption( + columnNameOrIndex: string | number, + option?: T | ColumnSettings, + value?: ColumnSettings[T], + // eslint-disable-next-line @typescript-eslint/no-invalid-void-type + ): Column | Column[T] | void { + const column = getColumnByIndexOrName( + this.columnsController.columns.unreactive_get(), + columnNameOrIndex, + ); + + if (!column) { + return; + } + + if (arguments.length === 1) { + return column; + } + + if (arguments.length === 2) { + if (isObject(option)) { + Object.entries(option).forEach(([optionName, optionValue]) => { + this.columnsController.columnOption( + column, + optionName as keyof Column, + optionValue, + ); + }); + } else { + return column[option as T]; + } + } + + if (arguments.length === 3) { + this.columnsController.columnOption(column, option as keyof Column, value); + } + } + }; +} diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/columns_controller/types.ts b/packages/devextreme/js/__internal/grids/new/grid_core/columns_controller/types.ts new file mode 100644 index 000000000000..442885030e18 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/new/grid_core/columns_controller/types.ts @@ -0,0 +1,53 @@ +import type { Format } from '@js/common'; +import type { ColumnBase } from '@js/common/grids'; + +type InheritedColumnProps = + | 'alignment' + | 'dataType' + | 'visible' + | 'visibleIndex' + | 'allowReordering' + | 'trueText' + | 'falseText' + | 'caption'; + +export type Column = Pick, InheritedColumnProps> & { + dataField?: string; + + name: string; + + calculateCellValue: (this: Column, data: unknown) => unknown; + + calculateDisplayValue: (this: Column, data: unknown) => unknown; + + format?: Format; + + customizeText?: (this: Column, info: { + value: unknown; + valueText: string; + }) => string; + + editorTemplate?: unknown; + + fieldTemplate?: unknown; +}; + +export type VisibleColumn = Column & { visible: true }; + +export interface Cell { + value: unknown; + + displayValue: unknown; + + text: string; + + column: Column; +} + +export interface DataRow { + cells: Cell[]; + + key: unknown; + + data: unknown; +} diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/columns_controller/utils.test.ts b/packages/devextreme/js/__internal/grids/new/grid_core/columns_controller/utils.test.ts new file mode 100644 index 000000000000..46bf9087e6cb --- /dev/null +++ b/packages/devextreme/js/__internal/grids/new/grid_core/columns_controller/utils.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from '@jest/globals'; + +import { getVisibleIndexes } from './utils'; + +describe('getVisibleIndexes', () => { + it('should create visible indexes if not present', () => { + expect(getVisibleIndexes([ + undefined, undefined, undefined, undefined, + ])).toEqual([ + 0, 1, 2, 3, + ]); + }); + + it('should preserve visible indexes if present', () => { + expect(getVisibleIndexes([ + 3, 1, 0, 2, + ])).toEqual([ + 3, 1, 0, 2, + ]); + }); + + it('should fill in missing indexes', () => { + expect(getVisibleIndexes([ + 3, undefined, 0, undefined, + ])).toEqual([ + 3, 1, 0, 2, + ]); + }); +}); diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/columns_controller/utils.ts b/packages/devextreme/js/__internal/grids/new/grid_core/columns_controller/utils.ts new file mode 100644 index 000000000000..00b2e980b482 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/new/grid_core/columns_controller/utils.ts @@ -0,0 +1,140 @@ +import { compileGetter } from '@js/core/utils/data'; +import { captionize } from '@js/core/utils/inflector'; +import { isDefined, isString } from '@js/core/utils/type'; + +import type { ColumnProperties, ColumnSettings, PreNormalizedColumn } from './options'; +import { defaultColumnProperties, defaultColumnPropertiesByDataType } from './options'; +import type { Column } from './types'; + +function normalizeColumn(column: PreNormalizedColumn): Column { + const dataTypeDefault = defaultColumnPropertiesByDataType[ + column.dataType ?? defaultColumnProperties.dataType + ]; + + const caption = captionize(column.name); + + const colWithDefaults = { + ...defaultColumnProperties, + ...dataTypeDefault, + caption, + ...column, + }; + + return { + ...colWithDefaults, + calculateDisplayValue: isString(colWithDefaults.calculateDisplayValue) + ? compileGetter(colWithDefaults.calculateDisplayValue) as (data: unknown) => string + : colWithDefaults.calculateDisplayValue, + }; +} + +export function getVisibleIndexes( + indexes: (number | undefined)[], +): number[] { + const newIndexes = [...indexes]; + let minNonExistingIndex = 0; + + indexes.forEach((visibleIndex, index) => { + while (newIndexes.includes(minNonExistingIndex)) { + minNonExistingIndex += 1; + } + + newIndexes[index] = visibleIndex ?? minNonExistingIndex; + }); + + return newIndexes as number[]; +} + +export function normalizeVisibleIndexes( + indexes: number[], + forceIndex?: number, +): number[] { + const indexMap = indexes.map( + (visibleIndex, index) => [index, visibleIndex], + ); + + const sortedIndexMap = new Array(indexes.length); + if (isDefined(forceIndex)) { + sortedIndexMap[indexes[forceIndex]] = forceIndex; + } + + let j = 0; + indexMap + .sort((a, b) => a[1] - b[1]) + .forEach(([index]) => { + if (index === forceIndex) { + return; + } + + if (isDefined(sortedIndexMap[j])) { + j += 1; + } + + sortedIndexMap[j] = index; + j += 1; + }); + + const returnIndexes = new Array(indexes.length); + sortedIndexMap.forEach((index, visibleIndex) => { + returnIndexes[index] = visibleIndex; + }); + return returnIndexes; +} + +export function normalizeColumns(columns: PreNormalizedColumn[]): Column[] { + const normalizedColumns = columns.map((c) => normalizeColumn(c)); + return normalizedColumns; +} + +export function preNormalizeColumns(columns: ColumnProperties[]): PreNormalizedColumn[] { + const normalizedColumns = columns + .map((column): ColumnSettings => { + if (typeof column === 'string') { + return { + dataField: column, + }; + } + + return column; + }) + .map((column, index) => ({ + ...column, + name: column.name ?? column.dataField ?? `column-${index}`, + })); + + const visibleIndexes = getVisibleIndexes( + normalizedColumns.map((c) => c.visibleIndex), + ); + + normalizedColumns.forEach((_, i) => { + normalizedColumns[i].visibleIndex = visibleIndexes[i]; + }); + + return normalizedColumns as PreNormalizedColumn[]; +} + +export function normalizeStringColumn(column: ColumnProperties): ColumnSettings { + if (typeof column === 'string') { + return { dataField: column }; + } + + return column; +} + +export function getColumnIndexByName(columns: PreNormalizedColumn[], name: string): number { + return columns.findIndex((c) => c.name === name); +} + +export function getColumnByIndexOrName( + columns: Column[], + columnNameOrIndex: string | number, +): Column | undefined { + const column = columns.find((c, i) => { + if (isString(columnNameOrIndex)) { + return c.name === columnNameOrIndex; + } + return i === columnNameOrIndex; + }); + + return column; +} diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/options.ts b/packages/devextreme/js/__internal/grids/new/grid_core/options.ts index 7020661d76d5..988fea008c30 100644 --- a/packages/devextreme/js/__internal/grids/new/grid_core/options.ts +++ b/packages/devextreme/js/__internal/grids/new/grid_core/options.ts @@ -2,15 +2,18 @@ import browser from '@js/core/utils/browser'; import { isMaterialBased } from '@js/ui/themes'; import type { WidgetOptions } from '@js/ui/widget/ui.widget'; +import * as columnsController from './columns_controller/index'; import type { GridCoreNew } from './widget'; /** * @interface */ export type Options = - & WidgetOptions; + & WidgetOptions + & columnsController.Options; export const defaultOptions = { + ...columnsController.defaultOptions, } satisfies Options; // TODO: separate by modules diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/options_controller/options_controller.mock.ts b/packages/devextreme/js/__internal/grids/new/grid_core/options_controller/options_controller.mock.ts index 9aedf84a4c13..18dd6d589a37 100644 --- a/packages/devextreme/js/__internal/grids/new/grid_core/options_controller/options_controller.mock.ts +++ b/packages/devextreme/js/__internal/grids/new/grid_core/options_controller/options_controller.mock.ts @@ -1,6 +1,11 @@ -import type { defaultOptions, Options } from '../options'; +import type { Options } from '../options'; +import { defaultOptions } from '../options'; import { OptionsControllerMock as OptionsControllerBaseMock } from './options_controller_base.mock'; export class OptionsControllerMock extends OptionsControllerBaseMock< Options, typeof defaultOptions -> {} +> { + constructor(options: Options) { + super(options, defaultOptions); + } +} diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/options_controller/options_controller_base.mock.ts b/packages/devextreme/js/__internal/grids/new/grid_core/options_controller/options_controller_base.mock.ts index 5f17604179a3..3a3a1747e202 100644 --- a/packages/devextreme/js/__internal/grids/new/grid_core/options_controller/options_controller_base.mock.ts +++ b/packages/devextreme/js/__internal/grids/new/grid_core/options_controller/options_controller_base.mock.ts @@ -13,9 +13,10 @@ export class OptionsControllerMock< TDefaultProps extends TProps, > extends OptionsController { private readonly componentMock: Component; - constructor(options: TProps) { + constructor(options: TProps, defaultOptions: TDefaultProps) { const componentMock = new Component(options); super(componentMock); + this.defaults = defaultOptions; this.componentMock = componentMock; } diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/options_controller/options_controller_base.ts b/packages/devextreme/js/__internal/grids/new/grid_core/options_controller/options_controller_base.ts index 4f8cb64fe4ad..8bc4c67b7958 100644 --- a/packages/devextreme/js/__internal/grids/new/grid_core/options_controller/options_controller_base.ts +++ b/packages/devextreme/js/__internal/grids/new/grid_core/options_controller/options_controller_base.ts @@ -74,7 +74,7 @@ export class OptionsController { private readonly props: SubsGetsUpd; - private readonly defaults: TDefaultProps; + protected defaults: TDefaultProps; public static dependencies = [Component]; diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/types.ts b/packages/devextreme/js/__internal/grids/new/grid_core/types.ts index e29614212c8a..6549371773f2 100644 --- a/packages/devextreme/js/__internal/grids/new/grid_core/types.ts +++ b/packages/devextreme/js/__internal/grids/new/grid_core/types.ts @@ -1,4 +1,9 @@ import type { template } from '@js/core/templates/template'; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type Constructor = new(...deps: TDeps) => T; + // TODO export type Template = (props: T) => HTMLDivElement | template; + +export type WithRequired = Omit & Required>; diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/widget.ts b/packages/devextreme/js/__internal/grids/new/grid_core/widget.ts index 3c1a6fd2d661..280e3cc84294 100644 --- a/packages/devextreme/js/__internal/grids/new/grid_core/widget.ts +++ b/packages/devextreme/js/__internal/grids/new/grid_core/widget.ts @@ -9,6 +9,7 @@ import { DIContext } from '@ts/core/di/index'; import type { Subscription } from '@ts/core/reactive/index'; import { render } from 'inferno'; +import * as ColumnsControllerModule from './columns_controller/index'; import { MainView } from './main_view'; import { defaultOptions, defaultOptionsRules, type Options } from './options'; @@ -19,11 +20,15 @@ export class GridCoreNewBase< protected diContext!: DIContext; + protected columnsController!: ColumnsControllerModule.ColumnsController; + protected _registerDIContext(): void { this.diContext = new DIContext(); + this.diContext.register(ColumnsControllerModule.ColumnsController); } protected _initDIContext(): void { + this.columnsController = this.diContext.get(ColumnsControllerModule.ColumnsController); } protected _init(): void { @@ -65,4 +70,6 @@ export class GridCoreNewBase< } } -export class GridCoreNew extends GridCoreNewBase {} +export class GridCoreNew extends ColumnsControllerModule.PublicMethods( + GridCoreNewBase, +) {} diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets/optionChanged_bundled.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets/optionChanged_bundled.tests.js index bc28d7d5f7d1..fcf3f2b30792 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/optionChanged_bundled.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/optionChanged_bundled.tests.js @@ -63,7 +63,8 @@ QUnit.module('OptionChanged', { } const excludedComponents = [ - 'dxLayoutManager' + 'dxLayoutManager', + 'dxCardView', ]; const getDefaultOptions = function(componentName) {