From 8c8ffb6a6b3331bd4ef791008a2cd4f8cf750234 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A9ry=20Debongnie?= Date: Sat, 22 Jun 2019 14:44:09 +0200 Subject: [PATCH] [IMP] qweb,component: make QWeb an event bus closes #197 --- doc/component.md | 83 +++++++++++++++++++++++++------------ doc/qweb.md | 23 ++++++---- src/component.ts | 21 +++++++++- src/qweb_core.ts | 14 ++++--- tests/component.test.ts | 83 ++++++++++++++++++++++++++----------- tools/playground/samples.js | 31 +++++++------- 6 files changed, 170 insertions(+), 85 deletions(-) diff --git a/doc/component.md b/doc/component.md index 995198fb..192763c4 100644 --- a/doc/component.md +++ b/doc/component.md @@ -4,12 +4,13 @@ - [Overview](#overview) - [Example](#example) -- [Root Component And Environment](#root-component-and-environment) - [Reference](#reference) - [Properties](#properties) - [Static Properties](#static-properties) - [Methods](#methods) - [Lifecycle](#lifecycle) + - [Root Component](#root-component) + - [Environment](#environment) - [Composition](#composition) - [Event Handling](#event-handling) - [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 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 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`. +### 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 The example above shows a QWeb template with a sub component. In a template, @@ -359,12 +389,11 @@ In this example, the `ParentComponent`'s template creates a component `MyCompone after the span. The `info` key will be added to the subcomponent's `props`. Each `props` is a string which represents a javascript (QWeb) expression, so it is dynamic. If it is necessary to give a string, this can be done by quoting it: -`someString="'somevalue'"`. +`someString="'somevalue'"`. 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. - ```xml
diff --git a/doc/qweb.md b/doc/qweb.md index d1a08bae..f046cfdf 100644 --- a/doc/qweb.md +++ b/doc/qweb.md @@ -65,16 +65,16 @@ We present here a list of all standard QWeb directives: The component system in Owl requires additional directives, to express various 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-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-on-*` | [Event handling](component.md#event-handling) | -| `t-transition` | [Defining an animation](animations.md#css-transitions) | -| `t-mounted` | [Callback when a node or component is mounted](component.md#t-mounted-directive) | -| `t-slot` | [Rendering a slot](component.md#slots) | -| `t-model` | [Form input bindings](component.md#form-input-bindings) | +| `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-on-*` | [Event handling](component.md#event-handling) | +| `t-transition` | [Defining an animation](animations.md#css-transitions) | +| `t-mounted` | [Callback when a node or component is mounted](component.md#t-mounted-directive) | +| `t-slot` | [Rendering a slot](component.md#slots) | +| `t-model` | [Form input bindings](component.md#form-input-bindings) | ## QWeb Engine @@ -135,6 +135,11 @@ It's API is quite simple: qweb.addTemplate("ParentComponent", "
"); ``` +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 We define in this section the specification of how `QWeb` templates should be diff --git a/src/component.ts b/src/component.ts index fbf219e8..a525558e 100644 --- a/src/component.ts +++ b/src/component.ts @@ -134,6 +134,19 @@ export class Component { parent.__owl__.children[id] = this; } else { 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__ = { id: id, @@ -593,7 +606,9 @@ export class Component { for (let i = 0, l = propsDef.length; i < l; i++) { if (!(propsDef[i] in props)) { 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 { if (!(propName in props)) { if (propsDef[propName] && !propsDef[propName].optional) { throw new Error( - `Missing props '${propName}' (component '${this.constructor.name}')` + `Missing props '${propName}' (component '${ + this.constructor.name + }')` ); } else { break; diff --git a/src/qweb_core.ts b/src/qweb_core.ts index 55ba92bd..5d0b536b 100644 --- a/src/qweb_core.ts +++ b/src/qweb_core.ts @@ -1,5 +1,6 @@ import { VNode, h } from "./vdom"; import { QWebVar, compileExpr } from "./qweb_expressions"; +import { EventBus } from "./event_bus"; /** * Owl QWeb Engine @@ -127,7 +128,7 @@ let nextID = 1; //------------------------------------------------------------------------------ // QWeb rendering engine //------------------------------------------------------------------------------ -export class QWeb { +export class QWeb extends EventBus { templates: { [name: string]: Template } = {}; utils = UTILS; static components = Object.create(null); @@ -146,6 +147,7 @@ export class QWeb { nextSlotId = 1; constructor(data?: string) { + super(); if (data) { this.addTemplates(data); } @@ -348,10 +350,10 @@ export class QWeb { const firstLetter = node.tagName[0]; if (firstLetter === firstLetter.toUpperCase()) { - // this is a component, we modify in place the xml document to change - // to - node.setAttribute('t-component', node.tagName); - node.nodeValue = 't'; + // this is a component, we modify in place the xml document to change + // to + node.setAttribute("t-component", node.tagName); + node.nodeValue = "t"; } const attributes = (node).attributes; @@ -391,7 +393,7 @@ export class QWeb { fullName = name; value = attributes[j].textContent; validDirectives.push({ directive, value, fullName }); - if (directive.name === "on" || directive.name === 'model') { + if (directive.name === "on" || directive.name === "model") { withHandlers = true; } } diff --git a/tests/component.test.ts b/tests/component.test.ts index 3cf895eb..590b113d 100644 --- a/tests/component.test.ts +++ b/tests/component.test.ts @@ -1,5 +1,6 @@ import { Component, Env } from "../src/component"; import { QWeb } from "../src/qweb_core"; +import { EventBus } from "../src/event_bus"; import { makeDeferred, makeTestFixture, @@ -547,10 +548,7 @@ describe("lifecycle hooks", () => { test("willUpdateProps hook is called", async () => { let def = makeDeferred(); - env.qweb.addTemplate( - "Parent", - '' - ); + env.qweb.addTemplate("Parent", ''); class Parent extends Widget { state = { n: 1 }; components = { Child: HookWidget }; @@ -599,10 +597,7 @@ describe("lifecycle hooks", () => { test("patched hook is called after updateProps", async () => { let n = 0; - env.qweb.addTemplate( - "Parent", - '
' - ); + env.qweb.addTemplate("Parent", '
'); class Parent extends Widget { state = { a: 1 }; components = { Child: TestWidget }; @@ -642,10 +637,7 @@ describe("lifecycle hooks", () => { test("shouldUpdate hook prevent rerendering", async () => { let shouldUpdate = false; - env.qweb.addTemplate( - "Parent", - `
` - ); + env.qweb.addTemplate("Parent", `
`); class Parent extends Widget { state = { val: 42 }; components = { Child: TestWidget }; @@ -867,7 +859,10 @@ describe("composition", () => { test("can use components from the global registry", async () => { QWeb.register("WidgetB", WidgetB); - env.qweb.addTemplate("ParentWidget", `
`); + env.qweb.addTemplate( + "ParentWidget", + `
` + ); class ParentWidget extends Widget {} const widget = new ParentWidget(env); await widget.mount(fixture); @@ -877,7 +872,10 @@ describe("composition", () => { test("don't fallback to global registry if widget defined locally", async () => { QWeb.register("WidgetB", WidgetB); // should not use this widget - env.qweb.addTemplate("ParentWidget", `
`); + env.qweb.addTemplate( + "ParentWidget", + `
` + ); env.qweb.addTemplate("AnotherWidgetB", `Belgium`); class AnotherWidgetB extends Widget {} class ParentWidget extends Widget { @@ -897,7 +895,7 @@ describe("composition", () => { `); class C extends Widget {} class P extends Widget { - components = {C}; + components = { C }; } const parent = new P(env); await parent.mount(fixture); @@ -906,10 +904,7 @@ describe("composition", () => { test("throw a nice error if it cannot find component", async () => { expect.assertions(1); - env.qweb.addTemplate( - "Parent", - `
` - ); + env.qweb.addTemplate("Parent", `
`); class Parent extends Widget { components = { SomeWidget: Widget }; } @@ -1012,7 +1007,10 @@ describe("composition", () => { }); test("modifying a sub widget", async () => { - env.qweb.addTemplate("ParentWidget", `
`); + env.qweb.addTemplate( + "ParentWidget", + `
` + ); class ParentWidget extends Widget { components = { Counter }; } @@ -1068,7 +1066,10 @@ describe("composition", () => { }); test("rerendering a widget with a sub widget", async () => { - env.qweb.addTemplate("ParentWidget", `
`); + env.qweb.addTemplate( + "ParentWidget", + `
` + ); class ParentWidget extends Widget { components = { Counter }; } @@ -2721,10 +2722,7 @@ describe("widget and observable state", () => { test("subcomponents cannot change observable state received from parent", async () => { expect.assertions(1); - env.qweb.addTemplate( - "Parent", - `
` - ); + env.qweb.addTemplate("Parent", `
`); class Parent extends Widget { state = { obj: { coffee: 1 } }; 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(` + +
+ Red + Blue +
+
+ `); + class App extends Widget {} + + const app = new App(env); + await app.mount(fixture); + + expect(fixture.innerHTML).toBe("
Red
"); + bus.trigger("some-event"); + await nextTick(); + expect(fixture.innerHTML).toBe("
Blue
"); + }); +}); diff --git a/tools/playground/samples.js b/tools/playground/samples.js index e037e42e..2ff0a373 100644 --- a/tools/playground/samples.js +++ b/tools/playground/samples.js @@ -942,31 +942,30 @@ class App extends owl.Component { } //------------------------------------------------------------------------------ -// Application Startup +// Responsive plugin //------------------------------------------------------------------------------ -function isMobile() { - return window.innerWidth <= 768; +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); } +//------------------------------------------------------------------------------ +// Application Startup +//------------------------------------------------------------------------------ const env = { qweb: new owl.QWeb(TEMPLATES), - isMobile: isMobile() }; - +setupResponsivePlugin(env); const app = new App(env); 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 = `