forked from wix-incubator/obsidian
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
useObservables + model.use pattern (wix-incubator#107)
This PR adds two features that aim to help writing MVVM applications with Obsidian. ### useObservers This hook lets us observe multiple observables with a single method call. <table> <tr> <td>Old</td> <td>New</td> </tr> <tr> <td> ```ts const [foo] = useObserver(fooObservable); const [bar] = useObserver(barObservable); ``` </td> <td> ```ts const {foo, bar} = useObservers({ foo: fooObservable, bar: barObservable }); ``` </td> </tr> </table> ### Model base class In MVVM, data and business logic in encapsulated in classes called "models". The models are made available to the views (typically functional components in React) via `useViewModel` hooks. This PR implements an abstract model class that models can extend. It allows developers to easily observe all observables declared in the model using the `model.use()` method. #### Example 1. Declare a model ```ts import { Model } from 'react-obsidian'; class FooModel extends Model { public readonly foo = new Observable(1); public readonly bar = new Observable('bar'); } ``` 2. Expose it via a graph ```ts @graph() class FooGraph extends ObjectGraph { @provides() fooModel() { return new FooModel(); } } ``` 3. Inject the model to a viewModel hook, and `use()` it ```ts const useFoo = ({ fooModel }: DependenciesOf<FooGraph, 'fooModel'>) => { const { foo, bar } = fooModel.use(); // { foo: number, bar: string } // Do something useful with foo and bar }; const useInjectedFoo = injectHook(useFoo, FooGraph); ``` Co-authored-by: Guy Carmeli <>
- Loading branch information
Showing
20 changed files
with
287 additions
and
19 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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
import 'jest-extended'; |
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
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,15 @@ | ||
import { useColdObservables } from '../observable/cold/useColdObservers'; | ||
import { Observable } from '../observable/Observable'; | ||
|
||
export abstract class Model { | ||
public use<T extends Model>(this: T) { | ||
const observables: Record<string, Observable<any>> = {}; | ||
Object.getOwnPropertyNames(this).forEach((propertyName: string) => { | ||
const property = (this as any)[propertyName]; | ||
if (property instanceof Observable) { | ||
observables[propertyName] = property; | ||
} | ||
}); | ||
return useColdObservables<T>(observables as any); | ||
} | ||
} |
This file was deleted.
Oops, something went wrong.
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,38 @@ | ||
import { Observable } from '../Observable'; | ||
import { ColdMediatorObservable } from './ColdMediatorObservable'; | ||
|
||
describe('ColdMediatorObservable', () => { | ||
let fooObservable: Observable<number>; | ||
let barObservable: Observable<string>; | ||
let uut: ColdMediatorObservable<{ foo: number; bar: string }>; | ||
|
||
beforeEach(() => { | ||
fooObservable = new Observable(1); | ||
barObservable = new Observable('bar'); | ||
uut = new ColdMediatorObservable({ foo: 1, bar: 'bar' }) | ||
.addSource(fooObservable, (nextFoo) => { | ||
uut.setValue('foo', nextFoo); | ||
}) | ||
.addSource(barObservable, (nextBar) => { | ||
uut.setValue('bar', nextBar); | ||
}); | ||
}); | ||
|
||
it('should be observable', () => { | ||
const onNext = jest.fn(); | ||
uut.subscribe(onNext); | ||
expect(uut.value.foo).toBe(1); | ||
|
||
fooObservable.value = 2; | ||
|
||
expect(uut.value.foo).toBe(2); | ||
}); | ||
|
||
it('should call subscribers only if a requested value changed', () => { | ||
const onNext = jest.fn(); | ||
uut.subscribe(onNext); | ||
|
||
fooObservable.value = 2; // foo is updated, but it is not requested by subscribers | ||
expect(onNext).toHaveBeenCalledTimes(0); | ||
}); | ||
}); |
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,47 @@ | ||
import { MediatorObservable } from '../mediator/MediatorObservable'; | ||
|
||
export class ColdMediatorObservable<T extends object> extends MediatorObservable<T> { | ||
constructor(obj: T, private readonly handler = new PropertyAccessTrackingProxy<T>()) { | ||
super(new Proxy(obj, handler)); | ||
} | ||
|
||
override set value(_: T) { | ||
throw new Error('Cannot set value of ColdMediatorObservable, use setValue(value, key) instead'); | ||
} | ||
|
||
override get value(): T { | ||
return super.value; | ||
} | ||
|
||
setValue(key: keyof T, value: any) { | ||
if (this.handler.hasAccessedProperty(key)) { | ||
this.handler.suspendTracking(); | ||
super.value = { ...this.value, [key]: value }; | ||
this.handler.resumeTracking(); | ||
} | ||
} | ||
} | ||
|
||
class PropertyAccessTrackingProxy<T extends object> implements ProxyHandler<T> { | ||
private readonly accessedProperties = new Set<keyof T>(); | ||
private trackingSuspended = false; | ||
|
||
get(target: T, p: string | symbol, receiver: any) { | ||
if (!this.trackingSuspended) { | ||
this.accessedProperties.add(p as keyof T); | ||
} | ||
return Reflect.get(target, p, receiver); | ||
} | ||
|
||
hasAccessedProperty(key: keyof T) { | ||
return this.accessedProperties.has(key); | ||
} | ||
|
||
public suspendTracking() { | ||
this.trackingSuspended = true; | ||
} | ||
|
||
public resumeTracking() { | ||
this.trackingSuspended = false; | ||
} | ||
} |
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,23 @@ | ||
import { useEffect, useState } from 'react'; | ||
import { ColdMediatorObservable } from './ColdMediatorObservable'; | ||
import { ObservedValues } from '../types'; | ||
import { mapObservablesToValues } from '../mapObservablesToValues'; | ||
|
||
export function useColdObservables<T extends Record<string, any>>(observables: T): ObservedValues<T> { | ||
const [mediator] = useState( | ||
() => new ColdMediatorObservable<T>(mapObservablesToValues(observables) as T), | ||
); | ||
const [values, setValues] = useState(() => mediator.value as ObservedValues<T>); | ||
|
||
useEffect(() => { | ||
Object.keys(observables as {}).forEach((key) => { | ||
mediator.addSource(observables[key], (value) => { | ||
mediator.setValue(key, value); | ||
}); | ||
}); | ||
|
||
return mediator.subscribe(setValues); | ||
}, []); | ||
|
||
return values; | ||
} |
4 changes: 2 additions & 2 deletions
4
src/observable/useObserver.test.ts → src/observable/cold/useObserver.test.ts
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,7 @@ | ||
import { ObservedValues } from './types'; | ||
|
||
export function mapObservablesToValues<T extends Record<string, any>>(observables: T): ObservedValues<T> { | ||
return Object.fromEntries( | ||
Object.entries(observables).map(([key, observable]) => [key, observable.value]), | ||
) as ObservedValues<T>; | ||
} |
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,9 @@ | ||
import { Observable } from '../Observable'; | ||
import { OnNext } from '../types'; | ||
|
||
export class MediatorObservable<T> extends Observable<T> { | ||
addSource<S>(source: Observable<S>, onNext: OnNext<S>) { | ||
source.subscribe(onNext); | ||
return this; | ||
} | ||
} |
2 changes: 1 addition & 1 deletion
2
src/observable/ObservableMediator.test.ts → ...vable/mediator/ObservableMediator.test.ts
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
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,34 @@ | ||
import { act, renderHook } from '@testing-library/react'; | ||
import { Observable } from './Observable'; | ||
import { useObservers } from './useObservers'; | ||
|
||
describe('useObservers', () => { | ||
let fooObservable: Observable<number>; | ||
let barObservable: Observable<string>; | ||
let bazObservable: Observable<boolean>; | ||
|
||
const uut = () => { | ||
const { foo, bar, baz } = useObservers({ foo: fooObservable, bar: barObservable, baz: bazObservable }); | ||
return { foo, bar, baz }; | ||
}; | ||
|
||
beforeEach(() => { | ||
fooObservable = new Observable(0); | ||
barObservable = new Observable('bar'); | ||
bazObservable = new Observable(true); | ||
}); | ||
|
||
it('should return the current values', () => { | ||
const { result } = renderHook(uut); | ||
expect(result.current.foo).toBe(0); | ||
expect(result.current.bar).toBe('bar'); | ||
expect(result.current.baz).toBe(true); | ||
}); | ||
|
||
it('should rerender when an observed value changes', () => { | ||
const { result } = renderHook(uut); | ||
expect(result.current.foo).toBe(0); | ||
act(() => { fooObservable.value = 1; }); | ||
expect(result.current.foo).toBe(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,21 @@ | ||
import { useEffect, useState } from 'react'; | ||
import { MediatorObservable } from './mediator/MediatorObservable'; | ||
import { ObservedValues } from './types'; | ||
import { mapObservablesToValues } from './mapObservablesToValues'; | ||
|
||
export function useObservers<T extends Record<string, any>>(observables: T): ObservedValues<T> { | ||
const [values, setValues] = useState(() => mapObservablesToValues(observables)); | ||
|
||
useEffect(() => { | ||
const mediator = new MediatorObservable(); | ||
Object.keys(observables as {}).forEach((key) => { | ||
mediator.addSource(observables[key], (value) => { | ||
setValues({ ...values, [key]: value }); | ||
}); | ||
}); | ||
|
||
return mediator.subscribe(); | ||
}, []); | ||
|
||
return values; | ||
} |
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,65 @@ | ||
import { act, renderHook } from '@testing-library/react'; | ||
import { | ||
DependenciesOf, | ||
Graph, | ||
Model, | ||
ObjectGraph, | ||
Observable, | ||
Provides, | ||
injectHook, | ||
} from '../../src'; | ||
|
||
describe('Model', () => { | ||
let model!: FooModel; | ||
let renderCount: number; | ||
|
||
beforeEach(() => { | ||
renderCount = 0; | ||
model = new FooModel(); | ||
}); | ||
|
||
it('should support getting all observables with a single function', () => { | ||
const { result } = renderHook(() => useInjectedFoo()); | ||
|
||
expect(result.current.foo).toBe(1); | ||
expect(result.current.bar).toBe('bar'); | ||
}); | ||
|
||
it('should rerender when an observed value changes', () => { | ||
const { result } = renderHook(() => useInjectedFoo()); | ||
|
||
act(() => { model.foo.value = 2; }); | ||
expect(result.current.foo).toBe(2); | ||
}); | ||
|
||
it('should not rerender when an unobserved value changes', () => { | ||
renderHook(() => useInjectedFoo()); | ||
expect(model.unusedObservable.value).toBe(true); | ||
|
||
act(() => { model.unusedObservable.value = false; }); | ||
|
||
expect(renderCount).toBe(1); | ||
}); | ||
|
||
class FooModel extends Model { | ||
public readonly foo = new Observable(1); | ||
public readonly bar = new Observable('bar'); | ||
public readonly unusedObservable = new Observable(true); | ||
} | ||
|
||
@Graph() | ||
class FooGraph extends ObjectGraph { | ||
@Provides() | ||
fooModel() { | ||
return model; | ||
} | ||
} | ||
|
||
const useFoo = ({ fooModel }: DependenciesOf<FooGraph, 'fooModel'>) => { | ||
const { foo, bar } = fooModel.use(); | ||
renderCount += 1; | ||
return { foo, bar }; | ||
}; | ||
|
||
const useInjectedFoo = injectHook(useFoo, FooGraph); | ||
}); |
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