[IMP] qweb,component: make QWeb an event bus

closes #197
This commit is contained in:
Géry Debongnie
2019-06-22 14:44:09 +02:00
parent 63a8fcd7e2
commit 8c8ffb6a6b
6 changed files with 170 additions and 85 deletions
+55 -26
View File
@@ -4,12 +4,13 @@
- [Overview](#overview) - [Overview](#overview)
- [Example](#example) - [Example](#example)
- [Root Component And Environment](#root-component-and-environment)
- [Reference](#reference) - [Reference](#reference)
- [Properties](#properties) - [Properties](#properties)
- [Static Properties](#static-properties) - [Static Properties](#static-properties)
- [Methods](#methods) - [Methods](#methods)
- [Lifecycle](#lifecycle) - [Lifecycle](#lifecycle)
- [Root Component](#root-component)
- [Environment](#environment)
- [Composition](#composition) - [Composition](#composition)
- [Event Handling](#event-handling) - [Event Handling](#event-handling)
- [Form Input Bindings](#form-input-bindings) - [Form Input Bindings](#form-input-bindings)
@@ -76,30 +77,6 @@ a state object is defined. It is not mandatory to use the state object, but it
is certainly encouraged. The state object is [observed](observer.md), and any is certainly encouraged. The state object is [observed](observer.md), and any
change to it will cause a rerendering. change to it will cause a rerendering.
## Root Component And Environment
Most of the time, an Owl component will be created automatically by a tag (or the `t-component`
directive) in a template. There is however an obvious exception: the root component
of an Owl application has to be created manually:
```js
class App extends owl.Component { ... }
const qweb = new owl.QWeb(TEMPLATES);
const env = { qweb: qweb };
const app = new App(env);
app.mount(document.body);
```
The root component needs an environment. In Owl, an environment is an object with
a `qweb` key, which has to be a [QWeb](qweb.md) instance. This qweb instance will
be used to render everything.
The environment will be given to each child, unchanged, in the `env` property.
This can be very useful to share common information/methods. For example, all
rpcs can be made through a `rpc` method in the environment. This makes it very
easy to test a component.
## Reference ## Reference
An Owl component is a small class which represent a component or some UI element. An Owl component is a small class which represent a component or some UI element.
@@ -335,6 +312,59 @@ the DOM. This is a good place to remove some listeners, for example.
This is the opposite method of `mounted`. This is the opposite method of `mounted`.
### Root Component
Most of the time, an Owl component will be created automatically by a tag (or the `t-component`
directive) in a template. There is however an obvious exception: the root component
of an Owl application has to be created manually:
```js
class App extends owl.Component { ... }
const qweb = new owl.QWeb(TEMPLATES);
const env = { qweb: qweb };
const app = new App(env);
app.mount(document.body);
```
The root component needs an environment.
### Environment
In Owl, an environment is an object with a `qweb` key, which has to be a
[QWeb](qweb.md) instance. This qweb instance will be used to render everything.
The environment is meant to contain (mostly) static global information and
methods for the whole application. For example, settings keys (`mode` to determine
if we are in desktop or mobile mode, or `theme`: dark or light), `rpc` methods,
session information, ...
The environment will be given to each child, unchanged, in the `env` property.
This can be very useful to share common information/methods. For example, all
rpcs can be made through a `rpc` method in the environment. This makes it very
easy to test a component.
Updating the environment is not as simple as changing a component's state: its
content is not observed, so updates will not be reflected immediately in the
user interface. There is however a mechanism to force root widgets to rerender
themselves whenever the environment is modified: one only needs to trigger the
`update` event on the QWeb instance. For example, a responsive environment
could be programmed like this:
```js
function setupResponsivePlugin(env) {
const isMobile = () => window.innerWidth <= 768;
env.isMobile = isMobile();
const updateEnv = owl.utils.debounce(() => {
if (env.isMobile !== isMobile()) {
env.isMobile = !env.isMobile;
env.qweb.trigger('update');
}
}, 15);
window.addEventListener("resize", updateEnv);
}
```
### Composition ### Composition
The example above shows a QWeb template with a sub component. In a template, The example above shows a QWeb template with a sub component. In a template,
@@ -364,7 +394,6 @@ dynamic. If it is necessary to give a string, this can be done by quoting it:
Note that the rendering context for the template is the component itself. This means Note that the rendering context for the template is the component itself. This means
that the template can access `state`, `props`, `env`, or any methods defined in the component. that the template can access `state`, `props`, `env`, or any methods defined in the component.
```xml ```xml
<div t-name="ParentComponent"> <div t-name="ParentComponent">
<ChildComponent count="state.val" /> <ChildComponent count="state.val" />
+6 -1
View File
@@ -66,7 +66,7 @@ The component system in Owl requires additional directives, to express various
needs. Here is a list of all Owl specific directives: needs. Here is a list of all Owl specific directives:
| Name | Description | | Name | Description |
| -------------------------------------------- | ----------------------------------------------------------------------------------- | | ------------------------------------------- | ----------------------------------------------------------------------------------- |
| `t-component`, `t-keepalive`, `t-asyncroot` | [Defining a sub component](component.md#composition) | | `t-component`, `t-keepalive`, `t-asyncroot` | [Defining a sub component](component.md#composition) |
| `t-ref` | [Setting a reference to a dom node or a sub component](component.md#references) | | `t-ref` | [Setting a reference to a dom node or a sub component](component.md#references) |
| `t-key` | [Defining a key (to help virtual dom reconciliation)](component.md#t-key-directive) | | `t-key` | [Defining a key (to help virtual dom reconciliation)](component.md#t-key-directive) |
@@ -135,6 +135,11 @@ It's API is quite simple:
qweb.addTemplate("ParentComponent", "<div><Dialog/></div>"); qweb.addTemplate("ParentComponent", "<div><Dialog/></div>");
``` ```
In some way, a `QWeb` instance is the core of an Owl application. It is the only
mandatory element of an [environment](component.md#environment). As such, it
has an extra responsability: it can act as an event bus for internal communication
between Owl classes. This is the reason why `QWeb` actually extends [EventBus](event_bus.md).
## Reference ## Reference
We define in this section the specification of how `QWeb` templates should be We define in this section the specification of how `QWeb` templates should be
+19 -2
View File
@@ -134,6 +134,19 @@ export class Component<T extends Env, Props extends {}, State extends {}> {
parent.__owl__.children[id] = this; parent.__owl__.children[id] = this;
} else { } else {
this.env = parent; this.env = parent;
this.env.qweb.on("update", this, () => {
if (this.__owl__.isMounted) {
this.render(true);
}
if (this.__owl__.isDestroyed) {
// this is unlikely to happen, but if a root widget is destroyed,
// we want to remove our subscription. The usual way to do that
// would be to perform some check in the destroy method, but since
// it is very performance sensitive, and since this is a rare event,
// we simply do it lazily
this.env.qweb.off("update", this);
}
});
} }
this.__owl__ = { this.__owl__ = {
id: id, id: id,
@@ -593,7 +606,9 @@ export class Component<T extends Env, Props extends {}, State extends {}> {
for (let i = 0, l = propsDef.length; i < l; i++) { for (let i = 0, l = propsDef.length; i < l; i++) {
if (!(propsDef[i] in props)) { if (!(propsDef[i] in props)) {
throw new Error( throw new Error(
`Missing props '${propsDef[i]}' (component '${this.constructor.name}')` `Missing props '${propsDef[i]}' (component '${
this.constructor.name
}')`
); );
} }
} }
@@ -603,7 +618,9 @@ export class Component<T extends Env, Props extends {}, State extends {}> {
if (!(propName in props)) { if (!(propName in props)) {
if (propsDef[propName] && !propsDef[propName].optional) { if (propsDef[propName] && !propsDef[propName].optional) {
throw new Error( throw new Error(
`Missing props '${propName}' (component '${this.constructor.name}')` `Missing props '${propName}' (component '${
this.constructor.name
}')`
); );
} else { } else {
break; break;
+6 -4
View File
@@ -1,5 +1,6 @@
import { VNode, h } from "./vdom"; import { VNode, h } from "./vdom";
import { QWebVar, compileExpr } from "./qweb_expressions"; import { QWebVar, compileExpr } from "./qweb_expressions";
import { EventBus } from "./event_bus";
/** /**
* Owl QWeb Engine * Owl QWeb Engine
@@ -127,7 +128,7 @@ let nextID = 1;
//------------------------------------------------------------------------------ //------------------------------------------------------------------------------
// QWeb rendering engine // QWeb rendering engine
//------------------------------------------------------------------------------ //------------------------------------------------------------------------------
export class QWeb { export class QWeb extends EventBus {
templates: { [name: string]: Template } = {}; templates: { [name: string]: Template } = {};
utils = UTILS; utils = UTILS;
static components = Object.create(null); static components = Object.create(null);
@@ -146,6 +147,7 @@ export class QWeb {
nextSlotId = 1; nextSlotId = 1;
constructor(data?: string) { constructor(data?: string) {
super();
if (data) { if (data) {
this.addTemplates(data); this.addTemplates(data);
} }
@@ -350,8 +352,8 @@ export class QWeb {
if (firstLetter === firstLetter.toUpperCase()) { if (firstLetter === firstLetter.toUpperCase()) {
// this is a component, we modify in place the xml document to change // this is a component, we modify in place the xml document to change
// <SomeComponent ... /> to <t t-component="SomeComponent" ... /> // <SomeComponent ... /> to <t t-component="SomeComponent" ... />
node.setAttribute('t-component', node.tagName); node.setAttribute("t-component", node.tagName);
node.nodeValue = 't'; node.nodeValue = "t";
} }
const attributes = (<Element>node).attributes; const attributes = (<Element>node).attributes;
@@ -391,7 +393,7 @@ export class QWeb {
fullName = name; fullName = name;
value = attributes[j].textContent; value = attributes[j].textContent;
validDirectives.push({ directive, value, fullName }); validDirectives.push({ directive, value, fullName });
if (directive.name === "on" || directive.name === 'model') { if (directive.name === "on" || directive.name === "model") {
withHandlers = true; withHandlers = true;
} }
} }
+57 -24
View File
@@ -1,5 +1,6 @@
import { Component, Env } from "../src/component"; import { Component, Env } from "../src/component";
import { QWeb } from "../src/qweb_core"; import { QWeb } from "../src/qweb_core";
import { EventBus } from "../src/event_bus";
import { import {
makeDeferred, makeDeferred,
makeTestFixture, makeTestFixture,
@@ -547,10 +548,7 @@ describe("lifecycle hooks", () => {
test("willUpdateProps hook is called", async () => { test("willUpdateProps hook is called", async () => {
let def = makeDeferred(); let def = makeDeferred();
env.qweb.addTemplate( env.qweb.addTemplate("Parent", '<span><Child n="state.n"/></span>');
"Parent",
'<span><Child n="state.n"/></span>'
);
class Parent extends Widget { class Parent extends Widget {
state = { n: 1 }; state = { n: 1 };
components = { Child: HookWidget }; components = { Child: HookWidget };
@@ -599,10 +597,7 @@ describe("lifecycle hooks", () => {
test("patched hook is called after updateProps", async () => { test("patched hook is called after updateProps", async () => {
let n = 0; let n = 0;
env.qweb.addTemplate( env.qweb.addTemplate("Parent", '<div><Child a="state.a"/></div>');
"Parent",
'<div><Child a="state.a"/></div>'
);
class Parent extends Widget { class Parent extends Widget {
state = { a: 1 }; state = { a: 1 };
components = { Child: TestWidget }; components = { Child: TestWidget };
@@ -642,10 +637,7 @@ describe("lifecycle hooks", () => {
test("shouldUpdate hook prevent rerendering", async () => { test("shouldUpdate hook prevent rerendering", async () => {
let shouldUpdate = false; let shouldUpdate = false;
env.qweb.addTemplate( env.qweb.addTemplate("Parent", `<div><Child val="state.val"/></div>`);
"Parent",
`<div><Child val="state.val"/></div>`
);
class Parent extends Widget { class Parent extends Widget {
state = { val: 42 }; state = { val: 42 };
components = { Child: TestWidget }; components = { Child: TestWidget };
@@ -867,7 +859,10 @@ describe("composition", () => {
test("can use components from the global registry", async () => { test("can use components from the global registry", async () => {
QWeb.register("WidgetB", WidgetB); QWeb.register("WidgetB", WidgetB);
env.qweb.addTemplate("ParentWidget", `<div><t t-component="WidgetB"/></div>`); env.qweb.addTemplate(
"ParentWidget",
`<div><t t-component="WidgetB"/></div>`
);
class ParentWidget extends Widget {} class ParentWidget extends Widget {}
const widget = new ParentWidget(env); const widget = new ParentWidget(env);
await widget.mount(fixture); await widget.mount(fixture);
@@ -877,7 +872,10 @@ describe("composition", () => {
test("don't fallback to global registry if widget defined locally", async () => { test("don't fallback to global registry if widget defined locally", async () => {
QWeb.register("WidgetB", WidgetB); // should not use this widget QWeb.register("WidgetB", WidgetB); // should not use this widget
env.qweb.addTemplate("ParentWidget", `<div><t t-component="WidgetB"/></div>`); env.qweb.addTemplate(
"ParentWidget",
`<div><t t-component="WidgetB"/></div>`
);
env.qweb.addTemplate("AnotherWidgetB", `<span>Belgium</span>`); env.qweb.addTemplate("AnotherWidgetB", `<span>Belgium</span>`);
class AnotherWidgetB extends Widget {} class AnotherWidgetB extends Widget {}
class ParentWidget extends Widget { class ParentWidget extends Widget {
@@ -906,10 +904,7 @@ describe("composition", () => {
test("throw a nice error if it cannot find component", async () => { test("throw a nice error if it cannot find component", async () => {
expect.assertions(1); expect.assertions(1);
env.qweb.addTemplate( env.qweb.addTemplate("Parent", `<div><SomeMispelledWidget /></div>`);
"Parent",
`<div><SomeMispelledWidget /></div>`
);
class Parent extends Widget { class Parent extends Widget {
components = { SomeWidget: Widget }; components = { SomeWidget: Widget };
} }
@@ -1012,7 +1007,10 @@ describe("composition", () => {
}); });
test("modifying a sub widget", async () => { test("modifying a sub widget", async () => {
env.qweb.addTemplate("ParentWidget", `<div><t t-component="Counter"/></div>`); env.qweb.addTemplate(
"ParentWidget",
`<div><t t-component="Counter"/></div>`
);
class ParentWidget extends Widget { class ParentWidget extends Widget {
components = { Counter }; components = { Counter };
} }
@@ -1068,7 +1066,10 @@ describe("composition", () => {
}); });
test("rerendering a widget with a sub widget", async () => { test("rerendering a widget with a sub widget", async () => {
env.qweb.addTemplate("ParentWidget", `<div><t t-component="Counter"/></div>`); env.qweb.addTemplate(
"ParentWidget",
`<div><t t-component="Counter"/></div>`
);
class ParentWidget extends Widget { class ParentWidget extends Widget {
components = { Counter }; components = { Counter };
} }
@@ -2721,10 +2722,7 @@ describe("widget and observable state", () => {
test("subcomponents cannot change observable state received from parent", async () => { test("subcomponents cannot change observable state received from parent", async () => {
expect.assertions(1); expect.assertions(1);
env.qweb.addTemplate( env.qweb.addTemplate("Parent", `<div><Child obj="state.obj"/></div>`);
"Parent",
`<div><Child obj="state.obj"/></div>`
);
class Parent extends Widget { class Parent extends Widget {
state = { obj: { coffee: 1 } }; state = { obj: { coffee: 1 } };
components = { Child }; components = { Child };
@@ -3215,3 +3213,38 @@ describe("t-model directive", () => {
); );
}); });
}); });
describe("environment and plugins", () => {
// some source of external events
let bus = new EventBus();
// definition of a plugin
const somePlugin = env => {
env.someFlag = true;
bus.on("some-event", null, () => {
env.someFlag = !env.someFlag;
env.qweb.trigger("update");
});
};
test("plugin works as expected", async () => {
somePlugin(env);
env.qweb.addTemplates(`
<templates>
<div t-name="App">
<t t-if="env.someFlag">Red</t>
<t t-else="1">Blue</t>
</div>
</templates>
`);
class App extends Widget {}
const app = new App(env);
await app.mount(fixture);
expect(fixture.innerHTML).toBe("<div>Red</div>");
bus.trigger("some-event");
await nextTick();
expect(fixture.innerHTML).toBe("<div>Blue</div>");
});
});
+15 -16
View File
@@ -942,31 +942,30 @@ class App extends owl.Component {
} }
//------------------------------------------------------------------------------ //------------------------------------------------------------------------------
// Application Startup // Responsive plugin
//------------------------------------------------------------------------------ //------------------------------------------------------------------------------
function isMobile() { function setupResponsivePlugin(env) {
return window.innerWidth <= 768; const isMobile = () => window.innerWidth <= 768;
env.isMobile = isMobile();
const updateEnv = owl.utils.debounce(() => {
if (env.isMobile !== isMobile()) {
env.isMobile = !env.isMobile;
env.qweb.trigger('update');
}
}, 15);
window.addEventListener("resize", updateEnv);
} }
//------------------------------------------------------------------------------
// Application Startup
//------------------------------------------------------------------------------
const env = { const env = {
qweb: new owl.QWeb(TEMPLATES), qweb: new owl.QWeb(TEMPLATES),
isMobile: isMobile()
}; };
setupResponsivePlugin(env);
const app = new App(env); const app = new App(env);
app.mount(document.body); app.mount(document.body);
function updateEnv() {
const _isMobile = isMobile();
if (_isMobile !== env.isMobile) {
app.updateEnv({
isMobile: _isMobile
});
}
}
window.addEventListener("resize", owl.utils.debounce(updateEnv, 20));
`; `;
const RESPONSIVE_XML = `<templates> const RESPONSIVE_XML = `<templates>