Skip to content

Commit

Permalink
useObservables + model.use pattern (#107)
Browse files Browse the repository at this point in the history
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
guyca committed Jul 16, 2023
1 parent 4badd04 commit 6dfdb4f
Show file tree
Hide file tree
Showing 20 changed files with 287 additions and 19 deletions.
1 change: 1 addition & 0 deletions global.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
import 'jest-extended';
3 changes: 2 additions & 1 deletion jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ const config = {
'test',
],
setupFilesAfterEnv: [
'./jest.setup-after-env.js'
'./jest.setup-after-env.js',
'jest-extended/all'
],
testEnvironment: 'jsdom'
};
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
"eslint-plugin-unused-imports": "2.x.x",
"jest": "29.5.x",
"jest-environment-jsdom": "^29.5.0",
"jest-extended": "^4.0.0",
"lodash": "^4.17.21",
"react": "18.2.x",
"react-dom": "18.2.x",
Expand Down
5 changes: 4 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,11 @@ export { injectComponent } from './injectors/components/InjectComponent';
export { injectHook, injectHookWithArguments } from './injectors/hooks/InjectHook';

export { useObserver } from './observable/useObserver';
export { useObservers } from './observable/useObservers';
export { Observable } from './observable/Observable';
export { MediatorObservable } from './observable/MediatorObservable';
export { MediatorObservable } from './observable/mediator/MediatorObservable';
export { OnNext, Unsubscribe } from './observable/types';

export { Model } from './model/Model';

export { testKit } from '../testkit';
15 changes: 15 additions & 0 deletions src/model/Model.ts
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);
}
}
9 changes: 0 additions & 9 deletions src/observable/MediatorObservable.ts

This file was deleted.

3 changes: 2 additions & 1 deletion src/observable/Observable.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Observable as IObservable, OnNext, Unsubscribe } from './types';

const NOOP = () => {};
export class Observable<T> implements IObservable<T> {
private subscribers: Set<OnNext<T>> = new Set();
private currentValue: T | undefined;
Expand All @@ -17,7 +18,7 @@ export class Observable<T> implements IObservable<T> {
this.subscribers.forEach((subscriber) => subscriber(value));
}

public subscribe(onNext: OnNext<T>): Unsubscribe {
public subscribe(onNext: OnNext<T> = NOOP): Unsubscribe {
if (this.subscribers.has(onNext)) {
throw new Error('Subscriber already subscribed');
}
Expand Down
38 changes: 38 additions & 0 deletions src/observable/cold/ColdMediatorObservable.test.ts
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);
});
});
47 changes: 47 additions & 0 deletions src/observable/cold/ColdMediatorObservable.ts
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;
}
}
23 changes: 23 additions & 0 deletions src/observable/cold/useColdObservers.ts
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;
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { act, renderHook } from '@testing-library/react';
import _ from 'lodash';
import { Observable } from './Observable';
import { useObserver } from './useObserver';
import { Observable } from '../Observable';
import { useObserver } from '../useObserver';

describe('useObserver', () => {
let observable: Observable<number>;
Expand Down
7 changes: 7 additions & 0 deletions src/observable/mapObservablesToValues.ts
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>;
}
9 changes: 9 additions & 0 deletions src/observable/mediator/MediatorObservable.ts
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;
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Observable } from './Observable';
import { Observable } from '../Observable';
import { MediatorObservable } from './MediatorObservable';

const NOOP = () => {};
Expand Down
2 changes: 2 additions & 0 deletions src/observable/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,5 @@ export interface Observable<T> {
value: T;
subscribe(onNext: OnNext<T>): Unsubscribe;
}

export type ObservedValues<T> = { [K in keyof T]: T[K] extends Observable<infer R> ? R : never };
34 changes: 34 additions & 0 deletions src/observable/useObservers.test.ts
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);
});
});
21 changes: 21 additions & 0 deletions src/observable/useObservers.ts
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;
}
65 changes: 65 additions & 0 deletions test/acceptance/model.test.ts
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);
});
3 changes: 2 additions & 1 deletion tsconfig.base.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
"src/**/*",
"transformers/**/*",
"test/**/*",
"./clearGraphs.ts"
"./clearGraphs.ts",
"global.d.ts"
],
"exclude": [
"node_modules",
Expand Down
14 changes: 11 additions & 3 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -504,7 +504,7 @@
dependencies:
"@babel/helper-plugin-utils" "^7.22.5"

"@babel/plugin-transform-class-properties@^7.22.5":
"@babel/plugin-transform-class-properties@7.22.x", "@babel/plugin-transform-class-properties@^7.22.5":
version "7.22.5"
resolved "https://repo.dev.wixpress.com/artifactory/api/npm/npm-repos/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.22.5.tgz#97a56e31ad8c9dc06a0b3710ce7803d5a48cca77"
integrity sha1-l6VuMa2MncBqCzcQzngD1aSMync=
Expand Down Expand Up @@ -3440,7 +3440,7 @@ jest-config@^29.6.1:
slash "^3.0.0"
strip-json-comments "^3.1.1"

jest-diff@^29.6.1:
jest-diff@^29.0.0, jest-diff@^29.6.1:
version "29.6.1"
resolved "https://repo.dev.wixpress.com/artifactory/api/npm/npm-repos/jest-diff/-/jest-diff-29.6.1.tgz#13df6db0a89ee6ad93c747c75c85c70ba941e545"
integrity sha1-E99tsKie5q2Tx0fHXIXHC6lB5UU=
Expand Down Expand Up @@ -3494,7 +3494,15 @@ jest-environment-node@^29.6.1:
jest-mock "^29.6.1"
jest-util "^29.6.1"

jest-get-type@^29.4.3:
jest-extended@^4.0.0:
version "4.0.0"
resolved "https://repo.dev.wixpress.com/artifactory/api/npm/npm-repos/jest-extended/-/jest-extended-4.0.0.tgz#00bb4b478566c28fd1b91ffd6f9cb405dc52d092"
integrity sha1-ALtLR4Vmwo/RuR/9b5y0BdxS0JI=
dependencies:
jest-diff "^29.0.0"
jest-get-type "^29.0.0"

jest-get-type@^29.0.0, jest-get-type@^29.4.3:
version "29.4.3"
resolved "https://repo.dev.wixpress.com/artifactory/api/npm/npm-repos/jest-get-type/-/jest-get-type-29.4.3.tgz#1ab7a5207c995161100b5187159ca82dd48b3dd5"
integrity sha1-GrelIHyZUWEQC1GHFZyoLdSLPdU=
Expand Down

0 comments on commit 6dfdb4f

Please sign in to comment.