-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
refactor(mcv): separated files for view types and viewProxy
- Loading branch information
Showing
7 changed files
with
296 additions
and
293 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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>; | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
}); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | ||
} | ||
}, | ||
}; | ||
} |
Oops, something went wrong.