From 5980e6d6d726aec6e5eb25c2ee90c5622633b49b Mon Sep 17 00:00:00 2001 From: Anthony Gubler Date: Tue, 21 Aug 2018 19:47:59 +0100 Subject: [PATCH 1/3] StoreProvider widget for injecting store state into a widget tree --- src/stores/README.md | 109 +++++----- src/stores/StoreInjector.ts | 8 +- src/stores/StoreProvider.ts | 77 +++++++ src/stores/middleware/localStorage.ts | 2 +- tests/stores/unit/StoreProvider.ts | 284 ++++++++++++++++++++++++++ tests/stores/unit/all.ts | 1 + 6 files changed, 423 insertions(+), 58 deletions(-) create mode 100644 src/stores/StoreProvider.ts create mode 100644 tests/stores/unit/StoreProvider.ts diff --git a/src/stores/README.md b/src/stores/README.md index 4c97131e0..452bbcdb5 100644 --- a/src/stores/README.md +++ b/src/stores/README.md @@ -21,8 +21,8 @@ An application store designed to complement @dojo/widgets and @dojo/widget-core - [Initial State](#initial-state) - [How Does This Differ From Redux](#how-does-this-differ-from-Redux) - [Advanced](#advanced) + - [Connecting Stores To Widgets](#connecting-stores-to-widgets) - [Subscribing To Store Changes](#subscribing-to-store-changes) - - [Connecting Store Updates To Widgets](#connecting-store-updates-to-widgets) - [Transforming Executor Arguments](#transforming-executor-arguments) - [Optimistic Update Pattern](#optimistic-update-pattern) - [Executing Concurrent Commands](#executing-concurrent-commands) @@ -328,38 +328,17 @@ Additionally, this means that there is no need to coordinate `actions` and `redu ## Advanced -### Subscribing to store changes - -To be notified of changes in the store, use the `onChange()` function, which takes a `path` or an array of `path`'s and a callback for when that portion of state changes, for example: - -```ts -store.onChange(store.path('foo', 'bar'), () => { - // do something when the state at foo/bar has been updated. -}); -``` - -or - -```ts -store.onChange([ - store.path('foo', 'bar'), - store.path('baz') -], () => { - // do something when the state at /foo/bar or /baz has been updated. -}); -``` +### Connecting Store To Widgets -In order to be notified when any changes occur within the store's state, simply register to the stores `.on()` method for a type of `invalidate` passing the function to be called. +Store data can be connected to widgets within your application using the `StoreProvider` widget provided by `@dojo/framework/stores`. -```ts -store.on('invalidate', () => { - // do something when the store's state has been updated. -}); -``` +Container Property API: -### Connecting Store Updates To Widgets + * `renderer`: A render function that has the store injected in order to access state and pass process to child widgets. + * `stateKey`: The key of the state in the registry. + * `paths` (optional): A function to connect the `Container` to sections of the state. -Store data can be connected to widgets within your application using the [Containers & Injectors Pattern](../widget-core#containers--injectors) supported by `@dojo/widget-core`. The `@dojo/framework/stores` package provides a specialized injector that invalidates store containers on two conditions: +There are two mechanisms to connect the `StoreProvider` to the `Store`: 1. The recommended approach is to register `paths` on container creation to ensure invalidation will only occur when state you are interested in changes. 2. A catch-all when no `paths` are defined for the container, it will invalidate when any data changes in the store. @@ -367,7 +346,7 @@ Store data can be connected to widgets within your application using the [Contai ```ts import { WidgetBase } from '@dojo/widget-core/WidgetBase'; import { Store } from '@dojo/framework/stores/Stores'; -import { StoreContainer } from '@dojo/framework/stores/StoreInjector'; +import StoreProvider from '@dojo/framework/stores/StoreProvider'; interface State { foo: string; @@ -376,37 +355,63 @@ interface State { } } -// Will only invalidate when the `foo` or `bar/baz` property is changed -const Container = StoreContainer(WidgetBase, 'state', { - paths(path) { - return [ - path('foo'), - path('bar', 'baz') - ]; - }, - getProperties(store: Store) { - return { - foo: store.get(store.path('foo')) - }; - } -}); - -// Catch all, will invalidate if _any_ state changes in the store even if the container is not interested in the changes -const Container = StoreContainer(WidgetBase, 'state', { getProperties(store: Store) { - return { - foo: store.get(store.path('foo')) +class MyApp extends WidgetBase { + protected render() { + return w(StoreProvider, { stateKey: 'state', (store: Store) => { + return v('div', [ store.get(store.path('foo')) ]); + }}) } -}}); +} ``` -Instead of typing `StoreContainer` for every usage, it is possible to create a pre-typed StoreContainer that can be used across your application. To do this use `createStoreContainer` from `@dojo/framework/stores/StoreInjector` passing the stores state interface as the generic argument. The result of this can be exported and used across your application. +A pre-typed container can be created by extending the standard `StoreProvider` and passing the `State` type as a generic. ```ts interface State { foo: string; } -export const StoreContainer = createStoreContainer(); +export class MyTypeStoreProvider extends StoreProvider {} +``` + +**However** in order for TypeScript to infer this correctly when using `w()`, the generic will need to be explicitly passed. + +```ts +w(MyTypeStoreProvider, { + stateKey: 'state', + renderer(store) { + return v('div', [ store.get(store.path('foo')) ]); + } +}); +``` + +### Subscribing to store changes + +To be notified of changes in the store, use the `onChange()` function, which takes a `path` or an array of `path`'s and a callback for when that portion of state changes, for example: + +```ts +store.onChange(store.path('foo', 'bar'), () => { + // do something when the state at foo/bar has been updated. +}); +``` + +or + +```ts +store.onChange([ + store.path('foo', 'bar'), + store.path('baz') +], () => { + // do something when the state at /foo/bar or /baz has been updated. +}); +``` + +In order to be notified when any changes occur within the store's state, simply register to the stores `.on()` method for a type of `invalidate` passing the function to be called. + +```ts +store.on('invalidate', () => { + // do something when the store's state has been updated. +}); ``` ### Transforming Executor Arguments diff --git a/src/stores/StoreInjector.ts b/src/stores/StoreInjector.ts index 3bbc40ed0..27096baec 100644 --- a/src/stores/StoreInjector.ts +++ b/src/stores/StoreInjector.ts @@ -5,8 +5,10 @@ import { handleDecorator } from '../widget-core/decorators/handleDecorator'; import { beforeProperties } from '../widget-core/decorators/beforeProperties'; import { alwaysRender } from '../widget-core/decorators/alwaysRender'; import { InjectorItem, RegistryLabel, Constructor, DNode } from '../widget-core/interfaces'; -import { Store, StatePaths, Path } from './Store'; +import { Store, Path } from './Store'; import { Registry } from '../widget-core/Registry'; +import { GetPaths } from './StoreProvider'; +export { GetPaths } from './StoreProvider'; const registeredInjectorsMap: WeakMap[]> = new WeakMap(); export interface GetProperties = WidgetBase> { @@ -27,10 +29,6 @@ export interface StoreContainerOptions { getProperties: GetProperties, W>; } -export interface GetPaths { - (path: StatePaths): Path[]; -} - export interface StoreInjectConfig { name: RegistryLabel; getProperties: GetProperties, any>; diff --git a/src/stores/StoreProvider.ts b/src/stores/StoreProvider.ts new file mode 100644 index 000000000..2e7f0a0c2 --- /dev/null +++ b/src/stores/StoreProvider.ts @@ -0,0 +1,77 @@ +import WidgetBase from '../widget-core/WidgetBase'; +import { DNode } from '../widget-core/interfaces'; +import { Store, StatePaths, Path } from './Store'; +import { diffProperty } from '../widget-core/decorators/diffProperty'; +import { Handle } from '../core/Destroyable'; +import { shallow } from '../widget-core/diff'; + +export interface GetPaths { + (path: StatePaths): Path[]; +} + +export interface StoreProviderProperties { + renderer: (store: Store) => DNode | DNode[]; + stateKey: string; + paths?: GetPaths; +} + +function mockPath(...paths: string[]): string { + return paths.join(','); +} + +function pathDiff(previousProperty: Function, newProperty: Function) { + const previousPaths = previousProperty ? previousProperty(mockPath) : []; + const currentPaths = newProperty ? newProperty(mockPath) : []; + const result = shallow(previousPaths, currentPaths); + return { + changed: result.changed, + value: newProperty + }; +} + +export class StoreProvider extends WidgetBase, never> { + private _handle: Handle | undefined; + + private _getStore(key: string): Store | undefined { + const item = this.registry.getInjector>(key); + if (item) { + return item.injector(); + } + } + + @diffProperty('stateKey') + @diffProperty('paths', pathDiff) + protected onChange(previousProperties: StoreProviderProperties, currentProperties: StoreProviderProperties) { + const { stateKey, paths } = currentProperties; + if (this._handle) { + this._handle.destroy(); + this._handle = undefined; + } + const store = this._getStore(stateKey); + if (store) { + if (paths) { + const handle = store.onChange(paths(store.path), () => this.invalidate()); + this._handle = { + destroy: () => { + handle.remove(); + } + }; + } else { + this._handle = store.on('invalidate', () => { + this.invalidate(); + }); + } + this.own(this._handle); + } + } + + protected render(): DNode | DNode[] { + const { stateKey, renderer } = this.properties; + const store = this._getStore(stateKey); + if (store) { + return renderer(store); + } + } +} + +export default StoreProvider; diff --git a/src/stores/middleware/localStorage.ts b/src/stores/middleware/localStorage.ts index a4e2c8ae5..07da314f8 100644 --- a/src/stores/middleware/localStorage.ts +++ b/src/stores/middleware/localStorage.ts @@ -1,7 +1,7 @@ import global from '../../shim/global'; import { ProcessError, ProcessResult, ProcessCallback, processExecutor } from '../process'; import { Store } from '../Store'; -import { GetPaths } from '../StoreInjector'; +import { GetPaths } from '../StoreProvider'; import { add } from '../state/operations'; export function collector(id: string, getPaths: GetPaths, callback?: ProcessCallback): ProcessCallback { diff --git a/tests/stores/unit/StoreProvider.ts b/tests/stores/unit/StoreProvider.ts new file mode 100644 index 000000000..12d12dbde --- /dev/null +++ b/tests/stores/unit/StoreProvider.ts @@ -0,0 +1,284 @@ +const { beforeEach, describe, it } = intern.getInterface('bdd'); +const { assert } = intern.getPlugin('chai'); +import { v } from '../../../src/widget-core/d'; +import { Registry } from '../../../src/widget-core/Registry'; + +import { StoreProvider } from '../../../src/stores/StoreProvider'; +import { createCommandFactory, createProcess, Process } from '../../../src/stores/process'; +import { replace } from '../../../src/stores/state/operations'; +import { Store } from '../../../src/stores/Store'; +import { VNode } from '../../../src/widget-core/interfaces'; + +interface State { + foo: string; + bar: string; + qux: { + baz: number; + foobar: number; + bar: { + foo: { + foobar: { + baz: { + barbaz: { + res: number; + }; + }; + }; + }; + }; + }; +} + +const commandFactory = createCommandFactory(); +const fooCommand = commandFactory(({ get, path }) => { + const currentFoo = get(path('foo')) || ''; + return [replace(path('foo'), `${currentFoo}foo`)]; +}); +const barCommand = commandFactory(({ get, path }) => { + const currentFoo = get(path('bar')); + return [replace(path('bar'), `${currentFoo}bar`)]; +}); +const bazCommand = commandFactory(({ get, path }) => { + const currentBaz = get(path('qux', 'baz')) || 0; + return [replace(path('qux', 'baz'), currentBaz + 1)]; +}); +const quxCommand = commandFactory(({ get, path }) => { + return [replace(path('qux'), { baz: 100 })]; +}); +const fooBarCommand = commandFactory(({ get, path }) => { + const currentFooBar = get(path('qux', 'foobar')) || 0; + return [replace(path('qux', 'foobar'), currentFooBar)]; +}); +const deepCommand = commandFactory(({ get, path }) => { + return [replace(path(path('qux', 'bar', 'foo', 'foobar', 'baz'), 'barbaz', 'res'), 0)]; +}); + +describe('StoreProvider', () => { + let store: Store; + let registry: Registry; + let fooProcess: Process; + let barProcess: Process; + let bazProcess: Process; + let quxProcess: Process; + let fooBarProcess: Process; + let deepProcess: Process; + + beforeEach(() => { + registry = new Registry(); + store = new Store(); + registry.defineInjector('state', () => () => store); + fooProcess = createProcess('foo', [fooCommand]); + barProcess = createProcess('bar', [barCommand]); + bazProcess = createProcess('baz', [bazCommand]); + quxProcess = createProcess('qux', [quxCommand]); + fooBarProcess = createProcess('foobar', [fooBarCommand]); + deepProcess = createProcess('deep', [deepCommand]); + }); + + it('should connect to the stores generally invalidate', () => { + let invalidateCount = 0; + class TestContainer extends StoreProvider { + invalidate() { + invalidateCount++; + } + } + const container = new TestContainer(); + container.registry.base = registry; + container.__setProperties__({ + stateKey: 'state', + renderer(injectedStore) { + assert.strictEqual(injectedStore, store); + return v('div'); + } + }); + invalidateCount = 0; + fooProcess(store)({}); + assert.strictEqual(invalidateCount, 1); + barProcess(store)({}); + assert.strictEqual(invalidateCount, 2); + bazProcess(store)({}); + assert.strictEqual(invalidateCount, 3); + quxProcess(store)({}); + assert.strictEqual(invalidateCount, 4); + fooBarProcess(store)({}); + assert.strictEqual(invalidateCount, 5); + deepProcess(store)({}); + assert.strictEqual(invalidateCount, 6); + }); + + it('should connect to the stores for paths', () => { + let invalidateCount = 0; + class TestContainer extends StoreProvider { + invalidate() { + invalidateCount++; + } + } + const container = new TestContainer(); + container.registry.base = registry; + container.__setProperties__({ + paths: (path) => { + return [path(path('qux', 'bar', 'foo', 'foobar', 'baz'), 'barbaz', 'res')]; + }, + stateKey: 'state', + renderer(injectedStore) { + assert.strictEqual(injectedStore, store); + return v('div'); + } + }); + invalidateCount = 0; + deepProcess(store)({}); + assert.strictEqual(invalidateCount, 1); + fooProcess(store)({}); + assert.strictEqual(invalidateCount, 1); + barProcess(store)({}); + assert.strictEqual(invalidateCount, 1); + bazProcess(store)({}); + assert.strictEqual(invalidateCount, 1); + quxProcess(store)({}); + assert.strictEqual(invalidateCount, 2); + fooBarProcess(store)({}); + assert.strictEqual(invalidateCount, 2); + }); + + it('should re-connect to the store when paths change', () => { + let invalidateCount = 0; + class TestContainer extends StoreProvider { + invalidate() { + invalidateCount++; + } + } + const container = new TestContainer(); + container.registry.base = registry; + container.__setProperties__({ + paths: (path) => { + return [path(path('qux', 'bar', 'foo', 'foobar', 'baz'), 'barbaz', 'res')]; + }, + stateKey: 'state', + renderer(injectedStore) { + assert.strictEqual(injectedStore, store); + return v('div'); + } + }); + invalidateCount = 0; + deepProcess(store)({}); + assert.strictEqual(invalidateCount, 1); + fooProcess(store)({}); + assert.strictEqual(invalidateCount, 1); + barProcess(store)({}); + assert.strictEqual(invalidateCount, 1); + bazProcess(store)({}); + assert.strictEqual(invalidateCount, 1); + quxProcess(store)({}); + assert.strictEqual(invalidateCount, 2); + fooBarProcess(store)({}); + assert.strictEqual(invalidateCount, 2); + container.__setProperties__({ + paths: (path) => { + return [path('foo')]; + }, + stateKey: 'state', + renderer(injectedStore) { + assert.strictEqual(injectedStore, store); + return v('div'); + } + }); + invalidateCount = 0; + deepProcess(store)({}); + assert.strictEqual(invalidateCount, 0); + fooProcess(store)({}); + assert.strictEqual(invalidateCount, 1); + barProcess(store)({}); + assert.strictEqual(invalidateCount, 1); + bazProcess(store)({}); + assert.strictEqual(invalidateCount, 1); + quxProcess(store)({}); + assert.strictEqual(invalidateCount, 1); + fooBarProcess(store)({}); + assert.strictEqual(invalidateCount, 1); + }); + + it('should re-connect to the entire store paths are not passed', () => { + let invalidateCount = 0; + class TestContainer extends StoreProvider { + invalidate() { + invalidateCount++; + } + } + const container = new TestContainer(); + container.registry.base = registry; + container.__setProperties__({ + paths: (path) => { + return [path(path('qux', 'bar', 'foo', 'foobar', 'baz'), 'barbaz', 'res')]; + }, + stateKey: 'state', + renderer(injectedStore) { + assert.strictEqual(injectedStore, store); + return v('div'); + } + }); + invalidateCount = 0; + deepProcess(store)({}); + assert.strictEqual(invalidateCount, 1); + fooProcess(store)({}); + assert.strictEqual(invalidateCount, 1); + barProcess(store)({}); + assert.strictEqual(invalidateCount, 1); + bazProcess(store)({}); + assert.strictEqual(invalidateCount, 1); + quxProcess(store)({}); + assert.strictEqual(invalidateCount, 2); + fooBarProcess(store)({}); + assert.strictEqual(invalidateCount, 2); + container.__setProperties__({ + stateKey: 'state', + renderer(injectedStore) { + assert.strictEqual(injectedStore, store); + return v('div'); + } + }); + invalidateCount = 0; + deepProcess(store)({}); + assert.strictEqual(invalidateCount, 1); + fooProcess(store)({}); + assert.strictEqual(invalidateCount, 2); + barProcess(store)({}); + assert.strictEqual(invalidateCount, 3); + bazProcess(store)({}); + assert.strictEqual(invalidateCount, 4); + quxProcess(store)({}); + assert.strictEqual(invalidateCount, 5); + fooBarProcess(store)({}); + assert.strictEqual(invalidateCount, 6); + }); + + it('should return the result of the renderer', () => { + const container = new StoreProvider(); + container.registry.base = registry; + container.__setProperties__({ + stateKey: 'state', + renderer(injectedStore) { + assert.strictEqual(injectedStore, store); + return v('div'); + } + }); + const result = container.__render__() as VNode; + assert.strictEqual(result.bind, container); + assert.deepEqual(result.properties, {}); + assert.isUndefined(result.children); + assert.strictEqual(result.tag, 'div'); + }); + + it('should return undefined is the store is not available in the registry', () => { + const container = new StoreProvider(); + container.registry.base = registry; + container.__setProperties__({ + stateKey: 'other-state', + renderer(injectedStore) { + assert.strictEqual(injectedStore, store); + return v('div'); + } + }); + const result = container.__render__() as VNode; + assert.isUndefined(result); + }); +}); diff --git a/tests/stores/unit/all.ts b/tests/stores/unit/all.ts index 12578bac5..9312a9169 100644 --- a/tests/stores/unit/all.ts +++ b/tests/stores/unit/all.ts @@ -4,4 +4,5 @@ import './Store'; import './process'; import './state/all'; import './StoreInjector'; +import './StoreProvider'; import './StoreContainer'; From 41d13fb27235e7ff8dd2331269d96c8a7d3468d7 Mon Sep 17 00:00:00 2001 From: Anthony Gubler Date: Tue, 21 Aug 2018 20:00:19 +0100 Subject: [PATCH 2/3] README --- src/stores/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/stores/README.md b/src/stores/README.md index 452bbcdb5..158d09f72 100644 --- a/src/stores/README.md +++ b/src/stores/README.md @@ -334,7 +334,7 @@ Store data can be connected to widgets within your application using the `StoreP Container Property API: - * `renderer`: A render function that has the store injected in order to access state and pass process to child widgets. + * `renderer`: A render function that has the store injected in order to access state and pass processes to child widgets. * `stateKey`: The key of the state in the registry. * `paths` (optional): A function to connect the `Container` to sections of the state. @@ -359,7 +359,7 @@ class MyApp extends WidgetBase { protected render() { return w(StoreProvider, { stateKey: 'state', (store: Store) => { return v('div', [ store.get(store.path('foo')) ]); - }}) + }}); } } ``` From 653f711af6fd8e13b4d59353651ec7deb7392542 Mon Sep 17 00:00:00 2001 From: Anthony Gubler Date: Wed, 22 Aug 2018 19:58:51 +0100 Subject: [PATCH 3/3] Update README.md --- src/stores/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/stores/README.md b/src/stores/README.md index 158d09f72..467b69f18 100644 --- a/src/stores/README.md +++ b/src/stores/README.md @@ -357,7 +357,7 @@ interface State { class MyApp extends WidgetBase { protected render() { - return w(StoreProvider, { stateKey: 'state', (store: Store) => { + return w(StoreProvider, { stateKey: 'state', renderer: (store: Store) => { return v('div', [ store.get(store.path('foo')) ]); }}); }