Skip to content

Commit

Permalink
refactor(mcv): separated files for view types and viewProxy
Browse files Browse the repository at this point in the history
  • Loading branch information
mnasyrov committed Apr 27, 2024
1 parent 0ef3d59 commit a6b2701
Show file tree
Hide file tree
Showing 7 changed files with 296 additions and 293 deletions.
11 changes: 5 additions & 6 deletions src/mvc/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,13 @@ export type {
} from './viewModel';
export { declareViewModel } from './viewModel';

export type { ViewBinding, ViewPropAtoms, ViewProps, ViewStatus } from './view';

export type {
InferViewPropsFromControllerContext,
ViewBinding,
ViewControllerContext,
ViewPropAtoms,
ViewProps,
ViewProxy,
ViewStatus,
} from './withView';
export { withView, provideView } from './withView';

export { createViewProxy, withView, provideView } from './withView';
export type { ViewProxy } from './viewProxy';
export { createViewProxy } from './viewProxy';
54 changes: 54 additions & 0 deletions src/mvc/view.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { Atom, Signal } from '../core';

/**
* ViewProps are the properties that are provided to the view
*/
export type ViewProps = Record<string, unknown>;

/**
* ViewPropAtoms are the atoms wit View's props that are provided
* to the controller by the ViewBinding
*/
export type ViewPropAtoms<TProps extends ViewProps> =
TProps extends Record<string, never>
? Record<string, never>
: Readonly<{
[K in keyof TProps]: Atom<TProps[K]>;
}>;

export type ViewStatus = 'unmounted' | 'mounted' | 'destroyed';

export const ViewStatuses = {
unmounted: 'unmounted' as ViewStatus,
mounted: 'mounted' as ViewStatus,
destroyed: 'destroyed' as ViewStatus,
} as const;

/**
* ViewBinding is the binding between the controller and the view
*/
export type ViewBinding<TProps extends ViewProps> = {
/**
* View's props
*/
readonly props: ViewPropAtoms<TProps>;

readonly status: Atom<ViewStatus>;

/**
* Signals that the view has been mounted
*/
readonly onMount: Signal<void>;

/**
* Signals that the view has been updated.
*
* Partial<TProps> is the properties that were updated.
*/
readonly onUpdate: Signal<Partial<TProps>>;

/**
* Signals that the view has been unmounted.
*/
readonly onUnmount: Signal<void>;
};
8 changes: 2 additions & 6 deletions src/mvc/viewModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,8 @@ import {
ExtensionFn,
ExtensionParamsProvider,
} from './controller';
import {
ViewControllerContext,
ViewPropAtoms,
ViewProps,
withView,
} from './withView';
import { ViewPropAtoms, ViewProps } from './view';
import { ViewControllerContext, withView } from './withView';

export type BaseViewModel = Record<string, unknown> & {
props?: Record<string, Atom<unknown>>;
Expand Down
111 changes: 111 additions & 0 deletions src/mvc/viewProxy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { syncEffect } from '../core';

import { createViewProxy } from './viewProxy';

describe('ViewProxy', () => {
describe('createViewProxy()', () => {
it('should create a view proxy', () => {
const view = createViewProxy();

expect(view).toEqual(
expect.objectContaining({
props: expect.any(Object),

onMount: expect.any(Function),
onUpdate: expect.any(Function),
onUnmount: expect.any(Function),

mount: expect.any(Function),
update: expect.any(Function),
unmount: expect.any(Function),
}),
);
});

it('should create a view proxy with initial props', () => {
const view = createViewProxy({ a: 1 });

expect(view.props.a()).toEqual(1);
});
});

describe('mount()', () => {
it('should notify the view is mounted by onMount signal', () => {
const view = createViewProxy();

const spy = jest.fn();
syncEffect(view.onMount, spy);
view.mount();

expect(spy).toHaveBeenCalledTimes(1);
});
});

describe('update()', () => {
it('should not notify if the view is not mounted', () => {
const view = createViewProxy({ value: 1 });

const spy = jest.fn();
syncEffect(view.onUpdate, spy);

view.update({ value: 2 });
expect(spy).toHaveBeenCalledTimes(0);
expect(view.props.value()).toBe(1);

spy.mockClear();
view.mount();
view.update({ value: 3 });
expect(spy).toHaveBeenCalledTimes(1);
expect(spy).toHaveBeenLastCalledWith({ value: 3 });
expect(view.props.value()).toBe(3);
});

it('should notify the view is updated by onUpdate signal', () => {
const view = createViewProxy({ value: 1 });

const spy = jest.fn();
syncEffect(view.onUpdate, spy);

view.mount();

view.update({ value: 2 });
expect(spy).toHaveBeenCalledTimes(1);
expect(spy).toHaveBeenLastCalledWith({ value: 2 });
expect(view.props.value()).toBe(2);

spy.mockClear();
view.update();
expect(spy).toHaveBeenCalledTimes(1);
expect(spy).toHaveBeenLastCalledWith({});
expect(view.props.value()).toBe(2);
});

it('should not fail if the new props contains the unknown keys', () => {
const view = createViewProxy({ value: 1 });

const spy = jest.fn();
syncEffect(view.onUpdate, spy);

view.mount();
view.update({ value: 2, unknown: 3 } as any);
expect(spy).toHaveBeenCalledTimes(1);
expect(spy).toHaveBeenLastCalledWith({ value: 2, unknown: 3 });

expect(view.props.value()).toBe(2);
expect((view.props as any)['unknown']).toEqual(undefined);
});
});

describe('unmount()', () => {
it('should notify the view is unmounted by onUnmount signal', () => {
const view = createViewProxy();
view.mount();

const spy = jest.fn();
syncEffect(view.onUnmount, spy);
view.unmount();

expect(spy).toHaveBeenCalledTimes(1);
});
});
});
120 changes: 120 additions & 0 deletions src/mvc/viewProxy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { Atom, atom, createScope, signal, WritableAtom } from '../core';

import {
ViewBinding,
ViewPropAtoms,
ViewProps,
ViewStatus,
ViewStatuses,
} from './view';

/**
* ViewProxy implements the binding between the controller and the view.
*
* It can be used to represent a view inside unit tests for the controller.
*/
export type ViewProxy<TProps extends ViewProps> = ViewBinding<TProps> & {
/**
* Mount the view
*/
readonly mount: () => void;

/**
* Update the view.
*
* @param props - The props that were updated
*/
readonly update: (props?: Partial<TProps>) => void;

/**
* Unmount the view
*/
readonly unmount: () => void;

/**
* Destroy the view
*/
readonly destroy: () => void;
};

/**
* Creates a view proxy that implements the view binding
*/
export function createViewProxy(): ViewProxy<Record<string, never>>;

/**
* Creates a view proxy that implements the view binding
*/
export function createViewProxy<TProps extends ViewProps>(
initialProps: TProps,
): ViewProxy<TProps>;

/**
* Creates a view proxy that implements the view binding
*/
export function createViewProxy<TProps extends ViewProps>(
initialProps?: TProps,
): ViewProxy<TProps> {
const status = atom<ViewStatus>(ViewStatuses.unmounted);
const props: Record<string, WritableAtom<any>> = {};
const readonlyProps: Record<string, Atom<any>> = {};

const scope = createScope();

if (initialProps) {
Object.keys(initialProps).forEach((key) => {
const store = scope.atom(initialProps[key]);
props[key] = store;
readonlyProps[key] = store.asReadonly();
});
}

const onMount = signal<void>({ sync: true });
const onUpdate = signal<Partial<TProps>>({ sync: true });
const onUnmount = signal<void>({ sync: true });

return {
status: status.asReadonly(),
props: readonlyProps as ViewPropAtoms<TProps>,

onMount,
onUpdate,
onUnmount,

mount(): void {
if (status() === ViewStatuses.unmounted) {
status.set(ViewStatuses.mounted);
onMount();
}
},

update(nextProps?: Partial<TProps>): void {
if (status() !== ViewStatuses.mounted) {
return;
}

if (nextProps) {
Object.keys(nextProps).forEach((key) => {
props[key]?.set(nextProps[key]);
});
}

onUpdate(nextProps ?? {});
},

unmount(): void {
if (status() === ViewStatuses.mounted) {
status.set(ViewStatuses.unmounted);
onUnmount();
}
},

destroy(): void {
if (status() !== ViewStatuses.destroyed) {
onUnmount();
status.set(ViewStatuses.destroyed);
scope.destroy();
}
},
};
}
Loading

0 comments on commit a6b2701

Please sign in to comment.