diff --git a/README.md b/README.md index 1d159c09..507de175 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@

🦉 Odoo Web Library 🦉

-_A no nonsense web framework for structured, dynamic and maintainable applications_ +_Class based components with hooks, reactive state and concurrent mode_ ## Project Overview -The Odoo Web Library (OWL) is a smallish (~18kb gzipped) UI framework intended to +The Odoo Web Library (OWL) is a smallish (~<20kb gzipped) UI framework intended to be the basis for the [Odoo](https://www.odoo.com/) Web Client. Owl is a modern framework, written in Typescript, taking the best ideas from React and Vue in a simple and consistent way. Owl's main features are: diff --git a/doc/learning/testing_components.md b/doc/learning/testing_components.md new file mode 100644 index 00000000..6e4cb392 --- /dev/null +++ b/doc/learning/testing_components.md @@ -0,0 +1,211 @@ +# 🦉 Testing and Debugging Owl components 🦉 + +## Content + +- [Overview](#overview) +- [Unit Tests](#unit-tests) +- [Debugging](#debugging) + +## Overview + +It is a good practice to test applications and components to ensure that they +behave as expected. There are many ways to test a user interface: manual +testing, integration testing, unit testing, ... + +In this section, we will discuss how to write unit tests for components, and +how to debug them if necessary. + +## Unit Tests + +Writing unit tests for Owl components really depends on the testing framework +used in a project. But usually, it involves the following steps: + +- create a test file: for example `SomeComponent.test.js`, +- in that file, import the code for `SomeComponent`, +- add a test case: + - create a real DOM element to use as test fixture, + - create a test environment + - create an instance of `SomeComponent`, mount it to the fixture + - interact with the component and assert some properties. + +To help with this, it is useful to have a `helper.js` file that contains some +common utility functions: + +```js +export function makeTestFixture() { + let fixture = document.createElement("div"); + document.body.appendChild(fixture); + return fixture; +} + +export function nextTick() { + let requestAnimationFrame = owl.Component.scheduler.requestAnimationFrame; + return new Promise(function(resolve) { + setTimeout(() => requestAnimationFrame(() => resolve())); + }); +} + +export function makeTestEnv() { + // application specific. It needs a way to load actual templates + const templates = ...; + + return { + qweb: new QWeb(templates), + ..., // each service can be mocked here + }; +} +``` + +With such a file, a typical test suite for Jest will look like this: + +```js +// in SomeComponent.test.js +import { SomeComponent } from "../../src/ui/SomeComponent"; +import { nextTick, makeTestFixture, makeTestEnv} from '../helpers'; + + +//------------------------------------------------------------------------------ +// Setup +//------------------------------------------------------------------------------ +let fixture: HTMLElement; +let env: Env; + +beforeEach(() => { + fixture = makeTestFixture(); + env = makeTestEnv(); + // we set here the default environment for each component created in the test + Component.env = env; +}); + +afterEach(() => { + fixture.remove(); +}); + +//------------------------------------------------------------------------------ +// Tests +//------------------------------------------------------------------------------ +describe("SomeComponent", () => { + test("component behaves as expected", async () => { + const props = {...}; // depends on the component + const comp = new SomeComponent(null, props); + await comp.mount(fixture); + + // do some assertions + expect(...).toBe(...); + + fixture.querySelector('button').click(); + await nextTick(); + + // some other assertions + expect(...).toBe(...); + }); +}); +``` + +Note that Owl does wait for the next animation frame to actually update the DOM. +This is why it is necessary to wait with the `nextTick` (or other methods) to +make sure that the DOM is up-to-date. + +It is sometimes useful to wait until Owl is completely done updating components +(in particular, if we have a highly concurrent user interface). This next +helper simply polls every 20ms the internal Owl task queue and returns a promise +which resolves when it is empty: + +```js +function afterUpdates() { + return new Promise((resolve, reject) => { + let timer = setTimeout(poll, 20); + let counter = 0; + function poll() { + counter++; + if (owl.Component.scheduler.tasks.length) { + if (counter > 10) { + reject(new Error("timeout")); + } else { + timer = setTimeout(poll); + } + } else { + resolve(); + } + } + }); +} +``` + +## Debugging + +Non trivial applications become quickly more difficult to understand. It is then +useful to have a solid understanding of what is going on. To help with that, +the following code can simply be copy/pasted in an application. Once it is +executed, it will log a lot of information on each component main hooks. + +```js +let current; +Object.defineProperty(owl.Component, "current", { + get() { + return current; + }, + set(comp) { + current = comp; + const name = comp.constructor.name; + let __owl__; + Object.defineProperty(current, "__owl__", { + get() { + return __owl__; + }, + set(val) { + __owl__ = val; + debugComponent(comp, name, __owl__.id); + } + }); + } +}); + +function toStr(props) { + let str = JSON.stringify(props || {}); + if (str.length > 200) { + str = str.slice(0, 200) + "..."; + } + return str; +} + +function debugComponent(component, name, id) { + console.log(`[DEBUG] constructor ${name}, props=${toStr(component.props)}`); + owl.hooks.onWillStart(() => { + console.log(`[DEBUG] willStart: '${name}'`); + }); + owl.hooks.onMounted(() => { + console.log(`[DEBUG] mounted: '${name}'`); + }); + owl.hooks.onWillUpdateProps(nextProps => { + console.log(`[DEBUG] willUpdateProps: '${name} nextprops=${toStr(nextProps)}`); + }); + owl.hooks.onWillPatch(() => { + console.log(`[DEBUG] willPatch: '${name}'`); + }); + owl.hooks.onPatched(() => { + console.log(`[DEBUG] patched: '${name}'`); + }); + owl.hooks.onWillUnmount(() => { + console.log(`[DEBUG] willUnmount: '${name}'`); + }); + const __render = component.__render.bind(component); + component.__render = function(...args) { + console.log(`[DEBUG] rendering template: '${name}'`); + __render(...args); + }; + const render = component.render.bind(component); + component.render = function(...args) { + console.log(`[DEBUG] render: '${name}'`); + return render(...args); + }; + const mount = component.mount.bind(component); + component.mount = function(...args) { + console.log(`[DEBUG] mount: '${name}'`); + return mount(...args); + }; +} +``` + +Note that it is certainly useful to run this code at some point in an application, +just to get a feel of what each user action implies, for the framework. diff --git a/doc/readme.md b/doc/readme.md index d7d27e61..43492b20 100644 --- a/doc/readme.md +++ b/doc/readme.md @@ -23,6 +23,7 @@ - [Quick Start: create an (almost) empty Owl application](learning/quick_start.md) - [Tutorial: create a TodoList application](learning/tutorial_todoapp.md) +- [Testing and Debugging Owl components](learning/testing_components.md) ## Miscellaneous diff --git a/doc/reference/hooks.md b/doc/reference/hooks.md index 7774c5ab..34d4373b 100644 --- a/doc/reference/hooks.md +++ b/doc/reference/hooks.md @@ -152,9 +152,15 @@ class SomeComponent extends Component { } ``` -In a hook, the `Component.current` static property is the reference to the -component instance that is currently being created. Hooks need to be called in -the constructor to ensure that this reference is properly set. +As you can see, the `useState` hook does not need to be given a reference to +the component. This is possible because there is a way to get a reference to the +current component: the `Component.current` static property is the reference to the +component instance that is currently being created. + +Hooks need to be called in the constructor to ensure that this reference is +properly set. This is also a good thing for performance reasons (Owl can use +this to optimize its implementation), and for a clean architecture (this makes +it easier for developers to understand what is really happening in a component). ### `useState` diff --git a/src/context.ts b/src/context.ts index b6c35368..5a9f6b7c 100644 --- a/src/context.ts +++ b/src/context.ts @@ -139,10 +139,10 @@ export function useContextWithCB(ctx: Context, component: Component, m } }); const __destroy = component.__destroy; - component.__destroy = (parent) => { + component.__destroy = parent => { ctx.off("update", component); delete mapping[id]; __destroy.call(component, parent); - } + }; return ctx.state; } diff --git a/src/qweb/qweb.ts b/src/qweb/qweb.ts index 250f27c4..b14e58a0 100644 --- a/src/qweb/qweb.ts +++ b/src/qweb/qweb.ts @@ -460,8 +460,10 @@ export class QWeb extends EventBus { // this is a component, we modify in place the xml document to change // to node.setAttribute("t-component", node.tagName); - } else if (node.tagName !== 't' && node.hasAttribute('t-component')) { - throw new Error(`Directive 't-component' can only be used on nodes (used on a <${node.tagName}>)`); + } else if (node.tagName !== "t" && node.hasAttribute("t-component")) { + throw new Error( + `Directive 't-component' can only be used on nodes (used on a <${node.tagName}>)` + ); } const attributes = (node).attributes; diff --git a/tests/component/component.test.ts b/tests/component/component.test.ts index 3dcb7097..d75a4cd6 100644 --- a/tests/component/component.test.ts +++ b/tests/component/component.test.ts @@ -1430,7 +1430,9 @@ describe("composition", () => { error = e; } expect(error).toBeDefined(); - expect(error.message).toBe(`Directive 't-component' can only be used on nodes (used on a
)`); + expect(error.message).toBe( + `Directive 't-component' can only be used on nodes (used on a
)` + ); }); test("sub components, loops, and shouldUpdate", async () => { @@ -1757,7 +1759,6 @@ describe("class and style attributes with t-component", () => { expect(error.message).toBe("Cannot read property 'crash' of undefined"); expect(fixture.innerHTML).toBe(""); }); - }); describe("other directives with t-component", () => {