diff --git a/packages/devextreme/js/__internal/core/di/index.test.ts b/packages/devextreme/js/__internal/core/di/index.test.ts new file mode 100644 index 000000000000..4e5ece50a074 --- /dev/null +++ b/packages/devextreme/js/__internal/core/di/index.test.ts @@ -0,0 +1,186 @@ +/* eslint-disable @typescript-eslint/no-extraneous-class */ +/* eslint-disable prefer-const */ +/* eslint-disable @typescript-eslint/init-declarations */ +/* eslint-disable max-classes-per-file */ +/* eslint-disable class-methods-use-this */ +import { describe, expect, it } from '@jest/globals'; + +import { DIContext } from './index'; + +describe('basic', () => { + describe('register', () => { + class MyClass { + static dependencies = [] as const; + + getNumber(): number { + return 1; + } + } + + it('should return registered class', () => { + const ctx = new DIContext(); + ctx.register(MyClass); + + expect(ctx.get(MyClass)).toBeInstanceOf(MyClass); + expect(ctx.get(MyClass).getNumber()).toBe(1); + }); + + it('should return registered class with tryGet', () => { + const ctx = new DIContext(); + ctx.register(MyClass); + + expect(ctx.tryGet(MyClass)).toBeInstanceOf(MyClass); + expect(ctx.tryGet(MyClass)?.getNumber()).toBe(1); + }); + + it('should return same instance each time', () => { + const ctx = new DIContext(); + ctx.register(MyClass); + + expect(ctx.get(MyClass)).toBe(ctx.get(MyClass)); + }); + }); + + describe('registerInstance', () => { + class MyClass { + static dependencies = [] as const; + + getNumber(): number { + return 1; + } + } + + const ctx = new DIContext(); + const instance = new MyClass(); + ctx.registerInstance(MyClass, instance); + + it('should work', () => { + expect(ctx.get(MyClass)).toBe(instance); + }); + }); + + describe('non registered items', () => { + const ctx = new DIContext(); + class MyClass { + static dependencies = [] as const; + + getNumber(): number { + return 1; + } + } + it('should throw', () => { + expect(() => ctx.get(MyClass)).toThrow(); + }); + it('should not throw if tryGet', () => { + expect(ctx.tryGet(MyClass)).toBe(null); + }); + }); +}); + +describe('dependencies', () => { + class MyUtilityClass { + static dependencies = [] as const; + + getNumber(): number { + return 2; + } + } + + class MyClass { + static dependencies = [MyUtilityClass] as const; + + constructor(private readonly utility: MyUtilityClass) {} + + getSuperNumber(): number { + return this.utility.getNumber() * 2; + } + } + + const ctx = new DIContext(); + ctx.register(MyUtilityClass); + ctx.register(MyClass); + + it('should return registered class', () => { + expect(ctx.get(MyClass)).toBeInstanceOf(MyClass); + expect(ctx.get(MyUtilityClass)).toBeInstanceOf(MyUtilityClass); + }); + + it('dependecies should work', () => { + expect(ctx.get(MyClass).getSuperNumber()).toBe(4); + }); +}); + +describe('mocks', () => { + class MyClass { + static dependencies = [] as const; + + getNumber(): number { + return 1; + } + } + + class MyClassMock implements MyClass { + static dependencies = [] as const; + + getNumber(): number { + return 2; + } + } + + const ctx = new DIContext(); + ctx.register(MyClass, MyClassMock); + + it('should return mock class when they are registered', () => { + expect(ctx.get(MyClass)).toBeInstanceOf(MyClassMock); + expect(ctx.get(MyClass).getNumber()).toBe(2); + }); +}); + +it('should work regardless of registration order', () => { + class MyClass { + static dependencies = [] as const; + + getNumber(): number { + return 1; + } + } + + class MyDependentClass { + static dependencies = [MyClass] as const; + + constructor(private readonly myClass: MyClass) {} + + getSuperNumber(): number { + return this.myClass.getNumber() * 2; + } + } + + const ctx = new DIContext(); + ctx.register(MyDependentClass); + ctx.register(MyClass); + expect(ctx.get(MyDependentClass).getSuperNumber()).toBe(2); +}); + +describe('dependency cycle', () => { + class MyClass1 { + // @ts-expect-error + // eslint-disable-next-line @typescript-eslint/no-use-before-define + static dependencies = [MyClass2] as const; + + constructor(private readonly myClass2: MyClass2) {} + } + class MyClass2 { + static dependencies = [MyClass1] as const; + + constructor(private readonly myClass1: MyClass1) {} + } + + const ctx = new DIContext(); + ctx.register(MyClass1); + ctx.register(MyClass2); + + it('should throw', () => { + expect(() => ctx.get(MyClass1)).toThrow(); + expect(() => ctx.get(MyClass2)).toThrow(); + }); +}); diff --git a/packages/devextreme/js/__internal/core/di/index.ts b/packages/devextreme/js/__internal/core/di/index.ts new file mode 100644 index 000000000000..4ec4380fb408 --- /dev/null +++ b/packages/devextreme/js/__internal/core/di/index.ts @@ -0,0 +1,90 @@ +/* eslint-disable spellcheck/spell-checker */ +/* eslint-disable @typescript-eslint/no-unsafe-return */ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +// eslint-disable-next-line @typescript-eslint/ban-types +interface AbstractType extends Function { + prototype: T; +} + +type Constructor = new(...deps: TDeps) => T; + +interface DIItem extends Constructor { + dependencies: readonly [...{ [P in keyof TDeps]: AbstractType }]; +} + +export class DIContext { + private readonly instances: Map = new Map(); + + private readonly fabrics: Map = new Map(); + + private readonly antiRecursionSet = new Set(); + + public register( + id: AbstractType, + fabric: DIItem, + ): void; + public register( + idAndFabric: DIItem, + ): void; + public register( + id: DIItem, + fabric?: DIItem, + ): void { + // eslint-disable-next-line no-param-reassign + fabric ??= id; + this.fabrics.set(id, fabric); + } + + public registerInstance( + id: AbstractType, + instance: T, + ): void { + this.instances.set(id, instance); + } + + public get( + id: AbstractType, + ): T { + const instance = this.tryGet(id); + + if (instance) { + return instance; + } + + throw new Error('DI item is not registered'); + } + + public tryGet( + id: AbstractType, + ): T | null { + if (this.instances.get(id)) { + return this.instances.get(id) as T; + } + + const fabric = this.fabrics.get(id); + if (fabric) { + const res: T = this.create(fabric as any); + this.instances.set(id, res); + this.instances.set(fabric, res); + return res; + } + + return null; + } + + private create(fabric: DIItem): T { + if (this.antiRecursionSet.has(fabric)) { + throw new Error('dependency cycle in DI'); + } + + this.antiRecursionSet.add(fabric); + + const args = fabric.dependencies.map((dependency) => this.get(dependency)); + + this.antiRecursionSet.delete(fabric); + + // eslint-disable-next-line new-cap + return new fabric(...args as any); + } +} diff --git a/packages/devextreme/js/__internal/core/reactive/core.ts b/packages/devextreme/js/__internal/core/reactive/core.ts new file mode 100644 index 000000000000..e0874385e623 --- /dev/null +++ b/packages/devextreme/js/__internal/core/reactive/core.ts @@ -0,0 +1,88 @@ +/* eslint-disable spellcheck/spell-checker */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable max-classes-per-file */ + +import { type Subscription, SubscriptionBag } from './subscription'; +import type { + Callback, Gettable, Subscribable, Updatable, +} from './types'; + +export class Observable implements Subscribable, Updatable, Gettable { + private readonly callbacks: Set> = new Set(); + + constructor(private value: T) {} + + update(value: T): void { + if (this.value === value) { + return; + } + this.value = value; + + this.callbacks.forEach((c) => { + c(value); + }); + } + + updateFunc(func: (oldValue: T) => T): void { + this.update(func(this.value)); + } + + subscribe(callback: Callback): Subscription { + this.callbacks.add(callback); + callback(this.value); + + return { + unsubscribe: () => this.callbacks.delete(callback), + }; + } + + unreactive_get(): T { + return this.value; + } + + dispose(): void { + this.callbacks.clear(); + } +} + +export class InterruptableComputed< + TArgs extends readonly any[], TValue, +> extends Observable { + private readonly depValues: [...TArgs]; + + private readonly depInitialized: boolean[]; + + private isInitialized = false; + + private readonly subscriptions = new SubscriptionBag(); + + constructor( + compute: (...args: TArgs) => TValue, + deps: { [I in keyof TArgs]: Subscribable }, + ) { + super(undefined as any); + + this.depValues = deps.map(() => undefined) as any; + this.depInitialized = deps.map(() => false); + + deps.forEach((dep, i) => { + this.subscriptions.add(dep.subscribe((v) => { + this.depValues[i] = v; + + if (!this.isInitialized) { + this.depInitialized[i] = true; + this.isInitialized = this.depInitialized.every((e) => e); + } + + if (this.isInitialized) { + this.update(compute(...this.depValues)); + } + })); + }); + } + + dispose(): void { + super.dispose(); + this.subscriptions.unsubscribe(); + } +} diff --git a/packages/devextreme/js/__internal/core/reactive/index.ts b/packages/devextreme/js/__internal/core/reactive/index.ts new file mode 100644 index 000000000000..e2e8474530df --- /dev/null +++ b/packages/devextreme/js/__internal/core/reactive/index.ts @@ -0,0 +1,3 @@ +export * from './subscription'; +export * from './types'; +export * from './utilities'; diff --git a/packages/devextreme/js/__internal/core/reactive/subscription.ts b/packages/devextreme/js/__internal/core/reactive/subscription.ts new file mode 100644 index 000000000000..d3bc303311df --- /dev/null +++ b/packages/devextreme/js/__internal/core/reactive/subscription.ts @@ -0,0 +1,17 @@ +export interface Subscription { + unsubscribe: () => void; +} + +export class SubscriptionBag implements Subscription { + private readonly subscriptions: Subscription[] = []; + + add(subscription: Subscription): void { + this.subscriptions.push(subscription); + } + + unsubscribe(): void { + this.subscriptions.forEach((subscription) => { + subscription.unsubscribe(); + }); + } +} diff --git a/packages/devextreme/js/__internal/core/reactive/types.ts b/packages/devextreme/js/__internal/core/reactive/types.ts new file mode 100644 index 000000000000..176361809ac4 --- /dev/null +++ b/packages/devextreme/js/__internal/core/reactive/types.ts @@ -0,0 +1,28 @@ +/* eslint-disable spellcheck/spell-checker */ +import type { Subscription } from './subscription'; + +export interface Subscribable { + subscribe: (callback: Callback) => Subscription; +} + +export type MaybeSubscribable = T | Subscribable; + +export type MapMaybeSubscribable = { [K in keyof T]: MaybeSubscribable }; + +export function isSubscribable(value: unknown): value is Subscribable { + return typeof value === 'object' && !!value && 'subscribe' in value; +} + +export type Callback = (value: T) => void; + +export interface Updatable { + update: (value: T) => void; + updateFunc: (func: (oldValue: T) => T) => void; +} + +export interface Gettable { + unreactive_get: () => T; +} + +export type SubsGets = Subscribable & Gettable; +export type SubsGetsUpd = Subscribable & Gettable & Updatable; diff --git a/packages/devextreme/js/__internal/core/reactive/utilities.test.ts b/packages/devextreme/js/__internal/core/reactive/utilities.test.ts new file mode 100644 index 000000000000..c2faab3d23c4 --- /dev/null +++ b/packages/devextreme/js/__internal/core/reactive/utilities.test.ts @@ -0,0 +1,217 @@ +/* eslint-disable spellcheck/spell-checker */ +import { + beforeEach, describe, expect, it, jest, +} from '@jest/globals'; + +import { + computed, interruptableComputed, state, toSubscribable, +} from './utilities'; + +describe('state', () => { + let myState = state('some value'); + + beforeEach(() => { + myState = state('some value'); + }); + + describe('unreactive_get', () => { + it('should return value', () => { + expect(myState.unreactive_get()).toBe('some value'); + }); + + it('should return current value if it was updated', () => { + myState.update('new value'); + expect(myState.unreactive_get()).toBe('new value'); + }); + }); + + describe('subscribe', () => { + it('should call callback on initial set', () => { + const callback = jest.fn(); + myState.subscribe(callback); + + expect(callback).toBeCalledTimes(1); + expect(callback).toBeCalledWith('some value'); + }); + + it('should call callback on update', () => { + const callback = jest.fn(); + myState.subscribe(callback); + + myState.update('new value'); + + expect(callback).toBeCalledTimes(2); + expect(callback).toHaveBeenNthCalledWith(1, 'some value'); + expect(callback).toHaveBeenNthCalledWith(2, 'new value'); + }); + + it('should not trigger update if value is not changed', () => { + const callback = jest.fn(); + myState.subscribe(callback); + + expect(callback).toBeCalledTimes(1); + + myState.update('some value'); + + expect(callback).toBeCalledTimes(1); + }); + }); + + describe('dispose', () => { + it('should prevent all updates', () => { + const callback = jest.fn(); + myState.subscribe(callback); + + expect(callback).toBeCalledTimes(1); + expect(callback).toBeCalledWith('some value'); + + // @ts-expect-error + myState.dispose(); + myState.update('new value'); + + expect(callback).toBeCalledTimes(1); + }); + }); +}); + +describe('computed', () => { + let myState1 = state('some value'); + let myState2 = state('other value'); + let myComputed = computed( + (v1, v2) => `${v1} ${v2}`, + [myState1, myState2], + ); + + beforeEach(() => { + myState1 = state('some value'); + myState2 = state('other value'); + myComputed = computed( + (v1, v2) => `${v1} ${v2}`, + [myState1, myState2], + ); + }); + + describe('unreactive_get', () => { + it('should calculate initial value', () => { + expect(myComputed.unreactive_get()).toBe('some value other value'); + }); + + it('should return current value if it dependency is updated', () => { + myState1.update('new value'); + expect(myComputed.unreactive_get()).toBe('new value other value'); + }); + }); + + describe('subscribe', () => { + it('should call callback on initial set', () => { + const callback = jest.fn(); + myComputed.subscribe(callback); + + expect(callback).toBeCalledTimes(1); + expect(callback).toBeCalledWith('some value other value'); + }); + + it('should call callback on update of dependency', () => { + const callback = jest.fn(); + myComputed.subscribe(callback); + + myState1.update('new value'); + + expect(callback).toBeCalledTimes(2); + expect(callback).toHaveBeenNthCalledWith(1, 'some value other value'); + expect(callback).toHaveBeenNthCalledWith(2, 'new value other value'); + }); + }); +}); + +describe('interruptableComputed', () => { + let myState1 = state('some value'); + let myState2 = state('other value'); + let myComputed = interruptableComputed( + (v1, v2) => `${v1} ${v2}`, + [myState1, myState2], + ); + + beforeEach(() => { + myState1 = state('some value'); + myState2 = state('other value'); + myComputed = interruptableComputed( + (v1, v2) => `${v1} ${v2}`, + [myState1, myState2], + ); + }); + + describe('unreactive_get', () => { + it('should calculate initial value', () => { + expect(myComputed.unreactive_get()).toBe('some value other value'); + }); + + it('should return current value if it was updated', () => { + myComputed.update('new value'); + expect(myComputed.unreactive_get()).toBe('new value'); + }); + + it('should return current value if it dependency is updated', () => { + myState1.update('new value'); + expect(myComputed.unreactive_get()).toBe('new value other value'); + }); + }); + + describe('subscribe', () => { + it('should call callback on initial set', () => { + const callback = jest.fn(); + myComputed.subscribe(callback); + + expect(callback).toBeCalledTimes(1); + expect(callback).toBeCalledWith('some value other value'); + }); + + it('should call callback on update', () => { + const callback = jest.fn(); + myComputed.subscribe(callback); + + myComputed.update('new value'); + + expect(callback).toBeCalledTimes(2); + expect(callback).toHaveBeenNthCalledWith(1, 'some value other value'); + expect(callback).toHaveBeenNthCalledWith(2, 'new value'); + }); + + it('should call callback on update of dependency', () => { + const callback = jest.fn(); + myComputed.subscribe(callback); + + myState1.update('new value'); + + expect(callback).toBeCalledTimes(2); + expect(callback).toHaveBeenNthCalledWith(1, 'some value other value'); + expect(callback).toHaveBeenNthCalledWith(2, 'new value other value'); + }); + + it('should not trigger update if value is not changed', () => { + const callback = jest.fn(); + myComputed.subscribe(callback); + + expect(callback).toBeCalledTimes(1); + + myComputed.update('some value other value'); + + expect(callback).toBeCalledTimes(1); + }); + }); +}); + +describe('toSubscribable', () => { + it('should wrap value if it is not subscribable', () => { + const callback = jest.fn(); + toSubscribable('some value').subscribe(callback); + + expect(callback).toBeCalledTimes(1); + expect(callback).toBeCalledWith('some value'); + }); + + it('should return value as is if subscribable', () => { + const myState = state(1); + expect(toSubscribable(myState)).toBe(myState); + }); +}); diff --git a/packages/devextreme/js/__internal/core/reactive/utilities.ts b/packages/devextreme/js/__internal/core/reactive/utilities.ts new file mode 100644 index 000000000000..4c1538242e2b --- /dev/null +++ b/packages/devextreme/js/__internal/core/reactive/utilities.ts @@ -0,0 +1,211 @@ +/* eslint-disable @typescript-eslint/no-invalid-void-type */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable spellcheck/spell-checker */ +import { InterruptableComputed, Observable } from './core'; +import { type Subscription, SubscriptionBag } from './subscription'; +import type { + Gettable, MapMaybeSubscribable, MaybeSubscribable, Subscribable, SubsGets, SubsGetsUpd, Updatable, +} from './types'; +import { isSubscribable } from './types'; + +/** + * Creates new reactive state atom. + * @example + * ``` + * const myState = state(0); + * myState.update(1); + * ``` + * @param value initial value of state + */ +export function state(value: T): Subscribable & Updatable & Gettable { + return new Observable(value); +} + +/** + * Creates computed atom based on other atoms. + * @example + * ``` + * const myState = state(0); + * const myComputed = computed( + * (value) => value + 1, + * [myState] + * ); + * ``` + * @param compute computation func + * @param deps dependency atoms + */ +export function computed( + compute: (t1: T1) => TValue, + deps: [Subscribable] +): SubsGets; +export function computed( + compute: (t1: T1, t2: T2) => TValue, + deps: [Subscribable, Subscribable] +): SubsGets; +export function computed( + compute: (t1: T1, t2: T2, t3: T3,) => TValue, + deps: [Subscribable, Subscribable, Subscribable] +): SubsGets; +export function computed( + compute: (t1: T1, t2: T2, t3: T3, t4: T4) => TValue, + deps: [Subscribable, Subscribable, Subscribable, Subscribable] +): SubsGets; +export function computed( + compute: (t1: T1, t2: T2, t3: T3, t4: T4, t5: T5) => TValue, + deps: [Subscribable, Subscribable, Subscribable, Subscribable, Subscribable] +): SubsGets; +export function computed( + compute: (t1: T1, t2: T2, t3: T3, t4: T4, t5: T5, t6: T6) => TValue, + // eslint-disable-next-line max-len + deps: [Subscribable, Subscribable, Subscribable, Subscribable, Subscribable, Subscribable] +): SubsGets; +export function computed( + compute: (...args: TArgs) => TValue, + deps: { [I in keyof TArgs]: Subscribable }, +): SubsGets; +export function computed( + compute: (...args: TArgs) => TValue, + deps: { [I in keyof TArgs]: Subscribable }, +): SubsGets { + return new InterruptableComputed(compute, deps); +} + +/** + * Computed, with ability to override value using `.update(...)` method. + * @see {@link computed} + */ +export function interruptableComputed( + compute: (t1: T1) => TValue, + deps: [Subscribable] +): SubsGetsUpd; +export function interruptableComputed( + compute: (t1: T1, t2: T2) => TValue, + deps: [Subscribable, Subscribable] +): SubsGetsUpd; +export function interruptableComputed( + compute: (t1: T1, t2: T2, t3: T3,) => TValue, + deps: [Subscribable, Subscribable, Subscribable] +): SubsGetsUpd; +export function interruptableComputed( + compute: (t1: T1, t2: T2, t3: T3, t4: T4) => TValue, + deps: [Subscribable, Subscribable, Subscribable, Subscribable] +): SubsGetsUpd; +export function interruptableComputed( + compute: (...args: TArgs) => TValue, + deps: { [I in keyof TArgs]: Subscribable }, +): SubsGetsUpd { + return new InterruptableComputed(compute, deps); +} + +/** + * Allows to subscribe function with some side effects to changes of dependency atoms. + * @param callback function which is executed each time any dependency is updated + * @param deps dependencies + */ +export function effect( + callback: (t1: T1) => ((() => void) | void), + deps: [Subscribable] +): Subscription; +export function effect( + callback: (t1: T1, t2: T2) => ((() => void) | void), + deps: [Subscribable, Subscribable] +): Subscription; +export function effect( + callback: (t1: T1, t2: T2, t3: T3,) => ((() => void) | void), + deps: [Subscribable, Subscribable, Subscribable] +): Subscription; +export function effect( + callback: (t1: T1, t2: T2, t3: T3, t4: T4) => ((() => void) | void), + deps: [Subscribable, Subscribable, Subscribable, Subscribable] +): Subscription; +export function effect( + callback: (t1: T1, t2: T2, t3: T3, t4: T4, t5: T5) => ((() => void) | void), + deps: [Subscribable, Subscribable, Subscribable, Subscribable, Subscribable] +): Subscription; +export function effect( + callback: (...args: TArgs) => ((() => void) | void), + deps: { [I in keyof TArgs]: Subscribable }, +): Subscription { + const depValues: [...TArgs] = deps.map(() => undefined) as any; + const depInitialized = deps.map(() => false); + let isInitialized = false; + + const subscription = new SubscriptionBag(); + + deps.forEach((dep, i) => { + subscription.add(dep.subscribe((v) => { + depValues[i] = v; + + if (!isInitialized) { + depInitialized[i] = true; + isInitialized = depInitialized.every((e) => e); + } + + if (isInitialized) { + callback(...depValues); + } + })); + }); + + return subscription; +} + +export function toSubscribable(v: MaybeSubscribable): Subscribable { + if (isSubscribable(v)) { + return v; + } + + return new Observable(v); +} + +/** + * Condition atom, basing whether `cond` is true or false, + * returns value of `ifTrue` or `ifFalse` param. + * @param cond + * @param ifTrue + * @param ifFalse + */ +export function iif( + cond: MaybeSubscribable, + ifTrue: MaybeSubscribable, + ifFalse: MaybeSubscribable, +): Subscribable { + const obs = state(undefined as any); + // eslint-disable-next-line @typescript-eslint/init-declarations + let subscription: Subscription | undefined; + + // eslint-disable-next-line @typescript-eslint/no-shadow + toSubscribable(cond).subscribe((cond) => { + subscription?.unsubscribe(); + const newSource = cond ? ifTrue : ifFalse; + subscription = toSubscribable(newSource).subscribe(obs.update.bind(obs)); + }); + + return obs; +} + +/** + * Combines object of Subscribables to Subscribable of object. + * @example + * ``` + * const myValueA = state(0); + * const myValueB = state(1); + * const obj = combine({ + * myValueA, myValueB + * }); + * + * obj.unreactive_get(); // {myValueA: 0, myValueB: 1} + * @returns + */ +export function combined( + obj: MapMaybeSubscribable, +): SubsGets { + const entries = Object.entries(obj) as any as [string, Subscribable][]; + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return computed( + (...args) => Object.fromEntries( + args.map((v, i) => [entries[i][0], v]), + ), + entries.map(([, v]) => toSubscribable(v)), + ) as any; +} diff --git a/packages/devextreme/js/__internal/grids/grid_core/header_panel/m_header_panel.ts b/packages/devextreme/js/__internal/grids/grid_core/header_panel/m_header_panel.ts index c4682b9c663d..57b5dd0307ec 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/header_panel/m_header_panel.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/header_panel/m_header_panel.ts @@ -2,12 +2,12 @@ import messageLocalization from '@js/common/core/localization/message'; import $ from '@js/core/renderer'; import { getPathParts } from '@js/core/utils/data'; -import { extend } from '@js/core/utils/extend'; -import { isDefined, isString } from '@js/core/utils/type'; +import { isDefined } from '@js/core/utils/type'; import type { Properties as ToolbarProperties } from '@js/ui/toolbar'; import Toolbar from '@js/ui/toolbar'; import type { EditingController } from '@ts/grids/grid_core/editing/m_editing'; import type { HeaderFilterController } from '@ts/grids/grid_core/header_filter/m_header_filter'; +import { normalizeToolbarItems } from '@ts/grids/new/grid_core/toolbar/utils'; import type { ModuleType } from '../m_types'; import { ColumnsView } from '../views/m_columns_view'; @@ -72,7 +72,11 @@ export class HeaderPanel extends ColumnsView { }; const userItems = userToolbarOptions?.items; - options.toolbarOptions.items = this._normalizeToolbarItems(options.toolbarOptions.items, userItems); + options.toolbarOptions.items = normalizeToolbarItems( + options.toolbarOptions.items, + userItems, + DEFAULT_TOOLBAR_ITEM_NAMES, + ); this.executeAction('onToolbarPreparing', options); @@ -84,51 +88,6 @@ export class HeaderPanel extends ColumnsView { return options.toolbarOptions; } - private _normalizeToolbarItems(defaultItems, userItems) { - defaultItems.forEach((button) => { - if (!DEFAULT_TOOLBAR_ITEM_NAMES.includes(button.name)) { - throw new Error(`Default toolbar item '${button.name}' is not added to DEFAULT_TOOLBAR_ITEM_NAMES`); - } - }); - - const defaultProps = { - location: 'after', - }; - - const isArray = Array.isArray(userItems); - - if (!isDefined(userItems)) { - return defaultItems; - } - - if (!isArray) { - userItems = [userItems]; - } - - const defaultButtonsByNames = {}; - defaultItems.forEach((button) => { - defaultButtonsByNames[button.name] = button; - }); - - const normalizedItems = userItems.map((button) => { - if (isString(button)) { - button = { name: button }; - } - - if (isDefined(button.name)) { - if (isDefined(defaultButtonsByNames[button.name])) { - button = extend(true, {}, defaultButtonsByNames[button.name], button); - } else if (DEFAULT_TOOLBAR_ITEM_NAMES.includes(button.name)) { - button = { ...button, visible: false }; - } - } - - return extend(true, {}, defaultProps, button); - }); - - return isArray ? normalizedItems : normalizedItems[0]; - } - protected _renderCore() { if (!this._toolbar) { const $headerPanel = this.element(); @@ -217,7 +176,11 @@ export class HeaderPanel extends ColumnsView { this._invalidate(); } else if (parts.length === 3) { // `toolbar.items[i]` case - const normalizedItem = this._normalizeToolbarItems(this._getToolbarItems(), args.value); + const normalizedItem = normalizeToolbarItems( + this._getToolbarItems(), + [args.value], + DEFAULT_TOOLBAR_ITEM_NAMES, + )[0]; this._toolbar?.option(optionName, normalizedItem); } else if (parts.length >= 4) { // `toolbar.items[i].prop` case diff --git a/packages/devextreme/js/__internal/grids/new/card_view/__snapshots__/widget.test.ts.snap b/packages/devextreme/js/__internal/grids/new/card_view/__snapshots__/widget.test.ts.snap new file mode 100644 index 000000000000..a3748ebef495 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/new/card_view/__snapshots__/widget.test.ts.snap @@ -0,0 +1,12 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`common initial render should be successfull 1`] = ` +
+ +
+ +
+
+`; diff --git a/packages/devextreme/js/__internal/grids/new/card_view/main_view.tsx b/packages/devextreme/js/__internal/grids/new/card_view/main_view.tsx new file mode 100644 index 000000000000..90378facc2a4 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/new/card_view/main_view.tsx @@ -0,0 +1,58 @@ +/* eslint-disable spellcheck/spell-checker */ +/* eslint-disable @typescript-eslint/explicit-member-accessibility */ +import { combined } from '@ts/core/reactive/index'; +import { View } from '@ts/grids/new/grid_core/core/view'; +import { PagerView } from '@ts/grids/new/grid_core/pager/view'; +import { ToolbarView } from '@ts/grids/new/grid_core/toolbar/view'; +import type { ComponentType } from 'inferno'; + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +interface MainViewProps { + Toolbar: ComponentType; + Pager: ComponentType; +} + +function MainViewComponent({ + Toolbar, Pager, +}: MainViewProps): JSX.Element { + return (<> + {/* @ts-expect-error */} + +
+ {/* + TODO: + Pager, as renovated component, has strange disposing. + See `inferno_renderer.remove` method. + It somehow mutates $V prop of parent element. + Without this div, CardView would be parent of Pager. + In this case all `componentWillUnmount`s aren't called + */} + {/* @ts-expect-error */} + +
+ ); +} + +export class MainView extends View { + protected override component = MainViewComponent; + + public static dependencies = [ + PagerView, ToolbarView, + ] as const; + + constructor( + private readonly pager: PagerView, + private readonly toolbar: ToolbarView, + ) { + super(); + } + + // eslint-disable-next-line max-len + // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/explicit-function-return-type + protected override getProps() { + return combined({ + Toolbar: this.toolbar.asInferno(), + Pager: this.pager.asInferno(), + }); + } +} diff --git a/packages/devextreme/js/__internal/grids/new/card_view/options.ts b/packages/devextreme/js/__internal/grids/new/card_view/options.ts new file mode 100644 index 000000000000..2448e49792c3 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/new/card_view/options.ts @@ -0,0 +1,11 @@ +import * as GridCore from '@ts/grids/new/grid_core/options'; + +/** + * @interface + */ +export type Options = + & GridCore.Options; + +export const defaultOptions = { + ...GridCore.defaultOptions, +} satisfies Options; 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/card_view/options_controller.ts b/packages/devextreme/js/__internal/grids/new/card_view/options_controller.ts new file mode 100644 index 000000000000..2e3c4cb2bcf2 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/new/card_view/options_controller.ts @@ -0,0 +1,7 @@ +import { OptionsController } from '@ts/grids/new/grid_core/options_controller/options_controller_base'; + +import type { defaultOptions, Options } from './options'; + +class CardViewOptionsController extends OptionsController {} + +export { CardViewOptionsController as OptionsController }; diff --git a/packages/devextreme/js/__internal/grids/new/card_view/widget.test.ts b/packages/devextreme/js/__internal/grids/new/card_view/widget.test.ts new file mode 100644 index 000000000000..baede2d8bbc0 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/new/card_view/widget.test.ts @@ -0,0 +1,15 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { describe, expect, it } from '@jest/globals'; + +import { CardView } from './widget'; + +describe('common', () => { + describe('initial render', () => { + it('should be successfull', () => { + const container = document.createElement('div'); + const cardView = new CardView(container, {}); + + expect(container).toMatchSnapshot(); + }); + }); +}); diff --git a/packages/devextreme/js/__internal/grids/new/card_view/widget.ts b/packages/devextreme/js/__internal/grids/new/card_view/widget.ts new file mode 100644 index 000000000000..420732885d7d --- /dev/null +++ b/packages/devextreme/js/__internal/grids/new/card_view/widget.ts @@ -0,0 +1,48 @@ +/* eslint-disable max-classes-per-file */ +/* eslint-disable spellcheck/spell-checker */ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import registerComponent from '@js/core/component_registrator'; +import $ from '@js/core/renderer'; +import { MainView as MainViewBase } from '@ts/grids/new/grid_core/main_view'; +import { OptionsController as OptionsControllerBase } from '@ts/grids/new/grid_core/options_controller/options_controller'; +import { GridCoreNew } from '@ts/grids/new/grid_core/widget'; + +import { MainView } from './main_view'; +import { defaultOptions } from './options'; +import { OptionsController } from './options_controller'; + +export class CardViewBase extends GridCoreNew { + protected _registerDIContext(): void { + super._registerDIContext(); + this.diContext.register(MainViewBase, MainView); + + const optionsController = new OptionsController(this); + this.diContext.registerInstance(OptionsController, optionsController); + this.diContext.registerInstance(OptionsControllerBase, optionsController); + } + + protected _initMarkup(): void { + super._initMarkup(); + $(this.$element()).addClass('dx-cardview'); + } + + protected _initDIContext(): void { + super._initDIContext(); + } + + // eslint-disable-next-line max-len + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type, @typescript-eslint/explicit-module-boundary-types + protected _getDefaultOptions() { + return { + ...super._getDefaultOptions(), + ...defaultOptions, + }; + } +} + +export class CardView extends CardViewBase {} + +// @ts-expect-error +registerComponent('dxCardView', CardView); + +export default CardView; 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/core/view.tsx b/packages/devextreme/js/__internal/grids/new/grid_core/core/view.tsx new file mode 100644 index 000000000000..497d30813c43 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/new/grid_core/core/view.tsx @@ -0,0 +1,64 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +/* eslint-disable @typescript-eslint/no-this-alias */ +/* eslint-disable @typescript-eslint/no-use-before-define */ +/* eslint-disable @typescript-eslint/no-unsafe-return */ +/* eslint-disable @typescript-eslint/ban-types */ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +/* eslint-disable max-classes-per-file */ +/* eslint-disable spellcheck/spell-checker */ +import type { Subscribable, Subscription } from '@ts/core/reactive/index'; +import { toSubscribable } from '@ts/core/reactive/index'; +import { Component, type ComponentType, render } from 'inferno'; + +export abstract class View { + private inferno: undefined | ComponentType; + + protected abstract component: ComponentType; + + protected abstract getProps(): Subscribable; + + public render(root: Element): Subscription { + const ViewComponent = this.component; + return toSubscribable(this.getProps()).subscribe((props: T) => { + // @ts-expect-error + render(, root); + }); + } + + public asInferno(): ComponentType { + // @ts-expect-error fixed in inferno v8 + // eslint-disable-next-line no-return-assign + return this.inferno ??= this._asInferno(); + } + + private _asInferno() { + const view = this; + + interface State { + props: T; + } + + return class InfernoView extends Component<{}, State> { + private readonly subscription: Subscription; + + constructor() { + super(); + this.subscription = toSubscribable(view.getProps()).subscribe((props) => { + this.state ??= { + props, + }; + + if (this.state.props !== props) { + this.setState({ props }); + } + }); + } + + public render(): JSX.Element | undefined { + const ViewComponent = view.component; + // @ts-expect-error + return ; + } + }; + } +} diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/data_controller/__snapshots__/options.test.ts.snap b/packages/devextreme/js/__internal/grids/new/grid_core/data_controller/__snapshots__/options.test.ts.snap new file mode 100644 index 000000000000..bdab937fa145 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/new/grid_core/data_controller/__snapshots__/options.test.ts.snap @@ -0,0 +1,10 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Options onDataErrorOccurred should be called when load error happens 1`] = ` +[ + { + "component": Any, + "error": [Error: my error], + }, +] +`; diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/data_controller/data_controller.ts b/packages/devextreme/js/__internal/grids/new/grid_core/data_controller/data_controller.ts new file mode 100644 index 000000000000..88d56c4a01bb --- /dev/null +++ b/packages/devextreme/js/__internal/grids/new/grid_core/data_controller/data_controller.ts @@ -0,0 +1,144 @@ +/* eslint-disable @typescript-eslint/no-invalid-void-type */ +/* eslint-disable no-param-reassign */ +/* eslint-disable spellcheck/spell-checker */ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import type { DataSource } from '@js/common/data'; +import type { SubsGets } from '@ts/core/reactive/index'; +import { + computed, effect, state, +} from '@ts/core/reactive/index'; +import { createPromise } from '@ts/core/utils/promise'; + +import { OptionsController } from '../options_controller/options_controller'; +import type { DataObject, Key } from './types'; +import { normalizeDataSource, updateItemsImmutable } from './utils'; + +export class DataController { + private readonly loadedPromise = createPromise(); + + private readonly dataSourceConfiguration = this.options.oneWay('dataSource'); + + private readonly keyExpr = this.options.oneWay('keyExpr'); + + public readonly dataSource = computed( + (dataSourceLike, keyExpr) => normalizeDataSource(dataSourceLike, keyExpr), + [this.dataSourceConfiguration, this.keyExpr], + ); + + // TODO + private readonly cacheEnabled = this.options.oneWay('cacheEnabled'); + + private readonly pagingEnabled = this.options.twoWay('paging.enabled'); + + public readonly pageIndex = this.options.twoWay('paging.pageIndex'); + + public readonly pageSize = this.options.twoWay('paging.pageSize'); + + // TODO + private readonly remoteOperations = this.options.oneWay('remoteOperations'); + + private readonly onDataErrorOccurred = this.options.action('onDataErrorOccurred'); + + private readonly _items = state([]); + + public readonly items: SubsGets = this._items; + + private readonly _totalCount = state(0); + + public readonly totalCount: SubsGets = this._totalCount; + + public readonly isLoading = state(false); + + public readonly pageCount = computed( + (totalCount, pageSize) => Math.ceil(totalCount / pageSize), + [this.totalCount, this.pageSize], + ); + + public static dependencies = [OptionsController] as const; + + constructor( + private readonly options: OptionsController, + ) { + effect( + (dataSource) => { + const changedCallback = (e?): void => { + this.onChanged(dataSource, e); + }; + const loadingChangedCallback = (): void => { + this.isLoading.update(dataSource.isLoading()); + }; + const loadErrorCallback = (error: string): void => { + const callback = this.onDataErrorOccurred.unreactive_get(); + callback({ error }); + changedCallback(); + }; + + if (dataSource.isLoaded()) { + changedCallback(); + } + dataSource.on('changed', changedCallback); + dataSource.on('loadingChanged', loadingChangedCallback); + dataSource.on('loadError', loadErrorCallback); + + return (): void => { + dataSource.off('changed', changedCallback); + dataSource.off('loadingChanged', loadingChangedCallback); + dataSource.off('loadError', loadErrorCallback); + }; + }, + [this.dataSource], + ); + + effect( + (dataSource, pageIndex, pageSize, pagingEnabled) => { + let someParamChanged = false; + if (dataSource.pageIndex() !== pageIndex) { + dataSource.pageIndex(pageIndex); + someParamChanged ||= true; + } + if (dataSource.pageSize() !== pageSize) { + dataSource.pageSize(pageSize); + someParamChanged ||= true; + } + // eslint-disable-next-line @typescript-eslint/no-unnecessary-boolean-literal-compare + if (dataSource.requireTotalCount() !== true) { + dataSource.requireTotalCount(true); + someParamChanged ||= true; + } + if (dataSource.paginate() !== pagingEnabled) { + dataSource.paginate(pagingEnabled); + someParamChanged ||= true; + } + + if (someParamChanged || !dataSource.isLoaded()) { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + dataSource.load(); + } + }, + [this.dataSource, this.pageIndex, this.pageSize, this.pagingEnabled], + ); + } + + private onChanged(dataSource: DataSource, e): void { + let items = dataSource.items() as DataObject[]; + + if (e?.changes) { + items = this._items.unreactive_get(); + items = updateItemsImmutable(items, e.changes, dataSource.store()); + } + + this._items.update(items); + this.pageIndex.update(dataSource.pageIndex()); + this.pageSize.update(dataSource.pageSize()); + this._totalCount.update(dataSource.totalCount()); + this.loadedPromise.resolve(); + } + + public getDataKey(data: DataObject): Key { + return this.dataSource.unreactive_get().store().keyOf(data); + } + + public waitLoaded(): Promise { + return this.loadedPromise.promise; + } +} diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/data_controller/index.ts b/packages/devextreme/js/__internal/grids/new/grid_core/data_controller/index.ts new file mode 100644 index 000000000000..11e3ebad75f5 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/new/grid_core/data_controller/index.ts @@ -0,0 +1,3 @@ +export { DataController } from './data_controller'; +export { defaultOptions, type Options } from './options'; +export { PublicMethods } from './public_methods'; diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/data_controller/options.test.ts b/packages/devextreme/js/__internal/grids/new/grid_core/data_controller/options.test.ts new file mode 100644 index 000000000000..6494e74ad5e9 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/new/grid_core/data_controller/options.test.ts @@ -0,0 +1,235 @@ +/* eslint-disable spellcheck/spell-checker */ +import { + afterAll, + beforeAll, + describe, expect, it, jest, +} from '@jest/globals'; +import { CustomStore } from '@js/common/data'; +import DataSource from '@js/data/data_source'; +import { logger } from '@ts/core/utils/m_console'; +import ArrayStore from '@ts/data/m_array_store'; + +import type { Options } from '../options'; +import { OptionsControllerMock } from '../options_controller/options_controller.mock'; +import { DataController } from './data_controller'; + +beforeAll(() => { + jest.spyOn(logger, 'error').mockImplementation(() => {}); +}); +afterAll(() => { + jest.restoreAllMocks(); +}); + +const setup = (options: Options) => { + const optionsController = new OptionsControllerMock(options); + const dataController = new DataController(optionsController); + + return { + optionsController, + dataController, + }; +}; + +describe('Options', () => { + describe('cacheEnabled', () => { + const setupForCacheEnabled = ({ cacheEnabled }) => { + const store = new ArrayStore({ + data: [ + { id: 1, value: 'value 1' }, + { id: 2, value: 'value 2' }, + { id: 3, value: 'value 3' }, + ], + key: 'id', + }); + + jest.spyOn(store, 'load'); + + const { dataController } = setup({ + cacheEnabled, + dataSource: store, + paging: { + pageSize: 1, + }, + }); + + return { store, dataController }; + }; + + describe('when it is false', () => { + it('should skip caching requests', () => { + const { store, dataController } = setupForCacheEnabled({ + cacheEnabled: false, + }); + expect(store.load).toBeCalledTimes(1); + + dataController.pageIndex.update(1); + expect(store.load).toBeCalledTimes(2); + + dataController.pageIndex.update(0); + expect(store.load).toBeCalledTimes(3); + }); + }); + + describe('when it is true', () => { + it.skip('should cache previously loaded pages', () => {}); + it.skip('should clear cache if not only pageIndex changed', () => {}); + }); + }); + + describe('dataSourse', () => { + describe('when it is dataSource instance', () => { + it('should pass dataSource as is', () => { + const dataSource = new DataSource({ + store: [{ a: 1 }, { b: 2 }], + }); + + const { dataController } = setup({ dataSource }); + + expect(dataController.dataSource.unreactive_get()).toBe(dataSource); + }); + }); + describe('when it is array', () => { + it('should normalize to DataSource with given items', () => { + const data = [{ a: 1 }, { b: 2 }]; + const { dataController } = setup({ dataSource: data }); + + const dataSource = dataController.dataSource.unreactive_get(); + + expect(dataSource).toBeInstanceOf(DataSource); + expect(dataSource.items()).toEqual(data); + }); + }); + describe('when it is empty', () => { + it('should should normalize to empty DataSource', () => { + const { dataController } = setup({}); + + const dataSource = dataController.dataSource.unreactive_get(); + + expect(dataSource).toBeInstanceOf(DataSource); + expect(dataSource.items()).toHaveLength(0); + }); + }); + }); + + describe('keyExpr', () => { + describe('when dataSource is array', () => { + it('should be passed as key to DataSource', () => { + const { dataController } = setup({ + dataSource: [{ myKeyExpr: 1 }, { myKeyExpr: 2 }], + keyExpr: 'myKeyExpr', + }); + + const dataSource = dataController.dataSource.unreactive_get(); + expect(dataSource.key()).toBe('myKeyExpr'); + }); + }); + describe('when dataSource is DataSource instance', () => { + it('should be ignored', () => { + const { dataController } = setup({ + dataSource: new ArrayStore({ + key: 'storeKeyExpr', + data: [{ storeKeyExpr: 1 }, { storeKeyExpr: 2 }], + }), + keyExpr: 'myKeyExpr', + }); + + const dataSource = dataController.dataSource.unreactive_get(); + expect(dataSource.key()).toBe('storeKeyExpr'); + }); + }); + }); + + describe('onDataErrorOccurred', () => { + it('should be called when load error happens', async () => { + const onDataErrorOccurred = jest.fn(); + + const { dataController } = setup({ + dataSource: new CustomStore({ + load() { + return Promise.reject(new Error('my error')); + }, + }), + onDataErrorOccurred, + }); + + await dataController.waitLoaded(); + + expect(onDataErrorOccurred).toBeCalledTimes(1); + expect(onDataErrorOccurred.mock.calls[0]).toMatchSnapshot([{ + component: expect.any(Object), + }]); + }); + }); + + describe('paging.enabled', () => { + describe('when it is true', () => { + it('should turn on pagination', () => { + const { dataController } = setup({ + dataSource: [{ a: '1' }, { a: '2' }, { a: '3' }, { a: '4' }], + paging: { + enabled: true, + pageSize: 2, + }, + }); + + const items = dataController.items.unreactive_get(); + expect(items).toHaveLength(2); + }); + }); + describe('when it is false', () => { + it('should turn on pagination', () => { + const { dataController } = setup({ + dataSource: [{ a: '1' }, { a: '2' }, { a: '3' }, { a: '4' }], + paging: { + enabled: false, + pageSize: 2, + }, + }); + + const items = dataController.items.unreactive_get(); + expect(items).toHaveLength(4); + }); + }); + }); + + describe('paging.pageIndex', () => { + it('should change current page', () => { + const { dataController, optionsController } = setup({ + dataSource: [{ a: '1' }, { a: '2' }, { a: '3' }, { a: '4' }], + paging: { + pageSize: 2, + pageIndex: 1, + }, + }); + + let items = dataController.items.unreactive_get(); + expect(items).toEqual([{ a: '3' }, { a: '4' }]); + + optionsController.option('paging.pageIndex', 0); + items = dataController.items.unreactive_get(); + expect(items).toEqual([{ a: '1' }, { a: '2' }]); + }); + }); + + describe('paging.pageSize', () => { + it('should change size of current page', () => { + const { dataController, optionsController } = setup({ + dataSource: [{ a: '1' }, { a: '2' }, { a: '3' }, { a: '4' }], + paging: { + pageSize: 2, + }, + }); + + let items = dataController.items.unreactive_get(); + expect(items).toEqual([{ a: '1' }, { a: '2' }]); + + optionsController.option('paging.pageSize', 3); + items = dataController.items.unreactive_get(); + expect(items).toEqual([{ a: '1' }, { a: '2' }, { a: '3' }]); + }); + }); + + describe.skip('remoteOperations', () => { + + }); +}); diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/data_controller/options.ts b/packages/devextreme/js/__internal/grids/new/grid_core/data_controller/options.ts new file mode 100644 index 000000000000..0667209bf3d6 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/new/grid_core/data_controller/options.ts @@ -0,0 +1,40 @@ +import type { DataSourceLike } from '@js/data/data_source'; + +import type { Action } from '../types'; + +interface PagingOptions { + enabled?: boolean; + pageSize?: number; + pageIndex?: number; +} + +interface RemoteOperationsOptions { + filtering?: boolean; + paging?: boolean; + sorting?: boolean; + summary?: boolean; +} + +export interface Options { + cacheEnabled?: boolean; + dataSource?: DataSourceLike; + keyExpr?: string | string[]; + onDataErrorOccurred?: Action<{ error: string }>; + paging?: PagingOptions; + remoteOperations?: RemoteOperationsOptions | boolean; +} + +export const defaultOptions = { + paging: { + enabled: true, + pageSize: 6, + pageIndex: 0, + }, + remoteOperations: { + filtering: false, + paging: false, + sorting: false, + summary: false, + }, + cacheEnabled: true, +} satisfies Options; diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/data_controller/public_methods.test.ts b/packages/devextreme/js/__internal/grids/new/grid_core/data_controller/public_methods.test.ts new file mode 100644 index 000000000000..c0adb8e137e7 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/new/grid_core/data_controller/public_methods.test.ts @@ -0,0 +1,171 @@ +/* eslint-disable spellcheck/spell-checker */ +import { + describe, expect, it, jest, +} from '@jest/globals'; +import ArrayStore from '@ts/data/m_array_store'; + +import type { Options } from '../options'; +import { OptionsControllerMock } from '../options_controller/options_controller.mock'; +import { DataController } from './data_controller'; +import { PublicMethods } from './public_methods'; + +const setup = (options: Options) => { + const optionsController = new OptionsControllerMock(options); + const dataController = new DataController(optionsController); + // @ts-expect-error + const gridCore = new (PublicMethods(class { + protected dataController = dataController; + }))(); + + return { + optionsController, + dataController, + gridCore, + }; +}; + +describe('PublicMethods', () => { + describe('getDataSource', () => { + it('should return current dataSource', () => { + const data = [{ a: 1 }, { b: 2 }]; + const { gridCore, dataController } = setup({ dataSource: data }); + + expect( + gridCore.getDataSource(), + ).toBe( + dataController.dataSource.unreactive_get(), + ); + }); + }); + describe('byKey', () => { + it('should return item by key', async () => { + const { gridCore } = setup({ + keyExpr: 'id', + dataSource: [ + { id: 1, value: 'value 1' }, + { id: 2, value: 'value 2' }, + ], + }); + + expect(await gridCore.byKey(1)).toEqual({ id: 1, value: 'value 1' }); + expect(await gridCore.byKey(2)).toEqual({ id: 2, value: 'value 2' }); + }); + + describe('when needed item is already loaded', () => { + it('should return item by given key without request', async () => { + const store = new ArrayStore({ + data: [ + { id: 1, value: 'value 1' }, + { id: 2, value: 'value 2' }, + { id: 3, value: 'value 3' }, + ], + key: 'id', + }); + + jest.spyOn(store, 'byKey'); + + const { gridCore, dataController } = setup({ dataSource: store }); + await dataController.waitLoaded(); + + const item = await gridCore.byKey(1); + expect(store.byKey).toBeCalledTimes(0); + expect(item).toEqual({ id: 1, value: 'value 1' }); + }); + }); + describe('when needed item is not already loaded', () => { + it('should make request to get item by given key', async () => { + const store = new ArrayStore({ + data: [ + { id: 1, value: 'value 1' }, + { id: 2, value: 'value 2' }, + { id: 3, value: 'value 3' }, + ], + key: 'id', + }); + + jest.spyOn(store, 'byKey'); + + const { gridCore, dataController } = setup({ + dataSource: store, + paging: { pageSize: 1 }, + }); + await dataController.waitLoaded(); + + const item = await gridCore.byKey(2); + expect(store.byKey).toBeCalledTimes(1); + expect(item).toEqual({ id: 2, value: 'value 2' }); + }); + }); + }); + describe('getFilter', () => { + // TODO: add test once some filter module (header filter, filter row etc) is implemented + it.skip('should return filter applied to dataSource', () => { + }); + }); + + describe('keyOf', () => { + it('should return key of given data object', () => { + const { gridCore } = setup({ keyExpr: 'id', dataSource: [] }); + const dataObject = { value: 'my value', id: 'my id' }; + + expect(gridCore.keyOf(dataObject)).toBe('my id'); + }); + }); + + describe('pageCount', () => { + it('should return current page count', () => { + const { gridCore, dataController } = setup({ + dataSource: [{ a: '1' }, { a: '2' }, { a: '3' }, { a: '4' }], + paging: { + pageSize: 2, + }, + }); + expect(gridCore.pageCount()).toBe(2); + + dataController.pageSize.update(4); + expect(gridCore.pageCount()).toBe(1); + }); + }); + + describe('pageSize', () => { + it('should return current page size', () => { + const { gridCore, dataController } = setup({ + dataSource: [{ a: '1' }, { a: '2' }, { a: '3' }, { a: '4' }], + paging: { + pageSize: 2, + }, + }); + expect(gridCore.pageSize()).toBe(2); + + dataController.pageSize.update(4); + expect(gridCore.pageSize()).toBe(4); + }); + }); + + describe('pageIndex', () => { + it('should return current page index', () => { + const { gridCore, dataController } = setup({ + dataSource: [{ a: '1' }, { a: '2' }, { a: '3' }, { a: '4' }], + paging: { + pageSize: 2, + }, + }); + expect(gridCore.pageIndex()).toBe(0); + + dataController.pageIndex.update(3); + expect(gridCore.pageIndex()).toBe(3); + }); + }); + + describe('totalCount', () => { + it('should return current total count', () => { + const { gridCore } = setup({ + dataSource: [{ a: '1' }, { a: '2' }, { a: '3' }, { a: '4' }], + paging: { + pageSize: 2, + }, + }); + expect(gridCore.totalCount()).toBe(4); + }); + }); +}); diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/data_controller/public_methods.ts b/packages/devextreme/js/__internal/grids/new/grid_core/data_controller/public_methods.ts new file mode 100644 index 000000000000..1a1ec73a2c19 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/new/grid_core/data_controller/public_methods.ts @@ -0,0 +1,71 @@ +/* eslint-disable consistent-return */ +/* eslint-disable @typescript-eslint/no-invalid-void-type */ +/* eslint-disable spellcheck/spell-checker */ +/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import type { FilterDescriptor } from '@js/data'; +import type DataSource from '@js/data/data_source'; +import { keysEqual } from '@ts/data/m_utils'; + +import type { Constructor } from '../types'; +import type { GridCoreNewBase } from '../widget'; +import type { DataObject, Key } from './types'; + +export function PublicMethods>(GridCore: T) { + return class GridCoreWithDataController extends GridCore { + public getDataSource(): DataSource { + return this.dataController.dataSource.unreactive_get(); + } + + public byKey(key: Key): Promise | undefined { + const items = this.getDataSource().items(); + const store = this.getDataSource().store(); + const keyExpr = store.key(); + + const foundItem = items.find( + (item) => keysEqual(keyExpr, key, this.keyOf(item)), + ); + + if (foundItem) { + return Promise.resolve(foundItem); + } + + return store.byKey(key); + } + + public getFilter(): FilterDescriptor | FilterDescriptor[] { + return this.getDataSource().filter(); + } + + public keyOf(obj: DataObject) { + return this.dataController.getDataKey(obj); + } + + public pageCount(): number { + return this.dataController.pageCount.unreactive_get(); + } + + public pageSize(): number; + public pageSize(value: number): void; + public pageSize(value?: number): number | void { + if (value === undefined) { + return this.dataController.pageSize.unreactive_get(); + } + this.dataController.pageSize.update(value); + } + + public pageIndex(): number; + public pageIndex(newIndex: number): void; + public pageIndex(newIndex?: number): number | void { + if (newIndex === undefined) { + return this.dataController.pageIndex.unreactive_get(); + } + // TODO: Promise (jQuery or native) + return this.dataController.pageIndex.update(newIndex); + } + + public totalCount(): number { + return this.dataController.totalCount.unreactive_get(); + } + }; +} diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/data_controller/types.ts b/packages/devextreme/js/__internal/grids/new/grid_core/data_controller/types.ts new file mode 100644 index 000000000000..ceb8279bec89 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/new/grid_core/data_controller/types.ts @@ -0,0 +1,3 @@ +export type DataObject = Record; +export type Key = unknown; +export type KeyExpr = unknown; diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/data_controller/utils.ts b/packages/devextreme/js/__internal/grids/new/grid_core/data_controller/utils.ts new file mode 100644 index 000000000000..4fa3c0239553 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/new/grid_core/data_controller/utils.ts @@ -0,0 +1,46 @@ +/* eslint-disable @typescript-eslint/no-unsafe-return */ +/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import type { DataSourceLike } from '@js/data/data_source'; +import DataSource from '@js/data/data_source'; +import { normalizeDataSourceOptions } from '@js/data/data_source/utils'; +import { applyBatch } from '@ts/data/m_array_utils'; + +import type { DataObject } from './types'; + +export function normalizeDataSource( + dataSourceLike: DataSourceLike | null | undefined, + keyExpr: string | string[] | undefined, +): DataSource { + if (dataSourceLike instanceof DataSource) { + return dataSourceLike; + } + + if (Array.isArray(dataSourceLike)) { + // eslint-disable-next-line no-param-reassign + dataSourceLike = { + store: { + type: 'array', + data: dataSourceLike, + key: keyExpr, + }, + }; + } + + // TODO: research making second param not required + return new DataSource(normalizeDataSourceOptions(dataSourceLike, undefined)); +} + +export function updateItemsImmutable( + data: DataObject[], + changes: any[], + keyInfo: any, +): DataObject[] { + // @ts-expect-error + return applyBatch({ + keyInfo, + data, + changes, + immutable: true, + }); +} diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/inferno_wrappers/button.ts b/packages/devextreme/js/__internal/grids/new/grid_core/inferno_wrappers/button.ts new file mode 100644 index 000000000000..75e101617849 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/new/grid_core/inferno_wrappers/button.ts @@ -0,0 +1,10 @@ +import type { Properties as ButtonProperties } from '@js/ui/button'; +import dxButton from '@js/ui/button'; + +import { InfernoWrapper } from './widget_wrapper'; + +export class Button extends InfernoWrapper { + protected getComponentFabric(): typeof dxButton { + return dxButton; + } +} diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/inferno_wrappers/pager.ts b/packages/devextreme/js/__internal/grids/new/grid_core/inferno_wrappers/pager.ts new file mode 100644 index 000000000000..589e4a33dbf7 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/new/grid_core/inferno_wrappers/pager.ts @@ -0,0 +1,10 @@ +import type { Properties as PaginationProperties } from '@js/ui/pagination'; +import dxPagination from '@js/ui/pagination'; + +import { InfernoWrapper } from './widget_wrapper'; + +export class Pager extends InfernoWrapper { + protected getComponentFabric(): typeof dxPagination { + return dxPagination; + } +} diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/inferno_wrappers/template_wrapper.tsx b/packages/devextreme/js/__internal/grids/new/grid_core/inferno_wrappers/template_wrapper.tsx new file mode 100644 index 000000000000..a6684fa54a11 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/new/grid_core/inferno_wrappers/template_wrapper.tsx @@ -0,0 +1,37 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +/* eslint-disable @typescript-eslint/ban-types */ +import type { dxElementWrapper } from '@js/core/renderer'; +import $ from '@js/core/renderer'; +import { Component, createRef } from 'inferno'; + +interface TemplateType { + render: (args: { model: T; container: dxElementWrapper }) => void; +} + +// eslint-disable-next-line max-len +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type, @typescript-eslint/explicit-module-boundary-types +export function TemplateWrapper(template: TemplateType) { + return class Template extends Component { + private readonly ref = createRef(); + + private renderTemplate(): void { + $(this.ref.current!).empty(); + template.render({ + container: $(this.ref.current!), + model: this.props, + }); + } + + public render(): JSX.Element { + return
; + } + + public componentDidUpdate(): void { + this.renderTemplate(); + } + + public componentDidMount(): void { + this.renderTemplate(); + } + }; +} diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/inferno_wrappers/toolbar.ts b/packages/devextreme/js/__internal/grids/new/grid_core/inferno_wrappers/toolbar.ts new file mode 100644 index 000000000000..c9e2d216081e --- /dev/null +++ b/packages/devextreme/js/__internal/grids/new/grid_core/inferno_wrappers/toolbar.ts @@ -0,0 +1,40 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import '@js/ui/button'; +import '@js/ui/check_box'; + +import dxToolbar from '@js/ui/toolbar'; + +import type { ToolbarProps } from '../toolbar/types'; +import { InfernoWrapper } from './widget_wrapper'; + +export class Toolbar extends InfernoWrapper { + protected getComponentFabric(): typeof dxToolbar { + return dxToolbar; + } + + protected updateComponentOptions(prevProps: ToolbarProps, props: ToolbarProps): void { + if ( + Array.isArray(props.items) + && Array.isArray(prevProps.items) + && props.items.length === prevProps.items.length + ) { + props.items?.forEach((item, index) => { + if (props.items![index] !== prevProps.items![index]) { + const prevItem = prevProps.items![index]; + + Object.keys(item).forEach((key) => { + if (item[key] !== prevItem[key]) { + this.component?.option(`items[${index}].${key}`, props.items![index][key]); + } + }); + } + }); + + const { items, ...propsToUpdate } = props; + + super.updateComponentOptions(prevProps, propsToUpdate); + } else { + super.updateComponentOptions(prevProps, props); + } + } +} diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/inferno_wrappers/widget_wrapper.tsx b/packages/devextreme/js/__internal/grids/new/grid_core/inferno_wrappers/widget_wrapper.tsx new file mode 100644 index 000000000000..1ed801dbf5d3 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/new/grid_core/inferno_wrappers/widget_wrapper.tsx @@ -0,0 +1,54 @@ +import type DOMComponent from '@js/core/dom_component'; +import type { InfernoNode, RefObject } from 'inferno'; +import { Component, createRef } from 'inferno'; + +interface WithRef { + componentRef?: RefObject; +} + +export abstract class InfernoWrapper< + TProperties, + TComponent extends DOMComponent, +> extends Component> { + protected readonly ref = createRef(); + + protected component?: TComponent; + + protected abstract getComponentFabric(): new ( + element: Element, options: TProperties + ) => TComponent; + + public render(): InfernoNode { + return
; + } + + private updateComponentRef(): void { + if (this.props.componentRef) { + // @ts-expect-error + this.props.componentRef.current = this.component; + } + } + + protected updateComponentOptions(prevProps: TProperties, props: TProperties): void { + Object.keys(props as object).forEach((key) => { + if (props[key] !== prevProps[key]) { + this.component?.option(key, props[key]); + } + }); + } + + public componentDidMount(): void { + // eslint-disable-next-line no-new, @typescript-eslint/no-non-null-assertion + this.component = new (this.getComponentFabric())(this.ref.current!, this.props); + this.updateComponentRef(); + } + + public componentDidUpdate(prevProps: TProperties): void { + this.updateComponentOptions(prevProps, this.props); + this.updateComponentRef(); + } + + public componentWillUnmount(): void { + this.component?.dispose(); + } +} diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/main_view.tsx b/packages/devextreme/js/__internal/grids/new/grid_core/main_view.tsx new file mode 100644 index 000000000000..bd1c6ba4d326 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/new/grid_core/main_view.tsx @@ -0,0 +1,7 @@ +/* eslint-disable @typescript-eslint/ban-types */ +/* eslint-disable spellcheck/spell-checker */ +/* eslint-disable @typescript-eslint/explicit-member-accessibility */ + +import { View } from './core/view'; + +export abstract class MainView extends View {} diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/options.ts b/packages/devextreme/js/__internal/grids/new/grid_core/options.ts new file mode 100644 index 000000000000..569f83aece83 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/new/grid_core/options.ts @@ -0,0 +1,65 @@ +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 * as dataController from './data_controller/index'; +import * as pager from './pager/index'; +import type * as toolbar from './toolbar/index'; +import type { GridCoreNew } from './widget'; + +/** + * @interface + */ +export type Options = + & WidgetOptions + & dataController.Options + & pager.Options + & columnsController.Options + & toolbar.Options; + +export const defaultOptions = { + ...dataController.defaultOptions, + ...columnsController.defaultOptions, + ...pager.defaultOptions, +} satisfies Options; + +// TODO: separate by modules +// TODO: add typing for defaultOptionRules +export const defaultOptionsRules = [ + { + device(): boolean { + // @ts-expect-error + return isMaterialBased(); + }, + options: { + headerFilter: { + height: 315, + }, + editing: { + useIcons: true, + }, + selection: { + showCheckBoxesMode: 'always', + }, + }, + }, + { + device(): boolean | undefined { + return browser.webkit; + }, + options: { + loadingTimeout: 30, // T344031 + loadPanel: { + animation: { + show: { + easing: 'cubic-bezier(1, 0, 1, 0)', + duration: 500, + from: { opacity: 0 }, + to: { opacity: 1 }, + }, + }, + }, + }, + }, +]; 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 new file mode 100644 index 000000000000..18dd6d589a37 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/new/grid_core/options_controller/options_controller.mock.ts @@ -0,0 +1,11 @@ +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.ts b/packages/devextreme/js/__internal/grids/new/grid_core/options_controller/options_controller.ts new file mode 100644 index 000000000000..b1b3dd97340d --- /dev/null +++ b/packages/devextreme/js/__internal/grids/new/grid_core/options_controller/options_controller.ts @@ -0,0 +1,7 @@ +/* eslint-disable @typescript-eslint/ban-types */ +import type { defaultOptions, Options } from '../options'; +import { OptionsController as OptionsControllerBase } from './options_controller_base'; + +class GridCoreOptionsController extends OptionsControllerBase {} + +export { GridCoreOptionsController as OptionsController }; 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 new file mode 100644 index 000000000000..7cb01f9e1f9c --- /dev/null +++ b/packages/devextreme/js/__internal/grids/new/grid_core/options_controller/options_controller_base.mock.ts @@ -0,0 +1,28 @@ +/* eslint-disable max-classes-per-file */ +/* eslint-disable @typescript-eslint/no-unsafe-return */ +/* eslint-disable max-len */ +/* eslint-disable spellcheck/spell-checker */ +/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { Component } from '@js/core/component'; + +import { OptionsController } from './options_controller_base'; + +export class OptionsControllerMock< + TProps, + TDefaultProps extends TProps, +> extends OptionsController { + private readonly componentMock: Component; + constructor(options: TProps, defaultOptions: TDefaultProps) { + const componentMock = new Component(options); + super(componentMock); + this.defaults = defaultOptions; + this.componentMock = componentMock; + } + + // TODO: add typing + public option(key?: string, value?: unknown): unknown { + // @ts-expect-error + return this.componentMock.option(key, value); + } +} diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/options_controller/options_controller_base.test.ts b/packages/devextreme/js/__internal/grids/new/grid_core/options_controller/options_controller_base.test.ts new file mode 100644 index 000000000000..e56a4ba02b73 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/new/grid_core/options_controller/options_controller_base.test.ts @@ -0,0 +1,99 @@ +/* eslint-disable spellcheck/spell-checker */ +/* eslint-disable @typescript-eslint/init-declarations */ +import { + beforeEach, + describe, expect, it, jest, +} from '@jest/globals'; +import { Component } from '@js/core/component'; + +import { OptionsController } from './options_controller_base'; + +interface Options { + value?: string; + + objectValue?: { + nestedValue?: string; + }; + + onOptionChanged?: () => void; +} + +const onOptionChanged = jest.fn(); +let component: Component; +let optionsController: OptionsController; + +beforeEach(() => { + component = new Component({ + value: 'initialValue', + objectValue: { nestedValue: 'nestedInitialValue' }, + onOptionChanged, + }); + optionsController = new OptionsController(component); + onOptionChanged.mockReset(); +}); + +describe('oneWay', () => { + describe('plain', () => { + it('should have initial value', () => { + const value = optionsController.oneWay('value'); + expect(value.unreactive_get()).toBe('initialValue'); + }); + + it('should update on options changed', () => { + const value = optionsController.oneWay('value'); + const fn = jest.fn(); + + value.subscribe(fn); + + component.option('value', 'newValue'); + expect(fn).toHaveBeenCalledTimes(2); + expect(fn).toHaveBeenCalledWith('newValue'); + }); + }); + + describe('nested', () => { + it('should have initial value', () => { + const a = optionsController.oneWay('objectValue.nestedValue'); + expect(a.unreactive_get()).toBe('nestedInitialValue'); + }); + }); +}); + +describe('twoWay', () => { + it('should have initial value', () => { + const value = optionsController.twoWay('value'); + expect(value.unreactive_get()).toBe('initialValue'); + }); + + it('should update on options changed', () => { + const value = optionsController.twoWay('value'); + const fn = jest.fn(); + + value.subscribe(fn); + + component.option('value', 'newValue'); + expect(fn).toHaveBeenCalledTimes(2); + expect(fn).toHaveBeenCalledWith('newValue'); + }); + + it('should return new value after update', () => { + const value = optionsController.twoWay('value'); + value.update('newValue'); + + expect(value.unreactive_get()).toBe('newValue'); + }); + + it('should call optionChanged on update', () => { + const value = optionsController.twoWay('value'); + value.update('newValue'); + + expect(onOptionChanged).toHaveBeenCalledTimes(1); + expect(onOptionChanged).toHaveBeenCalledWith({ + component, + fullName: 'value', + name: 'value', + previousValue: 'initialValue', + value: 'newValue', + }); + }); +}); 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 new file mode 100644 index 000000000000..98a2b5424748 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/new/grid_core/options_controller/options_controller_base.ts @@ -0,0 +1,176 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/no-unsafe-return */ +/* eslint-disable spellcheck/spell-checker */ +import { Component } from '@js/core/component'; +import { getPathParts } from '@js/core/utils/data'; +import type { ChangedOptionInfo } from '@js/events'; +import type { + SubsGets, SubsGetsUpd, +} from '@ts/core/reactive/index'; +import { computed, state } from '@ts/core/reactive/index'; +import type { ComponentType } from 'inferno'; + +import { TemplateWrapper } from '../inferno_wrappers/template_wrapper'; +import type { Action, Template } from '../types'; + +type OwnProperty = + TPropName extends keyof Required + ? Required[TPropName] + : unknown; + +type PropertyTypeBase = + TProp extends `${infer TOwnProp}.${infer TNestedProps}` + ? PropertyTypeBase, TNestedProps> + : OwnProperty; + +type PropertyType = + unknown extends PropertyTypeBase + ? unknown + : PropertyTypeBase | undefined; + +type PropertyWithDefaults = + unknown extends PropertyType + ? PropertyType + : NonNullable> | PropertyTypeBase; + +type TemplateProperty = + NonNullable> extends Template + ? ComponentType | undefined + : unknown; + +type ActionProperty = + NonNullable> extends Action + ? (args: TActionArgs) => void + : unknown; + +function cloneObjectValue | unknown[]>( + value: T, +): T { + // @ts-expect-error + return Array.isArray(value) ? [...value] : { ...value }; +} + +function updateImmutable | unknown[]>( + value: T, + newValue: T, + pathParts: string[], +): T { + const [pathPart, ...restPathParts] = pathParts; + const ret = cloneObjectValue(value); + + ret[pathPart] = restPathParts.length + ? updateImmutable(value[pathPart], newValue[pathPart], restPathParts) + : newValue[pathPart]; + + return ret; +} + +function getValue(obj: unknown, path: string): T { + let v: any = obj; + for (const pathPart of getPathParts(path)) { + v = v?.[pathPart]; + } + + return v; +} + +export class OptionsController { + private isControlledMode = false; + + private readonly props: SubsGetsUpd; + + protected defaults: TDefaultProps; + + public static dependencies = [Component]; + + constructor( + private readonly component: Component, + ) { + this.props = state(component.option()); + // @ts-expect-error + this.defaults = component._getDefaultOptions?.() ?? {}; + this.updateIsControlledMode(); + + component.on('optionChanged', (e: ChangedOptionInfo) => { + this.updateIsControlledMode(); + + const pathParts = getPathParts(e.fullName); + // @ts-expect-error + this.props.updateFunc((oldValue) => updateImmutable( + // @ts-expect-error + oldValue, + component.option(), + pathParts, + )); + }); + } + + private updateIsControlledMode(): void { + const isControlledMode = this.component.option('integrationOptions.isControlledMode'); + this.isControlledMode = (isControlledMode as boolean | undefined) ?? false; + } + + public oneWay( + name: TProp, + ): SubsGets> { + const obs = computed( + (props) => { + const value = getValue(props, name); + /* + NOTE: it is better not to use '??' operator, + because result will be different if value is 'null'. + Some code works differently if undefined is passed instead of null, + for example dataSource's getter-setter `.filter()` + */ + return value !== undefined ? value : getValue(this.defaults, name); + }, + [this.props], + ); + + return obs as any; + } + + public twoWay( + name: TProp, + ): SubsGetsUpd> { + const obs = state(this.component.option(name)); + this.oneWay(name).subscribe(obs.update.bind(obs) as any); + return { + subscribe: obs.subscribe.bind(obs) as any, + update: (value): void => { + const callbackName = `on${name}Change`; + const callback = this.component.option(callbackName) as any; + const isControlled = this.isControlledMode && this.component.option(name) !== undefined; + if (isControlled) { + callback?.(value); + } else { + // @ts-expect-error + this.component.option(name, value); + callback?.(value); + } + }, + // @ts-expect-error + unreactive_get: obs.unreactive_get.bind(obs), + }; + } + + public template( + name: TProp, + ): SubsGets> { + return computed( + // @ts-expect-error + (template) => template && TemplateWrapper(this.component._getTemplate(template)) as any, + [this.oneWay(name)], + ); + } + + public action( + name: TProp, + ): SubsGets> { + return computed( + // @ts-expect-error + () => this.component._createActionByOption(name) as any, + [this.oneWay(name)], + ); + } +} diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/pager/__snapshots__/view.test.ts.snap b/packages/devextreme/js/__internal/grids/new/grid_core/pager/__snapshots__/view.test.ts.snap new file mode 100644 index 000000000000..fc8eb1ebb701 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/new/grid_core/pager/__snapshots__/view.test.ts.snap @@ -0,0 +1,537 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Applying options when allowedPageSizes = 'auto' calculates pageSizes by pageSize 1`] = ` +
+ +
+`; + +exports[`Applying options when allowedPageSizes with custom values displays custom values 1`] = ` +
+ +
+`; + +exports[`Applying options when changing a visible to 'false' at runtime Pager should be hidden 1`] = ` +
+ +
+`; + +exports[`Applying options when changing a visible to 'true' at runtime Pager should be visible 1`] = ` +
+ +
+`; + +exports[`Applying options when changing an allowedPageSizes to custom values at runtime applies custom values 1`] = ` +
+ +
+`; + +exports[`Applying options when visible = 'auto' and pageCount <= 1 Pager should be hidden 1`] = ` +
+ +
+`; + +exports[`Applying options when visible = 'auto' and pageCount > 1 Pager should be visible 1`] = ` +
+ +
+`; + +exports[`Applying options when visible = 'false' Pager should be hidden 1`] = ` +
+ +
+`; + +exports[`Applying options when visible = 'true' Pager should be visible 1`] = ` +
+ +
+`; + +exports[`render PagerView with options 1`] = ` +
+ +
+`; + +exports[`render empty PagerView 1`] = ` +
+ +`; diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/pager/index.ts b/packages/devextreme/js/__internal/grids/new/grid_core/pager/index.ts new file mode 100644 index 000000000000..38129b24f9e3 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/new/grid_core/pager/index.ts @@ -0,0 +1,2 @@ +export { defaultOptions, type Options } from './options'; +export { PagerView as View } from './view'; diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/pager/options.ts b/packages/devextreme/js/__internal/grids/new/grid_core/pager/options.ts new file mode 100644 index 000000000000..2c6a75b63dd4 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/new/grid_core/pager/options.ts @@ -0,0 +1,27 @@ +import type { Mode } from '@js/common'; +import messageLocalization from '@js/localization/message'; +import type { PagerBase } from '@js/ui/pagination'; + +export type PageSize = number | 'all'; + +export type PageSizes = PageSize[] | Mode; + +export type PagerVisible = boolean | Mode; + +export interface PagerOptions extends PagerBase { + allowedPageSizes?: PageSizes; + visible?: PagerVisible; +} + +export interface Options { + pager?: PagerOptions; +} + +export const defaultOptions = { + pager: { + visible: 'auto', + showPageSizeSelector: false, + allowedPageSizes: 'auto', + label: messageLocalization.format('dxPager-ariaLabel'), + }, +} satisfies Options; diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/pager/pager.tsx b/packages/devextreme/js/__internal/grids/new/grid_core/pager/pager.tsx new file mode 100644 index 000000000000..9cdda688637d --- /dev/null +++ b/packages/devextreme/js/__internal/grids/new/grid_core/pager/pager.tsx @@ -0,0 +1,12 @@ +import type { PagerBase } from '@js/ui/pagination'; +import type { InfernoNode } from 'inferno'; + +import { Pager } from '../inferno_wrappers/pager'; + +export type PagerProps = PagerBase & { visible: boolean }; + +export function PagerView(props: PagerProps): InfernoNode { + return ( + props.visible && + ); +} diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/pager/utils.test.ts b/packages/devextreme/js/__internal/grids/new/grid_core/pager/utils.test.ts new file mode 100644 index 000000000000..4073777fed42 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/new/grid_core/pager/utils.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from '@jest/globals'; + +import { calculatePageSizes, isVisible } from './utils'; + +describe('calculatePageSizes', () => { + describe('when pageSizesConfig = \'auto\'', () => { + it('calculates pageSizes by pageSize', () => { + expect(calculatePageSizes(undefined, 'auto', 6)).toEqual([3, 6, 12]); + }); + }); + + describe('when pageSizesConfig with custom values', () => { + it('return custom values', () => { + expect(calculatePageSizes(undefined, [4, 10, 20], 6)).toEqual([4, 10, 20]); + }); + }); + + describe('when there is an initial value of pageSizes and pageSizesConfig = \'auto\'', () => { + it('return initial values', () => { + expect(calculatePageSizes([3, 6, 12], 'auto', 12)).toEqual([3, 6, 12]); + }); + }); +}); + +describe('isVisible', () => { + describe('when visibleConfig = true', () => { + it('visible should be equal to true', () => { + expect(isVisible(true, 1)).toBe(true); + }); + }); + + describe('when visibleConfig = false', () => { + it('visible should be equal to false', () => { + expect(isVisible(false, 2)).toBe(false); + }); + }); + + describe('when visibleConfig = \'auto\' and pageCount = 1', () => { + it('visible should be equal to false', () => { + expect(isVisible('auto', 1)).toBe(false); + }); + }); + + describe('when visibleConfig = \'auto\' and pageCount > 1', () => { + it('visible should be equal to true', () => { + expect(isVisible('auto', 2)).toBe(true); + }); + }); +}); diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/pager/utils.ts b/packages/devextreme/js/__internal/grids/new/grid_core/pager/utils.ts new file mode 100644 index 000000000000..51c877e18d02 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/new/grid_core/pager/utils.ts @@ -0,0 +1,30 @@ +import type { PagerVisible, PageSize, PageSizes } from './options'; + +// TODO: Need to fix case with runtime changes the allowedPageSizes property to 'auto' +export function calculatePageSizes( + allowedPageSizes: PageSize[] | undefined, + pageSizesConfig: PageSizes, + pageSize: number, +): PageSizes { + if (Array.isArray(pageSizesConfig)) { + return pageSizesConfig; + } + if (Array.isArray(allowedPageSizes) && allowedPageSizes.includes(pageSize)) { + return allowedPageSizes; + } + if (pageSizesConfig && pageSize > 1) { + return [Math.floor(pageSize / 2), pageSize, pageSize * 2]; + } + + return []; +} + +export function isVisible( + visibleConfig: PagerVisible, + pageCount: number, +): boolean { + if (visibleConfig === 'auto') { + return pageCount > 1; + } + return visibleConfig; +} diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/pager/view.test.ts b/packages/devextreme/js/__internal/grids/new/grid_core/pager/view.test.ts new file mode 100644 index 000000000000..d4977f2f12c4 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/new/grid_core/pager/view.test.ts @@ -0,0 +1,213 @@ +/* eslint-disable spellcheck/spell-checker */ +/* eslint-disable @typescript-eslint/dot-notation */ +import { describe, expect, it } from '@jest/globals'; + +import { DataController } from '../data_controller/data_controller'; +import type { Options } from '../options'; +import { OptionsControllerMock } from '../options_controller/options_controller.mock'; +import { PagerView } from './view'; + +const createPagerView = (options?: Options) => { + const rootElement = document.createElement('div'); + const optionsController = new OptionsControllerMock(options ?? { + dataSource: [], + pager: { + visible: true, + }, + }); + + const dataController = new DataController(optionsController); + const pager = new PagerView(dataController, optionsController); + + pager.render(rootElement); + + return { + rootElement, + optionsController, + }; +}; + +describe('render', () => { + it('empty PagerView', () => { + const { rootElement } = createPagerView(); + + expect(rootElement).toMatchSnapshot(); + }); + + it('PagerView with options', () => { + const { rootElement } = createPagerView({ + dataSource: [...new Array(20)].map((_, index) => ({ field: `test_${index}` })), + paging: { + pageIndex: 2, + }, + pager: { + showPageSizeSelector: true, + }, + }); + + expect(rootElement).toMatchSnapshot(); + }); +}); + +describe('Applying options', () => { + describe('when visible = \'auto\' and pageCount <= 1', () => { + it('Pager should be hidden', () => { + const { rootElement } = createPagerView({ + dataSource: [...new Array(4)].map((_, index) => ({ field: `test_${index}` })), + paging: { + pageIndex: 6, + }, + pager: { + visible: 'auto', + showPageSizeSelector: true, + }, + }); + + expect(rootElement).toMatchSnapshot(); + }); + }); + + describe('when visible = \'auto\' and pageCount > 1', () => { + it('Pager should be visible', () => { + const { rootElement } = createPagerView({ + dataSource: [...new Array(20)].map((_, index) => ({ field: `test_${index}` })), + paging: { + pageIndex: 6, + }, + pager: { + visible: 'auto', + showPageSizeSelector: true, + }, + }); + + expect(rootElement).toMatchSnapshot(); + }); + }); + + describe('when visible = \'true\'', () => { + it('Pager should be visible', () => { + const { rootElement } = createPagerView({ + dataSource: [...new Array(20)].map((_, index) => ({ field: `test_${index}` })), + paging: { + pageIndex: 6, + }, + pager: { + visible: true, + showPageSizeSelector: true, + }, + }); + + expect(rootElement).toMatchSnapshot(); + }); + }); + + describe('when visible = \'false\'', () => { + it('Pager should be hidden', () => { + const { rootElement } = createPagerView({ + dataSource: [...new Array(20)].map((_, index) => ({ field: `test_${index}` })), + paging: { + pageIndex: 6, + }, + pager: { + visible: false, + showPageSizeSelector: true, + }, + }); + + expect(rootElement).toMatchSnapshot(); + }); + }); + + describe('when changing a visible to \'false\' at runtime', () => { + it('Pager should be hidden', () => { + const { rootElement, optionsController } = createPagerView({ + dataSource: [...new Array(4)].map((_, index) => ({ field: `test_${index}` })), + paging: { + pageIndex: 6, + }, + pager: { + visible: true, + showPageSizeSelector: true, + }, + }); + + optionsController.option('pager.visible', false); + + expect(rootElement).toMatchSnapshot(); + }); + }); + + describe('when changing a visible to \'true\' at runtime', () => { + it('Pager should be visible', () => { + const { rootElement, optionsController } = createPagerView({ + dataSource: [...new Array(4)].map((_, index) => ({ field: `test_${index}` })), + paging: { + pageIndex: 6, + }, + pager: { + visible: false, + showPageSizeSelector: true, + }, + }); + + optionsController.option('pager.visible', true); + + expect(rootElement).toMatchSnapshot(); + }); + }); + + describe('when allowedPageSizes = \'auto\'', () => { + it('calculates pageSizes by pageSize', () => { + const { rootElement } = createPagerView({ + dataSource: [...new Array(4)].map((_, index) => ({ field: `test_${index}` })), + paging: { + pageIndex: 6, + }, + pager: { + visible: true, + allowedPageSizes: 'auto', + showPageSizeSelector: true, + }, + }); + + expect(rootElement).toMatchSnapshot(); + }); + }); + + describe('when allowedPageSizes with custom values', () => { + it('displays custom values', () => { + const { rootElement } = createPagerView({ + dataSource: [...new Array(20)].map((_, index) => ({ field: `test_${index}` })), + paging: { + pageIndex: 6, + }, + pager: { + visible: 'auto', + allowedPageSizes: [4, 10, 20], + showPageSizeSelector: true, + }, + }); + + expect(rootElement).toMatchSnapshot(); + }); + }); + + describe('when changing an allowedPageSizes to custom values at runtime', () => { + it('applies custom values', () => { + const { rootElement, optionsController } = createPagerView({ + dataSource: [...new Array(20)].map((_, index) => ({ field: `test_${index}` })), + paging: { + pageIndex: 6, + }, + pager: { + allowedPageSizes: 'auto', + showPageSizeSelector: true, + }, + }); + + optionsController.option('pager.allowedPageSizes', [4, 10, 20]); + + expect(rootElement).toMatchSnapshot(); + }); + }); +}); diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/pager/view.tsx b/packages/devextreme/js/__internal/grids/new/grid_core/pager/view.tsx new file mode 100644 index 000000000000..28f1ec5568da --- /dev/null +++ b/packages/devextreme/js/__internal/grids/new/grid_core/pager/view.tsx @@ -0,0 +1,63 @@ +/* eslint-disable spellcheck/spell-checker */ +import type { SubsGets } from '@ts/core/reactive/index'; +import { combined, computed } from '@ts/core/reactive/index'; + +import { View } from '../core/view'; +import { DataController } from '../data_controller/index'; +import { OptionsController } from '../options_controller/options_controller'; +import type { PagerProps } from './pager'; +import { PagerView as Pager } from './pager'; +import { calculatePageSizes, isVisible } from './utils'; + +export class PagerView extends View { + protected override component = Pager; + + public static dependencies = [DataController, OptionsController] as const; + + private readonly pageSizesConfig = this.options.oneWay('pager.allowedPageSizes'); + + private readonly allowedPageSizes = computed( + (pageSizesConfig, pageSize) => calculatePageSizes( + this.allowedPageSizes?.unreactive_get(), + pageSizesConfig, + pageSize, + ), + [this.pageSizesConfig, this.dataController.pageSize], + ); + + private readonly visibleConfig = this.options.oneWay('pager.visible'); + + private readonly visible = computed( + (visibleConfig, pageCount) => isVisible(visibleConfig, pageCount), + [this.visibleConfig, this.dataController.pageCount], + ); + + constructor( + private readonly dataController: DataController, + private readonly options: OptionsController, + ) { + super(); + } + + protected override getProps(): SubsGets { + return combined({ + itemCount: this.dataController.totalCount, + allowedPageSizes: this.allowedPageSizes, + visible: this.visible, + pageIndex: computed( + (pageIndex) => pageIndex + 1, + [this.dataController.pageIndex], + ), + pageIndexChanged: (value): void => this.dataController.pageIndex.update(value - 1), + pageSize: this.dataController.pageSize, + pageSizeChanged: (value): void => this.dataController.pageSize.update(value), + isGridCompatibility: false, + pageCount: this.dataController.pageCount, + showPageSizeSelector: this.options.oneWay('pager.showPageSizeSelector'), + _skipValidation: true, + tabIndex: 0, + showInfo: this.options.oneWay('pager.showInfo'), + showNavigationButtons: this.options.oneWay('pager.showNavigationButtons'), + }); + } +} diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/toolbar/__snapshots__/options.test.ts.snap b/packages/devextreme/js/__internal/grids/new/grid_core/toolbar/__snapshots__/options.test.ts.snap new file mode 100644 index 000000000000..5fa658a56425 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/new/grid_core/toolbar/__snapshots__/options.test.ts.snap @@ -0,0 +1,200 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Options disabled when it is 'false' Toolbar should not be disabled 1`] = ` +
+