Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

CardView - implement OptionsController #28540

Open
wants to merge 1 commit into
base: grids/cardview/main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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;
Loading