Compare commits

..

21 Commits

Author SHA1 Message Date
Géry Debongnie afc94c51a8 bump to v0.6.0 2019-04-11 10:24:37 +02:00
Géry Debongnie 90f6808f57 ref: rename _start to _prepare 2019-04-10 10:45:27 +02:00
Géry Debongnie f960149ba2 rem: remove destroyed hook 2019-04-10 09:35:20 +02:00
Géry Debongnie 74ce6a671e fix: properly destroy widget in some cases
when widget was (props)updated, then removed from DOM, it was not
destroyed.

closes #19
2019-04-09 15:31:03 +02:00
Géry Debongnie bc07c49a5f imp: add willUpdateProps hook to components 2019-04-09 13:55:13 +02:00
Géry Debongnie 816ffe1b44 imp: store dispatch functions can be synchronized 2019-04-09 13:16:22 +02:00
Géry Debongnie 94c33af8e9 ref: rename 'detach' method to 'unmount' 2019-04-09 12:57:34 +02:00
Géry Debongnie 6e0ef0fd5c ref: rename updated hook to 'patched' 2019-04-09 10:37:53 +02:00
Géry Debongnie 370a92b67e imp: t-ref directive is now dynamic
closes #11
2019-04-09 10:33:08 +02:00
Géry Debongnie c10b84cd99 ref: move magify function from utils to store 2019-04-09 10:09:29 +02:00
Géry Debongnie 7ea782bd60 fix: prevent issue in store with existing values in array 2019-04-09 10:08:37 +02:00
Géry Debongnie bf4cbaea33 imp: unskip test, add new test 2019-04-09 09:14:22 +02:00
Alexandre Kühn cde85269fd imp: make store reactive (observe state and react to changes)
This is a pretty big change, yet still not complete.
2019-04-08 23:11:38 +02:00
Alexandre Kühn 3340401f54 imp: give reference to dispatch function in actions 2019-04-08 23:11:32 +02:00
Alexandre Kühn 7027ff1eb6 imp: add env to store dispatch
This is useful to allow actions to use specific environment methods.
For example, one might want to add a rpc method to the environment.
2019-04-08 23:04:27 +02:00
Géry Debongnie d10426c720 update component doc 2019-04-05 23:44:07 +02:00
Géry Debongnie a951c484be ref: rename componentDidUpdate hook into 'updated'
closes #16
2019-04-05 23:29:16 +02:00
Géry Debongnie 14d3eb83eb imp: add willPatch hook 2019-04-05 23:23:00 +02:00
Géry Debongnie ca73ed07a6 doc: add link to github.io page 2019-04-05 15:13:18 +02:00
Géry Debongnie f2fc20f8e3 fix: connected components should recompute store props properly 2019-04-04 16:01:09 +02:00
Géry Debongnie 8badcbd21e doc: update readme 2019-04-04 16:01:00 +02:00
12 changed files with 945 additions and 177 deletions
+5 -1
View File
@@ -28,6 +28,10 @@ It is designed to be:
Note: the code is written in typescript. This does not mean that the main web
client will ever be converted to typescript (even though I would really like it).
## Try it online
You can experiment with the OWL project online: [https://odoo.github.io/owl/](https://odoo.github.io/owl/)
## Installing/Building
Some npm scripts are available:
@@ -117,4 +121,4 @@ More interesting examples on how to work with this web framework can be found in
## License
React is [GPL licensed](./LICENSE).
OWL is [GPL licensed](./LICENSE).
+94 -10
View File
@@ -7,16 +7,8 @@ For example:
```javascript
export class Counter extends Component {
inlineTemplate = `
<div t-name="counter">
<button t-on-click="increment(-1)">-</button>
<span style="font-weight:bold">Value: <t t-esc="state.counter"/></span>
<button t-on-click="increment(1)">+</button>
</div>`;
state = {
counter: 0
};
template = "counter";
state = { counter: 0 };
constructor(parent, props) {
super(parent, props);
@@ -28,3 +20,95 @@ export class Counter extends Component {
}
}
```
```xml
<div t-name="counter">
<button t-on-click="increment(-1)">-</button>
<span style="font-weight:bold">Value: <t t-esc="state.counter"/></span>
<button t-on-click="increment(1)">+</button>
</div>`;
```
## Lifecycle
A solid and robust component system needs useful hooks/methods to help
developers write components. Here is a description of the lifecycle of a owl
component:
- **[constructor](#constructor)**
- **[willStart](#willStart)**
- **[mounted](#mounted)**
- **[willPatch](#willPatch)**
- **[updated](#updated)**
- **[willUnmount](#willUnmount)**
Note: no hook method should ever be called manually. They are supposed to be
called by the owl framework whenever it is required.
### constructor
The constructor is not exactly a hook, it is the constructor of the component.
### willStart
willStart is an asynchronous hook that can be implemented to perform some
action before the initial rendering of a component.
It will be called exactly once before the initial rendering. It is useful
in some cases, for example, to load external assets (such as a JS library)
before the widget is rendered. Another use case is to load data from a server.
Note that a slow willStart method will slow down the rendering of the user
interface. Therefore, some effort should be made to make this method as
fast as possible.
### mounted
mounted is a hook that is called each time a component is attached to the
DOM. This is a good place to add some listeners, or to interact with the
DOM, if the component needs to perform some measure for example.
It is the opposite of _willUnmount_. If a component has been mounted, it will
be unmounted at some point.
### willUpdateProps
The willUpdateProps is an asynchronous hook, called just before new props
are set. This is useful if the component needs some asynchronous task
performed, depending on the props (for example, assuming that the props are
some record Id, fetching the record data).
This hook is not called during the first render (but willStart is called
and performs a similar job).
### willPatch
The willPatch hook is called just before the DOM patching process starts.
It is not called on the initial render. This is useful to get some
information which are in the DOM. For example, the current position of the
scrollbar
Note that at this point, it is not safe to rerender the widget. In
particular, updateState calls should be avoided.
### patched
This hook is called whenever a component did actually update its props,
state or env.
This method is not called on the initial render. It is useful to interact
with the DOM (for example, through an external library) whenever the
component was patched.
Updating the widget state in this hook is possible, but not encouraged.
One need to be careful, because updates here will cause rerender, which in
turn will cause other calls to patched. So, we need to be particularly
careful at avoiding endless cycles.
### willUnmount
willUnmount is a hook that is called each time just before a component is unmounted from
the DOM. This is a good place to remove some listeners, for example.
This is the opposite method of _mounted_. The _willUnmount_ method will be
called in reverse order: first the children, then the parents.
+2 -2
View File
@@ -4,13 +4,13 @@
<div t-name="web.web_client" class="o_web_client">
<t t-widget="Navbar" t-props="{inHome:state.inHome,app:state.currentApp}" />
<t t-widget="HomeMenu" t-if="state.inHome" t-keep-alive="1" t-props="{menuInfo:props.menuInfo}" />
<div class="o_content" t-att-class="state.inHome ? 'o_hidden' : ''" t-ref="content"/>
<div class="o_content" t-att-class="state.inHome ? 'o_hidden' : ''" t-ref="'content'"/>
<div class="o_notification_container">
<t t-foreach="state.notifications" t-as="notif">
<t t-widget="Notification" t-props="notif"/>
</t>
</div>
<div class="o_loading d-none" t-ref="loading_indicator">Loading</div>
<div class="o_loading d-none" t-ref="'loading_indicator'">Loading</div>
</div>
<div t-name="web.navbar" class="o_navbar" t-att-class="props.inHome ? 'o_in_home' : ''">
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "owl",
"version": "0.5.0",
"version": "0.6.0",
"description": "Odoo Web Lab",
"main": "src/index.ts",
"scripts": {
+44 -22
View File
@@ -149,6 +149,28 @@ export class Component<
*/
mounted() {}
/**
* The willUpdateProps is an asynchronous hook, called just before new props
* are set. This is useful if the component needs some asynchronous task
* performed, depending on the props (for example, assuming that the props are
* some record Id, fetching the record data).
*
* This hook is not called during the first render (but willStart is called
* and performs a similar job).
*/
async willUpdateProps(nextProps: Props) {}
/**
* The willPatch hook is called just before the DOM patching process starts.
* It is not called on the initial render. This is useful to get some
* information which are in the DOM. For example, the current position of the
* scrollbar
*
* Note that at this point, it is not safe to rerender the widget. In
* particular, updateState calls should be avoided.
*/
willPatch() {}
/**
* This hook is called whenever a component did actually update its props,
* state or env.
@@ -156,12 +178,18 @@ export class Component<
* This method is not called on the initial render. It is useful to interact
* with the DOM (for example, through an external library) whenever the
* component was updated.
*
* Updating the widget state in this hook is possible, but not encouraged.
* One need to be careful, because updates here will cause rerender, which in
* turn will cause other calls to updated. So, we need to be particularly
* careful at avoiding endless cycles.
*/
componentDidUpdate() {}
patched() {}
/**
* willUnmount is a hook that is called each time a component is detached from
* the DOM. This is a good place to remove some listeners, for example.
* willUnmount is a hook that is called each time just before a component is
* unmounted from the DOM. This is a good place to remove some listeners, for
* example.
*
* Note: this method should not be called manually.
*
@@ -169,14 +197,6 @@ export class Component<
*/
willUnmount() {}
/**
* destroyed is a hook called exactly once, when a component is destroyed.
* When a component is destroyed, its children will be destroyed first.
*
* Note: this method should not be called manually.
*/
destroyed() {}
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
@@ -200,7 +220,7 @@ export class Component<
}
async mount(target: HTMLElement): Promise<void> {
const vnode = await this._start();
const vnode = await this._prepare();
if (this.__widget__.isDestroyed) {
// widget was destroyed before we get here...
return;
@@ -220,7 +240,7 @@ export class Component<
}
}
detach() {
unmount() {
if (this.el) {
this._visitSubTree(w => {
if (w.__widget__.isMounted) {
@@ -268,7 +288,6 @@ export class Component<
}
this.clear();
this.__widget__.isDestroyed = true;
this.destroyed();
}
}
@@ -293,7 +312,7 @@ export class Component<
if (this.__widget__.isMounted) {
await this.render(true);
}
this.componentDidUpdate();
this.patched();
}
async updateProps(
@@ -325,7 +344,7 @@ export class Component<
if (this.__widget__.isStarted) {
await this.render();
}
this.componentDidUpdate();
this.patched();
}
//--------------------------------------------------------------------------
@@ -333,19 +352,22 @@ export class Component<
//--------------------------------------------------------------------------
async _updateProps(nextProps: Props): Promise<void> {
await this.willUpdateProps(nextProps);
this.props = nextProps;
await this.render();
this.componentDidUpdate();
this.patched();
}
_patch(vnode) {
this.__widget__.renderPromise = null;
this.__widget__.vnode = patch(
this.__widget__.vnode || document.createElement(vnode.sel!),
vnode
);
if (this.__widget__.vnode) {
this.willPatch();
this.__widget__.vnode = patch(this.__widget__.vnode, vnode);
} else {
this.__widget__.vnode = patch(document.createElement(vnode.sel!), vnode);
}
}
async _start(): Promise<VNode> {
async _prepare(): Promise<VNode> {
this.__widget__.renderProps = this.props;
this.__widget__.renderPromise = this.willStart().then(() => {
if (this.__widget__.isDestroyed) {
+13 -9
View File
@@ -900,10 +900,12 @@ const onDirective: Directive = {
const refDirective: Directive = {
name: "ref",
priority: 95,
atNodeCreation({ ctx, node, nodeID }) {
let ref = node.getAttribute("t-ref");
atNodeCreation({ ctx, node }) {
let ref = node.getAttribute("t-ref")!;
ctx.addLine(`p${ctx.parentNode}.hook = {
create: (_, n) => context.refs['${ref}'] = n.elm,
create: (_, n) => context.refs[${ctx.formatExpression(
ref
)}] = n.elm,
};`);
}
};
@@ -996,10 +998,10 @@ const widgetDirective: Directive = {
}
let ref = node.getAttribute("t-ref");
if (ref) {
ctx.addLine(`context.refs['${ref}'] = w${widgetID};`);
ctx.addLine(`context.refs[${ctx.formatExpression(ref)}] = w${widgetID};`);
}
ctx.addLine(`def${defID} = w${widgetID}._start();`);
ctx.addLine(`def${defID} = w${widgetID}._prepare();`);
ctx.closeIf();
ctx.closeIf();
@@ -1008,16 +1010,18 @@ const widgetDirective: Directive = {
`def${defID} = def${defID}.then(vnode=>{let pvnode=h(vnode.sel, {key: ${templateID}});c${
ctx.parentNode
}[_${dummyID}_index]=pvnode;pvnode.data.hook = {insert(vn){let nvn=w${widgetID}._mount(vnode, vn.elm);pvnode.elm=nvn.elm},remove(){w${widgetID}.${
keepAlive ? "detach" : "destroy"
keepAlive ? "unmount" : "destroy"
}()},destroy(){w${widgetID}.${
keepAlive ? "detach" : "destroy"
keepAlive ? "unmount" : "destroy"
}()}}; w${widgetID}.__widget__.pvnode = pvnode;});`
);
ctx.addElse();
ctx.addLine(
`def${defID} = def${defID}.then(()=>{if (w${widgetID}.__widget__.isDestroyed) {return};let vnode;if (!w${widgetID}.__widget__.vnode){vnode=w${widgetID}.__widget__.pvnode} else { vnode=h(w${widgetID}.__widget__.vnode.sel, {key: ${templateID}});vnode.elm=w${widgetID}.el;vnode.data.hook = {insert(a){a.elm.parentNode.replaceChild(w${widgetID}.el,a.elm);a.elm=w${widgetID}.el;w${widgetID}.__mount();},remove(){w${widgetID}.${
keepAlive ? "detach" : "destroy"
}()}}}c${ctx.parentNode}[_${dummyID}_index]=vnode;});`
keepAlive ? "unmount" : "destroy"
}()}, destroy() {w${widgetID}.${keepAlive ? "unmount" : "destroy"}()}}}c${
ctx.parentNode
}[_${dummyID}_index]=vnode;});`
);
ctx.closeIf();
+180 -67
View File
@@ -2,6 +2,95 @@ import { EventBus } from "./event_bus";
import { Component } from "./component";
import { shallowEqual } from "./utils";
interface StoreConfig {
env?: any;
state?: any;
actions?: any;
mutations?: any;
}
interface StoreOption {
debug?: boolean;
}
export class Store extends EventBus {
state: any;
mstate: any;
actions: any;
mutations: any;
_isMutating: boolean = false;
_isDirty: boolean = false;
history: any[] = [];
debug: boolean;
env: any;
constructor(config: StoreConfig, options: StoreOption = {}) {
super();
this.debug = options.debug || false;
this.state = Object.assign({}, config.state);
const self = this;
this.mstate = magify({
raw: this.state,
key: "state",
parent: this,
onDirty: function() {
self._isDirty = true;
}
});
this.actions = config.actions;
this.mutations = config.mutations;
this.env = config.env;
if (this.debug) {
this.history.push({ state: this.state });
}
}
dispatch(action, payload?: any): Promise<void> | void {
if (!this.actions[action]) {
throw new Error(`[Error] action ${action} is undefined`);
}
const result = this.actions[action](
{
commit: this.commit.bind(this),
dispatch: this.dispatch.bind(this),
env: this.env,
state: this.state
},
payload
);
if (result instanceof Promise) {
return new Promise((resolve, reject) => {
result.then(() => resolve());
result.catch(reject);
});
}
}
async commit(type, payload?: any) {
if (!this.mutations[type]) {
throw new Error(`[Error] mutation ${type} is undefined`);
}
this._isMutating = true;
this.mutations[type].call(null, this.mstate, payload);
if (this.debug) {
this.history.push({
state: this.state,
mutation: type,
payload: payload
});
}
await Promise.resolve();
if (this._isMutating) {
this._isMutating = false;
if (this._isDirty) {
this._isDirty = false;
this.trigger("update", this.state);
}
}
}
}
export function connect(mapStateToProps) {
return function(Comp) {
return class extends Comp {
@@ -30,6 +119,12 @@ export function connect(mapStateToProps) {
super.willUnmount();
}
updateProps(nextProps, forceUpdate) {
if (this.__widget__.ownProps !== nextProps) {
this.__widget__.currentStoreProps = mapStateToProps(
this.env.store.state,
nextProps
);
}
this.__widget__.ownProps = nextProps;
const mergedProps = Object.assign(
{},
@@ -42,74 +137,92 @@ export function connect(mapStateToProps) {
};
}
interface StoreConfig {
state?: any;
actions?: any;
mutations?: any;
}
interface StoreOption {
debug?: boolean;
}
export class Store extends EventBus {
_state: any;
actions: any;
mutations: any;
_isMutating: boolean = false;
history: any[] = [];
debug: boolean;
constructor(config: StoreConfig, options: StoreOption = {}) {
super();
this.debug = options.debug || false;
this._state = Object.assign({}, config.state);
this.actions = config.actions;
this.mutations = config.mutations;
if (this.debug) {
this.history.push({ state: this.state });
function _magifyArray({ raw, key, parent, magic, onDirty }) {
Object.defineProperty(magic, "length", {
get() {
return magic.raw.length;
}
}
get state() {
return this._clone(this._state);
}
dispatch(action, payload) {
if (!this.actions[action]) {
throw new Error(`[Error] action ${action} is undefined`);
}
this.actions[action](
{
commit: this.commit.bind(this),
state: this.state
},
payload
);
}
async commit(type, payload) {
if (!this.mutations[type]) {
throw new Error(`[Error] mutation ${type} is undefined`);
}
this._isMutating = true;
this.mutations[type].call(null, this._state, payload);
if (this.debug) {
this.history.push({
state: this.state,
mutation: type,
payload: payload
});
Object.assign(magic, {
push: function(item) {
onDirty();
parent.raw[key] = [...magic.raw, item];
magic.raw = parent.raw[key];
const index = magic.raw.length - 1;
let prop = magify({ raw: item, key: index, parent: magic, onDirty });
Object.defineProperty(magic, index, {
set(newVal) {
onDirty();
parent.raw[key] = [...magic.raw];
parent.raw[key][index] = newVal;
magic.raw = parent.raw[key];
prop = magify({ raw: newVal, key: index, parent: magic, onDirty });
},
get() {
return prop;
}
});
}
await Promise.resolve();
if (this._isMutating) {
this._isMutating = false;
this.trigger("update", this.state);
}
}
_clone(obj) {
return JSON.parse(JSON.stringify(obj));
}
});
raw.forEach((value, index) => {
let prop = magify({
raw: value,
key: index,
parent: magic,
onDirty
});
Object.defineProperty(magic, index, {
set(newVal) {
onDirty();
parent.raw[key] = [...magic.raw];
parent.raw[key][index] = newVal;
magic.raw = parent.raw[key];
prop = magify({ raw: newVal, key: index, parent: magic, onDirty });
},
get() {
return prop;
}
});
});
}
function magify({ raw, key, parent, onDirty }) {
if (!parent.magic) {
parent = {
raw: parent,
magic: true,
parent: null
};
}
if (raw.magic) {
return raw;
}
if (typeof raw !== "object") {
return raw;
}
let magic = { raw, key, parent, magic: true };
if (Array.isArray(raw)) {
_magifyArray({ raw, key, parent, magic, onDirty });
} else {
Object.keys(raw).forEach(propKey => {
let prop = magify({
raw: raw[propKey],
key: propKey,
parent: magic,
onDirty
});
Object.defineProperty(magic, propKey, {
set(newVal) {
onDirty();
parent.raw[key] = { ...magic.raw, [propKey]: newVal };
magic.raw = parent.raw[key];
prop = magify({ raw: newVal, key: propKey, parent: magic, onDirty });
},
get() {
return prop;
}
});
});
}
return magic;
}
+6 -6
View File
@@ -47,13 +47,13 @@ exports[`composition sub widgets with some state rendered in a loop 1`] = `
} else {
w7 = new context.widgets['ChildWidget'](owner, props7);
context.__widget__.cmap[key8] = w7.__widget__.id;
def6 = w7._start();
def6 = w7._prepare();
}
}
if (isNew7) {
def6 = def6.then(vnode=>{let pvnode=h(vnode.sel, {key: key8});c1[_5_index]=pvnode;pvnode.data.hook = {insert(vn){let nvn=w7._mount(vnode, vn.elm);pvnode.elm=nvn.elm},remove(){w7.destroy()},destroy(){w7.destroy()}}; w7.__widget__.pvnode = pvnode;});
} else {
def6 = def6.then(()=>{if (w7.__widget__.isDestroyed) {return};let vnode;if (!w7.__widget__.vnode){vnode=w7.__widget__.pvnode} else { vnode=h(w7.__widget__.vnode.sel, {key: key8});vnode.elm=w7.el;vnode.data.hook = {insert(a){a.elm.parentNode.replaceChild(w7.el,a.elm);a.elm=w7.el;w7.__mount();},remove(){w7.destroy()}}}c1[_5_index]=vnode;});
def6 = def6.then(()=>{if (w7.__widget__.isDestroyed) {return};let vnode;if (!w7.__widget__.vnode){vnode=w7.__widget__.pvnode} else { vnode=h(w7.__widget__.vnode.sel, {key: key8});vnode.elm=w7.el;vnode.data.hook = {insert(a){a.elm.parentNode.replaceChild(w7.el,a.elm);a.elm=w7.el;w7.__mount();},remove(){w7.destroy()}, destroy() {w7.destroy()}}}c1[_5_index]=vnode;});
}
extra.promises.push(def6);
}
@@ -95,13 +95,13 @@ exports[`random stuff/miscellaneous snapshotting compiled code 1`] = `
} else {
w4 = new context.widgets['child'](owner, props4);
context.__widget__.cmap[key5] = w4.__widget__.id;
def3 = w4._start();
def3 = w4._prepare();
}
}
if (isNew4) {
def3 = def3.then(vnode=>{let pvnode=h(vnode.sel, {key: key5});c1[_2_index]=pvnode;pvnode.data.hook = {insert(vn){let nvn=w4._mount(vnode, vn.elm);pvnode.elm=nvn.elm},remove(){w4.destroy()},destroy(){w4.destroy()}}; w4.__widget__.pvnode = pvnode;});
} else {
def3 = def3.then(()=>{if (w4.__widget__.isDestroyed) {return};let vnode;if (!w4.__widget__.vnode){vnode=w4.__widget__.pvnode} else { vnode=h(w4.__widget__.vnode.sel, {key: key5});vnode.elm=w4.el;vnode.data.hook = {insert(a){a.elm.parentNode.replaceChild(w4.el,a.elm);a.elm=w4.el;w4.__mount();},remove(){w4.destroy()}}}c1[_2_index]=vnode;});
def3 = def3.then(()=>{if (w4.__widget__.isDestroyed) {return};let vnode;if (!w4.__widget__.vnode){vnode=w4.__widget__.pvnode} else { vnode=h(w4.__widget__.vnode.sel, {key: key5});vnode.elm=w4.el;vnode.data.hook = {insert(a){a.elm.parentNode.replaceChild(w4.el,a.elm);a.elm=w4.el;w4.__mount();},remove(){w4.destroy()}, destroy() {w4.destroy()}}}c1[_2_index]=vnode;});
}
extra.promises.push(def3);
return vn1;
@@ -141,13 +141,13 @@ exports[`random stuff/miscellaneous t-props should not be undefined (snapshottin
} else {
w4 = new context.widgets['child'](owner, props4);
context.__widget__.cmap[4] = w4.__widget__.id;
def3 = w4._start();
def3 = w4._prepare();
}
}
if (isNew4) {
def3 = def3.then(vnode=>{let pvnode=h(vnode.sel, {key: 4});c1[_2_index]=pvnode;pvnode.data.hook = {insert(vn){let nvn=w4._mount(vnode, vn.elm);pvnode.elm=nvn.elm},remove(){w4.destroy()},destroy(){w4.destroy()}}; w4.__widget__.pvnode = pvnode;});
} else {
def3 = def3.then(()=>{if (w4.__widget__.isDestroyed) {return};let vnode;if (!w4.__widget__.vnode){vnode=w4.__widget__.pvnode} else { vnode=h(w4.__widget__.vnode.sel, {key: 4});vnode.elm=w4.el;vnode.data.hook = {insert(a){a.elm.parentNode.replaceChild(w4.el,a.elm);a.elm=w4.el;w4.__mount();},remove(){w4.destroy()}}}c1[_2_index]=vnode;});
def3 = def3.then(()=>{if (w4.__widget__.isDestroyed) {return};let vnode;if (!w4.__widget__.vnode){vnode=w4.__widget__.pvnode} else { vnode=h(w4.__widget__.vnode.sel, {key: 4});vnode.elm=w4.el;vnode.data.hook = {insert(a){a.elm.parentNode.replaceChild(w4.el,a.elm);a.elm=w4.el;w4.__mount();},remove(){w4.destroy()}, destroy() {w4.destroy()}}}c1[_2_index]=vnode;});
}
extra.promises.push(def3);
return vn1;
+16
View File
@@ -1189,6 +1189,22 @@ exports[`t-raw variable 1`] = `
}"
`;
exports[`t-ref can get a dynamic ref on a node 1`] = `
"function anonymous(context,extra
) {
var h = this.utils.h;
var c1 = [], p1 = {key:1};
var vn1 = h('div', p1, c1);
var c2 = [], p2 = {key:2};
var vn2 = h('span', p2, c2);
c1.push(vn2);
p2.hook = {
create: (_, n) => context.refs['myspan' + 3] = n.elm,
};
return vn1;
}"
`;
exports[`t-ref can get a ref on a node 1`] = `
"function anonymous(context,extra
) {
+111 -48
View File
@@ -312,21 +312,53 @@ describe("lifecycle hooks", () => {
willUnmount() {
steps.push("willunmount");
}
destroyed() {
steps.push("destroyed");
}
}
const widget = new ParentWidget(env);
await widget.mount(fixture);
expect(steps).toEqual(["init", "willstart", "mounted"]);
await widget.updateState({ ok: false });
expect(steps).toEqual([
"init",
"willstart",
"mounted",
"willunmount",
"destroyed"
]);
expect(steps).toEqual(["init", "willstart", "mounted", "willunmount"]);
});
test("widgets are unmounted and destroyed if no longer in DOM, even after updateprops", async () => {
let childUnmounted = false;
class ChildWidget extends Widget {
inlineTemplate = `<span><t t-esc="props.n"/></span>`;
willUnmount() {
childUnmounted = true;
}
increment() {
this.updateState({ n: this.state.n + 1 });
}
}
class ParentWidget extends Widget {
widgets = { ChildWidget };
inlineTemplate = `
<div>
<div t-if="state.flag">
<t t-widget="ChildWidget" t-props="{n: state.n}"/>
</div>
</div>`;
state = { n: 0, flag: true };
increment() {
this.updateState({ n: this.state.n + 1 });
}
toggleSubWidget() {
this.updateState({ flag: !this.state.flag });
}
}
const widget = new ParentWidget(env);
await widget.mount(fixture);
expect(fixture.innerHTML).toBe("<div><div><span>0</span></div></div>");
widget.increment();
await nextTick();
expect(fixture.innerHTML).toBe("<div><div><span>1</span></div></div>");
widget.toggleSubWidget();
await nextTick();
expect(fixture.innerHTML).toBe("<div></div>");
expect(childUnmounted).toBe(true);
});
test("hooks are called in proper order in widget creation/destruction", async () => {
@@ -347,9 +379,6 @@ describe("lifecycle hooks", () => {
willUnmount() {
steps.push("p willunmount");
}
destroyed() {
steps.push("p destroyed");
}
}
class ChildWidget extends Widget {
@@ -366,9 +395,6 @@ describe("lifecycle hooks", () => {
willUnmount() {
steps.push("c willunmount");
}
destroyed() {
steps.push("c destroyed");
}
}
const widget = new ParentWidget(env);
await widget.mount(fixture);
@@ -381,19 +407,38 @@ describe("lifecycle hooks", () => {
"p mounted",
"c mounted",
"c willunmount",
"c destroyed",
"p willunmount",
"p destroyed"
"p willunmount"
]);
});
test("componentDidUpdate hook is called after updateState", async () => {
test("willUpdateProps hook is called", async () => {
let def = makeDeferred();
class HookWidget extends Widget {
inlineTemplate = '<span><t t-esc="props.n"/></span>';
willUpdateProps(nextProps) {
expect(nextProps.n).toBe(2);
return def;
}
}
const widget = new HookWidget(env, { n: 1 });
await widget.mount(fixture);
expect(fixture.innerHTML).toBe("<span>1</span>");
widget.updateProps({ n: 2 });
await nextTick();
expect(fixture.innerHTML).toBe("<span>1</span>");
def.resolve();
await nextTick();
expect(fixture.innerHTML).toBe("<span>2</span>");
});
test("patched hook is called after updateState", async () => {
let n = 0;
class TestWidget extends Widget {
state = { a: 1 };
componentDidUpdate() {
patched() {
n++;
}
}
@@ -408,11 +453,11 @@ describe("lifecycle hooks", () => {
expect(n).toBe(1);
});
test("componentDidUpdate hook is called after updateProps", async () => {
test("patched hook is called after updateProps", async () => {
let n = 0;
class TestWidget extends Widget {
componentDidUpdate() {
patched() {
n++;
}
}
@@ -424,13 +469,13 @@ describe("lifecycle hooks", () => {
expect(n).toBe(1);
});
test("componentDidUpdate hook is called after updateEnv", async () => {
test("patched hook is called after updateEnv", async () => {
let n = 0;
class TestWidget extends Widget {
state = { a: 1 };
componentDidUpdate() {
patched() {
n++;
}
}
@@ -461,7 +506,6 @@ describe("lifecycle hooks", () => {
});
test("sub widget (inside sub node): hooks are correctly called", async () => {
let destroyed = false;
let created = false;
let mounted = false;
class ParentWidget extends Widget {
@@ -483,9 +527,6 @@ describe("lifecycle hooks", () => {
mounted() {
mounted = true;
}
destroyed() {
destroyed = true;
}
}
const widget = new ParentWidget(env);
await widget.mount(fixture);
@@ -494,9 +535,46 @@ describe("lifecycle hooks", () => {
await widget.updateState({ flag: true });
expect(mounted).toBe(true);
expect(created).toBe(true);
expect(destroyed).toBe(false);
await widget.updateState({ flag: false });
expect(destroyed).toBe(true);
});
test("willPatch/patched hook", async () => {
const steps: string[] = [];
class ParentWidget extends Widget {
inlineTemplate = `
<div>
<t t-widget="child" t-props="{v: state.n}"/>
</div>`;
widgets = { child: ChildWidget };
state = { n: 1 };
willPatch() {
steps.push("parent:willPatch");
}
patched() {
steps.push("parent:patched");
}
}
class ChildWidget extends Widget {
willPatch() {
steps.push("child:willPatch");
}
patched() {
steps.push("child:patched");
}
}
const widget = new ParentWidget(env);
await widget.mount(fixture);
expect(steps).toEqual([]);
await widget.updateState({ n: 2 });
// Not sure about this order. If you disagree, feel free to open an issue...
expect(steps).toEqual([
"child:willPatch",
"child:patched",
"parent:willPatch",
"parent:patched"
]);
});
});
@@ -511,21 +589,6 @@ describe("destroy method", () => {
expect(widget.__widget__.isDestroyed).toBe(true);
});
test("destroying a widget twice only call destroyed once", async () => {
let count = 0;
class TestWidget extends Widget {
destroyed() {
count++;
}
}
const widget = new TestWidget(env);
await widget.mount(fixture);
widget.destroy();
expect(count).toBe(1);
widget.destroy();
expect(count).toBe(1);
});
test("destroying a parent also destroys its children", async () => {
const parent = new WidgetA(env);
await parent.mount(fixture);
@@ -589,7 +652,7 @@ describe("composition", () => {
test("t-refs on widget are widgets", async () => {
class WidgetC extends Widget {
inlineTemplate = `<div>Hello<t t-ref="mywidgetb" t-widget="b"/></div>`;
inlineTemplate = `<div>Hello<t t-ref="'mywidgetb'" t-widget="b"/></div>`;
widgets = { b: WidgetB };
}
const widget = new WidgetC(env);
@@ -1229,7 +1292,7 @@ describe("updating environment", () => {
expect(n).toBe(1);
await widget.updateEnv(<any>{ somekey: 5 });
expect(n).toBe(2);
widget.detach();
widget.unmount();
expect(n).toBe(2);
await widget.updateEnv(<any>{ somekey: 5 });
expect(n).toBe(2);
+8 -1
View File
@@ -893,11 +893,18 @@ describe("t-on", () => {
describe("t-ref", () => {
test("can get a ref on a node", () => {
qweb.addTemplate("test", `<div><span t-ref="myspan"/></div>`);
qweb.addTemplate("test", `<div><span t-ref="'myspan'"/></div>`);
let refs: any = {};
renderToDOM(qweb, "test", { refs });
expect(refs.myspan.tagName).toBe("SPAN");
});
test("can get a dynamic ref on a node", () => {
qweb.addTemplate("test", `<div><span t-ref="'myspan' + 3"/></div>`);
let refs: any = {};
renderToDOM(qweb, "test", { refs });
expect(refs.myspan3.tagName).toBe("SPAN");
});
});
describe("loading templates", () => {
+465 -10
View File
@@ -41,6 +41,414 @@ describe("basic use", () => {
expect(store.state.n).toBe(15);
});
test("can dispatch an action in an action", () => {
const state = { n: 1 };
const mutations = {
inc(state, delta) {
state.n += delta;
}
};
const actions = {
inc({ commit }, delta) {
commit("inc", delta);
},
inc100({ dispatch }) {
dispatch("inc", 100);
}
};
const store = new Store({ state, mutations, actions });
expect(store.state.n).toBe(1);
store.dispatch("inc100");
expect(store.state.n).toBe(101);
});
test("dispatch allow synchronizing between actions", async () => {
const state = { n: 1 };
const mutations = {
inc(state, delta) {
state.n += delta;
},
setN(state, n) {
state.n = n;
}
};
const actions = {
async dosomething({ commit, dispatch }) {
await dispatch("setTo10");
commit("inc", 3);
},
async setTo10({ commit }) {
await Promise.resolve();
commit("setN", 10);
}
};
const store = new Store({ state, mutations, actions });
expect(store.state.n).toBe(1);
store.dispatch("dosomething");
expect(store.state.n).toBe(1);
await nextTick();
expect(store.state.n).toBe(13);
});
test("env is given to actions", () => {
expect.assertions(1);
const someEnv = {};
const actions = {
someaction({ env }) {
expect(env).toBe(someEnv);
}
};
const store = new Store({ state: {}, actions, env: someEnv });
store.dispatch("someaction");
});
});
describe("advanced state properties", () => {
test("state in the store is reference equal after empty mutation", async () => {
const mutations = {
donothing() {}
};
const store = new Store({ state: {}, mutations });
const state = store.state;
store.commit("donothing");
expect(store.state).toBe(state);
});
test("state in the store is not reference equal after changing one number value", async () => {
const mutations = {
dosomething(state) {
expect(state.rochefort).toBe(8);
state.rochefort += 2;
expect(state.rochefort).toBe(10);
}
};
const store = new Store({ state: { rochefort: 8 }, mutations });
const state = store.state;
expect(state.rochefort).toBe(8);
store.commit("dosomething");
expect(store.state.rochefort).toBe(10);
expect(store.state).not.toBe(state);
});
test("can push an item in a list with an existing item", async () => {
const mutations = {
addRochefort(state) {
state.rocheforts.push({ taste: 87 });
}
};
const store = new Store({
state: { rocheforts: [{ taste: 84 }] },
mutations
});
const state = store.state;
store.commit("addRochefort");
expect(store.state.rocheforts).toEqual([{ taste: 84 }, { taste: 87 }]);
expect(store.state).toBe(state);
});
test("state is reference equal after pushing in a list", async () => {
const mutations = {
addRochefort(state) {
state.rocheforts.push(10);
}
};
const store = new Store({ state: { rocheforts: [] }, mutations });
const state = store.state;
store.commit("addRochefort");
expect(store.state.rocheforts).toEqual([10]);
expect(store.state).toBe(state);
});
test("nested state in the store behaves properly", async () => {
const state = { jupiler: { maes: 1 } };
const mutations = {
dostuff(state, inc) {
state.jupiler.maes += inc;
}
};
const store = new Store({ state, mutations });
expect(store.state.jupiler.maes).toBe(1);
const stateA = store.state;
const jupiler = store.state.jupiler;
store.commit("dostuff", 10);
expect(store.state.jupiler.maes).toBe(11);
expect(store.state).toBe(stateA);
expect(store.state.jupiler).not.toBe(jupiler);
});
test("sibling properties are not affected", async () => {
const state = { jupiler: { maes: 1 }, stella: 3 };
const mutations = {
dostuff(state, inc) {
state.stella += inc;
}
};
const store = new Store({ state, mutations });
const jupiler = store.state.jupiler;
const stateA = store.state;
store.commit("dostuff", 10);
expect(store.state.stella).toBe(13);
expect(store.state).not.toBe(stateA);
expect(store.state.jupiler).toBe(jupiler);
});
test("interaction between mutations on sibling properties", async () => {
const state = { jupiler: { maes: 1 }, stella: 3 };
const mutations = {
dostuffA(state, inc) {
state.stella += inc;
},
dostuffB(state, inc) {
state.jupiler.maes += inc;
}
};
const store = new Store({ state, mutations });
store.commit("dostuffA", 10);
const jupiler = store.state.jupiler;
store.commit("dostuffB", 10);
expect(store.state.jupiler).not.toBe(jupiler);
});
test("can use object assign in store", async () => {
const mutations = {
dosomething(state) {
Object.assign(state.westmalle, { a: 3, b: 4 });
}
};
const store = new Store({
state: { westmalle: { a: 1, b: 2 } },
mutations
});
store.commit("dosomething");
expect(store.state.westmalle).toEqual({ a: 3, b: 4 });
});
test("aku reactive store state 1", async () => {
const mutations = {
inc(state) {
state.counter++;
}
};
const state = { counter: 0 };
const store = new Store({ state, mutations });
const curState = store.state;
expect(store.state.counter).toBe(0);
store.commit("inc", {});
expect(store.state.counter).toBe(1);
expect(store.state).not.toBe(curState);
});
test("aku reactive store state 2", async () => {
const mutations = {
inc(state) {
state.convo.counter++;
}
};
const state = { convo: { counter: 0 } };
const store = new Store({ state, mutations });
const curState = store.state;
const convo = state.convo;
expect(store.state.convo.counter).toBe(0);
store.commit("inc", {});
expect(store.state.convo.counter).toBe(1);
expect(store.state.convo).not.toBe(convo);
expect(store.state).toBe(curState);
});
test("aku reactive store state 3", async () => {
const mutations = {
inc1(state) {
state.threads[1].counter++;
},
inc2(state) {
state.threads[2].counter++;
}
};
const state = {
threads: {
1: { counter: 0 },
2: { counter: 0 }
}
};
const store = new Store({ state, mutations });
const curState = store.state;
const threads = state.threads;
const thread1 = state.threads[1];
const thread2 = state.threads[2];
expect(store.state.threads[1].counter).toBe(0);
expect(store.state.threads[2].counter).toBe(0);
store.commit("inc1", {});
expect(store.state.threads[1].counter).toBe(1);
expect(store.state.threads[2].counter).toBe(0);
expect(store.state.threads[1]).not.toBe(thread1);
expect(store.state.threads[2]).toBe(thread2);
expect(store.state.threads).toBe(threads);
expect(store.state).toBe(curState);
const newThread1 = curState.threads[1];
store.commit("inc2", {});
expect(store.state.threads[1].counter).toBe(1);
expect(store.state.threads[2].counter).toBe(1);
expect(store.state.threads[1]).toBe(newThread1);
expect(store.state.threads[2]).not.toBe(thread2);
expect(store.state.threads).toBe(threads);
expect(store.state).toBe(curState);
});
test("aku reactive store state 4", async () => {
const mutations = {
incT(state) {
state.threads[1].counter++;
},
incM(state) {
state.messages[1].counter++;
}
};
const state = {
threads: {
1: { counter: 0 }
},
messages: {
1: { counter: 0 }
}
};
const store = new Store({ state, mutations });
const curState = store.state;
const threads = state.threads;
const messages = state.messages;
const thread = state.threads[1];
const message = state.messages[1];
expect(store.state.threads[1].counter).toBe(0);
expect(store.state.messages[1].counter).toBe(0);
store.commit("incT", {});
expect(store.state.threads[1].counter).toBe(1);
expect(store.state.messages[1].counter).toBe(0);
expect(store.state.threads[1]).not.toBe(thread);
expect(store.state.messages[1]).toBe(message);
expect(store.state.threads).toBe(threads);
expect(store.state.messages).toBe(messages);
expect(store.state).toBe(curState);
const newThread = curState.threads[1];
store.commit("incM", {});
expect(store.state.threads[1].counter).toBe(1);
expect(store.state.messages[1].counter).toBe(1);
expect(store.state.threads[1]).toBe(newThread);
expect(store.state.messages[1]).not.toBe(message);
expect(store.state.threads).toBe(threads);
expect(store.state.messages).toBe(messages);
expect(store.state).toBe(curState);
});
test("aku reactive store state 5", async () => {
const mutations = {
inc(state) {
state.counter++;
},
incT(state) {
state.threads[1].counter++;
}
};
const state = {
threads: {
1: { counter: 0 }
},
counter: 0
};
const store = new Store({ state, mutations });
const curState = store.state;
const threads = state.threads;
const thread = state.threads[1];
expect(store.state.counter).toBe(0);
expect(store.state.threads[1].counter).toBe(0);
store.commit("inc", {});
expect(store.state.counter).toBe(1);
expect(store.state.threads[1].counter).toBe(0);
expect(store.state.threads[1]).toBe(thread);
expect(store.state.threads).toBe(threads);
expect(store.state).not.toBe(curState);
const newCurState = store.state;
store.commit("incT", {});
expect(store.state.counter).toBe(1);
expect(store.state.threads[1].counter).toBe(1);
expect(store.state.threads[1]).not.toBe(thread);
expect(store.state.threads).toBe(threads);
expect(store.state).toBe(newCurState);
});
test("aku reactive store state 6", async () => {
const mutations = {
inc1(state) {
state.threads[1].c1++;
},
inc2(state) {
state.threads[1].c2++;
}
};
const state = {
threads: {
1: { c1: 0, c2: 0 }
}
};
const store = new Store({ state, mutations });
const curState = store.state;
const threads = state.threads;
const thread = state.threads[1];
expect(store.state.threads[1].c1).toBe(0);
expect(store.state.threads[1].c2).toBe(0);
store.commit("inc1", {});
expect(store.state.threads[1].c1).toBe(1);
expect(store.state.threads[1].c2).toBe(0);
expect(store.state.threads[1]).not.toBe(thread);
expect(store.state.threads).toBe(threads);
expect(store.state).toBe(curState);
const newThread = store.state.threads[1];
store.commit("inc2", {});
expect(store.state.threads[1].c1).toBe(1);
expect(store.state.threads[1].c2).toBe(1);
expect(store.state.threads[1]).not.toBe(newThread);
expect(store.state.threads).toBe(threads);
expect(store.state).toBe(curState);
});
test("aku reactive store state 7", async () => {
const mutations = {
register(state) {
state.threads[1].messages.push(1);
}
};
const state = {
threads: {
1: {
messages: []
}
},
messages: {
1: {}
}
};
const store = new Store({ state, mutations });
const curState = store.state;
const threads = state.threads;
const messages = state.messages;
const thread = state.threads[1];
const threadMessages = state.threads[1].messages;
const message = state.messages[1];
expect(store.state.threads[1].messages.length).toBe(0);
store.commit("register", {});
expect(store.state.threads[1].messages.length).toBe(1);
expect(store.state.threads[1].messages[0]).toBe(1);
expect(store.state.threads[1].messages).not.toBe(threadMessages);
expect(store.state.threads[1]).toBe(thread);
expect(store.state.threads).toBe(threads);
expect(store.state.messages[1]).toBe(message);
expect(store.state.messages).toBe(messages);
expect(store.state).toBe(curState);
});
});
describe("updates triggered by the store", () => {
test("multiple commits trigger one update", async () => {
let updateCounter = 0;
const state = { n: 1 };
@@ -59,6 +467,26 @@ describe("basic use", () => {
await nextMicroTick();
expect(updateCounter).toBe(1);
});
test("empty commits do not trigger updates", async () => {
let updateCounter = 0;
const state = { n: 1 };
const mutations = {
inc(state, delta) {
state.n += delta;
},
noop() {}
};
const store = new Store({ state, mutations });
store.on("update", null, () => updateCounter++);
store.commit("noop");
await nextMicroTick();
expect(updateCounter).toBe(0);
store.commit("inc", 50);
await nextMicroTick();
expect(updateCounter).toBe(1);
});
});
describe("connecting a component to store", () => {
@@ -94,7 +522,10 @@ describe("connecting a component to store", () => {
state.todos.push({ msg });
}
};
const TodoApp = connect(s => s)(App);
function mapStateToProps(s) {
return { todos: s.todos };
}
const TodoApp = connect(mapStateToProps)(App);
const store = new Store({ state, mutations });
(<any>env).store = store;
const app = new TodoApp(env);
@@ -117,9 +548,6 @@ describe("connecting a component to store", () => {
willUnmount() {
steps.push("child:willUnmount");
}
destroyed() {
steps.push("child:destroyed");
}
}
const ConnectedChild = connect(s => s)(Child);
@@ -145,11 +573,7 @@ describe("connecting a component to store", () => {
expect(steps).toEqual(["child:mounted"]);
await parent.updateState({ child: false });
expect(steps).toEqual([
"child:mounted",
"child:willUnmount",
"child:destroyed"
]);
expect(steps).toEqual(["child:mounted", "child:willUnmount"]);
});
test("connect receives ownprops as second argument", async () => {
@@ -179,7 +603,10 @@ describe("connecting a component to store", () => {
widgets = { ConnectedTodo };
}
const ConnectedTodoList = connect(state => state)(TodoList);
function mapStateToProps(state) {
return { todos: state.todos };
}
const ConnectedTodoList = connect(mapStateToProps)(TodoList);
(<any>env).store = store;
const app = new ConnectedTodoList(env);
@@ -193,4 +620,32 @@ describe("connecting a component to store", () => {
"<div><span>jupiler</span><span>hoegaarden</span></div>"
);
});
test("connected component is updated when props are updated", async () => {
class Beer extends Component<any, any, any> {
inlineTemplate = `<span><t t-esc="props.name"/></span>`;
}
const ConnectedBeer = connect((state, props) => {
return state.beers[props.id];
})(Beer);
class App extends Component<any, any, any> {
inlineTemplate = `<div>
<t t-widget="ConnectedBeer" t-props="{id: state.beerId}"/>
</div>`;
widgets = { ConnectedBeer };
state = { 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>");
await app.updateState({ beerId: 2 });
expect(fixture.innerHTML).toBe("<div><span>kwak</span></div>");
});
});