[REF] store: replace ConnectedComponent by useStore hook

Closes #304
This commit is contained in:
Géry Debongnie
2019-10-08 09:14:41 +02:00
committed by aab-odoo
parent 19b10f7c1d
commit 895fe7c60f
15 changed files with 1182 additions and 1430 deletions
+19 -1
View File
@@ -15,6 +15,9 @@
- [`useContext`](#usecontext) - [`useContext`](#usecontext)
- [`useRef`](#useref) - [`useRef`](#useref)
- [`useSubEnv`](#usesubenv) - [`useSubEnv`](#usesubenv)
- [`useStore`](#usestore)
- [`useDispatch`](#usedispatch)
- [`useGetters`](#usegetters)
- [Making customized hooks](#making-customized-hooks) - [Making customized hooks](#making-customized-hooks)
## Overview ## 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 the parent environment. And of course, the parent environment will not be
affected. 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 ### Making customized hooks
Hooks are a wonderful way to organize the code of a complex component by feature 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. 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 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. way hooks are able to get a reference to the component currently being created.
+5 -3
View File
@@ -10,6 +10,7 @@ owl
Component Component
Context Context
QWeb QWeb
Store
useState useState
core core
EventBus EventBus
@@ -23,13 +24,13 @@ owl
useState useState
useRef useRef
useSubEnv useSubEnv
useStore
useDispatch
useGetters
router router
Link Link
RouteComponent RouteComponent
Router Router
store
Store
ConnectedComponent
tags tags
xml xml
utils utils
@@ -40,6 +41,7 @@ owl
whenReady whenReady
``` ```
Note that for convenience, the `useState` hook is also exported at the root of the `owl` object. Note that for convenience, the `useState` hook is also exported at the root of the `owl` object.
## Reference ## Reference
+4 -3
View File
@@ -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. component should own which part of the state.
Owl's solution to this issue is a centralized store. It is a class that owns 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`. some (or all) state, and let the developer update it in a structured way, with
Owl components can then connect to the store, and will be updated if necessary. `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. Note: Owl store is inspired by React Redux and VueX.
@@ -47,7 +48,7 @@ const state = {
}; };
const store = new owl.Store({ state, actions }); 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 // updating the state
store.dispatch("addTodo", "fix all bugs"); store.dispatch("addTodo", "fix all bugs");
+5 -1
View File
@@ -60,6 +60,10 @@ export class Context extends EventBus {
*/ */
export function useContext(ctx: Context): any { export function useContext(ctx: Context): any {
const component: Component<any, any> = Component.current!; const component: Component<any, any> = Component.current!;
return useContextWithCB(ctx, component, component.render.bind(component));
}
export function useContextWithCB(ctx: Context, component, method): any {
const __owl__ = component.__owl__; const __owl__ = component.__owl__;
const id = __owl__.id; const id = __owl__.id;
const mapping = ctx.mapping; const mapping = ctx.mapping;
@@ -75,7 +79,7 @@ export function useContext(ctx: Context): any {
ctx.on("update", component, async contextId => { ctx.on("update", component, async contextId => {
if (mapping[id] < contextId) { if (mapping[id] < contextId) {
mapping[id] = contextId; mapping[id] = contextId;
await component.render(); await method();
} }
}); });
onWillUnmount(() => { onWillUnmount(() => {
+142
View File
@@ -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> | 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<any, any> = 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> | 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;
}
+8 -4
View File
@@ -7,8 +7,7 @@
import { EventBus } from "./core/event_bus"; import { EventBus } from "./core/event_bus";
import { Observer } from "./core/observer"; import { Observer } from "./core/observer";
import { QWeb } from "./qweb/index"; import { QWeb } from "./qweb/index";
import { ConnectedComponent } from "./store/connected_component"; import * as _store from "./Store";
import { Store } from "./store/store";
import * as _utils from "./utils"; import * as _utils from "./utils";
import * as _tags from "./tags"; import * as _tags from "./tags";
import * as _hooks from "./hooks"; import * as _hooks from "./hooks";
@@ -24,10 +23,15 @@ export const Context = _context.Context;
export const useState = _hooks.useState; export const useState = _hooks.useState;
export const core = { EventBus, Observer }; export const core = { EventBus, Observer };
export const router = { Router, RouteComponent, Link }; export const router = { Router, RouteComponent, Link };
export const store = { Store, ConnectedComponent }; export const Store = _store.Store;
export const utils = _utils; export const utils = _utils;
export const tags = _tags; 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__ = {}; export const __info__ = {};
Object.defineProperty(__info__, "mode", { Object.defineProperty(__info__, "mode", {
-147
View File
@@ -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<T extends Env, P> extends Component<T, P> {
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<P>): Promise<VNode> {
const store = this.getStore(this.env);
const ownProps = this.props || {};
this.storeProps = (<any>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 = (<any>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;
}
}
View File
-69
View File
@@ -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> | 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;
}
}
+1
View File
@@ -99,6 +99,7 @@ describe("Context", () => {
expect(steps).toEqual(["child"]); expect(steps).toEqual(["child"]);
testContext.state.a = 3; testContext.state.a = 3;
await nextTick(); await nextTick();
expect(fixture.innerHTML).toBe("<div><span>32</span></div>");
expect(steps).toEqual(["child", "child"]); expect(steps).toEqual(["child", "child"]);
}); });
@@ -0,0 +1,5 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`various scenarios scenarios with async store updates and some components events 1`] = `"<div><button>Do stuff</button><div><span>Attachment 100</span><span>Name: text.txt</span></div></div>"`;
exports[`various scenarios scenarios with async store updates and some components events 2`] = `"<div><button>Do stuff</button></div>"`;
@@ -1,6 +1,6 @@
import { Env } from "../../src/component/component"; import { Env } from "../src/component/component";
import { Store, Getter } from "../../src/store/store"; import { Store, Getter } from "../src/store";
import { nextTick, nextMicroTick } from "../helpers"; import { nextTick, nextMicroTick } from "./helpers";
describe("basic use", () => { describe("basic use", () => {
test("dispatch an action", () => { test("dispatch an action", () => {
@@ -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`] = `"<div></div>"`;
exports[`connecting a component to store connecting a component to a local store 2`] = `"<div><span>hello</span></div>"`;
exports[`connecting a component to store connecting a component works 1`] = `"<div></div>"`;
exports[`connecting a component to store connecting a component works 2`] = `"<div><span>hello</span></div>"`;
exports[`connecting a component to store deep and shallow connecting a component 1`] = `"<div><span>Kasteel</span></div>"`;
exports[`connecting a component to store deep and shallow connecting a component 2`] = `"<div><span>Kasteel</span></div>"`;
exports[`connecting a component to store deep and shallow connecting a component 3`] = `"<div><span>Bertinchamps</span></div>"`;
exports[`connecting a component to store deep and shallow connecting a component 4`] = `"<div><span>Kasteel</span></div>"`;
exports[`various scenarios scenarios with async store updates and some components events 1`] = `"<div><button>Do stuff</button><div><span>Attachment 100</span><span>Name: text.txt</span></div></div>"`;
exports[`various scenarios scenarios with async store updates and some components events 2`] = `"<div><button>Do stuff</button></div>"`;
File diff suppressed because it is too large Load Diff
+990
View File
@@ -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<any, any> {
static template = xml`
<div>
<span t-foreach="todos" t-key="todo.id" t-as="todo"><t t-esc="todo.msg"/></span>
</div>`;
todos = useStore(state => state.todos);
}
(<any>env).store = store;
const app = new App(env);
await app.mount(fixture);
expect(fixture.innerHTML).toBe("<div></div>");
await store.dispatch("addTodo", "hello");
await nextTick();
expect(fixture.innerHTML).toBe("<div><span>hello</span></div>");
});
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<any, any> {
static template = xml`
<div>
<span t-esc="a.value"/>
<span t-esc="b.value"/>
</div>`;
a = useStore(state => ({value: state.a}));
b = useStore(state => ({value: state.b}));
}
App.prototype.__render = jest.fn(App.prototype.__render);
(<any>env).store = store;
const app = new App(env);
await app.mount(fixture);
expect(fixture.innerHTML).toBe("<div><span>1</span><span>2</span></div>");
expect(App.prototype.__render).toBeCalledTimes(1);
await store.dispatch("doSomething");
await nextTick();
expect(fixture.innerHTML).toBe("<div><span>2</span><span>3</span></div>");
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<any, any> {
static template = xml`
<div>
<span t-foreach="todos" t-key="todo.id" t-as="todo"><t t-esc="todo.msg"/></span>
</div>`;
todos = useStore(state => state.todos);
}
App.prototype.__render = jest.fn(App.prototype.__render);
(<any>env).store = store;
const app = new App(env);
await app.mount(fixture);
expect(fixture.innerHTML).toBe("<div></div>");
expect(App.prototype.__render).toBeCalledTimes(1);
store.state.todos.push({ id: 3, msg: "hello" });
await nextTick();
expect(fixture.innerHTML).toBe("<div><span>hello</span></div>");
expect(App.prototype.__render).toBeCalledTimes(2);
store.state.a = 2;
await nextTick();
expect(fixture.innerHTML).toBe("<div><span>hello</span></div>");
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<any, any> {
static template = xml`<div><t t-esc="nbrTodos.value"/></div>`;
nbrTodos = useStore(state => ({ value: state.todos.length }));
}
(<any>env).store = store;
const app = new App(env);
await app.mount(fixture);
expect(fixture.innerHTML).toBe("<div>0</div>");
await store.dispatch("addTodo", "hello");
await nextTick();
expect(fixture.innerHTML).toBe("<div>1</div>");
});
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<any, any> {
static template = xml`<div><t t-esc="nbrTodos.value"/></div>`;
nbrTodos = useStore(state => ({ value: state.todos.length }), { isEqual: shallowEqual });
}
App.prototype.__render = jest.fn(App.prototype.__render);
(<any>env).store = store;
const app = new App(env);
await app.mount(fixture);
expect(fixture.innerHTML).toBe("<div>0</div>");
expect(App.prototype.__render).toBeCalledTimes(1);
await store.dispatch("addTodo", "hello");
await nextTick();
expect(fixture.innerHTML).toBe("<div>1</div>");
expect(App.prototype.__render).toBeCalledTimes(2);
await store.dispatch("incrementA");
await nextTick();
expect(fixture.innerHTML).toBe("<div>1</div>");
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<any, any> {
static template = xml`
<div>
<span t-foreach="todos" t-key="todo.id" t-as="todo"><t t-esc="todo.msg"/></span>
</div>`;
todos = useStore(state => state.todos, { store });
}
const app = new App(env);
await app.mount(fixture);
expect(fixture.innerHTML).toBe("<div></div>");
await store.dispatch("addTodo", "hello");
await nextTick();
expect(fixture.innerHTML).toBe("<div><span>hello</span></div>");
});
test("can dispatch actions from a connected component", async () => {
const store = new Store({
state: { value: 1 },
actions: {
inc({ state }) {
state.value++;
}
}
});
(<any>env).store = store;
class App extends Component<any, any> {
static template = xml`
<div>
<button t-on-click="dispatch('inc')">Inc</button>
<span><t t-esc="storeState.value"/></span>
</div>`;
storeState = useStore(state => state);
dispatch = useDispatch();
}
const app = new App(env);
await app.mount(fixture);
expect(fixture.innerHTML).toBe("<div><button>Inc</button><span>1</span></div>");
fixture.querySelector("button")!.click();
await nextTick();
expect(fixture.innerHTML).toBe("<div><button>Inc</button><span>2</span></div>");
});
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<any, any> {
static template = xml`<span><t t-esc="todo.text"/></span>`;
todo = useStore((state, props) => {
return state.todos.find(t => t.id === props.todoId);
});
}
class App extends Component<any, any> {
static template = xml`<div><TodoItem todoId="state.currentId"/></div>`;
static components = { TodoItem };
state = useState({ currentId: 1 });
}
(<any>env).store = store;
const app = new App(env);
await app.mount(fixture);
expect(fixture.innerHTML).toBe("<div><span>jupiler</span></div>");
app.state.currentId = 2;
await nextTick();
expect(fixture.innerHTML).toBe("<div><span>chimay</span></div>");
});
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<any, any> {
static template = xml`<span><t t-esc="todo.text"/></span>`;
todo = useStore((state, props) => {
return state.todos.find(t => t.id === props.id);
});
}
class TodoList extends Component<any, any> {
static template = xml`
<div>
<TodoItem t-foreach="todos" t-as="todo" id="todo.id" t-key="todo.id"/>
</div>`;
static components = { TodoItem };
todos = useStore(state => state.todos);
}
(<any>env).store = store;
const app = new TodoList(env);
await app.mount(fixture);
expect(fixture.innerHTML).toBe("<div><span>jupiler</span></div>");
store.dispatch("addTodo", "hoegaarden");
await nextTick();
expect(fixture.innerHTML).toBe("<div><span>jupiler</span><span>hoegaarden</span></div>");
});
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<any, any> {
static template = xml`
<div>
<span><t t-esc="storeProps.activeTodoText"/></span>
<span><t t-esc="storeProps.importantTodoText"/></span>
</div>`;
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<any, any> {
static components = { TodoItem };
static template = xml`
<div>
<t t-foreach="todos" t-as="todo">
<TodoItem id="todo.id" t-key="todo.id"/>
</t>
</div>`;
todos = useStore(state => state.todos);
}
(<any>env).store = store;
const app = new TodoList(env);
await app.mount(fixture);
expect(fixture.innerHTML).toBe(
"<div><div><span>jupiler</span><span>jupiler</span></div><div><span>bertinchamps</span><span>jupiler</span></div></div>"
);
});
test("connected component is updated when props are updated", async () => {
class Beer extends Component<any, any> {
static template = xml`<span><t t-esc="beer.name"/></span>`;
beer = useStore((state, props) => state.beers[props.id]);
}
class App extends Component<any, any> {
static template = xml`<div><Beer id="state.beerId"/></div>`;
static components = { Beer };
state = useState({ beerId: 1 });
}
const state = { beers: { 1: { name: "jupiler" }, 2: { name: "kwak" } } };
const store = new Store({ state });
(<any>env).store = store;
const app = new App(env);
await app.mount(fixture);
expect(fixture.innerHTML).toBe("<div><span>jupiler</span></div>");
app.state.beerId = 2;
await nextTick();
expect(fixture.innerHTML).toBe("<div><span>kwak</span></div>");
});
test("connected component is updated when store is changed", async () => {
class App extends Component<any, any> {
static template = xml`
<div>
<span t-foreach="data.beers" t-as="beer" t-key="beer.name"><t t-esc="beer.name"/></span>
</div>`;
// 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 });
(<any>env).store = store;
const app = new App(env);
await app.mount(fixture);
expect(fixture.innerHTML).toBe("<div><span>jupiler</span></div>");
store.dispatch("addBeer", "kwak");
await nextTick();
expect(fixture.innerHTML).toBe("<div><span>jupiler</span><span>kwak</span></div>");
});
test("connected component with undefined, null and string props", async () => {
class Beer extends Component<any, any> {
static template = xml`
<div t-name="Beer">
<span>taster:<t t-esc="data.taster"/></span>
<span t-if="data.selected">selected:<t t-esc="data.selected.name"/></span>
<span t-if="data.consumed">consumed:<t t-esc="data.consumed.name"/></span>
</div>`;
data = useStore((state, props) => ({
selected: state.beers[props.id],
consumed: state.beers[state.consumedID] || null,
taster: state.taster
}));
}
class App extends Component<any, any> {
static template = xml`<div><Beer id="state.beerId"/></div>`;
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 });
(<any>env).store = store;
const app = new App(env);
await app.mount(fixture);
expect(fixture.innerHTML).toBe("<div><div><span>taster:aaron</span></div></div>");
app.state.beerId = 1;
await nextTick();
expect(fixture.innerHTML).toBe(
"<div><div><span>taster:aaron</span><span>selected:jupiler</span></div></div>"
);
store.dispatch("consume", 1);
await nextTick();
expect(fixture.innerHTML).toBe(
"<div><div><span>taster:aaron</span><span>selected:jupiler</span><span>consumed:jupiler</span></div></div>"
);
app.state.beerId = 0;
await nextTick();
expect(fixture.innerHTML).toBe(
"<div><div><span>taster:aaron</span><span>consumed:jupiler</span></div></div>"
);
});
test("connected component deeply reactive with undefined, null and string props", async () => {
class Beer extends Component<any, any> {
static template = xml`
<div>
<span>taster:<t t-esc="info.taster"/></span>
<span t-if="info.selected">selected:<t t-esc="info.selected.name"/></span>
<span t-if="info.consumed">consumed:<t t-esc="info.consumed.name"/></span>
</div>`;
info = useStore(function(state, props) {
return {
selected: state.beers[props.id],
consumed: state.beers[state.consumedID] || null,
taster: state.taster
};
});
}
class App extends Component<any, any> {
static template = xml`<div><Beer id="state.beerId"/></div>`;
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 });
(<any>env).store = store;
const app = new App(env);
await app.mount(fixture);
expect(fixture.innerHTML).toBe("<div><div><span>taster:aaron</span></div></div>");
app.state.beerId = 1;
await nextTick();
expect(fixture.innerHTML).toBe(
"<div><div><span>taster:aaron</span><span>selected:jupiler</span></div></div>"
);
store.dispatch("renameBeer", { beerId: 1, name: "kwak" });
await nextTick();
expect(fixture.innerHTML).toBe(
"<div><div><span>taster:aaron</span><span>selected:kwak</span></div></div>"
);
store.dispatch("consume", 1);
await nextTick();
expect(fixture.innerHTML).toBe(
"<div><div><span>taster:aaron</span><span>selected:kwak</span><span>consumed:kwak</span></div></div>"
);
app.state.beerId = 0;
await nextTick();
expect(fixture.innerHTML).toBe(
"<div><div><span>taster:aaron</span><span>consumed:kwak</span></div></div>"
);
store.dispatch("renameBeer", { beerId: 1, name: "jupiler" });
await nextTick();
expect(fixture.innerHTML).toBe(
"<div><div><span>taster:aaron</span><span>consumed:jupiler</span></div></div>"
);
store.dispatch("changeTaster", "matthieu");
await nextTick();
expect(fixture.innerHTML).toBe(
"<div><div><span>taster:matthieu</span><span>consumed:jupiler</span></div></div>"
);
});
test("correct update order when parent/children are connected", async () => {
const steps: string[] = [];
class Child extends Component<any, any> {
static template = xml`<span><t t-esc="state.msg"/></span>`;
state = useStore((state, props) => {
steps.push("child");
return { msg: state.msg[props.key] };
});
}
class Parent extends Component<any, any> {
static template = xml`<div><Child key="state.current"/></div>`;
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 });
(<any>env).store = store;
const app = new Parent(env);
await app.mount(fixture);
expect(fixture.innerHTML).toBe("<div><span>a</span></div>");
expect(steps).toEqual(["parent", "child"]);
store.dispatch("setCurrent", "b");
await nextTick();
expect(fixture.innerHTML).toBe("<div><span>b</span></div>");
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<any, any> {
static template = xml`<span><t t-esc="state.msg"/></span>`;
state = useStore((s, props) => {
steps.push("child");
return { msg: s.messages[props.someId] };
});
}
class Parent extends Component<any, any> {
static template = xml`
<div>
<Child t-if="state.flag" someId="state.someId"/>
</div>`;
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 });
(<any>env).store = store;
const app = new Parent(env);
await app.mount(fixture);
expect(fixture.innerHTML).toBe("<div><span>abc</span></div>");
expect(steps).toEqual(["parent", "child"]);
def = makeDeferred();
store.dispatch("setFlagToFalse");
await nextTick();
expect(fixture.innerHTML).toBe("<div><span>abc</span></div>");
expect(steps).toEqual(["parent", "child", "parent"]);
def.resolve();
await nextTick();
expect(steps).toEqual(["parent", "child", "parent"]);
expect(fixture.innerHTML).toBe("<div></div>");
});
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<any, any> {
static template = xml`
<div class="todo">
<t t-esc="state.todo.title"/>
<button class="destroy" t-on-click="editTodo">x</button>
</div>`;
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<any, any> {
static template = xml`
<div class="todoapp">
<t t-foreach="Object.values(state.todos)" t-as="todo">
<TodoItem t-key="todo.id" id="todo.id"/>
</t>
</div>`;
static components = { TodoItem };
state = useStore(state => {
steps.push("app:usestore");
return { todos: state.todos };
});
__render(f) {
steps.push("app:render");
return super.__render(f);
}
}
(<any>env).store = store;
const app = new TodoApp(env);
await app.mount(fixture);
expect(fixture.innerHTML).toBe(
'<div class="todoapp"><div class="todo">kikoou<button class="destroy">x</button></div></div>'
);
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(
'<div class="todoapp"><div class="todo">abc<button class="destroy">x</button></div></div>'
);
});
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<any, any> {
static template = xml`
<div class="todo">
<t t-esc="state.todo.title"/>
<button class="destroy" t-on-click="removeTodo">x</button>
</div>`;
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<any, any> {
static template = xml`
<div class="todoapp">
<t t-foreach="Object.values(state.todos)" t-as="todo">
<TodoItem t-key="todo.id" id="todo.id"/>
</t>
</div>`;
static components = { TodoItem };
state = useStore(state => {
steps.push("app:usestore");
return { todos: state.todos };
});
__render(f) {
steps.push("app:render");
return super.__render(f);
}
}
(<any>env).store = store;
const app = new TodoApp(env);
await app.mount(fixture);
expect(fixture.innerHTML).toBe(
'<div class="todoapp"><div class="todo">kikoou<button class="destroy">x</button></div></div>'
);
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('<div class="todoapp"></div>');
});
test("connected component willpatch/patch hooks are called on store updates", async () => {
const steps: string[] = [];
class App extends Component<any, any> {
static template = xml`<div><t t-esc="store.msg"/></div>`;
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 });
(<any>env).store = store;
const app = new App(env);
await app.mount(fixture);
expect(fixture.innerHTML).toBe("<div>a</div>");
store.dispatch("setMsg", "b");
await nextTick();
expect(fixture.innerHTML).toBe("<div>b</div>");
expect(steps).toEqual(["willpatch", "patched"]);
});
test("connected child components stop listening to store when destroyed", async () => {
let steps: any = [];
class Child extends Component<any, any> {
static template = xml`<div><t t-esc="store.val"/></div>`;
store = useStore(s => s);
}
class Parent extends Component<any, any> {
static template = xml`<div><Child t-if="state.child" /></div>`;
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 } });
(<any>env).store = store;
const parent = new Parent(env);
await parent.mount(fixture);
expect(steps).toEqual(["on:update"]);
expect(fixture.innerHTML).toBe("<div><div>1</div></div>");
parent.state.child = false;
await nextTick();
expect(fixture.innerHTML).toBe("<div></div>");
expect(steps).toEqual(["on:update", "off:update"]);
});
test("dispatch an action", async () => {
class App extends Component<any, any> {
static template = xml`<div><t t-esc="store.counter"/></div>`;
store = useStore(state => state);
dispatch = useDispatch();
}
const state = {
counter: 0
};
const actions = {
inc({ state }) {
return ++state.counter;
}
};
const store = new Store({ state, actions });
(<any>env).store = store;
const app = new App(env);
await app.mount(fixture);
expect(fixture.innerHTML).toBe("<div>0</div>");
const res = app.dispatch("inc");
expect(res).toBe(1);
await nextTick();
expect(fixture.innerHTML).toBe("<div>1</div>");
});
});
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<any, any> {
static template = xml`
<div>
<span>Attachment <t t-esc="props.id"/></span>
<span>Name: <t t-esc="attachment.name"/></span>
</div>`;
attachment = useStore((state, props) => ({ name: state.attachments[props.id].name }));
}
class Message extends Component<any, any> {
static template = xml`
<div>
<button t-on-click="doStuff">Do stuff</button>
<Attachment t-foreach="store.attachmentIds" t-key="attachmentId" t-as="attachmentId" id="attachmentId"/>
</div>`;
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;
}
}
(<any>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();
});
});