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 00000000000..2e3c4cb2bcf --- /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 f444199a201..420732885d7 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/inferno_wrappers/button.ts b/packages/devextreme/js/__internal/grids/new/grid_core/inferno_wrappers/button.ts new file mode 100644 index 00000000000..75e10161784 --- /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 00000000000..a6684fa54a1 --- /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 00000000000..1ed801dbf5d --- /dev/null +++ b/packages/devextreme/js/__internal/grids/new/grid_core/inferno_wrappers/widget_wrapper.tsx @@ -0,0 +1,54 @@ +import type DOMComponent from '@js/core/dom_component'; +import type { InfernoNode, RefObject } from 'inferno'; +import { Component, createRef } from 'inferno'; + +interface WithRef { + componentRef?: RefObject; +} + +export abstract class InfernoWrapper< + TProperties, + TComponent extends DOMComponent, +> extends Component> { + protected readonly ref = createRef(); + + protected component?: TComponent; + + protected abstract getComponentFabric(): new ( + element: Element, options: TProperties + ) => TComponent; + + public render(): InfernoNode { + return
; + } + + private updateComponentRef(): void { + if (this.props.componentRef) { + // @ts-expect-error + this.props.componentRef.current = this.component; + } + } + + protected updateComponentOptions(prevProps: TProperties, props: TProperties): void { + Object.keys(props as object).forEach((key) => { + if (props[key] !== prevProps[key]) { + this.component?.option(key, props[key]); + } + }); + } + + public componentDidMount(): void { + // eslint-disable-next-line no-new, @typescript-eslint/no-non-null-assertion + this.component = new (this.getComponentFabric())(this.ref.current!, this.props); + this.updateComponentRef(); + } + + public componentDidUpdate(prevProps: TProperties): void { + this.updateComponentOptions(prevProps, this.props); + this.updateComponentRef(); + } + + public componentWillUnmount(): void { + this.component?.dispose(); + } +} diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/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 00000000000..9aedf84a4c1 --- /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 00000000000..b1b3dd97340 --- /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 00000000000..5f17604179a --- /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 00000000000..e56a4ba02b7 --- /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 00000000000..4f8cb64fe4a --- /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 00000000000..e29614212c8 --- /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;