Skip to content

Commit

Permalink
CardView - implement OptionsController (#28540)
Browse files Browse the repository at this point in the history
  • Loading branch information
pomahtri authored Dec 16, 2024
1 parent f7dd6da commit 967997f
Show file tree
Hide file tree
Showing 8 changed files with 326 additions and 0 deletions.
Original file line number Diff line number Diff line change
@@ -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<Options, typeof defaultOptions> {}

export { CardViewOptionsController as OptionsController };
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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
> {}
Original file line number Diff line number Diff line change
@@ -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<Options, typeof defaultOptions> {}

export { GridCoreOptionsController as OptionsController };
Original file line number Diff line number Diff line change
@@ -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<TProps, TDefaultProps> {
private readonly componentMock: Component<TProps>;
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);
}
}
Original file line number Diff line number Diff line change
@@ -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<Options>;
let optionsController: OptionsController<Options>;

beforeEach(() => {
component = new Component<Options>({
value: 'initialValue',
objectValue: { nestedValue: 'nestedInitialValue' },
onOptionChanged,
});
optionsController = new OptionsController<Options>(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',
});
});
});
Original file line number Diff line number Diff line change
@@ -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<T, TPropName extends string> =
TPropName extends keyof Required<T>
? Required<T>[TPropName]
: unknown;

type PropertyTypeBase<T, TProp extends string> =
TProp extends `${infer TOwnProp}.${infer TNestedProps}`
? PropertyTypeBase<OwnProperty<T, TOwnProp>, TNestedProps>
: OwnProperty<T, TProp>;

type PropertyType<TProps, TProp extends string> =
unknown extends PropertyTypeBase<TProps, TProp>
? unknown
: PropertyTypeBase<TProps, TProp> | undefined;

type PropertyWithDefaults<TProps, TDefaults, TProp extends string> =
unknown extends PropertyType<TDefaults, TProp>
? PropertyType<TProps, TProp>
: NonNullable<PropertyType<TProps, TProp>> | PropertyTypeBase<TDefaults, TProp>;

type TemplateProperty<TProps, TProp extends string> =
NonNullable<PropertyType<TProps, TProp>> extends Template<infer TTemplateProps>
? ComponentType<TTemplateProps> | undefined
: unknown;

function cloneObjectValue<T extends Record<string, unknown> | unknown[]>(
value: T,
): T {
// @ts-expect-error
return Array.isArray(value) ? [...value] : { ...value };
}

function updateImmutable<T extends Record<string, unknown> | 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<T>(obj: unknown, path: string): T {
let v: any = obj;
for (const pathPart of getPathParts(path)) {
v = v?.[pathPart];
}

return v;
}

export class OptionsController<TProps, TDefaultProps extends TProps = TProps> {
private isControlledMode = false;

private readonly props: SubsGetsUpd<TProps>;

private readonly defaults: TDefaultProps;

public static dependencies = [Component];

constructor(
private readonly component: Component<TProps>,
) {
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<TProp extends string>(
name: TProp,
): SubsGets<PropertyWithDefaults<TProps, TDefaultProps, TProp>> {
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<TProp extends string>(
name: TProp,
): SubsGetsUpd<PropertyWithDefaults<TProps, TDefaultProps, TProp>> {
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<TProp extends string>(
name: TProp,
): SubsGets<TemplateProperty<TProps, TProp>> {
return computed(
// @ts-expect-error
(template) => template && TemplateWrapper(this.component._getTemplate(template)) as any,
[this.oneWay(name)],
);
}

public action<TProp extends string>(
name: TProp,
): SubsGets<PropertyWithDefaults<TProps, TDefaultProps, TProp>> {
return computed(
// @ts-expect-error
() => this.component._createActionByOption(name) as any,
[this.oneWay(name)],
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import type { template } from '@js/core/templates/template';

// TODO
export type Template<T> = (props: T) => HTMLDivElement | template;

0 comments on commit 967997f

Please sign in to comment.