From 47fee2064c3cb84eca0b97cef2564cd7e77ad184 Mon Sep 17 00:00:00 2001 From: Roman Semenov Date: Thu, 28 Nov 2024 15:26:21 +0400 Subject: [PATCH 01/45] CardView - implement observables (#28422) --- .../js/__internal/core/reactive/core.ts | 88 +++++++ .../js/__internal/core/reactive/index.ts | 3 + .../__internal/core/reactive/subscription.ts | 17 ++ .../js/__internal/core/reactive/types.ts | 28 +++ .../core/reactive/utilities.test.ts | 217 ++++++++++++++++++ .../js/__internal/core/reactive/utilities.ts | 211 +++++++++++++++++ 6 files changed, 564 insertions(+) create mode 100644 packages/devextreme/js/__internal/core/reactive/core.ts create mode 100644 packages/devextreme/js/__internal/core/reactive/index.ts create mode 100644 packages/devextreme/js/__internal/core/reactive/subscription.ts create mode 100644 packages/devextreme/js/__internal/core/reactive/types.ts create mode 100644 packages/devextreme/js/__internal/core/reactive/utilities.test.ts create mode 100644 packages/devextreme/js/__internal/core/reactive/utilities.ts 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; +} From 2745cf98c421390672c3d664e1df208371593e39 Mon Sep 17 00:00:00 2001 From: Roman Semenov Date: Fri, 29 Nov 2024 16:21:47 +0400 Subject: [PATCH 02/45] CardView - implement DI (#28450) --- .../js/__internal/core/di/index.test.ts | 186 ++++++++++++++++++ .../devextreme/js/__internal/core/di/index.ts | 90 +++++++++ 2 files changed, 276 insertions(+) create mode 100644 packages/devextreme/js/__internal/core/di/index.test.ts create mode 100644 packages/devextreme/js/__internal/core/di/index.ts 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); + } +} From e0ad81d070da4048bc3b258ac78ba23ccc420697 Mon Sep 17 00:00:00 2001 From: Roman Semenov Date: Mon, 2 Dec 2024 18:45:05 +0400 Subject: [PATCH 03/45] CardView - component base (#28463) --- .../__snapshots__/widget.test.ts.snap | 9 +++ .../grids/new/card_view/main_view.tsx | 30 ++++++++ .../__internal/grids/new/card_view/options.ts | 11 +++ .../grids/new/card_view/widget.test.ts | 15 ++++ .../__internal/grids/new/card_view/widget.ts | 42 ++++++++++++ .../grids/new/grid_core/core/view.tsx | 64 +++++++++++++++++ .../grids/new/grid_core/main_view.tsx | 7 ++ .../__internal/grids/new/grid_core/options.ts | 54 +++++++++++++++ .../__internal/grids/new/grid_core/widget.ts | 68 +++++++++++++++++++ .../js/bundles/modules/parts/widgets-web.js | 1 + packages/devextreme/js/ui/card_view.js | 1 + 11 files changed, 302 insertions(+) create mode 100644 packages/devextreme/js/__internal/grids/new/card_view/__snapshots__/widget.test.ts.snap create mode 100644 packages/devextreme/js/__internal/grids/new/card_view/main_view.tsx create mode 100644 packages/devextreme/js/__internal/grids/new/card_view/options.ts create mode 100644 packages/devextreme/js/__internal/grids/new/card_view/widget.test.ts create mode 100644 packages/devextreme/js/__internal/grids/new/card_view/widget.ts create mode 100644 packages/devextreme/js/__internal/grids/new/grid_core/core/view.tsx create mode 100644 packages/devextreme/js/__internal/grids/new/grid_core/main_view.tsx create mode 100644 packages/devextreme/js/__internal/grids/new/grid_core/options.ts create mode 100644 packages/devextreme/js/__internal/grids/new/grid_core/widget.ts create mode 100644 packages/devextreme/js/ui/card_view.js 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..b8a3b92dc104 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/new/card_view/__snapshots__/widget.test.ts.snap @@ -0,0 +1,9 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`common initial render should be successfull 1`] = ` +
+ This is cardView +
+`; 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..55b5b65437d2 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/new/card_view/main_view.tsx @@ -0,0 +1,30 @@ +/* eslint-disable spellcheck/spell-checker */ +/* eslint-disable @typescript-eslint/explicit-member-accessibility */ +import { state } from '@ts/core/reactive/index'; +import { View } from '@ts/grids/new/grid_core/core/view'; + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +interface MainViewProps { + +} + +// eslint-disable-next-line no-empty-pattern +function MainViewComponent({ + +}: MainViewProps): JSX.Element { + return (<> + This is cardView + ); +} + +export class MainView extends View { + protected override component = MainViewComponent; + + public static dependencies = [] as const; + + // 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 state({}); + } +} 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/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..f444199a2015 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/new/card_view/widget.ts @@ -0,0 +1,42 @@ +/* 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 { GridCoreNew } from '@ts/grids/new/grid_core/widget'; + +import { MainView } from './main_view'; +import { defaultOptions } from './options'; + +export class CardViewBase extends GridCoreNew { + protected _registerDIContext(): void { + super._registerDIContext(); + this.diContext.register(MainViewBase, MainView); + } + + 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/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/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..7020661d76d5 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/new/grid_core/options.ts @@ -0,0 +1,54 @@ +import browser from '@js/core/utils/browser'; +import { isMaterialBased } from '@js/ui/themes'; +import type { WidgetOptions } from '@js/ui/widget/ui.widget'; + +import type { GridCoreNew } from './widget'; + +/** + * @interface + */ +export type Options = + & WidgetOptions; + +export const 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/widget.ts b/packages/devextreme/js/__internal/grids/new/grid_core/widget.ts new file mode 100644 index 000000000000..3c1a6fd2d661 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/new/grid_core/widget.ts @@ -0,0 +1,68 @@ +/* eslint-disable @typescript-eslint/ban-types */ +/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +/* eslint-disable spellcheck/spell-checker */ +// eslint-disable-next-line max-classes-per-file +import Widget from '@js/ui/widget/ui.widget'; +import { DIContext } from '@ts/core/di/index'; +import type { Subscription } from '@ts/core/reactive/index'; +import { render } from 'inferno'; + +import { MainView } from './main_view'; +import { defaultOptions, defaultOptionsRules, type Options } from './options'; + +export class GridCoreNewBase< + TProperties extends Options = Options, +> extends Widget { + protected renderSubscription?: Subscription; + + protected diContext!: DIContext; + + protected _registerDIContext(): void { + this.diContext = new DIContext(); + } + + protected _initDIContext(): void { + } + + protected _init(): void { + // @ts-expect-error + super._init(); + this._registerDIContext(); + this._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 { + // @ts-expect-error + ...super._getDefaultOptions() as {}, + ...defaultOptions, + }; + } + + protected _defaultOptionsRules() { + // @ts-expect-error + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return super._defaultOptionsRules().concat(defaultOptionsRules); + } + + protected _initMarkup(): void { + // @ts-expect-error + super._initMarkup(); + // @ts-expect-error + this.renderSubscription = this.diContext.get(MainView).render(this.$element().get(0)); + } + + protected _clean(): void { + this.renderSubscription?.unsubscribe(); + // @ts-expect-error + render(null, this.$element().get(0)); + // @ts-expect-error + super._clean(); + } +} + +export class GridCoreNew extends GridCoreNewBase {} diff --git a/packages/devextreme/js/bundles/modules/parts/widgets-web.js b/packages/devextreme/js/bundles/modules/parts/widgets-web.js index e8d6d70fd681..959a240fde2a 100644 --- a/packages/devextreme/js/bundles/modules/parts/widgets-web.js +++ b/packages/devextreme/js/bundles/modules/parts/widgets-web.js @@ -9,6 +9,7 @@ ui.dxAccordion = require('../../../ui/accordion'); ui.dxContextMenu = require('../../../ui/context_menu'); ui.dxDataGrid = require('../../../ui/data_grid'); ui.dxTreeList = require('../../../ui/tree_list'); +ui.dxCardView = require('../../../ui/card_view'); ui.dxMenu = require('../../../ui/menu'); ui.dxPivotGrid = require('../../../ui/pivot_grid'); ui.dxPivotGridFieldChooser = require('../../../ui/pivot_grid_field_chooser'); diff --git a/packages/devextreme/js/ui/card_view.js b/packages/devextreme/js/ui/card_view.js new file mode 100644 index 000000000000..663a6aca8521 --- /dev/null +++ b/packages/devextreme/js/ui/card_view.js @@ -0,0 +1 @@ +export { default } from '../__internal/grids/new/card_view/widget'; From 763d32a5b155b6f983f0c762e829c950f421bec6 Mon Sep 17 00:00:00 2001 From: Roman Semenov Date: Wed, 11 Dec 2024 19:37:45 +0400 Subject: [PATCH 04/45] CardView - inferno utils (#28510) --- .../new/grid_core/inferno_wrappers/button.ts | 10 ++++ .../inferno_wrappers/template_wrapper.tsx | 37 +++++++++++++ .../inferno_wrappers/widget_wrapper.tsx | 54 +++++++++++++++++++ 3 files changed, 101 insertions(+) create mode 100644 packages/devextreme/js/__internal/grids/new/grid_core/inferno_wrappers/button.ts create mode 100644 packages/devextreme/js/__internal/grids/new/grid_core/inferno_wrappers/template_wrapper.tsx create mode 100644 packages/devextreme/js/__internal/grids/new/grid_core/inferno_wrappers/widget_wrapper.tsx 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/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/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(); + } +} From 967997fdbad8f910011a4524e33f9bbb9b1e8cfa Mon Sep 17 00:00:00 2001 From: Roman Semenov Date: Mon, 16 Dec 2024 20:10:51 +0400 Subject: [PATCH 05/45] CardView - implement OptionsController (#28540) --- .../grids/new/card_view/options_controller.ts | 7 + .../__internal/grids/new/card_view/widget.ts | 6 + .../options_controller.mock.ts | 6 + .../options_controller/options_controller.ts | 7 + .../options_controller_base.mock.ts | 26 +++ .../options_controller_base.test.ts | 99 ++++++++++ .../options_controller_base.ts | 171 ++++++++++++++++++ .../__internal/grids/new/grid_core/types.ts | 4 + 8 files changed, 326 insertions(+) create mode 100644 packages/devextreme/js/__internal/grids/new/card_view/options_controller.ts create mode 100644 packages/devextreme/js/__internal/grids/new/grid_core/options_controller/options_controller.mock.ts create mode 100644 packages/devextreme/js/__internal/grids/new/grid_core/options_controller/options_controller.ts create mode 100644 packages/devextreme/js/__internal/grids/new/grid_core/options_controller/options_controller_base.mock.ts create mode 100644 packages/devextreme/js/__internal/grids/new/grid_core/options_controller/options_controller_base.test.ts create mode 100644 packages/devextreme/js/__internal/grids/new/grid_core/options_controller/options_controller_base.ts create mode 100644 packages/devextreme/js/__internal/grids/new/grid_core/types.ts 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.ts b/packages/devextreme/js/__internal/grids/new/card_view/widget.ts index f444199a2015..420732885d7d 100644 --- a/packages/devextreme/js/__internal/grids/new/card_view/widget.ts +++ b/packages/devextreme/js/__internal/grids/new/card_view/widget.ts @@ -4,15 +4,21 @@ 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 { 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..9aedf84a4c13 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/new/grid_core/options_controller/options_controller.mock.ts @@ -0,0 +1,6 @@ +import type { defaultOptions, Options } from '../options'; +import { OptionsControllerMock as OptionsControllerBaseMock } from './options_controller_base.mock'; + +export class OptionsControllerMock extends OptionsControllerBaseMock< +Options, typeof 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..5f17604179a3 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/new/grid_core/options_controller/options_controller_base.mock.ts @@ -0,0 +1,26 @@ +/* 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) { + const componentMock = new Component(options); + super(componentMock); + this.componentMock = componentMock; + } + + 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..4f8cb64fe4ad --- /dev/null +++ b/packages/devextreme/js/__internal/grids/new/grid_core/options_controller/options_controller_base.ts @@ -0,0 +1,171 @@ +/* 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 { 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; + +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; + + private readonly 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/types.ts b/packages/devextreme/js/__internal/grids/new/grid_core/types.ts new file mode 100644 index 000000000000..e29614212c8a --- /dev/null +++ b/packages/devextreme/js/__internal/grids/new/grid_core/types.ts @@ -0,0 +1,4 @@ +import type { template } from '@js/core/templates/template'; + +// TODO +export type Template = (props: T) => HTMLDivElement | template; From 8c49bf596edc24aa71323414b3ee7dab4c9ec6ff Mon Sep 17 00:00:00 2001 From: Roman Semenov Date: Thu, 26 Dec 2024 13:23:58 +0400 Subject: [PATCH 06/45] CardView - add default values for optionsController mock (#28590) --- .../grids/new/card_view/options_controller.mock.ts | 14 ++++++++++++++ .../options_controller/options_controller.mock.ts | 9 +++++++-- .../options_controller_base.mock.ts | 3 ++- .../options_controller/options_controller_base.ts | 2 +- 4 files changed, 24 insertions(+), 4 deletions(-) create mode 100644 packages/devextreme/js/__internal/grids/new/card_view/options_controller.mock.ts diff --git a/packages/devextreme/js/__internal/grids/new/card_view/options_controller.mock.ts b/packages/devextreme/js/__internal/grids/new/card_view/options_controller.mock.ts new file mode 100644 index 000000000000..d66ec5d1e7c9 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/new/card_view/options_controller.mock.ts @@ -0,0 +1,14 @@ +import { + OptionsControllerMock as OptionsControllerBaseMock, +} from '@ts/grids/new/grid_core/options_controller/options_controller_base.mock'; + +import type { Options } from './options'; +import { defaultOptions } from './options'; + +export class OptionsControllerMock extends OptionsControllerBaseMock< +Options, typeof defaultOptions +> { + constructor(options: Options) { + super(options, defaultOptions); + } +} diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/options_controller/options_controller.mock.ts b/packages/devextreme/js/__internal/grids/new/grid_core/options_controller/options_controller.mock.ts index 9aedf84a4c13..18dd6d589a37 100644 --- a/packages/devextreme/js/__internal/grids/new/grid_core/options_controller/options_controller.mock.ts +++ b/packages/devextreme/js/__internal/grids/new/grid_core/options_controller/options_controller.mock.ts @@ -1,6 +1,11 @@ -import type { defaultOptions, Options } from '../options'; +import type { Options } from '../options'; +import { defaultOptions } from '../options'; import { OptionsControllerMock as OptionsControllerBaseMock } from './options_controller_base.mock'; export class OptionsControllerMock extends OptionsControllerBaseMock< Options, typeof defaultOptions -> {} +> { + constructor(options: Options) { + super(options, defaultOptions); + } +} diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/options_controller/options_controller_base.mock.ts b/packages/devextreme/js/__internal/grids/new/grid_core/options_controller/options_controller_base.mock.ts index 5f17604179a3..3a3a1747e202 100644 --- a/packages/devextreme/js/__internal/grids/new/grid_core/options_controller/options_controller_base.mock.ts +++ b/packages/devextreme/js/__internal/grids/new/grid_core/options_controller/options_controller_base.mock.ts @@ -13,9 +13,10 @@ export class OptionsControllerMock< TDefaultProps extends TProps, > extends OptionsController { private readonly componentMock: Component; - constructor(options: TProps) { + constructor(options: TProps, defaultOptions: TDefaultProps) { const componentMock = new Component(options); super(componentMock); + this.defaults = defaultOptions; this.componentMock = componentMock; } diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/options_controller/options_controller_base.ts b/packages/devextreme/js/__internal/grids/new/grid_core/options_controller/options_controller_base.ts index 4f8cb64fe4ad..8bc4c67b7958 100644 --- a/packages/devextreme/js/__internal/grids/new/grid_core/options_controller/options_controller_base.ts +++ b/packages/devextreme/js/__internal/grids/new/grid_core/options_controller/options_controller_base.ts @@ -74,7 +74,7 @@ export class OptionsController { private readonly props: SubsGetsUpd; - private readonly defaults: TDefaultProps; + protected defaults: TDefaultProps; public static dependencies = [Component]; From 659d50fe9918c37c0b6b65c66f7f50c35e0ebb8c Mon Sep 17 00:00:00 2001 From: Roman Semenov Date: Thu, 26 Dec 2024 13:24:31 +0400 Subject: [PATCH 07/45] CardView - implement ColumnsController (#28591) --- .../columns_controller.test.ts.snap | 98 +++++++ .../__snapshots__/options.test.ts.snap | 95 ++++++ .../columns_controller.test.ts | 173 +++++++++++ .../columns_controller/columns_controller.ts | 138 +++++++++ .../new/grid_core/columns_controller/index.ts | 3 + .../columns_controller/options.test.ts | 271 ++++++++++++++++++ .../grid_core/columns_controller/options.ts | 69 +++++ .../columns_controller/public_methods.test.ts | 65 +++++ .../columns_controller/public_methods.ts | 109 +++++++ .../new/grid_core/columns_controller/types.ts | 53 ++++ .../columns_controller/utils.test.ts | 29 ++ .../new/grid_core/columns_controller/utils.ts | 140 +++++++++ .../__internal/grids/new/grid_core/options.ts | 5 +- .../__internal/grids/new/grid_core/types.ts | 5 + .../__internal/grids/new/grid_core/widget.ts | 9 +- .../optionChanged_bundled.tests.js | 3 +- 16 files changed, 1262 insertions(+), 3 deletions(-) create mode 100644 packages/devextreme/js/__internal/grids/new/grid_core/columns_controller/__snapshots__/columns_controller.test.ts.snap create mode 100644 packages/devextreme/js/__internal/grids/new/grid_core/columns_controller/__snapshots__/options.test.ts.snap create mode 100644 packages/devextreme/js/__internal/grids/new/grid_core/columns_controller/columns_controller.test.ts create mode 100644 packages/devextreme/js/__internal/grids/new/grid_core/columns_controller/columns_controller.ts create mode 100644 packages/devextreme/js/__internal/grids/new/grid_core/columns_controller/index.ts create mode 100644 packages/devextreme/js/__internal/grids/new/grid_core/columns_controller/options.test.ts create mode 100644 packages/devextreme/js/__internal/grids/new/grid_core/columns_controller/options.ts create mode 100644 packages/devextreme/js/__internal/grids/new/grid_core/columns_controller/public_methods.test.ts create mode 100644 packages/devextreme/js/__internal/grids/new/grid_core/columns_controller/public_methods.ts create mode 100644 packages/devextreme/js/__internal/grids/new/grid_core/columns_controller/types.ts create mode 100644 packages/devextreme/js/__internal/grids/new/grid_core/columns_controller/utils.test.ts create mode 100644 packages/devextreme/js/__internal/grids/new/grid_core/columns_controller/utils.ts diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/columns_controller/__snapshots__/columns_controller.test.ts.snap b/packages/devextreme/js/__internal/grids/new/grid_core/columns_controller/__snapshots__/columns_controller.test.ts.snap new file mode 100644 index 000000000000..da54d5acb102 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/new/grid_core/columns_controller/__snapshots__/columns_controller.test.ts.snap @@ -0,0 +1,98 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ColumnsController columns should contain processed column configs 1`] = ` +[ + { + "alignment": "left", + "allowReordering": true, + "calculateCellValue": [Function], + "calculateDisplayValue": [Function], + "caption": "A", + "dataField": "a", + "dataType": "string", + "falseText": "false", + "name": "a", + "trueText": "true", + "visible": true, + "visibleIndex": 0, + }, + { + "alignment": "left", + "allowReordering": true, + "calculateCellValue": [Function], + "calculateDisplayValue": [Function], + "caption": "B", + "dataField": "b", + "dataType": "string", + "falseText": "false", + "name": "b", + "trueText": "true", + "visible": true, + "visibleIndex": 1, + }, + { + "alignment": "left", + "allowReordering": true, + "calculateCellValue": [Function], + "calculateDisplayValue": [Function], + "caption": "C", + "dataField": "c", + "dataType": "string", + "falseText": "false", + "name": "c", + "trueText": "true", + "visible": false, + "visibleIndex": 2, + }, +] +`; + +exports[`ColumnsController createDataRow should process data object to data row using column configuration 1`] = ` +{ + "cells": [ + { + "column": { + "alignment": "left", + "allowReordering": true, + "calculateCellValue": [Function], + "calculateDisplayValue": [Function], + "caption": "A", + "dataField": "a", + "dataType": "string", + "falseText": "false", + "name": "a", + "trueText": "true", + "visible": true, + "visibleIndex": 0, + }, + "displayValue": "my a value", + "text": "my a value", + "value": "my a value", + }, + { + "column": { + "alignment": "left", + "allowReordering": true, + "calculateCellValue": [Function], + "calculateDisplayValue": [Function], + "caption": "B", + "dataField": "b", + "dataType": "string", + "falseText": "false", + "name": "b", + "trueText": "true", + "visible": true, + "visibleIndex": 1, + }, + "displayValue": "my b value", + "text": "my b value", + "value": "my b value", + }, + ], + "data": { + "a": "my a value", + "b": "my b value", + }, + "key": undefined, +} +`; diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/columns_controller/__snapshots__/options.test.ts.snap b/packages/devextreme/js/__internal/grids/new/grid_core/columns_controller/__snapshots__/options.test.ts.snap new file mode 100644 index 000000000000..472e587f2151 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/new/grid_core/columns_controller/__snapshots__/options.test.ts.snap @@ -0,0 +1,95 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Options columns when given as object should be normalized 1`] = ` +[ + { + "alignment": "left", + "allowReordering": true, + "calculateCellValue": [Function], + "calculateDisplayValue": [Function], + "caption": "A", + "dataField": "a", + "dataType": "string", + "falseText": "false", + "name": "a", + "trueText": "true", + "visible": true, + "visibleIndex": 0, + }, + { + "alignment": "left", + "allowReordering": true, + "calculateCellValue": [Function], + "calculateDisplayValue": [Function], + "caption": "B", + "dataField": "b", + "dataType": "string", + "falseText": "false", + "name": "b", + "trueText": "true", + "visible": true, + "visibleIndex": 1, + }, + { + "alignment": "left", + "allowReordering": true, + "calculateCellValue": [Function], + "calculateDisplayValue": [Function], + "caption": "C", + "dataField": "c", + "dataType": "string", + "falseText": "false", + "name": "c", + "trueText": "true", + "visible": true, + "visibleIndex": 2, + }, +] +`; + +exports[`Options columns when given as string should be normalized 1`] = ` +[ + { + "alignment": "left", + "allowReordering": true, + "calculateCellValue": [Function], + "calculateDisplayValue": [Function], + "caption": "A", + "dataField": "a", + "dataType": "string", + "falseText": "false", + "name": "a", + "trueText": "true", + "visible": true, + "visibleIndex": 0, + }, + { + "alignment": "left", + "allowReordering": true, + "calculateCellValue": [Function], + "calculateDisplayValue": [Function], + "caption": "B", + "dataField": "b", + "dataType": "string", + "falseText": "false", + "name": "b", + "trueText": "true", + "visible": true, + "visibleIndex": 1, + }, + { + "alignment": "left", + "allowReordering": true, + "calculateCellValue": [Function], + "calculateDisplayValue": [Function], + "caption": "C", + "dataField": "c", + "dataType": "string", + "falseText": "false", + "name": "c", + "trueText": "true", + "visible": true, + "visibleIndex": 2, + }, +] +`; diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/columns_controller/columns_controller.test.ts b/packages/devextreme/js/__internal/grids/new/grid_core/columns_controller/columns_controller.test.ts new file mode 100644 index 000000000000..fc86a7139ca5 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/new/grid_core/columns_controller/columns_controller.test.ts @@ -0,0 +1,173 @@ +/* eslint-disable spellcheck/spell-checker */ +import { describe, expect, it } from '@jest/globals'; + +import type { Options } from '../options'; +import { OptionsControllerMock } from '../options_controller/options_controller.mock'; +import { ColumnsController } from './columns_controller'; + +const setup = (config: Options = {}) => { + const options = new OptionsControllerMock(config); + + const columnsController = new ColumnsController(options); + + return { + options, + columnsController, + }; +}; + +describe('ColumnsController', () => { + describe('columns', () => { + it('should contain processed column configs', () => { + const { columnsController } = setup({ + columns: [ + 'a', + { dataField: 'b' }, + { dataField: 'c', visible: false }, + ], + }); + + const columns = columnsController.columns.unreactive_get(); + expect(columns).toMatchSnapshot(); + }); + }); + describe('visibleColumns', () => { + it('should contain visible columns', () => { + const { columnsController } = setup({ + columns: [ + 'a', + { dataField: 'b' }, + { dataField: 'c', visible: false }, + ], + }); + + const visibleColumns = columnsController.visibleColumns.unreactive_get(); + expect(visibleColumns).toHaveLength(2); + expect(visibleColumns[0].name).toBe('a'); + expect(visibleColumns[1].name).toBe('b'); + }); + }); + describe('nonVisibleColumns', () => { + it('should contain non visible columns', () => { + const { columnsController } = setup({ + columns: [ + 'a', + { dataField: 'b' }, + { dataField: 'c', visible: false }, + ], + }); + + const nonVisibleColumns = columnsController.nonVisibleColumns.unreactive_get(); + expect(nonVisibleColumns).toHaveLength(1); + expect(nonVisibleColumns[0].name).toBe('c'); + }); + }); + + describe('createDataRow', () => { + it('should process data object to data row using column configuration', () => { + const { columnsController } = setup({ + columns: [ + 'a', + { dataField: 'b' }, + ], + }); + + const columns = columnsController.columns.unreactive_get(); + const dataObject = { a: 'my a value', b: 'my b value' }; + const dataRow = columnsController.createDataRow(dataObject, columns); + expect(dataRow).toMatchSnapshot(); + }); + }); + + describe('addColumn', () => { + it('should add new column to columns', () => { + const { columnsController } = setup( + { columns: ['a', 'b'] }, + ); + + let columns = columnsController.columns.unreactive_get(); + expect(columns).toHaveLength(2); + expect(columns).toMatchObject([ + { dataField: 'a' }, + { dataField: 'b' }, + ]); + + columnsController.addColumn('c'); + + columns = columnsController.columns.unreactive_get(); + expect(columns).toHaveLength(3); + expect(columns).toMatchObject([ + { dataField: 'a' }, + { dataField: 'b' }, + { dataField: 'c' }, + ]); + }); + }); + + describe('deleteColumn', () => { + it('should remove given column from columns', () => { + const { columnsController } = setup( + { columns: ['a', 'b'] }, + ); + + let columns = columnsController.columns.unreactive_get(); + expect(columns).toHaveLength(2); + expect(columns).toMatchObject([ + { dataField: 'a' }, + { dataField: 'b' }, + ]); + + columnsController.deleteColumn(columns[1]); + + columns = columnsController.columns.unreactive_get(); + expect(columns).toHaveLength(1); + expect(columns).toMatchObject([ + { dataField: 'a' }, + ]); + }); + }); + + describe('columnOption', () => { + it('should update option of given column', () => { + const { columnsController } = setup( + { columns: ['a', 'b'] }, + ); + + let columns = columnsController.columns.unreactive_get(); + expect(columns).toMatchObject([ + { dataField: 'a', visible: true }, + { dataField: 'b', visible: true }, + ]); + + columnsController.columnOption(columns[1], 'visible', false); + + columns = columnsController.columns.unreactive_get(); + expect(columns).toMatchObject([ + { dataField: 'a', visible: true }, + { dataField: 'b', visible: false }, + ]); + }); + + it('should correctly update visibleIndex option for all columns', () => { + const { columnsController } = setup( + { columns: ['a', 'b', 'c'] }, + ); + + let columns = columnsController.columns.unreactive_get(); + expect(columns).toMatchObject([ + { dataField: 'a', visibleIndex: 0 }, + { dataField: 'b', visibleIndex: 1 }, + { dataField: 'c', visibleIndex: 2 }, + ]); + + columnsController.columnOption(columns[2], 'visibleIndex', 0); + + columns = columnsController.columns.unreactive_get(); + expect(columns).toMatchObject([ + { dataField: 'a', visibleIndex: 1 }, + { dataField: 'b', visibleIndex: 2 }, + { dataField: 'c', visibleIndex: 0 }, + ]); + }); + }); +}); diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/columns_controller/columns_controller.ts b/packages/devextreme/js/__internal/grids/new/grid_core/columns_controller/columns_controller.ts new file mode 100644 index 000000000000..3b2649a579ca --- /dev/null +++ b/packages/devextreme/js/__internal/grids/new/grid_core/columns_controller/columns_controller.ts @@ -0,0 +1,138 @@ +/* eslint-disable @typescript-eslint/no-shadow */ +/* eslint-disable spellcheck/spell-checker */ +import formatHelper from '@js/format_helper'; +import type { Subscribable, SubsGets, SubsGetsUpd } from '@ts/core/reactive/index'; +import { + computed, interruptableComputed, +} from '@ts/core/reactive/index'; + +import { OptionsController } from '../options_controller/options_controller'; +import type { ColumnProperties, ColumnSettings, PreNormalizedColumn } from './options'; +import type { Column, DataRow, VisibleColumn } from './types'; +import { + getColumnIndexByName, normalizeColumns, normalizeVisibleIndexes, preNormalizeColumns, +} from './utils'; + +export class ColumnsController { + private readonly columnsConfiguration: Subscribable; + private readonly columnsSettings: SubsGetsUpd; + + public readonly columns: SubsGets; + + public readonly visibleColumns: SubsGets; + + public readonly nonVisibleColumns: SubsGets; + + public readonly allowColumnReordering: Subscribable; + + public static dependencies = [OptionsController] as const; + + constructor( + private readonly options: OptionsController, + ) { + this.columnsConfiguration = this.options.oneWay('columns'); + + this.columnsSettings = interruptableComputed( + (columnsConfiguration) => preNormalizeColumns(columnsConfiguration ?? []), + [ + this.columnsConfiguration, + ], + ); + + this.columns = computed( + (columnsSettings) => normalizeColumns(columnsSettings ?? []), + [ + this.columnsSettings, + ], + ); + + this.visibleColumns = computed( + (columns) => columns + .filter((column): column is VisibleColumn => column.visible) + .sort((a, b) => a.visibleIndex - b.visibleIndex), + [this.columns], + ); + + this.nonVisibleColumns = computed( + (columns) => columns.filter((column) => !column.visible), + [this.columns], + ); + + this.allowColumnReordering = this.options.oneWay('allowColumnReordering'); + } + + public createDataRow(data: unknown, columns: Column[]): DataRow { + return { + cells: columns.map((c) => { + const displayValue = c.calculateDisplayValue(data); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let text = formatHelper.format(displayValue as any, c.format); + + if (c.customizeText) { + text = c.customizeText({ + value: displayValue, + valueText: text, + }); + } + + return { + column: c, + value: c.calculateCellValue(data), + displayValue, + text, + }; + }), + key: undefined, + data, + }; + } + + public addColumn(columnProps: ColumnProperties): void { + this.columnsSettings.updateFunc((columns) => preNormalizeColumns([ + ...columns, + columnProps, + ])); + } + + public deleteColumn(column: Column): void { + this.columnsSettings.updateFunc( + (columns) => columns.filter((c) => c.name !== column.name), + ); + } + + public columnOption( + column: Column, + option: TProp, + value: ColumnSettings[TProp], + ): void { + this.columnsSettings.updateFunc((columns) => { + const index = getColumnIndexByName(columns, column.name); + const newColumns = [...columns]; + + if (columns[index][option] === value) { + return columns; + } + + newColumns[index] = { + ...newColumns[index], + [option]: value, + }; + + const visibleIndexes = normalizeVisibleIndexes( + newColumns.map((c) => c.visibleIndex), + index, + ); + + visibleIndexes.forEach((visibleIndex, i) => { + if (newColumns[i].visibleIndex !== visibleIndex) { + newColumns[i] = { + ...newColumns[i], + visibleIndex, + }; + } + }); + + return newColumns; + }); + } +} diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/columns_controller/index.ts b/packages/devextreme/js/__internal/grids/new/grid_core/columns_controller/index.ts new file mode 100644 index 000000000000..45601c054834 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/new/grid_core/columns_controller/index.ts @@ -0,0 +1,3 @@ +export { ColumnsController } from './columns_controller'; +export { defaultOptions, type Options } from './options'; +export { PublicMethods } from './public_methods'; diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/columns_controller/options.test.ts b/packages/devextreme/js/__internal/grids/new/grid_core/columns_controller/options.test.ts new file mode 100644 index 000000000000..b28b870fddfb --- /dev/null +++ b/packages/devextreme/js/__internal/grids/new/grid_core/columns_controller/options.test.ts @@ -0,0 +1,271 @@ +/* eslint-disable spellcheck/spell-checker */ +import { describe, expect, it } from '@jest/globals'; + +import type { Options } from '../options'; +import { OptionsControllerMock } from '../options_controller/options_controller.mock'; +import { ColumnsController } from './columns_controller'; + +const setup = (config: Options) => { + const options = new OptionsControllerMock(config); + + const columnsController = new ColumnsController(options); + + return { + options, + columnsController, + }; +}; + +describe('Options', () => { + describe('columns', () => { + describe('when given as string', () => { + it('should be normalized', () => { + const { columnsController } = setup({ columns: ['a', 'b', 'c'] }); + const columns = columnsController.columns.unreactive_get(); + + expect(columns).toMatchSnapshot(); + }); + it('should use given string as dataField', () => { + const { columnsController } = setup({ columns: ['a', 'b', 'c'] }); + const columns = columnsController.columns.unreactive_get(); + + expect(columns[0].dataField).toBe('a'); + expect(columns[1].dataField).toBe('b'); + expect(columns[2].dataField).toBe('c'); + }); + it('should be the same as if we passed objects with dataField only', () => { + const { columnsController: columnsController1 } = setup({ + columns: ['a', 'b', 'c'], + }); + const columns1 = columnsController1.columns.unreactive_get(); + + const { columnsController: columnsController2 } = setup({ + columns: [ + { dataField: 'a' }, + { dataField: 'b' }, + { dataField: 'c' }, + ], + }); + const columns2 = columnsController2.columns.unreactive_get(); + + expect(columns1).toEqual(columns2); + }); + }); + describe('when given as object', () => { + it('should be normalized', () => { + const { columnsController } = setup({ + columns: [ + { dataField: 'a' }, + { dataField: 'b' }, + { dataField: 'c' }, + ], + }); + const columns = columnsController.columns.unreactive_get(); + expect(columns).toMatchSnapshot(); + }); + }); + }); + + describe('columns[].visible', () => { + describe('when it is true', () => { + it('should include column to visibleColumns', () => { + const { columnsController } = setup({ + columns: [ + { dataField: 'a', visible: true }, + { dataField: 'b', visible: true }, + ], + }); + + const visibleColumns = columnsController.visibleColumns.unreactive_get(); + expect(visibleColumns).toHaveLength(2); + expect(visibleColumns[0].name).toBe('a'); + expect(visibleColumns[1].name).toBe('b'); + }); + }); + + describe('when it is false', () => { + it('should exclude column from visibleColumns', () => { + const { columnsController } = setup({ + columns: [ + { dataField: 'a', visible: true }, + { dataField: 'b', visible: false }, + ], + }); + + const visibleColumns = columnsController.visibleColumns.unreactive_get(); + expect(visibleColumns).toHaveLength(1); + expect(visibleColumns[0].name).toBe('a'); + }); + }); + }); + + describe('columns[].visibleIndex', () => { + it('should affect order in visibleColumns', () => { + const { columnsController } = setup({ + columns: [ + { dataField: 'a', visibleIndex: 1 }, + { dataField: 'b' }, + ], + }); + const visibleColumns = columnsController.visibleColumns.unreactive_get(); + + expect(visibleColumns).toHaveLength(2); + expect(visibleColumns[0]).toMatchObject({ + name: 'b', + visibleIndex: 0, + }); + expect(visibleColumns[1]).toMatchObject({ + name: 'a', + visibleIndex: 1, + }); + }); + }); + + describe('column[].calculateCellValue', () => { + it('should override value in DataRow', () => { + const { columnsController } = setup({ + columns: [ + { calculateCellValue: (data: any) => `${data.a} ${data.b}` }, + ], + }); + + const dataObject = { a: 'a', b: 'b' }; + const columns = columnsController.columns.unreactive_get(); + const dataRow = columnsController.createDataRow(dataObject, columns); + + expect(dataRow.cells).toHaveLength(1); + expect(dataRow.cells[0].value).toBe('a b'); + }); + + it('should take priority over dataField', () => { + const { columnsController } = setup({ + columns: [ + { + calculateCellValue: (data: any) => `${data.a} ${data.b}`, + dataField: 'a', + }, + ], + }); + + const dataObject = { a: 'a', b: 'b' }; + const columns = columnsController.columns.unreactive_get(); + const dataRow = columnsController.createDataRow(dataObject, columns); + + expect(dataRow.cells).toHaveLength(1); + expect(dataRow.cells[0].value).toBe('a b'); + }); + }); + + describe('column[].calculateDisplayValue', () => { + it('should override displayValue in DataRow', () => { + const { columnsController } = setup({ + columns: [ + { calculateDisplayValue: (data: any) => `${data.a} ${data.b}` }, + ], + }); + + const dataObject = { a: 'a', b: 'b' }; + const columns = columnsController.columns.unreactive_get(); + const dataRow = columnsController.createDataRow(dataObject, columns); + + expect(dataRow.cells).toHaveLength(1); + expect(dataRow.cells[0].displayValue).toBe('a b'); + }); + }); + + describe('column[].customizeText', () => { + it('should override text in DataRow', () => { + const { columnsController } = setup({ + columns: [ + { + dataField: 'a', + customizeText: ({ valueText }) => `aa ${valueText} aa`, + }, + ], + }); + + const dataObject = { a: 'a', b: 'b' }; + const columns = columnsController.columns.unreactive_get(); + const dataRow = columnsController.createDataRow(dataObject, columns); + + expect(dataRow.cells).toHaveLength(1); + expect(dataRow.cells[0].text).toBe('aa a aa'); + }); + }); + + describe('column[].dataField', () => { + it('should determine which value from data will be used', () => { + const { columnsController } = setup({ + columns: [{ dataField: 'a' }, { dataField: 'b' }], + }); + + const dataObject = { a: 'a text', b: 'b text' }; + const columns = columnsController.columns.unreactive_get(); + const dataRow = columnsController.createDataRow(dataObject, columns); + + expect(dataRow.cells).toHaveLength(2); + expect(dataRow.cells[0].text).toBe('a text'); + expect(dataRow.cells[1].text).toBe('b text'); + }); + }); + + describe('column[].dataType', () => { + it('should affect column default settings', () => { + const { columnsController } = setup({ + columns: [ + { dataField: 'a', dataType: 'number' }, + { dataField: 'b', dataType: 'boolean' }, + ], + }); + + const columns = columnsController.columns.unreactive_get(); + + expect(columns).toHaveLength(2); + expect(columns[0].alignment).toMatchInlineSnapshot('"right"'); + expect(columns[1].alignment).toMatchInlineSnapshot('"center"'); + }); + }); + + (['falseText', 'trueText'] as const).forEach((propName) => { + describe(`column[].${propName}`, () => { + it('should be used as text for boolean column', () => { + const { columnsController } = setup({ + columns: [ + { + dataField: 'a', + dataType: 'boolean', + [propName]: `my ${propName} text`, + }, + ], + }); + + const dataObject = { a: propName === 'trueText' }; + const columns = columnsController.columns.unreactive_get(); + const dataRow = columnsController.createDataRow(dataObject, columns); + + expect(dataRow.cells).toHaveLength(1); + expect(dataRow.cells[0].text).toBe(`my ${propName} text`); + }); + }); + }); + + describe('column[].format', () => { + it('should affect dataRow text', () => { + const { columnsController } = setup({ + columns: [ + { + dataField: 'a', + format: 'currency', + }, + ], + }); + + const dataObject = { a: 123 }; + const columns = columnsController.columns.unreactive_get(); + const dataRow = columnsController.createDataRow(dataObject, columns); + + expect(dataRow.cells).toHaveLength(1); + expect(dataRow.cells[0].text).toBe('$123'); + }); + }); +}); diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/columns_controller/options.ts b/packages/devextreme/js/__internal/grids/new/grid_core/columns_controller/options.ts new file mode 100644 index 000000000000..bcbe80dc058c --- /dev/null +++ b/packages/devextreme/js/__internal/grids/new/grid_core/columns_controller/options.ts @@ -0,0 +1,69 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import type { DataType } from '@js/common'; +import messageLocalization from '@js/localization/message'; + +import type { WithRequired } from '../types'; +import type { Column } from './types'; + +export type ColumnSettings = Partial & { + calculateDisplayValue: string | ((this: Column, data: unknown) => unknown); +}>; + +export type PreNormalizedColumn = WithRequired; + +export type ColumnProperties = ColumnSettings | string; + +export const defaultColumnProperties = { + dataType: 'string', + calculateCellValue(data): unknown { + // @ts-expect-error + return data[this.dataField!]; + }, + calculateDisplayValue(data): unknown { + return this.calculateCellValue(data); + }, + alignment: 'left', + visible: true, + allowReordering: true, + trueText: messageLocalization.format('dxDataGrid-trueText'), + falseText: messageLocalization.format('dxDataGrid-falseText'), +} satisfies Partial; + +export const defaultColumnPropertiesByDataType: Record< +DataType, +Exclude +> = { + boolean: { + alignment: 'center', + customizeText({ value }): string { + return value + ? this.trueText + : this.falseText; + }, + }, + string: { + + }, + date: { + + }, + datetime: { + + }, + number: { + alignment: 'right', + }, + object: { + + }, +}; + +export interface Options { + columns?: ColumnProperties[]; + + allowColumnReordering?: boolean; +} + +export const defaultOptions = { + allowColumnReordering: false, +} satisfies Options; diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/columns_controller/public_methods.test.ts b/packages/devextreme/js/__internal/grids/new/grid_core/columns_controller/public_methods.test.ts new file mode 100644 index 000000000000..9ef479fdc33d --- /dev/null +++ b/packages/devextreme/js/__internal/grids/new/grid_core/columns_controller/public_methods.test.ts @@ -0,0 +1,65 @@ +/* eslint-disable spellcheck/spell-checker */ +import { describe, expect, it } from '@jest/globals'; + +import { OptionsControllerMock } from '../options_controller/options_controller.mock'; +import { ColumnsController } from './columns_controller'; +import type { Options } from './options'; +import { PublicMethods } from './public_methods'; + +const setup = (config: Options = {}) => { + const options = new OptionsControllerMock(config); + const columnsController = new ColumnsController(options); + + // @ts-expect-error + const gridCore = new (PublicMethods(class { + protected columnsController = columnsController; + }))(); + + return { + options, + columnsController, + gridCore, + }; +}; + +describe('PublicMethods', () => { + describe('getVisibleColumns', () => { + it('should return visible columns', () => { + const { gridCore } = setup({ + columns: ['a', 'b', { dataField: 'c', visible: false }], + }); + + expect(gridCore.getVisibleColumns()).toMatchObject([ + { name: 'a' }, + { name: 'b' }, + ]); + }); + }); + + describe('addColumn', () => { + // tested in columns_controller.test.ts + }); + + describe('getVisibleColumnIndex', () => { + const { gridCore } = setup({ + columns: [{ dataField: 'a', visible: false }, 'b', 'c'], + }); + + it('should return visible index of visible column', () => { + expect(gridCore.getVisibleColumnIndex('b')).toBe(0); + expect(gridCore.getVisibleColumnIndex('c')).toBe(1); + }); + + it('should return -1 for non-visible colunm', () => { + expect(gridCore.getVisibleColumnIndex('a')).toBe(-1); + }); + }); + + describe('deleteColumn', () => { + // tested in columns_controller.test.ts + }); + + describe('columnOption', () => { + // tested in columns_controller.test.ts + }); +}); diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/columns_controller/public_methods.ts b/packages/devextreme/js/__internal/grids/new/grid_core/columns_controller/public_methods.ts new file mode 100644 index 000000000000..043bddfc20b0 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/new/grid_core/columns_controller/public_methods.ts @@ -0,0 +1,109 @@ +/* eslint-disable max-classes-per-file */ +/* eslint-disable consistent-return */ +/* eslint-disable spellcheck/spell-checker */ +/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ + +import { isObject } from '@js/core/utils/type'; + +import type { Constructor } from '../types'; +import type { GridCoreNewBase } from '../widget'; +import type { ColumnProperties, ColumnSettings } from './options'; +import type { Column } from './types'; +import { getColumnByIndexOrName } from './utils'; + +export function PublicMethods>(GridCore: TBase) { + return class GridCoreWithColumnsController extends GridCore { + public getVisibleColumns(): Column[] { + return this.columnsController.visibleColumns.unreactive_get(); + } + + public addColumn(column: ColumnProperties): void { + this.columnsController.addColumn(column); + } + + public getVisibleColumnIndex(columnNameOrIndex: string | number): number { + const column = getColumnByIndexOrName( + this.columnsController.columns.unreactive_get(), + columnNameOrIndex, + ); + + return this.columnsController.visibleColumns.unreactive_get() + .findIndex( + (c) => c.name === column?.name, + ); + } + + public deleteColumn(columnNameOrIndex: string | number): void { + const column = getColumnByIndexOrName( + this.columnsController.columns.unreactive_get(), + columnNameOrIndex, + ); + + if (!column) { + return; + } + + this.columnsController.deleteColumn(column); + } + + public columnOption( + columnNameOrIndex: string | number, + ): Column; + public columnOption( + columnNameOrIndex: string | number, + options: ColumnSettings, + ): void; + public columnOption( + columnNameOrIndex: string | number, + option: T, + value: ColumnSettings[T] + ): void; + public columnOption( + columnNameOrIndex: string | number, + option: T, + value: ColumnSettings[T] + ): void; + public columnOption( + columnNameOrIndex: string | number, + option: T + ): Column[T]; + public columnOption( + columnNameOrIndex: string | number, + option?: T | ColumnSettings, + value?: ColumnSettings[T], + // eslint-disable-next-line @typescript-eslint/no-invalid-void-type + ): Column | Column[T] | void { + const column = getColumnByIndexOrName( + this.columnsController.columns.unreactive_get(), + columnNameOrIndex, + ); + + if (!column) { + return; + } + + if (arguments.length === 1) { + return column; + } + + if (arguments.length === 2) { + if (isObject(option)) { + Object.entries(option).forEach(([optionName, optionValue]) => { + this.columnsController.columnOption( + column, + optionName as keyof Column, + optionValue, + ); + }); + } else { + return column[option as T]; + } + } + + if (arguments.length === 3) { + this.columnsController.columnOption(column, option as keyof Column, value); + } + } + }; +} diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/columns_controller/types.ts b/packages/devextreme/js/__internal/grids/new/grid_core/columns_controller/types.ts new file mode 100644 index 000000000000..442885030e18 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/new/grid_core/columns_controller/types.ts @@ -0,0 +1,53 @@ +import type { Format } from '@js/common'; +import type { ColumnBase } from '@js/common/grids'; + +type InheritedColumnProps = + | 'alignment' + | 'dataType' + | 'visible' + | 'visibleIndex' + | 'allowReordering' + | 'trueText' + | 'falseText' + | 'caption'; + +export type Column = Pick, InheritedColumnProps> & { + dataField?: string; + + name: string; + + calculateCellValue: (this: Column, data: unknown) => unknown; + + calculateDisplayValue: (this: Column, data: unknown) => unknown; + + format?: Format; + + customizeText?: (this: Column, info: { + value: unknown; + valueText: string; + }) => string; + + editorTemplate?: unknown; + + fieldTemplate?: unknown; +}; + +export type VisibleColumn = Column & { visible: true }; + +export interface Cell { + value: unknown; + + displayValue: unknown; + + text: string; + + column: Column; +} + +export interface DataRow { + cells: Cell[]; + + key: unknown; + + data: unknown; +} diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/columns_controller/utils.test.ts b/packages/devextreme/js/__internal/grids/new/grid_core/columns_controller/utils.test.ts new file mode 100644 index 000000000000..46bf9087e6cb --- /dev/null +++ b/packages/devextreme/js/__internal/grids/new/grid_core/columns_controller/utils.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from '@jest/globals'; + +import { getVisibleIndexes } from './utils'; + +describe('getVisibleIndexes', () => { + it('should create visible indexes if not present', () => { + expect(getVisibleIndexes([ + undefined, undefined, undefined, undefined, + ])).toEqual([ + 0, 1, 2, 3, + ]); + }); + + it('should preserve visible indexes if present', () => { + expect(getVisibleIndexes([ + 3, 1, 0, 2, + ])).toEqual([ + 3, 1, 0, 2, + ]); + }); + + it('should fill in missing indexes', () => { + expect(getVisibleIndexes([ + 3, undefined, 0, undefined, + ])).toEqual([ + 3, 1, 0, 2, + ]); + }); +}); diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/columns_controller/utils.ts b/packages/devextreme/js/__internal/grids/new/grid_core/columns_controller/utils.ts new file mode 100644 index 000000000000..00b2e980b482 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/new/grid_core/columns_controller/utils.ts @@ -0,0 +1,140 @@ +import { compileGetter } from '@js/core/utils/data'; +import { captionize } from '@js/core/utils/inflector'; +import { isDefined, isString } from '@js/core/utils/type'; + +import type { ColumnProperties, ColumnSettings, PreNormalizedColumn } from './options'; +import { defaultColumnProperties, defaultColumnPropertiesByDataType } from './options'; +import type { Column } from './types'; + +function normalizeColumn(column: PreNormalizedColumn): Column { + const dataTypeDefault = defaultColumnPropertiesByDataType[ + column.dataType ?? defaultColumnProperties.dataType + ]; + + const caption = captionize(column.name); + + const colWithDefaults = { + ...defaultColumnProperties, + ...dataTypeDefault, + caption, + ...column, + }; + + return { + ...colWithDefaults, + calculateDisplayValue: isString(colWithDefaults.calculateDisplayValue) + ? compileGetter(colWithDefaults.calculateDisplayValue) as (data: unknown) => string + : colWithDefaults.calculateDisplayValue, + }; +} + +export function getVisibleIndexes( + indexes: (number | undefined)[], +): number[] { + const newIndexes = [...indexes]; + let minNonExistingIndex = 0; + + indexes.forEach((visibleIndex, index) => { + while (newIndexes.includes(minNonExistingIndex)) { + minNonExistingIndex += 1; + } + + newIndexes[index] = visibleIndex ?? minNonExistingIndex; + }); + + return newIndexes as number[]; +} + +export function normalizeVisibleIndexes( + indexes: number[], + forceIndex?: number, +): number[] { + const indexMap = indexes.map( + (visibleIndex, index) => [index, visibleIndex], + ); + + const sortedIndexMap = new Array(indexes.length); + if (isDefined(forceIndex)) { + sortedIndexMap[indexes[forceIndex]] = forceIndex; + } + + let j = 0; + indexMap + .sort((a, b) => a[1] - b[1]) + .forEach(([index]) => { + if (index === forceIndex) { + return; + } + + if (isDefined(sortedIndexMap[j])) { + j += 1; + } + + sortedIndexMap[j] = index; + j += 1; + }); + + const returnIndexes = new Array(indexes.length); + sortedIndexMap.forEach((index, visibleIndex) => { + returnIndexes[index] = visibleIndex; + }); + return returnIndexes; +} + +export function normalizeColumns(columns: PreNormalizedColumn[]): Column[] { + const normalizedColumns = columns.map((c) => normalizeColumn(c)); + return normalizedColumns; +} + +export function preNormalizeColumns(columns: ColumnProperties[]): PreNormalizedColumn[] { + const normalizedColumns = columns + .map((column): ColumnSettings => { + if (typeof column === 'string') { + return { + dataField: column, + }; + } + + return column; + }) + .map((column, index) => ({ + ...column, + name: column.name ?? column.dataField ?? `column-${index}`, + })); + + const visibleIndexes = getVisibleIndexes( + normalizedColumns.map((c) => c.visibleIndex), + ); + + normalizedColumns.forEach((_, i) => { + normalizedColumns[i].visibleIndex = visibleIndexes[i]; + }); + + return normalizedColumns as PreNormalizedColumn[]; +} + +export function normalizeStringColumn(column: ColumnProperties): ColumnSettings { + if (typeof column === 'string') { + return { dataField: column }; + } + + return column; +} + +export function getColumnIndexByName(columns: PreNormalizedColumn[], name: string): number { + return columns.findIndex((c) => c.name === name); +} + +export function getColumnByIndexOrName( + columns: Column[], + columnNameOrIndex: string | number, +): Column | undefined { + const column = columns.find((c, i) => { + if (isString(columnNameOrIndex)) { + return c.name === columnNameOrIndex; + } + return i === columnNameOrIndex; + }); + + return column; +} diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/options.ts b/packages/devextreme/js/__internal/grids/new/grid_core/options.ts index 7020661d76d5..988fea008c30 100644 --- a/packages/devextreme/js/__internal/grids/new/grid_core/options.ts +++ b/packages/devextreme/js/__internal/grids/new/grid_core/options.ts @@ -2,15 +2,18 @@ import browser from '@js/core/utils/browser'; import { isMaterialBased } from '@js/ui/themes'; import type { WidgetOptions } from '@js/ui/widget/ui.widget'; +import * as columnsController from './columns_controller/index'; import type { GridCoreNew } from './widget'; /** * @interface */ export type Options = - & WidgetOptions; + & WidgetOptions + & columnsController.Options; export const defaultOptions = { + ...columnsController.defaultOptions, } satisfies Options; // TODO: separate by modules diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/types.ts b/packages/devextreme/js/__internal/grids/new/grid_core/types.ts index e29614212c8a..6549371773f2 100644 --- a/packages/devextreme/js/__internal/grids/new/grid_core/types.ts +++ b/packages/devextreme/js/__internal/grids/new/grid_core/types.ts @@ -1,4 +1,9 @@ import type { template } from '@js/core/templates/template'; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type Constructor = new(...deps: TDeps) => T; + // TODO export type Template = (props: T) => HTMLDivElement | template; + +export type WithRequired = Omit & Required>; diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/widget.ts b/packages/devextreme/js/__internal/grids/new/grid_core/widget.ts index 3c1a6fd2d661..280e3cc84294 100644 --- a/packages/devextreme/js/__internal/grids/new/grid_core/widget.ts +++ b/packages/devextreme/js/__internal/grids/new/grid_core/widget.ts @@ -9,6 +9,7 @@ import { DIContext } from '@ts/core/di/index'; import type { Subscription } from '@ts/core/reactive/index'; import { render } from 'inferno'; +import * as ColumnsControllerModule from './columns_controller/index'; import { MainView } from './main_view'; import { defaultOptions, defaultOptionsRules, type Options } from './options'; @@ -19,11 +20,15 @@ export class GridCoreNewBase< protected diContext!: DIContext; + protected columnsController!: ColumnsControllerModule.ColumnsController; + protected _registerDIContext(): void { this.diContext = new DIContext(); + this.diContext.register(ColumnsControllerModule.ColumnsController); } protected _initDIContext(): void { + this.columnsController = this.diContext.get(ColumnsControllerModule.ColumnsController); } protected _init(): void { @@ -65,4 +70,6 @@ export class GridCoreNewBase< } } -export class GridCoreNew extends GridCoreNewBase {} +export class GridCoreNew extends ColumnsControllerModule.PublicMethods( + GridCoreNewBase, +) {} diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets/optionChanged_bundled.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets/optionChanged_bundled.tests.js index bc28d7d5f7d1..fcf3f2b30792 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/optionChanged_bundled.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/optionChanged_bundled.tests.js @@ -63,7 +63,8 @@ QUnit.module('OptionChanged', { } const excludedComponents = [ - 'dxLayoutManager' + 'dxLayoutManager', + 'dxCardView', ]; const getDefaultOptions = function(componentName) { From a9c1925d91d1898517f09c70db5b99d0fd42d4a2 Mon Sep 17 00:00:00 2001 From: Roman Semenov Date: Wed, 15 Jan 2025 10:31:05 +0400 Subject: [PATCH 08/45] CardView - implement dataController (#28688) --- .../__snapshots__/options.test.ts.snap | 10 + .../data_controller/data_controller.ts | 144 +++++++++++ .../new/grid_core/data_controller/index.ts | 3 + .../grid_core/data_controller/options.test.ts | 235 ++++++++++++++++++ .../new/grid_core/data_controller/options.ts | 40 +++ .../data_controller/public_methods.test.ts | 171 +++++++++++++ .../data_controller/public_methods.ts | 71 ++++++ .../new/grid_core/data_controller/types.ts | 3 + .../new/grid_core/data_controller/utils.ts | 46 ++++ .../__internal/grids/new/grid_core/options.ts | 3 + .../options_controller_base.mock.ts | 1 + .../options_controller_base.ts | 11 +- .../__internal/grids/new/grid_core/types.ts | 9 +- .../__internal/grids/new/grid_core/widget.ts | 9 +- 14 files changed, 751 insertions(+), 5 deletions(-) create mode 100644 packages/devextreme/js/__internal/grids/new/grid_core/data_controller/__snapshots__/options.test.ts.snap create mode 100644 packages/devextreme/js/__internal/grids/new/grid_core/data_controller/data_controller.ts create mode 100644 packages/devextreme/js/__internal/grids/new/grid_core/data_controller/index.ts create mode 100644 packages/devextreme/js/__internal/grids/new/grid_core/data_controller/options.test.ts create mode 100644 packages/devextreme/js/__internal/grids/new/grid_core/data_controller/options.ts create mode 100644 packages/devextreme/js/__internal/grids/new/grid_core/data_controller/public_methods.test.ts create mode 100644 packages/devextreme/js/__internal/grids/new/grid_core/data_controller/public_methods.ts create mode 100644 packages/devextreme/js/__internal/grids/new/grid_core/data_controller/types.ts create mode 100644 packages/devextreme/js/__internal/grids/new/grid_core/data_controller/utils.ts 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/options.ts b/packages/devextreme/js/__internal/grids/new/grid_core/options.ts index 988fea008c30..b3fbbf69a803 100644 --- a/packages/devextreme/js/__internal/grids/new/grid_core/options.ts +++ b/packages/devextreme/js/__internal/grids/new/grid_core/options.ts @@ -3,6 +3,7 @@ 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 type { GridCoreNew } from './widget'; /** @@ -10,9 +11,11 @@ import type { GridCoreNew } from './widget'; */ export type Options = & WidgetOptions + & dataController.Options & columnsController.Options; export const defaultOptions = { + ...dataController.defaultOptions, ...columnsController.defaultOptions, } satisfies Options; diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/options_controller/options_controller_base.mock.ts b/packages/devextreme/js/__internal/grids/new/grid_core/options_controller/options_controller_base.mock.ts index 3a3a1747e202..7cb01f9e1f9c 100644 --- a/packages/devextreme/js/__internal/grids/new/grid_core/options_controller/options_controller_base.mock.ts +++ b/packages/devextreme/js/__internal/grids/new/grid_core/options_controller/options_controller_base.mock.ts @@ -20,6 +20,7 @@ export class OptionsControllerMock< 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.ts b/packages/devextreme/js/__internal/grids/new/grid_core/options_controller/options_controller_base.ts index 8bc4c67b7958..98a2b5424748 100644 --- a/packages/devextreme/js/__internal/grids/new/grid_core/options_controller/options_controller_base.ts +++ b/packages/devextreme/js/__internal/grids/new/grid_core/options_controller/options_controller_base.ts @@ -11,7 +11,7 @@ import { computed, state } from '@ts/core/reactive/index'; import type { ComponentType } from 'inferno'; import { TemplateWrapper } from '../inferno_wrappers/template_wrapper'; -import type { Template } from '../types'; +import type { Action, Template } from '../types'; type OwnProperty = TPropName extends keyof Required @@ -38,6 +38,11 @@ type TemplateProperty = ? ComponentType | undefined : unknown; +type ActionProperty = + NonNullable> extends Action + ? (args: TActionArgs) => void + : unknown; + function cloneObjectValue | unknown[]>( value: T, ): T { @@ -83,7 +88,7 @@ export class OptionsController { ) { this.props = state(component.option()); // @ts-expect-error - this.defaults = component._getDefaultOptions(); + this.defaults = component._getDefaultOptions?.() ?? {}; this.updateIsControlledMode(); component.on('optionChanged', (e: ChangedOptionInfo) => { @@ -161,7 +166,7 @@ export class OptionsController { public action( name: TProp, - ): SubsGets> { + ): SubsGets> { return computed( // @ts-expect-error () => this.component._createActionByOption(name) as any, diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/types.ts b/packages/devextreme/js/__internal/grids/new/grid_core/types.ts index 6549371773f2..0b91c9e01175 100644 --- a/packages/devextreme/js/__internal/grids/new/grid_core/types.ts +++ b/packages/devextreme/js/__internal/grids/new/grid_core/types.ts @@ -1,9 +1,16 @@ import type { template } from '@js/core/templates/template'; +import type { EventInfo } from '@js/events'; + +import type { GridCoreNew } from './widget'; // eslint-disable-next-line @typescript-eslint/no-explicit-any export type Constructor = new(...deps: TDeps) => T; // TODO -export type Template = (props: T) => HTMLDivElement | template; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export type Template = (props: TProps) => HTMLDivElement | template; + +// TODO: add TComponent +export type Action = (args: TArgs & EventInfo) => void; export type WithRequired = Omit & Required>; diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/widget.ts b/packages/devextreme/js/__internal/grids/new/grid_core/widget.ts index 280e3cc84294..0ad060d8e9b4 100644 --- a/packages/devextreme/js/__internal/grids/new/grid_core/widget.ts +++ b/packages/devextreme/js/__internal/grids/new/grid_core/widget.ts @@ -10,6 +10,7 @@ import type { Subscription } from '@ts/core/reactive/index'; import { render } from 'inferno'; import * as ColumnsControllerModule from './columns_controller/index'; +import * as DataControllerModule from './data_controller/index'; import { MainView } from './main_view'; import { defaultOptions, defaultOptionsRules, type Options } from './options'; @@ -20,14 +21,18 @@ export class GridCoreNewBase< protected diContext!: DIContext; + protected dataController!: DataControllerModule.DataController; + protected columnsController!: ColumnsControllerModule.ColumnsController; protected _registerDIContext(): void { this.diContext = new DIContext(); + this.diContext.register(DataControllerModule.DataController); this.diContext.register(ColumnsControllerModule.ColumnsController); } protected _initDIContext(): void { + this.dataController = this.diContext.get(DataControllerModule.DataController); this.columnsController = this.diContext.get(ColumnsControllerModule.ColumnsController); } @@ -71,5 +76,7 @@ export class GridCoreNewBase< } export class GridCoreNew extends ColumnsControllerModule.PublicMethods( - GridCoreNewBase, + DataControllerModule.PublicMethods( + GridCoreNewBase, + ), ) {} From c37e6adff8d593dd91cdebe2fc8dbd12cad8c178 Mon Sep 17 00:00:00 2001 From: Alyar Date: Mon, 20 Jan 2025 11:45:25 +0300 Subject: [PATCH 09/45] CardView: Implement Toolbar (#28693) Co-authored-by: Alyar <> --- .../grid_core/header_panel/m_header_panel.ts | 61 ++---- .../__snapshots__/widget.test.ts.snap | 1 + .../grids/new/card_view/main_view.tsx | 22 +- .../new/grid_core/inferno_wrappers/toolbar.ts | 40 ++++ .../__internal/grids/new/grid_core/options.ts | 4 +- .../__snapshots__/options.test.ts.snap | 200 ++++++++++++++++++ .../new/grid_core/toolbar/controller.test.ts | 86 ++++++++ .../grids/new/grid_core/toolbar/controller.ts | 62 ++++++ .../grids/new/grid_core/toolbar/defaults.tsx | 6 + .../grids/new/grid_core/toolbar/index.ts | 3 + .../new/grid_core/toolbar/options.test.ts | 152 +++++++++++++ .../grids/new/grid_core/toolbar/options.ts | 5 + .../grids/new/grid_core/toolbar/toolbar.tsx | 10 + .../grids/new/grid_core/toolbar/types.ts | 18 ++ .../grids/new/grid_core/toolbar/utils.test.ts | 97 +++++++++ .../grids/new/grid_core/toolbar/utils.ts | 65 ++++++ .../grids/new/grid_core/toolbar/view.tsx | 38 ++++ .../__internal/grids/new/grid_core/widget.ts | 10 + .../headerPanel.tests.js | 16 -- 19 files changed, 825 insertions(+), 71 deletions(-) create mode 100644 packages/devextreme/js/__internal/grids/new/grid_core/inferno_wrappers/toolbar.ts create mode 100644 packages/devextreme/js/__internal/grids/new/grid_core/toolbar/__snapshots__/options.test.ts.snap create mode 100644 packages/devextreme/js/__internal/grids/new/grid_core/toolbar/controller.test.ts create mode 100644 packages/devextreme/js/__internal/grids/new/grid_core/toolbar/controller.ts create mode 100644 packages/devextreme/js/__internal/grids/new/grid_core/toolbar/defaults.tsx create mode 100644 packages/devextreme/js/__internal/grids/new/grid_core/toolbar/index.ts create mode 100644 packages/devextreme/js/__internal/grids/new/grid_core/toolbar/options.test.ts create mode 100644 packages/devextreme/js/__internal/grids/new/grid_core/toolbar/options.ts create mode 100644 packages/devextreme/js/__internal/grids/new/grid_core/toolbar/toolbar.tsx create mode 100644 packages/devextreme/js/__internal/grids/new/grid_core/toolbar/types.ts create mode 100644 packages/devextreme/js/__internal/grids/new/grid_core/toolbar/utils.test.ts create mode 100644 packages/devextreme/js/__internal/grids/new/grid_core/toolbar/utils.ts create mode 100644 packages/devextreme/js/__internal/grids/new/grid_core/toolbar/view.tsx 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 index b8a3b92dc104..d64f709fa54e 100644 --- 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 @@ -4,6 +4,7 @@ exports[`common initial render should be successfull 1`] = `
+ This is cardView
`; 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 index 55b5b65437d2..18a673b2714a 100644 --- a/packages/devextreme/js/__internal/grids/new/card_view/main_view.tsx +++ b/packages/devextreme/js/__internal/grids/new/card_view/main_view.tsx @@ -1,18 +1,22 @@ /* eslint-disable spellcheck/spell-checker */ /* eslint-disable @typescript-eslint/explicit-member-accessibility */ -import { state } from '@ts/core/reactive/index'; +import { combined } from '@ts/core/reactive/index'; import { View } from '@ts/grids/new/grid_core/core/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; } // eslint-disable-next-line no-empty-pattern function MainViewComponent({ - + Toolbar, }: MainViewProps): JSX.Element { return (<> + {/* @ts-expect-error */} + This is cardView ); } @@ -20,11 +24,19 @@ function MainViewComponent({ export class MainView extends View { protected override component = MainViewComponent; - public static dependencies = [] as const; + public static dependencies = [ToolbarView] as const; + + constructor( + 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 state({}); + return combined({ + Toolbar: this.toolbar.asInferno(), + }); } } 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/options.ts b/packages/devextreme/js/__internal/grids/new/grid_core/options.ts index b3fbbf69a803..b2d6bfc693a1 100644 --- a/packages/devextreme/js/__internal/grids/new/grid_core/options.ts +++ b/packages/devextreme/js/__internal/grids/new/grid_core/options.ts @@ -4,6 +4,7 @@ import type { WidgetOptions } from '@js/ui/widget/ui.widget'; import * as columnsController from './columns_controller/index'; import * as dataController from './data_controller/index'; +import type * as toolbar from './toolbar'; import type { GridCoreNew } from './widget'; /** @@ -12,7 +13,8 @@ import type { GridCoreNew } from './widget'; export type Options = & WidgetOptions & dataController.Options - & columnsController.Options; + & columnsController.Options + & toolbar.Options; export const defaultOptions = { ...dataController.defaultOptions, 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`] = ` +
+