mirror of
https://github.com/odoo/owl.git
synced 2025-10-06 19:59:41 +07:00
+19
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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", {
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user