From 895fe7c60fa6d84e4e95d09e9d5e229a1103548c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A9ry=20Debongnie?= Date: Tue, 8 Oct 2019 09:14:41 +0200 Subject: [PATCH] [REF] store: replace ConnectedComponent by useStore hook Closes #304 --- doc/hooks.md | 20 +- doc/readme.md | 8 +- doc/store.md | 7 +- src/Context.ts | 6 +- src/Store.ts | 142 ++ src/index.ts | 12 +- src/store/connected_component.ts | 147 -- src/store/hooks.ts | 0 src/store/store.ts | 69 - tests/Context.test.ts | 1 + tests/__snapshots__/store_hooks.test.ts.snap | 5 + tests/{store => }/store.test.ts | 6 +- .../connected_component.test.ts.snap | 21 - tests/store/connected_component.test.ts | 1178 ----------------- tests/store_hooks.test.ts | 990 ++++++++++++++ 15 files changed, 1182 insertions(+), 1430 deletions(-) create mode 100644 src/Store.ts delete mode 100644 src/store/connected_component.ts delete mode 100644 src/store/hooks.ts delete mode 100644 src/store/store.ts create mode 100644 tests/__snapshots__/store_hooks.test.ts.snap rename tests/{store => }/store.test.ts (98%) delete mode 100644 tests/store/__snapshots__/connected_component.test.ts.snap delete mode 100644 tests/store/connected_component.test.ts create mode 100644 tests/store_hooks.test.ts diff --git a/doc/hooks.md b/doc/hooks.md index d9cae5ff..87801de0 100644 --- a/doc/hooks.md +++ b/doc/hooks.md @@ -15,6 +15,9 @@ - [`useContext`](#usecontext) - [`useRef`](#useref) - [`useSubEnv`](#usesubenv) + - [`useStore`](#usestore) + - [`useDispatch`](#usedispatch) + - [`useGetters`](#usegetters) - [Making customized hooks](#making-customized-hooks) ## Overview @@ -283,6 +286,21 @@ will be added to the parent environment. Note that it will extend, not replace the parent environment. And of course, the parent environment will not be affected. +### `useStore` + +The `useStore` hook is the entry point for a component to connect to the store. +See the [store documentation](store.md) for more information. + +### `useDispatch` + +The `useDispatch` hook is the way for components to get a reference to the store +`dispatch` function. See the [store documentation](store.md) for more information. + +### `useGetters` + +The `useGetters` hook is the way for components to get a reference to the store +getters. See the [store documentation](store.md) for more information. + ### Making customized hooks Hooks are a wonderful way to organize the code of a complex component by feature @@ -346,4 +364,4 @@ not the solution to every problem. test our components, we can just add a mock router in the environment. Note: the code above makes use of the `Component.current` property. This is the -way hooks are able to get a reference to the component currently being created. \ No newline at end of file +way hooks are able to get a reference to the component currently being created. diff --git a/doc/readme.md b/doc/readme.md index 3533e9a9..e79c461d 100644 --- a/doc/readme.md +++ b/doc/readme.md @@ -10,6 +10,7 @@ owl Component Context QWeb + Store useState core EventBus @@ -23,13 +24,13 @@ owl useState useRef useSubEnv + useStore + useDispatch + useGetters router Link RouteComponent Router - store - Store - ConnectedComponent tags xml utils @@ -40,6 +41,7 @@ owl whenReady ``` + Note that for convenience, the `useState` hook is also exported at the root of the `owl` object. ## Reference diff --git a/doc/store.md b/doc/store.md index 67b08bab..7f50709b 100644 --- a/doc/store.md +++ b/doc/store.md @@ -21,8 +21,9 @@ in various parts of the user interface, and then, it is not obvious which component should own which part of the state. Owl's solution to this issue is a centralized store. It is a class that owns -some state, and let the developer update it in a structured way, with `actions`. -Owl components can then connect to the store, and will be updated if necessary. +some (or all) state, and let the developer update it in a structured way, with +`actions`. Owl components can then connect to the store, and will be updated if +necessary. Note: Owl store is inspired by React Redux and VueX. @@ -47,7 +48,7 @@ const state = { }; const store = new owl.Store({ state, actions }); -store.on("update", () => console.log(store.state)); +store.on("update", null, () => console.log(store.state)); // updating the state store.dispatch("addTodo", "fix all bugs"); diff --git a/src/Context.ts b/src/Context.ts index 9279c206..59333a3e 100644 --- a/src/Context.ts +++ b/src/Context.ts @@ -60,6 +60,10 @@ export class Context extends EventBus { */ export function useContext(ctx: Context): any { const component: Component = Component.current!; + return useContextWithCB(ctx, component, component.render.bind(component)); +} + +export function useContextWithCB(ctx: Context, component, method): any { const __owl__ = component.__owl__; const id = __owl__.id; const mapping = ctx.mapping; @@ -75,7 +79,7 @@ export function useContext(ctx: Context): any { ctx.on("update", component, async contextId => { if (mapping[id] < contextId) { mapping[id] = contextId; - await component.render(); + await method(); } }); onWillUnmount(() => { diff --git a/src/Store.ts b/src/Store.ts new file mode 100644 index 00000000..dbe6cc96 --- /dev/null +++ b/src/Store.ts @@ -0,0 +1,142 @@ +import { Component } from "./component/component"; +import { Env } from "./component/component"; +import { Context, useContextWithCB } from "./Context"; +import { onWillUpdateProps } from "./hooks"; + +/** + * Owl Store + * + * We have here: + * - a Store class + * - useStore hook + * - useDispatch hook + * - useGetters hook + * + * The Owl store is our answer to the problem of managing complex state across + * components. The main idea is that the store owns some state, allow external + * code to modify it through actions, and for each state changes, + * connected component will be notified, and updated if necessary. + * + * Note that this code is partly inspired by VueX and React/Redux + */ + +//------------------------------------------------------------------------------ +// Store Definition +//------------------------------------------------------------------------------ + +export type Action = ({ state, dispatch, env, getters }, ...payload: any) => any; +export type Getter = ({ state: any, getters }, payload?) => any; + +interface StoreConfig { + env?: Env; + state?: any; + actions?: { [name: string]: Action }; + getters?: { [name: string]: Getter }; +} + +export class Store extends Context { + actions: any; + env: any; + getters: { [name: string]: (payload?) => any }; + updateFunctions: { [key: number]: (() => boolean)[] }; + + constructor(config: StoreConfig) { + super(config.state); + this.actions = config.actions; + this.env = config.env; + this.getters = {}; + this.updateFunctions = []; + if (config.getters) { + const firstArg = { + state: this.state, + getters: this.getters + }; + for (let g in config.getters) { + this.getters[g] = config.getters[g].bind(this, firstArg); + } + } + } + + dispatch(action: string, ...payload: any): Promise | void { + if (!this.actions[action]) { + throw new Error(`[Error] action ${action} is undefined`); + } + const result = this.actions[action]( + { + dispatch: this.dispatch.bind(this), + env: this.env, + state: this.state, + getters: this.getters + }, + ...payload + ); + return result; + } +} + +interface SelectorOptions { + store?: Store; + isEqual?: (a: any, b: any) => boolean; +} + +const isStrictEqual = (a, b) => a === b; + +export function useStore(selector, options: SelectorOptions = {}): any { + const component: Component = Component.current!; + const store = options.store || (component.env.store as Store); + let result = selector(store.state, component.props); + const hashFn = store.observer.deepRevNumber.bind(store.observer); + let revNumber = hashFn(result) || result; + const isEqual = options.isEqual || isStrictEqual; + if (!store.updateFunctions[component.__owl__.id]) { + store.updateFunctions[component.__owl__.id] = []; + } + const updateFunctions = store.updateFunctions[component.__owl__.id]; + updateFunctions.push(function(): boolean { + const oldResult = result; + result = selector(store!.state, component.props); + const newRevNumber = hashFn(result); + if ( + (newRevNumber > 0 && revNumber !== newRevNumber) || + (newRevNumber === 0 && !isEqual(oldResult, result)) + ) { + revNumber = newRevNumber; + return true; + } + return false; + }) + + useContextWithCB(store, component, function(): Promise | void { + let shouldRender = false; + updateFunctions.forEach(function (updateFn) { + shouldRender = updateFn() || shouldRender; + }); + if (shouldRender) { + return component.render(); + } + }); + onWillUpdateProps(props => { + // FIXME: only do that if not keepalive + do it in destroy in that case + delete store.updateFunctions[component.__owl__.id]; + result = selector(store.state, props); + }); + return new Proxy(result, { + get(target, k) { + return result[k]; + }, + set(target, k, v) { + result[k] = v; + return true; + } + }); +} + +export function useDispatch(store?: Store): Store["dispatch"] { + store = store || (Component.current!.env.store as Store); + return store.dispatch.bind(store); +} + +export function useGetters(store?: Store): Store["getters"] { + store = store || (Component.current!.env.store as Store); + return store.getters; +} diff --git a/src/index.ts b/src/index.ts index 90cdfb49..895acfb5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,8 +7,7 @@ import { EventBus } from "./core/event_bus"; import { Observer } from "./core/observer"; import { QWeb } from "./qweb/index"; -import { ConnectedComponent } from "./store/connected_component"; -import { Store } from "./store/store"; +import * as _store from "./Store"; import * as _utils from "./utils"; import * as _tags from "./tags"; import * as _hooks from "./hooks"; @@ -24,10 +23,15 @@ export const Context = _context.Context; export const useState = _hooks.useState; export const core = { EventBus, Observer }; export const router = { Router, RouteComponent, Link }; -export const store = { Store, ConnectedComponent }; +export const Store = _store.Store; export const utils = _utils; export const tags = _tags; -export const hooks = Object.assign({}, _hooks, { useContext: _context.useContext }); +export const hooks = Object.assign({}, _hooks, { + useContext: _context.useContext, + useDispatch: _store.useDispatch, + useGetters: _store.useGetters, + useStore: _store.useStore +}); export const __info__ = {}; Object.defineProperty(__info__, "mode", { diff --git a/src/store/connected_component.ts b/src/store/connected_component.ts deleted file mode 100644 index 626086ab..00000000 --- a/src/store/connected_component.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { Component, Env, Fiber } from "../component/component"; -import { VNode } from "../vdom/index"; - -//------------------------------------------------------------------------------ -// Connect function -//------------------------------------------------------------------------------ - -type HashFunction = (a: any, b: any) => number; - -export class ConnectedComponent extends Component { - deep: boolean = true; - getStore(env) { - return env.store; - } - - storeProps: any; - - hashFunction: HashFunction = (storeProps, options) => { - const revFn = (this.__owl__ as any).revFn; - const rev = revFn(storeProps); - if (rev > 0) { - return rev; - } - let hash = 0; - for (let key in storeProps) { - const val = storeProps[key]; - const hashVal = revFn(val); - if (hashVal === 0) { - if (val !== options.prevStoreProps[key]) { - options.didChange = true; - } - } else { - hash += hashVal; - } - } - return hash; - }; - - static mapStoreToProps(storeState, ownProps, getters) { - return {}; - } - - dispatch(name, ...payload) { - return (this.__owl__ as any).store.dispatch(name, ...payload); - } - - /** - * Need to do this here so 'deep' can be overrided by subcomponent easily - */ - async __prepareAndRender(fiber: Fiber

): Promise { - const store = this.getStore(this.env); - const ownProps = this.props || {}; - this.storeProps = (this.constructor).mapStoreToProps(store.state, ownProps, store.getters); - const observer = store.observer; - const revFn = this.deep ? observer.deepRevNumber : observer.revNumber; - (this.__owl__ as any).store = store; - (this.__owl__ as any).ownProps = this.props; - (this.__owl__ as any).revFn = revFn.bind(observer); - (this.__owl__ as any).storeHash = this.hashFunction(this.storeProps, { - prevStoreProps: this.storeProps - }); - (this.__owl__ as any).rev = observer.rev; - return super.__prepareAndRender(fiber); - } - /** - * We do not use the mounted hook here for a subtle reason: we want the - * updates to be called for the parents before the children. However, - * if we use the mounted hook, this will be done in the reverse order. - */ - __callMounted() { - (this.__owl__ as any).store.on("update", this, this.__checkUpdate); - super.__callMounted(); - } - __callWillUnmount() { - (this.__owl__ as any).store.off("update", this); - super.__callWillUnmount(); - } - __destroy(parent: any) { - (this.__owl__ as any).store.off("update", this); - super.__destroy(parent); - } - - async render(force: boolean = false) { - this.__updateStoreProps(this.props); - - // this is quite technical, so this deserves some explanation. - // When we have a connected component, it can be updated for 3 reasons: - // - some internal state changes (this will go through this method) - // - some props changes (if a parent is changed and need to rerender itself) - // - a store update - // - // It is possible (with connected component and parent) to have the following - // situation: the parent component is rendered first (from its state change), - // then immediately after, it is rendered (from store update). Then, if the - // __checkUpdate method is immediately over, the children component will - // be rendered again by the store update, even though it is supposed to be - // destroyed by the first rendering. - // - // So, the solution is to keep the information that there is a current - // rendering occuring with the same store state, the same props, and return - // that in the __checkUpdate method. To do this, we use the renderPromise - // deferred, which is not used by the component system once the - // component is ready, so we can use it for our own purpose. - (this.__owl__ as any).renderPromise = super.render(force); - return (this.__owl__ as any).renderPromise; - } - - async __updateProps(nextProps: P, f, s, v) { - this.__updateStoreProps(nextProps); - return super.__updateProps(nextProps, f, s, v); - } - - __updateStoreProps(nextProps): boolean { - const __owl__ = this.__owl__ as any; - const store = __owl__.store; - const observer = store.observer; - if (observer.rev === __owl__.rev && nextProps === __owl__.ownProps) { - return false; - } - - const storeProps = (this.constructor).mapStoreToProps( - store.state, - nextProps, - store.getters - ); - const options = { prevStoreProps: this.storeProps, didChange: false }; - const storeHash = this.hashFunction(storeProps, options); - this.storeProps = storeProps; - let didChange = options.didChange; - if (storeHash !== __owl__.storeHash) { - __owl__.storeHash = storeHash; - didChange = true; - } - __owl__.rev = store.observer.rev; - __owl__.ownProps = nextProps; - return didChange; - } - - async __checkUpdate() { - const didChange = this.__updateStoreProps(this.props); - if (didChange) { - return this.render(); - } - // see note in render method - return (this.__owl__ as any).renderPromise; - } -} diff --git a/src/store/hooks.ts b/src/store/hooks.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/src/store/store.ts b/src/store/store.ts deleted file mode 100644 index 1ac3c06e..00000000 --- a/src/store/store.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { Env } from "../component/component"; -import { Context } from "../Context"; - -/** - * Owl Store - * - * We have here: - * - a Store class - * - the ConnectedComponent class - * - * The Owl store is our answer to the problem of managing complex state across - * components. The main idea is that the store owns some state, allow external - * code to modify it through actions, and for each state changes, - * connected component will be notified, and updated if necessary. - * - * Note that this code is partly inspired by VueX and React/Redux - */ - -//------------------------------------------------------------------------------ -// Store Definition -//------------------------------------------------------------------------------ - -export type Action = ({ state, dispatch, env, getters }, ...payload: any) => any; -export type Getter = ({ state: any, getters }, payload?) => any; - -interface StoreConfig { - env?: Env; - state?: any; - actions?: { [name: string]: Action }; - getters?: { [name: string]: Getter }; -} - -export class Store extends Context { - actions: any; - env: any; - getters: { [name: string]: (payload?) => any }; - - constructor(config: StoreConfig) { - super(config.state); - this.actions = config.actions; - this.env = config.env; - this.getters = {}; - if (config.getters) { - const firstArg = { - state: this.state, - getters: this.getters - }; - for (let g in config.getters) { - this.getters[g] = config.getters[g].bind(this, firstArg); - } - } - } - - dispatch(action: string, ...payload: any): Promise | void { - if (!this.actions[action]) { - throw new Error(`[Error] action ${action} is undefined`); - } - const result = this.actions[action]( - { - dispatch: this.dispatch.bind(this), - env: this.env, - state: this.state, - getters: this.getters - }, - ...payload - ); - return result; - } -} diff --git a/tests/Context.test.ts b/tests/Context.test.ts index c5de29a9..533524e8 100644 --- a/tests/Context.test.ts +++ b/tests/Context.test.ts @@ -99,6 +99,7 @@ describe("Context", () => { expect(steps).toEqual(["child"]); testContext.state.a = 3; await nextTick(); + expect(fixture.innerHTML).toBe("

32
"); expect(steps).toEqual(["child", "child"]); }); diff --git a/tests/__snapshots__/store_hooks.test.ts.snap b/tests/__snapshots__/store_hooks.test.ts.snap new file mode 100644 index 00000000..e829c864 --- /dev/null +++ b/tests/__snapshots__/store_hooks.test.ts.snap @@ -0,0 +1,5 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`various scenarios scenarios with async store updates and some components events 1`] = `"
Attachment 100Name: text.txt
"`; + +exports[`various scenarios scenarios with async store updates and some components events 2`] = `"
"`; diff --git a/tests/store/store.test.ts b/tests/store.test.ts similarity index 98% rename from tests/store/store.test.ts rename to tests/store.test.ts index 6372c100..16c7424d 100644 --- a/tests/store/store.test.ts +++ b/tests/store.test.ts @@ -1,6 +1,6 @@ -import { Env } from "../../src/component/component"; -import { Store, Getter } from "../../src/store/store"; -import { nextTick, nextMicroTick } from "../helpers"; +import { Env } from "../src/component/component"; +import { Store, Getter } from "../src/store"; +import { nextTick, nextMicroTick } from "./helpers"; describe("basic use", () => { test("dispatch an action", () => { diff --git a/tests/store/__snapshots__/connected_component.test.ts.snap b/tests/store/__snapshots__/connected_component.test.ts.snap deleted file mode 100644 index a75eee66..00000000 --- a/tests/store/__snapshots__/connected_component.test.ts.snap +++ /dev/null @@ -1,21 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`connecting a component to store connecting a component to a local store 1`] = `"
"`; - -exports[`connecting a component to store connecting a component to a local store 2`] = `"
hello
"`; - -exports[`connecting a component to store connecting a component works 1`] = `"
"`; - -exports[`connecting a component to store connecting a component works 2`] = `"
hello
"`; - -exports[`connecting a component to store deep and shallow connecting a component 1`] = `"
Kasteel
"`; - -exports[`connecting a component to store deep and shallow connecting a component 2`] = `"
Kasteel
"`; - -exports[`connecting a component to store deep and shallow connecting a component 3`] = `"
Bertinchamps
"`; - -exports[`connecting a component to store deep and shallow connecting a component 4`] = `"
Kasteel
"`; - -exports[`various scenarios scenarios with async store updates and some components events 1`] = `"
Attachment 100Name: text.txt
"`; - -exports[`various scenarios scenarios with async store updates and some components events 2`] = `"
"`; diff --git a/tests/store/connected_component.test.ts b/tests/store/connected_component.test.ts deleted file mode 100644 index 9a65bc3c..00000000 --- a/tests/store/connected_component.test.ts +++ /dev/null @@ -1,1178 +0,0 @@ -import { Component, Env } from "../../src/component/component"; -import { ConnectedComponent } from "../../src/store/connected_component"; -import { Store } from "../../src/store/store"; -import { useState } from "../../src/hooks"; -import { makeTestEnv, makeTestFixture, nextTick, makeDeferred } from "../helpers"; - -describe("connecting a component to store", () => { - let fixture: HTMLElement; - let env: Env; - - beforeEach(() => { - fixture = makeTestFixture(); - env = makeTestEnv(); - }); - - afterEach(() => { - fixture.remove(); - }); - - test("connecting a component works", async () => { - env.qweb.addTemplates(` - -
- - - -
- -
- `); - class Todo extends Component {} - class App extends ConnectedComponent { - static components = { Todo }; - static mapStoreToProps(s) { - return { todos: s.todos }; - } - } - const state = { todos: [] }; - const actions = { - addTodo({ state }, msg) { - state.todos.push({ msg }); - } - }; - const store = new Store({ state, actions }); - (env).store = store; - const app = new App(env); - - await app.mount(fixture); - expect(fixture.innerHTML).toMatchSnapshot(); - - store.dispatch("addTodo", "hello"); - await nextTick(); - expect(fixture.innerHTML).toMatchSnapshot(); - }); - - test("deep and shallow connecting a component", async () => { - env.qweb.addTemplates(` - -
- - - -
-
- `); - const state = { todos: [{ title: "Kasteel" }] }; - const actions = { - edit({ state }, title) { - state.todos[0].title = title; - } - }; - const store = new Store({ state, actions }); - - class App extends ConnectedComponent { - static mapStoreToProps(s) { - return { todos: s.todos }; - } - } - class DeepTodoApp extends App { - deep = true; - } - class ShallowTodoApp extends App { - deep = false; - } - - (env).store = store; - const deepTodoApp = new DeepTodoApp(env); - const shallowTodoApp = new ShallowTodoApp(env); - - await deepTodoApp.mount(fixture); - - const shallowFix = makeTestFixture(); - await shallowTodoApp.mount(shallowFix); - - expect(fixture.innerHTML).toMatchSnapshot(); - expect(shallowFix.innerHTML).toMatchSnapshot(); - - store.dispatch("edit", "Bertinchamps"); - await nextTick(); - expect(fixture.innerHTML).toMatchSnapshot(); - expect(shallowFix.innerHTML).toMatchSnapshot(); - }); - - test("connecting a component to a local store", async () => { - env.qweb.addTemplates(` - -
- - - -
- -
- `); - class Todo extends Component {} - - const store = new Store({ - state: { todos: [] }, - actions: { - addTodo({ state }, msg) { - state.todos.push({ msg }); - } - } - }); - class App extends ConnectedComponent { - static components = { Todo }; - static mapStoreToProps(s) { - return { todos: s.todos }; - } - getStore() { - return store; - } - } - const app = new App(env); - - await app.mount(fixture); - expect(fixture.innerHTML).toMatchSnapshot(); - - (app.__owl__).store.dispatch("addTodo", "hello"); - await nextTick(); - expect(fixture.innerHTML).toMatchSnapshot(); - }); - - test("can dispatch actions from a connected component", async () => { - env.qweb.addTemplates(` - -
- - -
-
- `); - - const store = new Store({ - state: { value: 1 }, - actions: { - inc({ state }) { - state.value++; - } - } - }); - (env).store = store; - - class App extends ConnectedComponent { - static mapStoreToProps(s) { - return { value: s.value }; - } - } - const app = new App(env); - - await app.mount(fixture); - expect(fixture.innerHTML).toBe("
1
"); - - const button = (app.el).getElementsByTagName("button")[0]; - await button.click(); - await nextTick(); - - expect(fixture.innerHTML).toBe("
2
"); - }); - - test("connected child components with custom hooks", async () => { - let steps: any = []; - env.qweb.addTemplates(` - -
- -
-
- - `); - class Child extends ConnectedComponent { - static mapStoreToProps(s) { - return s; - } - mounted() { - steps.push("child:mounted"); - } - willUnmount() { - steps.push("child:willUnmount"); - } - } - - class Parent extends Component { - static components = { Child }; - state: any; - constructor(env: Env) { - super(env); - this.state = useState({ child: true }); - } - } - - const store = new Store({ state: {} }); - (env).store = store; - const parent = new Parent(env); - - await parent.mount(fixture); - expect(steps).toEqual(["child:mounted"]); - - parent.state.child = false; - await nextTick(); - expect(steps).toEqual(["child:mounted", "child:willUnmount"]); - }); - - test("mapStoreToProps receives ownprops as second argument", async () => { - const state = { todos: [{ id: 1, text: "jupiler" }] }; - let nextId = 2; - const actions = { - addTodo({ state }, text) { - state.todos.push({ text, id: nextId++ }); - } - }; - const store = new Store({ state, actions }); - - env.qweb.addTemplates(` - - -
- - - -
-
- `); - class TodoItem extends ConnectedComponent { - static mapStoreToProps(state, props) { - const todo = state.todos.find(t => t.id === props.id); - return todo; - } - } - - class TodoList extends ConnectedComponent { - static components = { TodoItem }; - static mapStoreToProps(state) { - return { todos: state.todos }; - } - } - - (env).store = store; - const app = new TodoList(env); - - await app.mount(fixture); - expect(fixture.innerHTML).toBe("
jupiler
"); - - store.dispatch("addTodo", "hoegaarden"); - await nextTick(); - expect(fixture.innerHTML).toBe("
jupilerhoegaarden
"); - }); - - test("mapStoreToProps receives store getters as third argument", async () => { - const state = { - importantID: 1, - todos: [{ id: 1, text: "jupiler" }, { id: 2, text: "bertinchamps" }] - }; - const getters = { - importantTodoText({ state }) { - return state.todos.find(todo => todo.id === state.importantID).text; - }, - text({ state }, id) { - return state.todos.find(todo => todo.id === id).text; - } - }; - const store = new Store({ state, getters }); - - env.qweb.addTemplates(` - -
- - -
-
- - - -
-
- `); - - class TodoItem extends ConnectedComponent { - static mapStoreToProps(state, props, getters) { - const todo = state.todos.find(t => t.id === props.id); - return { - activeTodoText: getters.text(todo.id), - importantTodoText: getters.importantTodoText() - }; - } - } - - class TodoList extends ConnectedComponent { - static components = { TodoItem }; - static mapStoreToProps(state) { - return { todos: state.todos }; - } - } - - (env).store = store; - const app = new TodoList(env); - - await app.mount(fixture); - expect(fixture.innerHTML).toBe( - "
jupilerjupiler
bertinchampsjupiler
" - ); - }); - - test("connected component is updated when props are updated", async () => { - env.qweb.addTemplates(` - - -
- -
-
- `); - - class Beer extends ConnectedComponent { - static mapStoreToProps(state, props) { - return state.beers[props.id]; - } - } - - class App extends Component { - static components = { Beer }; - state = useState({ beerId: 1 }); - } - - const state = { beers: { 1: { name: "jupiler" }, 2: { name: "kwak" } } }; - const store = new Store({ state }); - (env).store = store; - const app = new App(env); - - await app.mount(fixture); - expect(fixture.innerHTML).toBe("
jupiler
"); - - app.state.beerId = 2; - await nextTick(); - expect(fixture.innerHTML).toBe("
kwak
"); - }); - - test("connected component is updated when store is changed", async () => { - env.qweb.addTemplates(` - -
- -
-
- `); - - class App extends ConnectedComponent { - static mapStoreToProps(state) { - return { beers: state.beers, otherKey: 1 }; - } - } - - const actions = { - addBeer({ state }, name) { - state.beers.push({ name }); - } - }; - - const state = { beers: [{ name: "jupiler" }] }; - const store = new Store({ state, actions }); - (env).store = store; - - const app = new App(env); - - await app.mount(fixture); - expect(fixture.innerHTML).toBe("
jupiler
"); - - store.dispatch("addBeer", "kwak"); - await nextTick(); - expect(fixture.innerHTML).toBe("
jupilerkwak
"); - }); - - test("connected component with undefined, null and string props", async () => { - env.qweb.addTemplates(` - -
- taster: - selected: - consumed: -
-
- -
-
- `); - - class Beer extends ConnectedComponent { - static mapStoreToProps(state, props) { - return { - selected: state.beers[props.id], - consumed: state.beers[state.consumedID] || null, - taster: state.taster - }; - } - } - - class App extends Component { - static components = { Beer }; - state = useState({ beerId: 0 }); - } - - const actions = { - consume({ state }, beerId) { - state.consumedID = beerId; - } - }; - const state = { - beers: { - 1: { name: "jupiler" } - }, - consumedID: null, - taster: "aaron" - }; - const store = new Store({ state, actions }); - (env).store = store; - const app = new App(env); - - await app.mount(fixture); - expect(fixture.innerHTML).toBe("
taster:aaron
"); - - app.state.beerId = 1; - await nextTick(); - expect(fixture.innerHTML).toBe( - "
taster:aaronselected:jupiler
" - ); - - store.dispatch("consume", 1); - await nextTick(); - expect(fixture.innerHTML).toBe( - "
taster:aaronselected:jupilerconsumed:jupiler
" - ); - - app.state.beerId = 0; - await nextTick(); - expect(fixture.innerHTML).toBe( - "
taster:aaronconsumed:jupiler
" - ); - }); - - test("connected component deeply reactive with undefined, null and string props", async () => { - env.qweb.addTemplates(` - -
- taster: - selected: - consumed: -
-
- -
-
- `); - - class Beer extends ConnectedComponent { - static mapStoreToProps(storeState, props) { - return { - selected: storeState.beers[props.id], - consumed: storeState.beers[storeState.consumedID] || null, - taster: storeState.taster - }; - } - } - - class App extends Component { - static components = { Beer }; - state = useState({ beerId: 0 }); - } - - const actions = { - changeTaster({ state }, newTaster) { - state.taster = newTaster; - }, - consume({ state }, beerId) { - state.consumedID = beerId; - }, - renameBeer({ state }, { beerId, name }) { - state.beers[beerId].name = name; - } - }; - const state = { - beers: { - 1: { name: "jupiler" } - }, - consumedID: null, - taster: "aaron" - }; - const store = new Store({ state, actions }); - (env).store = store; - const app = new App(env); - - await app.mount(fixture); - expect(fixture.innerHTML).toBe("
taster:aaron
"); - - app.state.beerId = 1; - await nextTick(); - expect(fixture.innerHTML).toBe( - "
taster:aaronselected:jupiler
" - ); - - store.dispatch("renameBeer", { beerId: 1, name: "kwak" }); - await nextTick(); - expect(fixture.innerHTML).toBe( - "
taster:aaronselected:kwak
" - ); - - store.dispatch("consume", 1); - await nextTick(); - expect(fixture.innerHTML).toBe( - "
taster:aaronselected:kwakconsumed:kwak
" - ); - - app.state.beerId = 0; - await nextTick(); - expect(fixture.innerHTML).toBe( - "
taster:aaronconsumed:kwak
" - ); - - store.dispatch("renameBeer", { beerId: 1, name: "jupiler" }); - await nextTick(); - expect(fixture.innerHTML).toBe( - "
taster:aaronconsumed:jupiler
" - ); - - store.dispatch("changeTaster", "matthieu"); - await nextTick(); - expect(fixture.innerHTML).toBe( - "
taster:matthieuconsumed:jupiler
" - ); - }); - - test("correct update order when parent/children are connected", async () => { - const steps: string[] = []; - - env.qweb.addTemplates(` - -
- -
- -
- `); - - class Child extends ConnectedComponent { - static mapStoreToProps(s, props) { - steps.push("child"); - return { msg: s.msg[props.key] }; - } - } - class Parent extends ConnectedComponent { - static components = { Child }; - static mapStoreToProps(s) { - steps.push("parent"); - return { current: s.current, isvisible: s.isvisible }; - } - } - - const state = { current: "a", msg: { a: "a", b: "b" } }; - const actions = { - setCurrent({ state }, c) { - state.current = c; - } - }; - - const store = new Store({ state, actions }); - (env).store = store; - const app = new Parent(env); - - await app.mount(fixture); - expect(fixture.innerHTML).toBe("
a
"); - expect(steps).toEqual(["parent", "child"]); - - store.dispatch("setCurrent", "b"); - await nextTick(); - expect(fixture.innerHTML).toBe("
b
"); - expect(steps).toEqual(["parent", "child", "parent", "child"]); - }); - - test("correct update order when parent/children are connectedddd", async () => { - const steps: string[] = []; - let def = makeDeferred(); - def.resolve(); - - env.qweb.addTemplates(` - -
- -
- -
- `); - - class Child extends ConnectedComponent { - static mapStoreToProps(s, props) { - steps.push("child"); - return { msg: s.messages[props.someId] }; - } - } - class Parent extends ConnectedComponent { - static components = { Child }; - static mapStoreToProps(s) { - steps.push("parent"); - return { flag: s.flag, someId: s.someId }; - } - async render(force) { - await def; - return super.render(force); - } - } - - const state = { someId: 1, flag: true, messages: { 1: "abc" } }; - const actions = { - setFlagToFalse({ state }) { - state.flag = false; - } - }; - - const store = new Store({ state, actions }); - (env).store = store; - const app = new Parent(env); - - await app.mount(fixture); - expect(fixture.innerHTML).toBe("
abc
"); - expect(steps).toEqual(["parent", "child"]); - - def = makeDeferred(); - store.dispatch("setFlagToFalse"); - await nextTick(); - expect(fixture.innerHTML).toBe("
abc
"); - expect(steps).toEqual(["parent", "child", "parent"]); - - def.resolve(); - await nextTick(); - expect(steps).toEqual(["parent", "child", "parent"]); - expect(fixture.innerHTML).toBe("
"); - }); - - test("connected parent/children: no double rendering", async () => { - const actions = { - editTodo({ state }) { - state.todos[1].title = "abc"; - } - }; - const todos = { 1: { id: 1, title: "kikoou" } }; - const state = { - todos - }; - const store = new Store({ - state, - actions - }); - - env.qweb.addTemplates(` - -
- - - -
- -
- - -
-
- `); - - let renderCount = 0; - let fCount = 0; - - class TodoItem extends ConnectedComponent { - state = { isEditing: false }; - static mapStoreToProps(state, ownProps) { - fCount++; - return { - todo: state.todos[ownProps.id] - }; - } - - editTodo() { - this.env.store.dispatch("editTodo"); - } - __render(f) { - renderCount++; - return super.__render(f); - } - } - class TodoApp extends ConnectedComponent { - static components = { TodoItem }; - static mapStoreToProps(state) { - return { - todos: state.todos - }; - } - } - - (env).store = store; - const app = new TodoApp(env); - - await app.mount(fixture); - expect(fixture.innerHTML).toBe( - '
kikoou
' - ); - - expect(renderCount).toBe(1); - expect(fCount).toBe(1); - fixture.querySelector("button")!.click(); - await nextTick(); - expect(renderCount).toBe(2); - expect(fCount).toBe(2); - expect(fixture.innerHTML).toBe( - '
abc
' - ); - }); - - test("connected parent/children: no rendering if child is destroyed", async () => { - const actions = { - removeTodo({ state }) { - delete state.todos[1]; - } - }; - const todos = { 1: { id: 1, title: "kikoou" } }; - const state = { - todos - }; - const store = new Store({ - state, - actions - }); - - env.qweb.addTemplates(` - -
- - - -
- -
- - -
-
- `); - - let renderCount = 0; - let fCount = 0; - - class TodoItem extends ConnectedComponent { - state = { isEditing: false }; - - static mapStoreToProps(state, ownProps) { - fCount++; - return { - todo: state.todos[ownProps.id] - }; - } - removeTodo() { - this.env.store.dispatch("removeTodo"); - } - __render(f) { - renderCount++; - return super.__render(f); - } - } - - class TodoApp extends ConnectedComponent { - static components = { TodoItem }; - static mapStoreToProps(state) { - return { - todos: state.todos - }; - } - } - - (env).store = store; - const app = new TodoApp(env); - - await app.mount(fixture); - expect(fixture.innerHTML).toBe( - '
kikoou
' - ); - - expect(renderCount).toBe(1); - expect(fCount).toBe(1); - fixture.querySelector("button")!.click(); - await nextTick(); - expect(renderCount).toBe(1); - expect(fCount).toBe(1); - expect(fixture.innerHTML).toBe('
'); - }); - - test("connected component willpatch/patch hooks are called on store updates", async () => { - const steps: string[] = []; - - env.qweb.addTemplates(` - -
-
- `); - - class App extends ConnectedComponent { - static mapStoreToProps(s) { - return { msg: s.msg }; - } - willPatch() { - steps.push("willpatch"); - } - patched() { - steps.push("patched"); - } - } - - const state = { msg: "a" }; - const actions = { - setMsg({ state }, c) { - state.msg = c; - } - }; - - const store = new Store({ state, actions }); - (env).store = store; - const app = new App(env); - - await app.mount(fixture); - expect(fixture.innerHTML).toBe("
a
"); - - store.dispatch("setMsg", "b"); - await nextTick(); - expect(fixture.innerHTML).toBe("
b
"); - expect(steps).toEqual(["willpatch", "patched"]); - }); -}); - -describe("connected components and default values", () => { - let fixture: HTMLElement; - let env: Env; - - beforeEach(() => { - fixture = makeTestFixture(); - env = makeTestEnv(); - }); - - afterEach(() => { - fixture.remove(); - }); - - test("can set default values", async () => { - env.qweb.addTemplates(` - -
Hello,
-
-
- `); - - class Greeter extends ConnectedComponent { - static defaultProps = { recipient: "John" }; - } - - class App extends Component { - static components = { Greeter }; - } - - const store = new Store({ state: {} }); - (env).store = store; - const app = new App(env); - - await app.mount(fixture); - expect(fixture.innerHTML).toBe("
Hello, John
"); - }); - - test("can set default values", async () => { - env.qweb.addTemplates(` - -
Hello,
-
-
- `); - - class Greeter extends ConnectedComponent { - static defaultProps = { recipient: "John" }; - } - - class App extends Component { - static components = { Greeter }; - } - - const store = new Store({ state: {} }); - (env).store = store; - const app = new App(env); - - await app.mount(fixture); - expect(fixture.innerHTML).toBe("
Hello, John
"); - - const fiber = app.__createFiber(true, undefined, undefined, undefined); - await app.__updateProps({ initialRecipient: "James" }, fiber); - await app.render(); - expect(fixture.innerHTML).toBe("
Hello, James
"); - - await app.__updateProps({ initialRecipient: undefined }, fiber); - await app.render(); - expect(fixture.innerHTML).toBe("
Hello, John
"); - }); - - test("can set default values (v2)", async () => { - env.qweb.addTemplates(` - -
- - -
-
- - - -
-
-
- `); - - class Message extends ConnectedComponent { - static defaultProps = { showId: true }; - static mapStoreToProps = function(state, ownProps) { - return { - message: state.messages[ownProps.messageId] - }; - }; - } - - class Thread extends ConnectedComponent { - static components = { Message }; - static defaultProps = { showMessages: true }; - static mapStoreToProps = function(state, ownProps) { - const thread = state.threads[ownProps.threadId]; - return { - thread - }; - }; - } - - class App extends Component { - static components = { Thread }; - static defaultProps = { threadId: 1 }; - } - - const state = { - threads: { - 1: { - messages: [100, 101] - }, - 2: { - messages: [200] - } - }, - messages: { - 100: { - content: "Message100" - }, - 101: { - content: "Message101" - }, - 200: { - content: "Message200" - } - } - }; - - const actions = { - changeMessageContent({ state }, messageId, newContent) { - state.messages[messageId].content = newContent; - } - }; - - const store = new Store({ state, actions }); - (env).store = store; - const app = new App(env); - - await app.mount(fixture); - expect(fixture.innerHTML).toBe( - "
100Message100
101Message101
" - ); - - const fiber = app.__createFiber(true, undefined, undefined, undefined); - await app.__updateProps({ threadId: 2 }, fiber); - await app.render(); - expect(fixture.innerHTML).toBe("
200Message200
"); - - store.dispatch("changeMessageContent", 200, "UpdatedMessage200"); - await nextTick(); - expect(fixture.innerHTML).toBe("
200UpdatedMessage200
"); - }); - - test("connected child components stop listening to store when destroyed", async () => { - let steps: any = []; - env.qweb.addTemplates(` - -
- -
-
-
- `); - class Child extends ConnectedComponent { - static mapStoreToProps(s) { - return s; - } - } - - class Parent extends Component { - static components = { Child }; - state = useState({ child: true }); - } - - class TestStore extends Store { - on(eventType, owner, callback) { - steps.push(`on:${eventType}`); - super.on(eventType, owner, callback); - } - off(eventType, owner) { - steps.push(`off:${eventType}`); - super.off(eventType, owner); - } - } - const store = new TestStore({ state: { val: 1 } }); - (env).store = store; - const parent = new Parent(env); - - await parent.mount(fixture); - expect(steps).toEqual(["on:update"]); - expect(fixture.innerHTML).toBe("
1
"); - - parent.state.child = false; - await nextTick(); - expect(fixture.innerHTML).toBe("
"); - expect(steps).toEqual(["on:update", "off:update"]); - }); - - test("dispatch an action", async () => { - env.qweb.addTemplates(` - -
- -
-
- `); - - class App extends ConnectedComponent { - static mapStoreToProps = function(state) { - return { - counter: state.counter - }; - }; - } - - const state = { - counter: 0 - }; - - const actions = { - inc({ state }) { - return ++state.counter; - } - }; - - const store = new Store({ state, actions }); - (env).store = store; - const app = new App(env); - - await app.mount(fixture); - expect(fixture.innerHTML).toBe("
0
"); - - const res = app.dispatch("inc"); - expect(res).toBe(1); - await nextTick(); - expect(fixture.innerHTML).toBe("
1
"); - }); -}); - -describe("various scenarios", () => { - let fixture: HTMLElement; - let env: Env; - - beforeEach(() => { - fixture = makeTestFixture(); - env = makeTestEnv(); - }); - - afterEach(() => { - fixture.remove(); - }); - - test("scenarios with async store updates and some components events", async () => { - const actions = { - async deleteAttachment({ state }) { - await Promise.resolve(); - delete state.attachments[100]; - state.messages[10].attachmentIds = []; - } - }; - const state = { - attachments: { - 100: { - id: 100, - name: "text.txt" - } - }, - messages: { - 10: { - attachmentIds: [100], - id: 10 - } - } - }; - const store = new Store({ actions, state }); - - env.qweb.addTemplates(` - -
- - -
-
- Attachment - Name: -
-
- `); - class Attachment extends ConnectedComponent { - static mapStoreToProps(state, ownProps) { - return { - name: state.attachments[ownProps.id].name - }; - } - } - class Message extends ConnectedComponent { - static mapStoreToProps(state) { - return { - attachmentIds: state.messages[10].attachmentIds - }; - } - static components = { Attachment }; - state = { isAttachmentDeleted: false }; - doStuff() { - this.dispatch("deleteAttachment", 100); - this.state.isAttachmentDeleted = true; - } - } - - (env).store = store; - const message = new Message(env); - await message.mount(fixture); - - expect(fixture.innerHTML).toMatchSnapshot(); - - fixture.querySelector("button")!.click(); - await nextTick(); - expect(fixture.innerHTML).toMatchSnapshot(); - }); -}); diff --git a/tests/store_hooks.test.ts b/tests/store_hooks.test.ts new file mode 100644 index 00000000..d503211f --- /dev/null +++ b/tests/store_hooks.test.ts @@ -0,0 +1,990 @@ +import { Component, Env } from "../src/component/component"; +import { Store, useStore, useDispatch, useGetters } from "../src/Store"; +import { useState } from "../src/hooks"; +import { xml } from "../src/tags"; +import { shallowEqual } from "../src/utils"; +import { makeTestEnv, makeTestFixture, nextTick, makeDeferred } from "./helpers"; + +describe("connecting a component to store", () => { + let fixture: HTMLElement; + let env: Env; + + beforeEach(() => { + fixture = makeTestFixture(); + env = makeTestEnv(); + }); + + afterEach(() => { + fixture.remove(); + }); + + test("connecting a component works, with useStore", async () => { + let nextId = 1; + const state = { todos: [] }; + const actions = { + addTodo({ state }, msg) { + state.todos.push({ msg, id: nextId++ }); + } + }; + const store = new Store({ state, actions }); + + class App extends Component { + static template = xml` +
+ +
`; + todos = useStore(state => state.todos); + } + + (env).store = store; + const app = new App(env); + + await app.mount(fixture); + expect(fixture.innerHTML).toBe("
"); + + await store.dispatch("addTodo", "hello"); + await nextTick(); + expect(fixture.innerHTML).toBe("
hello
"); + }); + + test("can use useStore twice in a component", async () => { + const state = { a: 1, b: 2 }; + const actions = { + doSomething({ state }) { + state.a = 2; + state.b = 3; + } + }; + const store = new Store({ state, actions }); + + class App extends Component { + static template = xml` +
+ + +
`; + a = useStore(state => ({value: state.a})); + b = useStore(state => ({value: state.b})); + } + App.prototype.__render = jest.fn(App.prototype.__render); + + (env).store = store; + const app = new App(env); + + await app.mount(fixture); + expect(fixture.innerHTML).toBe("
12
"); + expect(App.prototype.__render).toBeCalledTimes(1); + + await store.dispatch("doSomething"); + await nextTick(); + expect(fixture.innerHTML).toBe("
23
"); + expect(App.prototype.__render).toBeCalledTimes(2); + }); + + test("useStore: do not re-render if not changed", async () => { + let nextId = 1; + const state = { todos: [], a: 1 }; + const actions = { + addTodo({ state }, msg) { + state.todos.push({ msg, id: nextId++ }); + } + }; + const store = new Store({ state, actions }); + + class App extends Component { + static template = xml` +
+ +
`; + todos = useStore(state => state.todos); + } + App.prototype.__render = jest.fn(App.prototype.__render); + + (env).store = store; + const app = new App(env); + + await app.mount(fixture); + expect(fixture.innerHTML).toBe("
"); + expect(App.prototype.__render).toBeCalledTimes(1); + + store.state.todos.push({ id: 3, msg: "hello" }); + await nextTick(); + expect(fixture.innerHTML).toBe("
hello
"); + expect(App.prototype.__render).toBeCalledTimes(2); + + store.state.a = 2; + await nextTick(); + expect(fixture.innerHTML).toBe("
hello
"); + expect(App.prototype.__render).toBeCalledTimes(2); + }); + + test("connecting a component with useStore returning number", async () => { + let nextId = 1; + const state = { todos: [] }; + const actions = { + addTodo({ state }, msg) { + state.todos.push({ msg, id: nextId++ }); + } + }; + const store = new Store({ state, actions }); + + class App extends Component { + static template = xml`
`; + nbrTodos = useStore(state => ({ value: state.todos.length })); + } + + (env).store = store; + const app = new App(env); + + await app.mount(fixture); + expect(fixture.innerHTML).toBe("
0
"); + + await store.dispatch("addTodo", "hello"); + await nextTick(); + expect(fixture.innerHTML).toBe("
1
"); + }); + + test("connecting a component with useStore returning number", async () => { + let nextId = 1; + const state = { todos: [], a: 1 }; + const actions = { + addTodo({ state }, msg) { + state.todos.push({ msg, id: nextId++ }); + }, + incrementA({ state }) { + state.a++; + } + }; + const store = new Store({ state, actions }); + class App extends Component { + static template = xml`
`; + nbrTodos = useStore(state => ({ value: state.todos.length }), { isEqual: shallowEqual }); + } + App.prototype.__render = jest.fn(App.prototype.__render); + + (env).store = store; + const app = new App(env); + + await app.mount(fixture); + expect(fixture.innerHTML).toBe("
0
"); + expect(App.prototype.__render).toBeCalledTimes(1); + + await store.dispatch("addTodo", "hello"); + await nextTick(); + expect(fixture.innerHTML).toBe("
1
"); + expect(App.prototype.__render).toBeCalledTimes(2); + + await store.dispatch("incrementA"); + await nextTick(); + expect(fixture.innerHTML).toBe("
1
"); + expect(App.prototype.__render).toBeCalledTimes(2); + }); + + test("connecting a component to a local store", async () => { + let nextId = 1; + const state = { todos: [] }; + const actions = { + addTodo({ state }, msg) { + state.todos.push({ msg, id: nextId++ }); + } + }; + const store = new Store({ state, actions }); + + class App extends Component { + static template = xml` +
+ +
`; + todos = useStore(state => state.todos, { store }); + } + + const app = new App(env); + + await app.mount(fixture); + expect(fixture.innerHTML).toBe("
"); + + await store.dispatch("addTodo", "hello"); + await nextTick(); + expect(fixture.innerHTML).toBe("
hello
"); + }); + + test("can dispatch actions from a connected component", async () => { + const store = new Store({ + state: { value: 1 }, + actions: { + inc({ state }) { + state.value++; + } + } + }); + (env).store = store; + + class App extends Component { + static template = xml` +
+ + +
`; + storeState = useStore(state => state); + dispatch = useDispatch(); + } + const app = new App(env); + + await app.mount(fixture); + expect(fixture.innerHTML).toBe("
1
"); + + fixture.querySelector("button")!.click(); + await nextTick(); + + expect(fixture.innerHTML).toBe("
2
"); + }); + + test("useStore can use props", async () => { + const state = { todos: [{ id: 1, text: "jupiler" }, { id: 2, text: "chimay" }] }; + const store = new Store({ state, actions: {} }); + + class TodoItem extends Component { + static template = xml``; + todo = useStore((state, props) => { + return state.todos.find(t => t.id === props.todoId); + }); + } + + class App extends Component { + static template = xml`
`; + static components = { TodoItem }; + state = useState({ currentId: 1 }); + } + + (env).store = store; + const app = new App(env); + + await app.mount(fixture); + expect(fixture.innerHTML).toBe("
jupiler
"); + + app.state.currentId = 2; + await nextTick(); + expect(fixture.innerHTML).toBe("
chimay
"); + }); + + test("useStore receives props as second argument", async () => { + const state = { todos: [{ id: 1, text: "jupiler" }] }; + let nextId = 2; + const actions = { + addTodo({ state }, text) { + state.todos.push({ text, id: nextId++ }); + } + }; + const store = new Store({ state, actions }); + + class TodoItem extends Component { + static template = xml``; + todo = useStore((state, props) => { + return state.todos.find(t => t.id === props.id); + }); + } + + class TodoList extends Component { + static template = xml` +
+ +
`; + static components = { TodoItem }; + todos = useStore(state => state.todos); + } + + (env).store = store; + const app = new TodoList(env); + + await app.mount(fixture); + expect(fixture.innerHTML).toBe("
jupiler
"); + + store.dispatch("addTodo", "hoegaarden"); + await nextTick(); + expect(fixture.innerHTML).toBe("
jupilerhoegaarden
"); + }); + + test("can call useGetters to receive store getters", async () => { + const state = { + importantID: 1, + todos: [{ id: 1, text: "jupiler" }, { id: 2, text: "bertinchamps" }] + }; + const getters = { + importantTodoText({ state }) { + return state.todos.find(todo => todo.id === state.importantID).text; + }, + text({ state }, id) { + return state.todos.find(todo => todo.id === id).text; + } + }; + const store = new Store({ state, getters }); + + class TodoItem extends Component { + static template = xml` +
+ + +
`; + getters = useGetters(); + storeProps = useStore((state, props) => { + const todo = state.todos.find(t => t.id === props.id); + return { + activeTodoText: this.getters.text(todo.id), + importantTodoText: this.getters.importantTodoText() + }; + }); + } + + class TodoList extends Component { + static components = { TodoItem }; + static template = xml` +
+ + + +
`; + todos = useStore(state => state.todos); + } + + (env).store = store; + const app = new TodoList(env); + + await app.mount(fixture); + expect(fixture.innerHTML).toBe( + "
jupilerjupiler
bertinchampsjupiler
" + ); + }); + + test("connected component is updated when props are updated", async () => { + class Beer extends Component { + static template = xml``; + beer = useStore((state, props) => state.beers[props.id]); + } + + class App extends Component { + static template = xml`
`; + static components = { Beer }; + state = useState({ beerId: 1 }); + } + + const state = { beers: { 1: { name: "jupiler" }, 2: { name: "kwak" } } }; + const store = new Store({ state }); + (env).store = store; + const app = new App(env); + + await app.mount(fixture); + expect(fixture.innerHTML).toBe("
jupiler
"); + + app.state.beerId = 2; + await nextTick(); + expect(fixture.innerHTML).toBe("
kwak
"); + }); + + test("connected component is updated when store is changed", async () => { + class App extends Component { + static template = xml` +
+ +
`; + + // we have here a new object + data = useStore(state => ({ beers: state.beers, otherKey: 1 })); + } + + const actions = { + addBeer({ state }, name) { + state.beers.push({ name }); + } + }; + + const state = { beers: [{ name: "jupiler" }] }; + const store = new Store({ state, actions }); + (env).store = store; + + const app = new App(env); + + await app.mount(fixture); + expect(fixture.innerHTML).toBe("
jupiler
"); + + store.dispatch("addBeer", "kwak"); + await nextTick(); + expect(fixture.innerHTML).toBe("
jupilerkwak
"); + }); + + test("connected component with undefined, null and string props", async () => { + class Beer extends Component { + static template = xml` +
+ taster: + selected: + consumed: +
`; + data = useStore((state, props) => ({ + selected: state.beers[props.id], + consumed: state.beers[state.consumedID] || null, + taster: state.taster + })); + } + + class App extends Component { + static template = xml`
`; + static components = { Beer }; + state = useState({ beerId: 0 }); + } + + const actions = { + consume({ state }, beerId) { + state.consumedID = beerId; + } + }; + const state = { + beers: { + 1: { name: "jupiler" } + }, + consumedID: null, + taster: "aaron" + }; + const store = new Store({ state, actions }); + (env).store = store; + const app = new App(env); + + await app.mount(fixture); + expect(fixture.innerHTML).toBe("
taster:aaron
"); + + app.state.beerId = 1; + await nextTick(); + expect(fixture.innerHTML).toBe( + "
taster:aaronselected:jupiler
" + ); + + store.dispatch("consume", 1); + await nextTick(); + expect(fixture.innerHTML).toBe( + "
taster:aaronselected:jupilerconsumed:jupiler
" + ); + + app.state.beerId = 0; + await nextTick(); + expect(fixture.innerHTML).toBe( + "
taster:aaronconsumed:jupiler
" + ); + }); + + test("connected component deeply reactive with undefined, null and string props", async () => { + class Beer extends Component { + static template = xml` +
+ taster: + selected: + consumed: +
`; + info = useStore(function(state, props) { + return { + selected: state.beers[props.id], + consumed: state.beers[state.consumedID] || null, + taster: state.taster + }; + }); + } + + class App extends Component { + static template = xml`
`; + static components = { Beer }; + state = useState({ beerId: 0 }); + } + + const actions = { + changeTaster({ state }, newTaster) { + state.taster = newTaster; + }, + consume({ state }, beerId) { + state.consumedID = beerId; + }, + renameBeer({ state }, { beerId, name }) { + state.beers[beerId].name = name; + } + }; + const state = { + beers: { + 1: { name: "jupiler" } + }, + consumedID: null, + taster: "aaron" + }; + const store = new Store({ state, actions }); + (env).store = store; + const app = new App(env); + + await app.mount(fixture); + expect(fixture.innerHTML).toBe("
taster:aaron
"); + + app.state.beerId = 1; + await nextTick(); + expect(fixture.innerHTML).toBe( + "
taster:aaronselected:jupiler
" + ); + + store.dispatch("renameBeer", { beerId: 1, name: "kwak" }); + await nextTick(); + expect(fixture.innerHTML).toBe( + "
taster:aaronselected:kwak
" + ); + + store.dispatch("consume", 1); + await nextTick(); + expect(fixture.innerHTML).toBe( + "
taster:aaronselected:kwakconsumed:kwak
" + ); + + app.state.beerId = 0; + await nextTick(); + expect(fixture.innerHTML).toBe( + "
taster:aaronconsumed:kwak
" + ); + + store.dispatch("renameBeer", { beerId: 1, name: "jupiler" }); + await nextTick(); + expect(fixture.innerHTML).toBe( + "
taster:aaronconsumed:jupiler
" + ); + + store.dispatch("changeTaster", "matthieu"); + await nextTick(); + expect(fixture.innerHTML).toBe( + "
taster:matthieuconsumed:jupiler
" + ); + }); + + test("correct update order when parent/children are connected", async () => { + const steps: string[] = []; + + class Child extends Component { + static template = xml``; + + state = useStore((state, props) => { + steps.push("child"); + return { msg: state.msg[props.key] }; + }); + } + class Parent extends Component { + static template = xml`
`; + static components = { Child }; + + state = useStore(state => { + steps.push("parent"); + return { + current: state.current, + isvisible: state.isvisible + }; + }); + } + + const state = { current: "a", msg: { a: "a", b: "b" } }; + const actions = { + setCurrent({ state }, c) { + state.current = c; + } + }; + + const store = new Store({ state, actions }); + (env).store = store; + const app = new Parent(env); + + await app.mount(fixture); + expect(fixture.innerHTML).toBe("
a
"); + expect(steps).toEqual(["parent", "child"]); + + store.dispatch("setCurrent", "b"); + await nextTick(); + expect(fixture.innerHTML).toBe("
b
"); + expect(steps).toEqual(["parent", "child", "parent", "child"]); + }); + + test("correct update order when parent/children are connected, part 2", async () => { + const steps: string[] = []; + let def = makeDeferred(); + def.resolve(); + + class Child extends Component { + static template = xml``; + state = useStore((s, props) => { + steps.push("child"); + return { msg: s.messages[props.someId] }; + }); + } + + class Parent extends Component { + static template = xml` +
+ +
`; + static components = { Child }; + state = useStore(s => { + steps.push("parent"); + return { flag: s.flag, someId: s.someId }; + }); + + async render(force) { + await def; + return super.render(force); + } + } + + const state = { someId: 1, flag: true, messages: { 1: "abc" } }; + const actions = { + setFlagToFalse({ state }) { + state.flag = false; + } + }; + + const store = new Store({ state, actions }); + (env).store = store; + const app = new Parent(env); + + await app.mount(fixture); + expect(fixture.innerHTML).toBe("
abc
"); + expect(steps).toEqual(["parent", "child"]); + + def = makeDeferred(); + store.dispatch("setFlagToFalse"); + await nextTick(); + expect(fixture.innerHTML).toBe("
abc
"); + expect(steps).toEqual(["parent", "child", "parent"]); + + def.resolve(); + await nextTick(); + expect(steps).toEqual(["parent", "child", "parent"]); + expect(fixture.innerHTML).toBe("
"); + }); + + test("connected parent/children: no double rendering", async () => { + let steps: string[] = []; + const actions = { + editTodo({ state }) { + state.todos[1].title = "abc"; + } + }; + const todos = { 1: { id: 1, title: "kikoou" } }; + const state = { + todos + }; + const store = new Store({ + state, + actions + }); + + class TodoItem extends Component { + static template = xml` +
+ + +
`; + state = useStore((state, props) => { + steps.push("item:usestore"); + return { + todo: state.todos[props.id] + }; + }); + + editTodo() { + this.env.store.dispatch("editTodo"); + } + __render(f) { + steps.push("item:render"); + return super.__render(f); + } + } + class TodoApp extends Component { + static template = xml` +
+ + + +
`; + static components = { TodoItem }; + state = useStore(state => { + steps.push("app:usestore"); + return { todos: state.todos }; + }); + __render(f) { + steps.push("app:render"); + return super.__render(f); + } + } + + (env).store = store; + const app = new TodoApp(env); + + await app.mount(fixture); + expect(fixture.innerHTML).toBe( + '
kikoou
' + ); + + expect(steps).toEqual(["app:usestore", "app:render", "item:usestore", "item:render"]); + steps = []; + fixture.querySelector("button")!.click(); + await nextTick(); + expect(steps).toEqual(["app:usestore", "app:render", "item:usestore", "item:render"]); + expect(fixture.innerHTML).toBe( + '
abc
' + ); + }); + + test("connected parent/children: no rendering if child is destroyed", async () => { + let steps: string[] = []; + const actions = { + removeTodo({ state }) { + delete state.todos[1]; + } + }; + const todos = { 1: { id: 1, title: "kikoou" } }; + const state = { + todos + }; + const store = new Store({ + state, + actions + }); + + class TodoItem extends Component { + static template = xml` +
+ + +
`; + state = useStore((state, props) => { + steps.push("item:usestore"); + return { + todo: state.todos[props.id] + }; + }); + + removeTodo() { + this.env.store.dispatch("removeTodo"); + } + __render(f) { + steps.push("item:render"); + return super.__render(f); + } + } + + class TodoApp extends Component { + static template = xml` +
+ + + +
`; + static components = { TodoItem }; + state = useStore(state => { + steps.push("app:usestore"); + return { todos: state.todos }; + }); + __render(f) { + steps.push("app:render"); + return super.__render(f); + } + } + + (env).store = store; + const app = new TodoApp(env); + + await app.mount(fixture); + expect(fixture.innerHTML).toBe( + '
kikoou
' + ); + + expect(steps).toEqual(["app:usestore", "app:render", "item:usestore", "item:render"]); + + fixture.querySelector("button")!.click(); + await nextTick(); + expect(steps).toEqual([ + "app:usestore", + "app:render", + "item:usestore", + "item:render", + "app:usestore", + "app:render" + ]); + expect(fixture.innerHTML).toBe('
'); + }); + + test("connected component willpatch/patch hooks are called on store updates", async () => { + const steps: string[] = []; + + class App extends Component { + static template = xml`
`; + store = useStore(s => ({ msg: s.msg })); + + willPatch() { + steps.push("willpatch"); + } + patched() { + steps.push("patched"); + } + } + + const state = { msg: "a" }; + const actions = { + setMsg({ state }, c) { + state.msg = c; + } + }; + + const store = new Store({ state, actions }); + (env).store = store; + const app = new App(env); + + await app.mount(fixture); + expect(fixture.innerHTML).toBe("
a
"); + + store.dispatch("setMsg", "b"); + await nextTick(); + expect(fixture.innerHTML).toBe("
b
"); + expect(steps).toEqual(["willpatch", "patched"]); + }); + + test("connected child components stop listening to store when destroyed", async () => { + let steps: any = []; + + class Child extends Component { + static template = xml`
`; + store = useStore(s => s); + } + + class Parent extends Component { + static template = xml`
`; + static components = { Child }; + state = useState({ child: true }); + } + + class TestStore extends Store { + on(eventType, owner, callback) { + steps.push(`on:${eventType}`); + super.on(eventType, owner, callback); + } + off(eventType, owner) { + steps.push(`off:${eventType}`); + super.off(eventType, owner); + } + } + const store = new TestStore({ state: { val: 1 } }); + (env).store = store; + const parent = new Parent(env); + + await parent.mount(fixture); + expect(steps).toEqual(["on:update"]); + expect(fixture.innerHTML).toBe("
1
"); + + parent.state.child = false; + await nextTick(); + expect(fixture.innerHTML).toBe("
"); + expect(steps).toEqual(["on:update", "off:update"]); + }); + + test("dispatch an action", async () => { + class App extends Component { + static template = xml`
`; + store = useStore(state => state); + dispatch = useDispatch(); + } + + const state = { + counter: 0 + }; + + const actions = { + inc({ state }) { + return ++state.counter; + } + }; + + const store = new Store({ state, actions }); + (env).store = store; + const app = new App(env); + + await app.mount(fixture); + expect(fixture.innerHTML).toBe("
0
"); + + const res = app.dispatch("inc"); + expect(res).toBe(1); + await nextTick(); + expect(fixture.innerHTML).toBe("
1
"); + }); +}); + +describe("various scenarios", () => { + let fixture: HTMLElement; + let env: Env; + + beforeEach(() => { + fixture = makeTestFixture(); + env = makeTestEnv(); + }); + + afterEach(() => { + fixture.remove(); + }); + + test("scenarios with async store updates and some components events", async () => { + const actions = { + async deleteAttachment({ state }) { + await Promise.resolve(); + delete state.attachments[100]; + state.messages[10].attachmentIds = []; + } + }; + const state = { + attachments: { + 100: { + id: 100, + name: "text.txt" + } + }, + messages: { + 10: { + attachmentIds: [100], + id: 10 + } + } + }; + const store = new Store({ actions, state }); + + class Attachment extends Component { + static template = xml` +
+ Attachment + Name: +
`; + + attachment = useStore((state, props) => ({ name: state.attachments[props.id].name })); + } + + class Message extends Component { + static template = xml` +
+ + +
`; + + store = useStore(state => ({ attachmentIds: state.messages[10].attachmentIds })); + static components = { Attachment }; + state = { isAttachmentDeleted: false }; + dispatch = useDispatch(); + doStuff() { + this.dispatch("deleteAttachment", 100); + this.state.isAttachmentDeleted = true; + } + } + + (env).store = store; + const message = new Message(env); + await message.mount(fixture); + + expect(fixture.innerHTML).toMatchSnapshot(); + + fixture.querySelector("button")!.click(); + await nextTick(); + expect(fixture.innerHTML).toMatchSnapshot(); + }); +});