Compare commits

..

1 Commits

Author SHA1 Message Date
Géry Debongnie 7933328d0a wip 2019-12-02 13:18:50 +01:00
144 changed files with 22675 additions and 28615 deletions
-27
View File
@@ -1,27 +0,0 @@
# This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions
name: Node.js CI
on:
pull_request:
branches: [ master ]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [12.x, 14.x, 16.x]
steps:
- uses: actions/checkout@v2
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v1
with:
node-version: ${{ matrix.node-version }}
- run: npm install
- run: npm run test
- run: npm run check-formatting
-8
View File
@@ -15,7 +15,6 @@ yarn-debug.log*
yarn-error.log* yarn-error.log*
package-lock.json package-lock.json
yarn.lock
#ide's #ide's
.vscode .vscode
@@ -25,10 +24,3 @@ node_modules
# Extras temp file # Extras temp file
/tools/owl.js /tools/owl.js
release-notes.md
.rpt2_cache
# useful in some cases
/temp
+160 -39
View File
@@ -1,8 +1,4 @@
<h1 align="center">🦉 <a href="https://odoo.github.io/owl/">OWL Framework</a> 🦉</h1> <h1 align="center">🦉 <a href="https://odoo.github.io/owl/">Odoo Web Library</a> 🦉</h1>
[![License: LGPL v3](https://img.shields.io/badge/License-LGPL%20v3-blue.svg)](https://www.gnu.org/licenses/lgpl-3.0)
[![npm version](https://badge.fury.io/js/@odoo%2Fowl.svg)](https://badge.fury.io/js/@odoo%2Fowl)
[![Downloads](https://img.shields.io/npm/dm/@odoo%2Fowl.svg)](https://www.npmjs.com/package/@odoo/owl)
_Class based components with hooks, reactive state and concurrent mode_ _Class based components with hooks, reactive state and concurrent mode_
@@ -15,37 +11,24 @@ simple and consistent way. Owl's main features are:
- a declarative component system, - a declarative component system,
- a reactivity system based on hooks, - a reactivity system based on hooks,
- concurrent mode by default, - a store implementation (for state management),
- a store and a frontend router - a small frontend router
Owl components are defined with ES6 classes, they use QWeb templates, an Owl components are defined with ES6 classes, they use QWeb templates, an underlying
underlying virtual DOM, integrates beautifully with hooks, and the rendering is virtual dom, integrates beautifully with hooks, and the rendering is asynchronous.
asynchronous.
**Try it online!** An online playground is available at **Try it online!** An online playground is available at [https://odoo.github.io/owl/playground](https://odoo.github.io/owl/playground) to let you experiment with the Owl framework. There
[https://odoo.github.io/owl/playground](https://odoo.github.io/owl/playground) are some code examples to showcase some interesting features.
to let you experiment with the Owl framework. There are some code examples to
showcase some interesting features.
Owl is currently stable. Possible future changes are explained in the Owl is currently mostly stable. Possible future changes are explained in the
[roadmap](roadmap.md). [roadmap](roadmap.md).
## Why Owl?
Why did Odoo decide to make Yet Another Framework? This is really a question
that deserves [a long answer](doc/miscellaneous/why_owl.md). But in short, we believe that
while the current state of the art frameworks are excellent, they are not
optimized for our use case, and there is still room for something else.
If you are interested in a comparison with React or Vue, you will
find some more additional information [here](doc/miscellaneous/comparison.md).
## Example ## Example
Here is a short example to illustrate interactive components: Here is a short example to illustrate interactive components:
```javascript ```javascript
const { Component, useState, mount } = owl; const { Component, useState } = owl;
const { xml } = owl.tags; const { xml } = owl.tags;
class Counter extends Component { class Counter extends Component {
@@ -67,7 +50,8 @@ class App extends Component {
static components = { Counter }; static components = { Counter };
} }
mount(App, { target: document.body }); const app = new App();
app.mount(document.body);
``` ```
Note that the counter component is made reactive with the [`useState` hook](doc/reference/hooks.md#usestate). Note that the counter component is made reactive with the [`useState` hook](doc/reference/hooks.md#usestate).
@@ -95,9 +79,10 @@ requirements are common and code needs to be maintained by large teams.
Owl is not designed to be fast nor small (even though it is quite good on those Owl is not designed to be fast nor small (even though it is quite good on those
two topics). It is a no nonsense framework to build applications. There is only two topics). It is a no nonsense framework to build applications. There is only
one way to define components (with classes). There is no black magic. It just one way to define components (with classes).
works.
If you are interested in a comparison with React or Vue, you will
find some more information [here](doc/comparison.md).
## Documentation ## Documentation
@@ -105,26 +90,162 @@ A complete documentation for Owl can be found here:
- [Main documentation page](doc/readme.md). - [Main documentation page](doc/readme.md).
Some of the most important pages are: The most important sections are:
- [Tutorial: TodoList application](doc/learning/tutorial_todoapp.md) - [Tutorial: TodoList application](doc/learning/tutorial_todoapp.md)
- [How to start an Owl project](doc/learning/quick_start.md)
- [QWeb templating language](doc/reference/qweb_templating_language.md) - [QWeb templating language](doc/reference/qweb_templating_language.md)
- [Component](doc/reference/component.md) - [Component](doc/reference/component.md)
- [Hooks](doc/reference/hooks.md) - [Hooks](doc/reference/hooks.md)
Found an issue in the documentation? A broken link? Some outdated information?
Submit a PR!
## Installing Owl ## Installing/Building
Owl is available on `npm` and can be installed with the following command:
```
npm install @odoo/owl
```
If you want to use a simple `<script>` tag, the last release can be downloaded here: If you want to use a simple `<script>` tag, the last release can be downloaded here:
- [owl-1.4.10](https://github.com/odoo/owl/releases/tag/v1.4.10) - [owl-1.0.0-alpha5.js](https://github.com/odoo/owl/releases/download/v1.0.0-alpha5/owl.js)
- [owl-1.0.0-alpha5.min.js](https://github.com/odoo/owl/releases/download/v1.0.0-alpha5/owl.min.js)
Some npm scripts are available:
| Command | Description |
| ---------------- | -------------------------------------------------- |
| `npm install` | install every dependency required for this project |
| `npm run build` | build a bundle of _owl_ in the _/dist/_ folder |
| `npm run minify` | minify the prebuilt owl.js file |
| `npm run test` | run all (owl) tests |
## Quick Overview
Owl components in an application are used to define a (dynamic) tree of components.
```
Root
/ \
A B
/ \
C D
```
**State:** each component can manage its own local state. It is a simple ES6
class, there are no special rules:
```js
class Counter extends Component {
static template = xml`
<button t-on-click="increment">
Click Me! [<t t-esc="state.value"/>]
</button>`;
state = { value: 0 };
increment() {
this.state.value++;
this.render();
}
}
```
The example above shows a component with a local state. Note that since there
is nothing magical to the `state` object, we need to manually call the `render`
function whenever we update it. This can quickly become annoying (and not
efficient if we do it too much). There is a better way: using the `useState`
hook, which transforms an object into a reactive version of itself:
```js
const { useState } = owl.hooks;
class Counter extends Component {
static template = xml`
<button t-on-click="increment">
Click Me! [<t t-esc="state.value"/>]
</button>`;
state = useState({ value: 0 });
increment() {
this.state.value++;
}
}
```
Note that the `t-on-click` handler can even be replaced by an inline statement:
```xml
<button t-on-click="state.value++">
```
**Props:** sub components often needs some information from their parents. This
is done by adding the required information to the template. This will then be
accessible by the sub component in the `props` object. Note that there is an
important rule here: the information contained in the `props` object is not
owned by the sub component, and should never be modified.
```js
class Child extends Component {
static template = xml`<div>Hello <t t-esc="props.name"/></div>`;
}
class Parent extends Component {
static template = xml`
<div>
<Child name="'Owl'" />
<Child name="'Framework'" />
</div>`;
static components = { Child };
}
```
**Communication:** there are multiple ways to communicate information between
components. However, the two most important ways are the following:
- from parent to children: by using `props`,
- from a children to one of its parent: by triggering events.
The following example illustrate both mechanisms:
```js
class OrderLine extends Component {
static template = xml`
<div t-on-click="add">
<div><t t-esc="props.line.name"/></div>
<div>Quantity: <t t-esc="props.line.quantity"/></div>
</div>`;
add() {
this.trigger("add-to-order", { line: props.line });
}
}
class Parent extends Component {
static template = xml`
<div t-on-add-to-order="addToOrder">
<OrderLine
t-foreach="orders"
t-as="line"
line="line" />
</div>`;
static components = { OrderLine };
orders = useState([{ id: 1, name: "Coffee", quantity: 0 }, { id: 2, name: "Tea", quantity: 0 }]);
addToOrder(event) {
const line = event.detail.line;
line.quantity++;
}
}
```
In this example, the `OrderLine` component trigger a `add-to-order` event. This
will generate a DOM event which will bubble along the DOM tree. It will then be
intercepted by the parent component, which will then get the line (from the
`detail` key) and then increment its quantity. See the section on [event handling](doc/reference/component.md#event-handling)
for more details on how events work.
Note that this example would have also worked if the `OrderLine` component
directly modifies the `line` object. However, this is not a good practice: this
only works because the `props` object received by the child component is reactive,
so the child component is then coupled to the parents implementation.
## License ## License
@@ -4,7 +4,7 @@ OWL, React and Vue have the same main feature: they allow developers to build
declarative user interfaces. To do that, all these frameworks uses a virtual dom. However, there are still obviously many differences. declarative user interfaces. To do that, all these frameworks uses a virtual dom. However, there are still obviously many differences.
In this page, we try to highlight some of these differences. Obviously, a lot of In this page, we try to highlight some of these differences. Obviously, a lot of
effort was put to be fair. However, if you disagree with some of the points effort was done to be fair. However, if you disagree with some of the points
discussed, feel free to open an issue/submit a PR to correct this text. discussed, feel free to open an issue/submit a PR to correct this text.
## Content ## Content
@@ -47,7 +47,7 @@ components are fast enough for all our usecases, and making it as simple as
possible for developers is more valuable (for us). possible for developers is more valuable (for us).
Also, functions or class based components are more than just syntax. Functions Also, functions or class based components are more than just syntax. Functions
come with a mindset of composition and class are about inheritance. Clearly, comes with a mindset of composition and class are about inheritance. Clearly,
both of these are important mechanisms for reusing code. Also, one does not both of these are important mechanisms for reusing code. Also, one does not
exclude the other. exclude the other.
@@ -79,7 +79,7 @@ additional tools, we made a lot of effort to make the most of the web platform.
For example, Owl uses the standard `xml` parser that comes with every browser. For example, Owl uses the standard `xml` parser that comes with every browser.
Because of that, Owl did not have to write its own template parser. Another Because of that, Owl did not have to write its own template parser. Another
example is the [`xml`](../reference/tags.md#xml-tag) tag helper function, which makes use of example is the [`xml`](reference/tags.md#xml-tag) tag helper function, which makes use of
native template literals to allow in a natural way to write `xml` templates native template literals to allow in a natural way to write `xml` templates
directly in the javascript code. This can be easily integrated with editor directly in the javascript code. This can be easily integrated with editor
plugins to have autocompletion inside the template. plugins to have autocompletion inside the template.
@@ -127,7 +127,7 @@ structured than a template language. Note that the tooling is quite impressive:
there is a syntax highlighter for jsx here on github! there is a syntax highlighter for jsx here on github!
By comparison, here is the equivalent Owl component, written with the By comparison, here is the equivalent Owl component, written with the
[`xml`](../reference/tags.md#xml-tag) tag helper: [`xml`](reference/tags.md#xml-tag) tag helper:
```js ```js
class Clock extends Component { class Clock extends Component {
@@ -251,17 +251,17 @@ keeps track of who get data, and retrigger a render when it was changed.
Owl store is a little bit like a mix of redux and vuex: it has actions (but not Owl store is a little bit like a mix of redux and vuex: it has actions (but not
mutations), and like VueX, it keeps track of the state changes. However, it does mutations), and like VueX, it keeps track of the state changes. However, it does
not notify a component when the state changes. Instead, components need to connect not notify a component when the state changes. Instead, components need to connect
to the store like in redux, with the `useStore` hook (see the [store documentation](../reference/store.md#connecting-a-component)). to the store like in redux, with the `useStore` hook (see the [store documentation](reference/store.md#connecting-a-component)).
```javascript ```javascript
const actions = { const actions = {
increment({ state }, val) { increment({ state }, val) {
state.counter.value += val; state.counter.value += val;
}, }
}; };
const state = { const state = {
counter: { value: 0 }, counter: { value: 0 }
}; };
const store = new owl.Store({ state, actions }); const store = new owl.Store({ state, actions });
@@ -270,7 +270,7 @@ class Counter extends Component {
<button t-name="Counter" t-on-click="dispatch('increment')"> <button t-name="Counter" t-on-click="dispatch('increment')">
Click Me! [<t t-esc="counter.value"/>] Click Me! [<t t-esc="counter.value"/>]
</button>`; </button>`;
counter = useStore((state) => state.counter); counter = useStore(state => state.counter);
dispatch = useDispatch(); dispatch = useDispatch();
} }
@@ -315,7 +315,7 @@ This work is based on the new ideas introduced by React hooks.
From the way React and Vue introduce their hooks, it may look like hooks are not From the way React and Vue introduce their hooks, it may look like hooks are not
compatible with class components. However, this is not the case, as shown by compatible with class components. However, this is not the case, as shown by
Owl [hooks](../reference/hooks.md). They are inspired by both React and Vue. For example, Owl [hooks](reference/hooks.md). They are inspired by both React and Vue. For example,
the `useState` hook is named after React, but its API is closer to the `reactive` the `useState` hook is named after React, but its API is closer to the `reactive`
Vue hook. Vue hook.
-43
View File
@@ -1,43 +0,0 @@
# 🦉 How to debug Owl applications 🦉
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,
logging useful information is extremely valuable. There is a [javascript file](../../tools/debug.js) which can be evaluated in an application.
Once it is executed, it will log a lot of information on each component main hooks. The following code is a minified version to make it easier to copy/paste:
```
function debugOwl(t,e){let n,o="[OWL_DEBUG]";function r(t){let e;try{e=JSON.stringify(t||{})}catch(t){e="<JSON error>"}return e.length>200&&(e=e.slice(0,200)+"..."),e}if(Object.defineProperty(t.Component,"current",{get:()=>n,set(s){n=s;const i=s.constructor.name;if(e.componentBlackList&&e.componentBlackList.test(i))return;if(e.componentWhiteList&&!e.componentWhiteList.test(i))return;let l;Object.defineProperty(n,"__owl__",{get:()=>l,set(n){!function(n,s,i){let l=`${s}<id=${i}>`,c=t=>console.log(`${o} ${l} ${t}`),u=t=>(!e.methodBlackList||!e.methodBlackList.includes(t))&&!(e.methodWhiteList&&!e.methodWhiteList.includes(t));u("constructor")&&c(`constructor, props=${r(n.props)}`);u("willStart")&&t.hooks.onWillStart(()=>{c("willStart")});u("mounted")&&t.hooks.onMounted(()=>{c("mounted")});u("willUpdateProps")&&t.hooks.onWillUpdateProps(t=>{c(`willUpdateProps, nextprops=${r(t)}`)});u("willPatch")&&t.hooks.onWillPatch(()=>{c("willPatch")});u("patched")&&t.hooks.onPatched(()=>{c("patched")});u("willUnmount")&&t.hooks.onWillUnmount(()=>{c("willUnmount")});const d=n.__render.bind(n);n.__render=function(...t){c("rendering template"),d(...t)};const h=n.render.bind(n);n.render=function(...t){const e=n.__owl__;let o="render";return e.isMounted||e.currentFiber||(o+=" (warning: component is not mounted, this render has no effect)"),c(o),h(...t)};const p=n.mount.bind(n);n.mount=function(...t){return c("mount"),p(...t)}}(s,i,(l=n).id)}})}}),e.logScheduler){let e=t.Component.scheduler.start,n=t.Component.scheduler.stop;t.Component.scheduler.start=function(){this.isRunning||console.log(`${o} scheduler: start running tasks queue`),e.call(this)},t.Component.scheduler.stop=function(){this.isRunning&&console.log(`${o} scheduler: stop running tasks queue`),n.call(this)}}if(e.logStore){let e=t.Store.prototype.dispatch;t.Store.prototype.dispatch=function(t,...n){return console.log(`${o} store: action '${t}' dispatched. Payload: '${r(n)}'`),e.call(this,t,...n)}}}
debugOwl(owl, {
// componentBlackList: /App/, // regexp
// componentWhiteList: /SomeComponent/, // regexp
// methodBlackList: ["mounted"], // list of method names
// methodWhiteList: ["willStart"], // list of method names
logScheduler: false, // display/mute scheduler logs
logStore: true, // display/mute store logs
});
```
The above code, once pasted somewhere in the main javascript file of an owl
application, will log information looking like this:
```
[OWL_DEBUG] TodoApp<id=1> constructor, props={}
[OWL_DEBUG] TodoApp<id=1> mount
[OWL_DEBUG] TodoApp<id=1> willStart
[OWL_DEBUG] TodoApp<id=1> rendering template
[OWL_DEBUG] TodoItem<id=2> constructor, props={"id":2,"completed":false,"title":"hey"}
[OWL_DEBUG] TodoItem<id=2> willStart
[OWL_DEBUG] TodoItem<id=3> constructor, props={"id":4,"completed":false,"title":"aaa"}
[OWL_DEBUG] TodoItem<id=3> willStart
[OWL_DEBUG] TodoItem<id=2> rendering template
[OWL_DEBUG] TodoItem<id=3> rendering template
[OWL_DEBUG] TodoItem<id=3> mounted
[OWL_DEBUG] TodoItem<id=2> mounted
[OWL_DEBUG] TodoApp<id=1> mounted
```
Each component has an internal `id`, which is very useful when debugging.
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.
-52
View File
@@ -1,52 +0,0 @@
# 🦉 How to write Single File Components 🦉
It is very useful to group code by feature instead of by type of file. It makes
it easier to scale application to larger size.
To do so, Owl has two small helpers that make it easy to define a
template or a stylesheet inside a javascript (or typescript) file: the
[`xml`](../reference/tags.md#xml-tag) and [`css`](../reference/tags.md#css-tag)
helper.
This means that the template, the style and the javascript code can be defined in
the same file. For example:
```js
const { Component } = owl;
const { xml, css } = owl.tags;
// -----------------------------------------------------------------------------
// TEMPLATE
// -----------------------------------------------------------------------------
const TEMPLATE = xml/* xml */ `
<div class="main">
<Sidebar/>
<Content />
</div>`;
// -----------------------------------------------------------------------------
// STYLE
// -----------------------------------------------------------------------------
const STYLE = css/* css */ `
.main {
display: grid;
grid-template-columns: 200px auto;
}
`;
// -----------------------------------------------------------------------------
// CODE
// -----------------------------------------------------------------------------
class Main extends Component {
static template = TEMPLATE;
static style = STYLE;
static components = { Sidebar, Content };
// rest of component...
}
```
Note that the above example has an inline xml comment, just after the `xml` call.
This is useful for some editor plugins, such as the VS Code addon
`Comment tagged template`, which, if installed, add syntax highlighting to the
content of the template string.
-133
View File
@@ -1,133 +0,0 @@
# 🦉 Quick Overview 🦉
Owl components in an application are used to define a (dynamic) tree of components.
```
Root
/ \
A B
/ \
C D
```
**State:** each component can manage its own local state. It is a simple ES6
class, there are no special rules:
```js
class Counter extends Component {
static template = xml`
<button t-on-click="increment">
Click Me! [<t t-esc="state.value"/>]
</button>`;
state = { value: 0 };
increment() {
this.state.value++;
this.render();
}
}
```
The example above shows a component with a local state. Note that since there
is nothing magical to the `state` object, we need to manually call the `render`
function whenever we update it. This can quickly become annoying (and not
efficient if we do it too much). There is a better way: using the `useState`
hook, which transforms an object into a reactive version of itself:
```js
const { useState } = owl.hooks;
class Counter extends Component {
static template = xml`
<button t-on-click="increment">
Click Me! [<t t-esc="state.value"/>]
</button>`;
state = useState({ value: 0 });
increment() {
this.state.value++;
}
}
```
Note that the `t-on-click` handler can even be replaced by an inline statement:
```xml
<button t-on-click="state.value++">
```
**Props:** sub components often needs some information from their parents. This
is done by adding the required information to the template. This will then be
accessible by the sub component in the `props` object. Note that there is an
important rule here: the information contained in the `props` object is not
owned by the sub component, and should never be modified.
```js
class Child extends Component {
static template = xml`<div>Hello <t t-esc="props.name"/></div>`;
}
class Parent extends Component {
static template = xml`
<div>
<Child name="'Owl'" />
<Child name="'Framework'" />
</div>`;
static components = { Child };
}
```
**Communication:** there are multiple ways to communicate information between
components. However, the two most important ways are the following:
- from parent to children: by using `props`,
- from a children to one of its parent: by triggering events.
The following example illustrate both mechanisms:
```js
class OrderLine extends Component {
static template = xml`
<div t-on-click="add">
<div><t t-esc="props.line.name"/></div>
<div>Quantity: <t t-esc="props.line.quantity"/></div>
</div>`;
add() {
this.trigger("add-to-order", { line: this.props.line });
}
}
class Parent extends Component {
static template = xml`
<div t-on-add-to-order="addToOrder">
<OrderLine
t-foreach="orders"
t-as="line"
line="line" />
</div>`;
static components = { OrderLine };
orders = useState([
{ id: 1, name: "Coffee", quantity: 0 },
{ id: 2, name: "Tea", quantity: 0 },
]);
addToOrder(event) {
const line = event.detail.line;
line.quantity++;
}
}
```
In this example, the `OrderLine` component trigger a `add-to-order` event. This
will generate a DOM event which will bubble along the DOM tree. It will then be
intercepted by the parent component, which will then get the line (from the
`detail` key) and then increment its quantity. See the page on [event handling](../reference/event_handling.md)
for more details on how events work.
Note that this example would have also worked if the `OrderLine` component
directly modifies the `line` object. However, this is not a good practice: this
only works because the `props` object received by the child component is reactive,
so the child component is then coupled to the parents implementation.
+77 -378
View File
@@ -1,408 +1,107 @@
# 🦉 How to start an Owl project 🦉 # 🦉 Quick Start 🦉
## Content ## Static Server
- [Overview](#overview) Let us assume that we have a static server running somewhere. Let us start by
- [Simple html file](#simple-html-file) adding an html page with a few extra files:
- [With a static server](#with-a-static-server)
- [Standard Javascript project](#standard-javascript-project)
## Overview
Each software project has its specific needs. Many of these needs can be solved
with some tooling: `webpack`, `gulp`, css preprocessor, bundlers, transpilers, ...
Because of that, it is usually not simple to just start a project. Some
frameworks provide their own tooling to help with that. But then, you have to
integrate and learn how these applications work.
Owl is designed to be used with no tooling at all. Because of that, Owl can
"easily" be integrated in a modern build toolchain. In this section, we will
discuss a few different setups to start a project. Each of these setups has
advantages and disadvantages in different situations.
## Simple html file
The simplest possible setup is the following: a simple javascript file with your
code. To do that, let us create the following file structure:
``` ```
hello_owl/ my-app/
index.html index.html
owl.js app.css
app.js app.js
owl-X.Y.Z.js
templates.xml
``` ```
The file `owl.js` can be downloaded from the last release published at ### HTML and CSS
[https://github.com/odoo/owl/releases](https://github.com/odoo/owl/releases). It
is a single javascript file which export all Owl into the global `owl` object.
Now, `index.html` should contain the following: In a file `index.html`:
```html ```html
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<title>Hello Owl</title> <meta charset="UTF-8" />
<script src="owl.js"></script> <title>My OWL App</title>
<script src="app.js"></script> <link href="app.css" rel="stylesheet" />
<script src="owl-X.Y.Z.js"></script>
</head> </head>
<body></body> <body>
<div id="main"></div>
<script src="app.js" type="module"></script>
</body>
</html> </html>
``` ```
And `app.js` should look like this: In `app.css`:
```js ```css
const { Component, mount } = owl; button {
const { xml } = owl.tags; color: darkred;
const { whenReady } = owl.utils; font-size: 30px;
width: 220px;
// Owl Components
class App extends Component {
static template = xml`<div>Hello Owl</div>`;
}
// Setup code
function setup() {
mount(App, target: { document.body })
}
whenReady(setup);
```
Now, simply loading this html file in a browser should display a welcome message.
This setup is not fancy, but it is extremely simple. There are no tooling at
all required. It can be slightly optimized by using the minified build of Owl.
## With a static server
The previous setup has a big disadvantage: the application code is located in a
single file. Obviously, we could split it in several files and add multiple
`<script>` tags in the html page, but then we need to make sure the script are
inserted in the proper order, we need to export each file content in global
variables and we lose autocompletion across files.
There is a low tech solution to this issue: using native javascript modules.
This however has a requirement: for security reasons, browsers will not accept
modules on content served through the `file` protocol. This means that we need
to use a static server.
Let us start a new project with the following file structure:
```
hello_owl/
src/
app.js
index.html
main.js
owl.js
```
As previously, the file `owl.js` can be downloaded from the last release published at
[https://github.com/odoo/owl/releases](https://github.com/odoo/owl/releases).
Now, `index.html` should contain the following:
```html
<!DOCTYPE html>
<html lang="en">
<head>
<title>Hello Owl</title>
<script src="owl.js"></script>
<script src="main.js" type="module"></script>
</head>
<body></body>
</html>
```
Not that the `main.js` script tag has the `type="module"` attribute. This means
that the browser will parse the script as a module, and load all its dependencies.
Here is the content of `app.js` and `main.js`:
```js
// app.js ----------------------------------------------------------------------
const { Component, mount } = owl;
const { xml } = owl.tags;
export class App extends Component {
static template = xml`<div>Hello Owl</div>`;
}
// main.js ---------------------------------------------------------------------
import { App } from "./app.js";
function setup() {
mount(App, { target: document.body });
}
owl.utils.whenReady(setup);
```
The `main.js` file import the `app.js` file. Note that the import statement has
a `.js` suffix, which is important. Most text editor can understand this syntax
and will provide autocompletion.
Now, to execute this code, we need to serve the `src` folder statically. A low
tech way to do that is to use for example the python `SimpleHTTPServer` feature:
```
$ cd src
$ python -m SimpleHTTPServer 8022 # now content is available at localhost:8022
```
Another more "javascripty" way to do it is to create a `npm` application. To do
that, we can add the following `package.json` file at the root of the project:
```json
{
"name": "hello_owl",
"version": "0.1.0",
"description": "Starting Owl app",
"main": "src/index.html",
"scripts": {
"serve": "serve src"
},
"author": "John",
"license": "ISC",
"devDependencies": {
"serve": "^11.3.0"
}
} }
``` ```
We can now install the `serve` tool with the command `npm install`, and then, Also, let's not forget to add a release of OWL (`owl-X.Y.Z.js`)
start a static server with the simple `npm run serve` command.
## Standard Javascript project ### XML
The previous setup works, and is certainly good for some usecases, including In `templates.xml`:
quick prototyping. However, it lacks some useful features, such as livereload,
a test suite, or bundling the code in a single file.
Each of these features, and many others, can be done in many different ways. ```xml
Since it is really not trivial to configure such a project, we provide here an <templates>
example that can be used as a starting point. <button t-name="clickcounter" t-on-click="increment">
Click Me! [<t t-esc="state.value"/>]
Our standard Owl project has the following file structure: </button>
</templates>
```
hello_owl/
public/
index.html
src/
components/
App.js
main.js
tests/
components/
App.test.js
helpers.js
.gitignore
package.json
webpack.config.js
``` ```
This project as a `public` folder, meant to contain all static assets, such as ### JS
images and styles. The `src` folder has the javascript source code, and finally,
`tests` contains the test suite.
Here is the content of `index.html`: To build an application (or a sub-part of an application), we need two things:
```html - an environment: it is the global context in which we are working. It needs to
<!DOCTYPE html> contain a QWeb instance (preloaded with templates), and anything else that we
<html lang="en"> need. In practice, it could be used to contain some user session information, some
<head> configuration keys (for example, isMobile = true/false if we are in mobile mode).
<title>Hello Owl</title>
</head> - a description of the user interface: there should be a root component, which can
<body></body> have sub components
</html>
``` Here are a few steps that we may take to get started:
Note that there are no `<script>` tag here. They will be injected by webpack. - get the templates
Now, let's have a look at the javascript files: - create a qweb engine, with the templates
- create an environment
```js - create an instance of the root component
// src/components/App.js ------------------------------------------------------- - mount the root component to a DOM element
import { Component, tags, useState } from "@odoo/owl";
Let us now add the javascript to make it work, in `app.js`:
const { xml } = tags;
```javascript
export class App extends Component { const useState = owl.hooks.useState;
static template = xml`<div t-on-click="update">Hello <t t-esc="state.text"/></div>`;
state = useState({ text: "Owl" }); class ClickCounter extends owl.Component {
update() { static template = "clickcounter";
this.state.text = this.state.text === "Owl" ? "World" : "Owl"; state = useState({ value: 0 });
}
} increment() {
this.state.value++;
// src/main.js ----------------------------------------------------------------- }
import { utils, mount } from "@odoo/owl"; }
import { App } from "./components/App";
//------------------------------------------------------------------------------
function setup() { // Application initialization
mount(App, { target: document.body }); //------------------------------------------------------------------------------
} async function start() {
const templates = await owl.utils.loadFile("templates.xml");
utils.whenReady(setup); ClickCounter.env = { qweb: new owl.QWeb({ templates }) };
const counter = new ClickCounter();
// tests/components/App.test.js ------------------------------------------------ const target = document.getElementById("main");
import { App } from "../../src/components/App"; await counter.mount(target);
import { makeTestFixture, nextTick, click } from "../helpers"; }
import { mount } from "@odoo/owl";
start();
let fixture;
beforeEach(() => {
fixture = makeTestFixture();
});
afterEach(() => {
fixture.remove();
});
describe("App", () => {
test("Works as expected...", async () => {
await mount(App, { target: fixture });
expect(fixture.innerHTML).toBe("<div>Hello Owl</div>");
click(fixture, "div");
await nextTick();
expect(fixture.innerHTML).toBe("<div>Hello World</div>");
});
});
// tests/helpers.js ------------------------------------------------------------
import { Component } from "@odoo/owl";
import "regenerator-runtime/runtime";
export async function nextTick() {
return new Promise(function (resolve) {
setTimeout(() => Component.scheduler.requestAnimationFrame(() => resolve()));
});
}
export function makeTestFixture() {
let fixture = document.createElement("div");
document.body.appendChild(fixture);
return fixture;
}
export function click(elem, selector) {
elem.querySelector(selector).dispatchEvent(new Event("click"));
}
```
Finally, here is the configuration files `.gitignore`, `package.json` and
`webpack.config.js`:
```
node_modules/
package-lock.json
dist/
```
```json
{
"name": "hello_owl",
"version": "0.1.0",
"description": "Demo app",
"main": "src/index.html",
"scripts": {
"test": "jest",
"build": "webpack --mode production",
"dev": "webpack-dev-server --mode development"
},
"author": "Someone",
"license": "ISC",
"devDependencies": {
"@babel/core": "^7.8.4",
"@babel/plugin-proposal-class-properties": "^7.8.3",
"babel-jest": "^25.1.0",
"babel-loader": "^8.0.6",
"babel-plugin-transform-es2015-modules-commonjs": "^6.26.2",
"html-webpack-plugin": "^3.2.0",
"jest": "^25.1.0",
"regenerator-runtime": "^0.13.3",
"serve": "^11.3.0",
"webpack": "^4.41.5",
"webpack-cli": "^3.3.10",
"webpack-dev-server": "^3.10.2"
},
"dependencies": {
"@odoo/owl": "^1.0.4"
},
"babel": {
"plugins": ["@babel/plugin-proposal-class-properties"],
"env": {
"test": {
"plugins": ["transform-es2015-modules-commonjs"]
}
}
},
"jest": {
"verbose": false,
"testRegex": "(/tests/.*(test|spec))\\.js?$",
"moduleFileExtensions": ["js"],
"transform": {
"^.+\\.[t|j]sx?$": "babel-jest"
}
}
}
```
```js
const path = require("path");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const host = process.env.HOST || "localhost";
module.exports = function (env, argv) {
const mode = argv.mode || "development";
return {
mode: mode,
entry: "./src/main.js",
output: {
filename: "main.js",
path: path.resolve(__dirname, "dist"),
},
module: {
rules: [
{
test: /\.jsx?$/,
loader: "babel-loader",
exclude: /node_modules/,
},
],
},
resolve: {
extensions: [".js", ".jsx"],
},
devServer: {
contentBase: path.resolve(__dirname, "public/index.html"),
compress: true,
hot: true,
host,
port: 3000,
publicPath: "/",
},
plugins: [
new HtmlWebpackPlugin({
inject: true,
template: path.resolve(__dirname, "public/index.html"),
}),
],
};
};
```
With this setup, we can now use the following script commands:
```
npm run build # build the full application in prod mode in dist/
npm run dev # start a dev server with livereload
npm run test # run the jest test suite
``` ```
@@ -1,4 +1,4 @@
# 🦉 How to test Components 🦉 # 🦉 Testing Owl components 🦉
## Content ## Content
@@ -11,7 +11,8 @@ 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 behave as expected. There are many ways to test a user interface: manual
testing, integration testing, unit testing, ... testing, integration testing, unit testing, ...
In this section, we will discuss how to write unit tests for components. In this section, we will discuss how to write unit tests for components, and
how to debug them if necessary.
## Unit Tests ## Unit Tests
@@ -85,7 +86,8 @@ afterEach(() => {
describe("SomeComponent", () => { describe("SomeComponent", () => {
test("component behaves as expected", async () => { test("component behaves as expected", async () => {
const props = {...}; // depends on the component const props = {...}; // depends on the component
const comp = await mount(SomeComponent, { target: fixture, props }); const comp = new SomeComponent(null, props);
await comp.mount(fixture);
// do some assertions // do some assertions
expect(...).toBe(...); expect(...).toBe(...);
+35 -32
View File
@@ -37,7 +37,7 @@ todoapp/
index.html index.html
app.css app.css
app.js app.js
owl.js owl.min.js
``` ```
The entry point for this application is the file `index.html`, which should have The entry point for this application is the file `index.html`, which should have
@@ -70,9 +70,7 @@ just put the following code:
Note that we put everything inside an immediately executed function to avoid leaking Note that we put everything inside an immediately executed function to avoid leaking
anything to the global scope. anything to the global scope.
Finally, `owl.js` should be the last version downloaded from the Owl repository (you can use `owl.min.js` if you prefer). Be aware that you should download the `owl.iife.js` or `owl.iife.min.js`, because these files Finally, `owl.js` should be the last version downloaded from the Owl repository (you can use `owl.min.js` if you prefer).
are built to run directly on the browser (other files such as `owl.cjs.js` are
built to be bundled by other tools).
Now, the project should be ready. Loading the `index.html` file into a browser Now, the project should be ready. Loading the `index.html` file into a browser
should show an empty page, with the title `Owl Todo App`, and it should log a should show an empty page, with the title `Owl Todo App`, and it should log a
@@ -85,7 +83,7 @@ a single root component. Let us start by defining an `App` component. Replace th
content of the function in `app.js` by the following code: content of the function in `app.js` by the following code:
```js ```js
const { Component, mount } = owl; const { Component } = owl;
const { xml } = owl.tags; const { xml } = owl.tags;
const { whenReady } = owl.utils; const { whenReady } = owl.utils;
@@ -96,7 +94,8 @@ class App extends Component {
// Setup code // Setup code
function setup() { function setup() {
mount(App, { target: document.body }); const app = new App();
app.mount(document.body);
} }
whenReady(setup); whenReady(setup);
@@ -171,13 +170,13 @@ class App extends Component {
{ {
id: 1, id: 1,
title: "buy milk", title: "buy milk",
isCompleted: true, isCompleted: true
}, },
{ {
id: 2, id: 2,
title: "clean house", title: "clean house",
isCompleted: false, isCompleted: false
}, }
]; ];
} }
``` ```
@@ -280,7 +279,8 @@ class App extends Component {
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
function setup() { function setup() {
owl.config.mode = "dev"; owl.config.mode = "dev";
mount(App, { target: document.body }); const app = new App();
app.mount(document.body);
} }
whenReady(setup); whenReady(setup);
@@ -446,7 +446,7 @@ Now, this is an interesting situation: the task is displayed by the `Task`
component, but it is not the owner of its state, so it cannot modify it. Instead, component, but it is not the owner of its state, so it cannot modify it. Instead,
we want to communicate the request to toggle a task to the `App` component. we want to communicate the request to toggle a task to the `App` component.
Since `App` is a parent of `Task`, we can Since `App` is a parent of `Task`, we can
[trigger](../reference/event_handling.md) an event in `Task` and listen [trigger](../reference/component.md#event-handling) an event in `Task` and listen
for it in `App`. for it in `App`.
In `Task`, change the `input` to: In `Task`, change the `input` to:
@@ -547,7 +547,7 @@ application), since it involves extracting all task related code out of the
components. Here is the new content of the `app.js` file: components. Here is the new content of the `app.js` file:
```js ```js
const { Component, Store, mount } = owl; const { Component, Store } = owl;
const { xml } = owl.tags; const { xml } = owl.tags;
const { whenReady } = owl.utils; const { whenReady } = owl.utils;
const { useRef, useDispatch, useStore } = owl.hooks; const { useRef, useDispatch, useStore } = owl.hooks;
@@ -562,23 +562,23 @@ const actions = {
const task = { const task = {
id: state.nextId++, id: state.nextId++,
title: title, title: title,
isCompleted: false, isCompleted: false
}; };
state.tasks.push(task); state.tasks.push(task);
} }
}, },
toggleTask({ state }, id) { toggleTask({ state }, id) {
const task = state.tasks.find((t) => t.id === id); const task = state.tasks.find(t => t.id === id);
task.isCompleted = !task.isCompleted; task.isCompleted = !task.isCompleted;
}, },
deleteTask({ state }, id) { deleteTask({ state }, id) {
const index = state.tasks.findIndex((t) => t.id === id); const index = state.tasks.findIndex(t => t.id === id);
state.tasks.splice(index, 1); state.tasks.splice(index, 1);
}, }
}; };
const initialState = { const initialState = {
nextId: 1, nextId: 1,
tasks: [], tasks: []
}; };
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
@@ -616,7 +616,7 @@ class App extends Component {
static components = { Task }; static components = { Task };
inputRef = useRef("add-input"); inputRef = useRef("add-input");
tasks = useStore((state) => state.tasks); tasks = useStore(state => state.tasks);
dispatch = useDispatch(); dispatch = useDispatch();
mounted() { mounted() {
@@ -639,7 +639,8 @@ function setup() {
owl.config.mode = "dev"; owl.config.mode = "dev";
const store = new Store({ actions, state: initialState }); const store = new Store({ actions, state: initialState });
App.env.store = store; App.env.store = store;
mount(App, { target: document.body }); const app = new App();
app.mount(document.body);
} }
whenReady(setup); whenReady(setup);
@@ -665,8 +666,9 @@ function makeStore() {
function setup() { function setup() {
owl.config.mode = "dev"; owl.config.mode = "dev";
const env = { store: makeStore() }; App.env.store = makeStore();
mount(App, { target: document.body, env }); const app = new App();
app.mount(document.body);
} }
``` ```
@@ -810,7 +812,7 @@ For reference, here is the final code:
```js ```js
(function() { (function() {
const { Component, Store, mount } = owl; const { Component, Store } = owl;
const { xml } = owl.tags; const { xml } = owl.tags;
const { whenReady } = owl.utils; const { whenReady } = owl.utils;
const { useRef, useDispatch, useState, useStore } = owl.hooks; const { useRef, useDispatch, useState, useStore } = owl.hooks;
@@ -825,24 +827,24 @@ For reference, here is the final code:
const task = { const task = {
id: state.nextId++, id: state.nextId++,
title: title, title: title,
isCompleted: false, isCompleted: false
}; };
state.tasks.push(task); state.tasks.push(task);
} }
}, },
toggleTask({ state }, id) { toggleTask({ state }, id) {
const task = state.tasks.find((t) => t.id === id); const task = state.tasks.find(t => t.id === id);
task.isCompleted = !task.isCompleted; task.isCompleted = !task.isCompleted;
}, },
deleteTask({ state }, id) { deleteTask({ state }, id) {
const index = state.tasks.findIndex((t) => t.id === id); const index = state.tasks.findIndex(t => t.id === id);
state.tasks.splice(index, 1); state.tasks.splice(index, 1);
}, }
}; };
const initialState = { const initialState = {
nextId: 1, nextId: 1,
tasks: [], tasks: []
}; };
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
@@ -895,7 +897,7 @@ For reference, here is the final code:
static components = { Task }; static components = { Task };
inputRef = useRef("add-input"); inputRef = useRef("add-input");
tasks = useStore((state) => state.tasks); tasks = useStore(state => state.tasks);
filter = useState({ value: "all" }); filter = useState({ value: "all" });
dispatch = useDispatch(); dispatch = useDispatch();
@@ -914,9 +916,9 @@ For reference, here is the final code:
get displayedTasks() { get displayedTasks() {
switch (this.filter.value) { switch (this.filter.value) {
case "active": case "active":
return this.tasks.filter((t) => !t.isCompleted); return this.tasks.filter(t => !t.isCompleted);
case "completed": case "completed":
return this.tasks.filter((t) => t.isCompleted); return this.tasks.filter(t => t.isCompleted);
case "all": case "all":
return this.tasks; return this.tasks;
} }
@@ -941,8 +943,9 @@ For reference, here is the final code:
function setup() { function setup() {
owl.config.mode = "dev"; owl.config.mode = "dev";
const env = { store: makeStore() }; App.env.store = makeStore();
mount(App, { target: document.body, env }); const app = new App();
app.mount(document.body);
} }
whenReady(setup); whenReady(setup);
-180
View File
@@ -1,180 +0,0 @@
# 🦉 Why Owl ? 🦉
The common wisdom is that one should not reinvent the wheel, because that would
waste effort and resources. It is certainly true in many cases. A javascript
framework is a considerable investment, so it is quite logical to ask the question:
why did Odoo decide to make OWL instead of using a standard/well known framework,
such as React or Vue?
As you might expect, the answer to that question is not simple. But most of the
reasons discussed in this page are a consequence from a single fact: Odoo is
extremely modular.
This means, for example, that the core parts of Odoo are not aware, before runtime,
of what files will be loaded/executed, or what will be the state of the UI. Because
of that, Odoo cannot rely on a standard build toolchain. Also, this implies that
the core parts of Odoo need to be extremely generic. In other words, Odoo is not
really an application with a user interface. It is an application which generates
a dynamic user interface. And most frameworks are not up to the task.
Betting on Owl was not an easy choice to make, because there certainly are a lot
of conflicting needs that we want to carefully balance. Choosing anything other
than a well known framework is bound to be controversial. This page will explain
some of the reason why we still believe that building Owl is a worthwile
endeavour.
## Strategy
It is true that we want to keep control of our technology, in the sense that we
do not want to depend on Facebook or Google, or any other large (or small)
company. If they decide to change their license, or to go in a direction that
will not work for us, this may be a problem. This is even more true because
Odoo is not a conventional javascript application, and our needs are probably
quite different as most other applications.
## Class components
It is clear that the biggest frameworks are moving away from class components.
There is an implicit assumption that class components are terrible, and that
functional programming is the way to go. React even goes as far as to say that
classes are confusing for developers.
While there is some truth to that, and to the fact that composition is certainly
a good mechanism for code reuse, we believe that classes and inheritance are
important tools.
Sharing code between generic components with inheritance is the way Odoo built
its web client. And it is clear that inheritance is not the root of all evils.
It is often a perfectly simple and appropriate solution. What matter most is
the architectural decisions.
Also, Odoo has another specific use out of class components: each method of a
class provides an extension point for addons. This may not be a clean architecture
pattern, but it is a pragmatic decision that served Odoo well: classes are
sometimes monkey-patched to add behaviour from the outside. A little bit like
mixins, but from the outside.
Using React or Vue would make it significantly harder to monkey patch components,
because a lot of the state is hidden in their internals.
## Tooling
React or Vue have a huge community, and a lot of effort have been made into their
tooling. This is wonderful, but at the same time, a pretty big issue for Odoo:
since the assets are totally dynamic (and could change whenever the user installs
or removes an addon), we need to have all that kind of tooling on the production
servers. This is certainly not ideal.
Also, this makes it very complicated to setup Vue or React tools: Odoo code is
not a simple file that import other files. It changes all the time, assets
are bundled differently in different contexts. This is the reason why Odoo has
its own module system, which are resolved at runtime, by the browser. The
dynamic nature of Odoo means that we often need to delay work as late as possible
(in other word, we want a JIT user interface!)
Our ideal framework has minimal (mandatory) tooling, which makes it easier to
deploy. Using React without JSX, or Vue without vue file is not very appealing.
At the same time, Owl is designed to solve this issue: it compiles templates
by the browser, it doesn't need much code for that, since we use the XML parser
built into each browser. Owl works with or without any additional tooling. It
can use template strings to write single file components, and is easy to integrate
in any html page, with a simple `<script>` tag.
## Template based
Odoo stores templates as XML documents in a database. This is very powerful, since
this allow the use of xpaths to customize other templates. This is a very
important feature of odoo, and one of the key to Odoo modularity.
Because of that, we still expect to write our templates in an XML document.
Weirdly enough, no major framework uses XML to store templates, even though it
is extremely convenient.
So, using React or Vue means that we need to make a template compiler. For React,
that would be a compiler that would take a QWeb template, and convert it to a
React render function. For Vue, it would convert it to a Vue template. Then
we need to bundle the vue template compiler as well.
Not only this would be complex (compiling a templating language into another is
not an easy task), but it would negatively impact the developer experience as
well. Writing Vue or React components in a QWeb template would certainly be
awkward, and very confusing.
## Developer Experience
This brings us to the following point: developer experience. We see this choice
as an investment for the future, and we want to make onboarding developers as
easy as possible.
While many javascript professionals clearly think that react/vue is not difficult
(which is true to some extent), it is alsy true that many non js specialists are
overwhelmed with the frontend world: functional components, hooks, and many other
fancy words. Also, what is available in the compilation context may be difficult,
there is a lot of black magic going on in pretty much every framework. Vue
somehow join various namespaces into one, under the hood, and add various internal
keys. Svelte transform the code. React require that state transformations are
deep, and not shallow.
Owl is trying very hard to have a simple and familiar API. It uses classes. Its
reactivity system is explicit, not implicit. The scoping rules are obvious. In
case of doubt, we err on the side of not implementing a feature.
It is certainly different from React or Vue, but at the same time, kind of
familiar for experienced developers.
## JIT compilation
There is also a clear trend in the frontend world to compile code
as much as possible ahead of time. Most frameworks will compile templates ahead
of time. And now Svelte is trying to compile the JS code away, so it can remove
itself from the bundle.
This is certainly reasonable for many usecases. However, this is not what Odoo
needs: Odoo will fetch templates from the database and need to compile them only
at the last possible moment, so we can apply all necessary xpaths.
Even more: Odoo needs to be able to generate (and compile) templates at runtime.
Currently, Odoo form views interpret an xml description. But the form view code
then needs to do a lot of complicated operations. With Owl, we will be able to
transform a view description into a QWeb template, then compile that and use it
immediately.
## Reactivity
There are other design choices that we feel are not optimal in other frameworks.
For example, the reactivity system. We like the way Vue did it, but it has a
flaw: it is not really optional. There is actually a way to opt out of the reactivity
system by freezing the state, but then, it is freezed.
And there certainly are situations where we need a state, which is not read-only,
and not observed. For example, imagine a spreadsheet component. It may have a
very large internal state, and it knows exactly when it needs to be rendered
(basically, whenever the user performs some action). Then, observing its state
is a net performance loss, both for the CPU and the memory.
## Concurrency
Many applications are happy to simply display a spinner whenever a new asynchronous
action is performed, but Odoo wants a different user experience: most asynchronous
state changes are not displayed until ready. This is sometimes called a concurrent
mode: the UI is rendered in memory, and displayed only when it is ready (and
only if it has not been cancelled by subsequent user actions).
React has now an experimental concurrent mode, but it was not ready when Owl
started. Vue has not really an equivalent API (suspense is not what we need).
Also, React concurrent mode is complex to use. Concurrency was one of the rare
strong point of the former Odoo js framework (widgets), and we feel that Owl has
now a very strong concurrent mode, which is simple and powerful at the same time.
## Conclusion
This lengthy discussion showed that there are many small and not so small reasons
that current standard frameworks are not tailored to our needs. It is perfectly
fine, because they each chose a different set of tradeoffs.
However, we feel that there is still room in the framework world for something
that is different. For a framework that makes choices compatible with Odoo.
And that is why we built Owl 🦉.
+50 -30
View File
@@ -1,34 +1,15 @@
# 🦉 OWL Documentation 🦉 # 🦉 OWL Documentation 🦉
## Learning Owl
Are you new to Owl? This is the place to start!
- [Tutorial: create a TodoList application](learning/tutorial_todoapp.md)
- [Quick Overview](learning/overview.md)
- [How to start an Owl project](learning/quick_start.md)
- [How to test Components](learning/how_to_test.md)
- [How to write Single File Components](learning/how_to_write_sfc.md)
- [How to write debug Owl applications](learning/how_to_debug.md)
## Reference ## Reference
You will find here a complete reference of every feature, class or object
provided by Owl.
- [Animations](reference/animations.md) - [Animations](reference/animations.md)
- [Browser](reference/browser.md)
- [Component](reference/component.md) - [Component](reference/component.md)
- [Content](reference/content.md)
- [Concurrency Model](reference/concurrency_model.md) - [Concurrency Model](reference/concurrency_model.md)
- [Configuration](reference/config.md) - [Configuration](reference/config.md)
- [Context](reference/context.md) - [Context](reference/context.md)
- [Environment](reference/environment.md) - [Environment](reference/environment.md)
- [Event Bus](reference/event_bus.md) - [Event Bus](reference/event_bus.md)
- [Event Handling](reference/event_handling.md)
- [Error Handling](reference/error_handling.md)
- [Hooks](reference/hooks.md) - [Hooks](reference/hooks.md)
- [Mounting a component](reference/mounting.md)
- [Miscellaneous Components](reference/misc.md) - [Miscellaneous Components](reference/misc.md)
- [Observer](reference/observer.md) - [Observer](reference/observer.md)
- [Props](reference/props.md) - [Props](reference/props.md)
@@ -37,21 +18,60 @@ provided by Owl.
- [QWeb Engine](reference/qweb_engine.md) - [QWeb Engine](reference/qweb_engine.md)
- [Router](reference/router.md) - [Router](reference/router.md)
- [Store](reference/store.md) - [Store](reference/store.md)
- [Slots](reference/slots.md)
- [Tags](reference/tags.md) - [Tags](reference/tags.md)
- [Utils](reference/utils.md) - [Utils](reference/utils.md)
## Other Topics ## Learning Resources
This section provides miscellaneous document that explains some topics - [Quick Start: create an (almost) empty Owl application](learning/quick_start.md)
which cannot be considered either a tutorial, or reference documentation. - [Tutorial: create a TodoList application](learning/tutorial_todoapp.md)
- [Testing Owl components](learning/testing_components.md)
- [Owl architecture: the Virtual DOM](miscellaneous/vdom.md) ## Miscellaneous
- [Owl architecture: the rendering pipeline](miscellaneous/rendering.md)
- [Comparison with React/Vue](miscellaneous/comparison.md)
- [Why did Odoo built Owl?](miscellaneous/why_owl.md)
--- - [Comparison with React/Vue](comparison.md)
- [Tooling](tooling.md)
- [Templates to start Owl applications (external link)](https://github.com/ged-odoo/owl-templates)
Found an issue in the documentation? A broken link? Some outdated information? ## Architecture
Please open an issue or submit a PR!
This section explains in more detail the inner workings of Owl. It is targeted
for developers working on Owl itself.
- [Virtual DOM](architecture/vdom.md)
- [Rendering](architecture/rendering.md)
## Owl Content
Here is a complete visual representation of everything exported by the `owl`
global object (so, for example, `Component` is available at `owl.Component`,
and `EventBus` is exported as `owl.core.EventBus`):
```
Component misc
Context AsyncRoot
QWeb router
Store Link
useState RouteComponent
config Router
mode tags
core xml
EventBus utils
Observer debounce
hooks escape
onWillStart loadJS
onMounted loadFile
onWillUpdateProps shallowEqual
onWillPatch whenReady
onPatched
onWillUnmount
useContext
useState
useRef
useSubEnv
useStore
useDispatch
useGetters
```
Note that for convenience, the `useState` hook is also exported at the root of the `owl` object.
+9 -41
View File
@@ -52,20 +52,21 @@ sequence of events will happen:
At node insertion: At node insertion:
- the css classes `name-enter` and `name-enter-active` will be added directly - the css classes `name-enter` and `name-enter-active` will be added directly
when the node is inserted into the DOM. when the node is inserted into the DOM,
- on the next animation frame: the css class `name-enter` will be removed and the - on the next animation frame: the css class `name-enter` will be removed and the
class `name-enter-to` will be added (so they can be used to trigger css class `name-enter-to` will be added (so they can be used to trigger css
transition effects). transition effects),
- at the end of the transition, `name-enter-to` and `name-enter-active` will be removed. - the css class `name-enter-active` will be removed whenever a css transition
ends.
At node destruction: At node destruction:
- the css classes `name-leave` and `name-leave-active` will be added before the - the css classes `name-leave` and `name-leave-active` will be added before the
node is removed to the DOM. node is removed to the DOM,
- on the next animation frame: the css class `name-leave` will be removed and the - the css class `name-leave` will be removed on the next animation frame (so it
class `name-leave-to` will be added (so they can be used to trigger css can be used to trigger css transition effects),
transition effects). - the css class `name-leave-active` will be removed whenever a css transition
- at the end of the transition, `name-leave-to` and `name-leave-active` will be removed. ends. Only then will the element be removed from the DOM.
For example, a simple fade in/out effect can be done with this: For example, a simple fade in/out effect can be done with this:
@@ -92,36 +93,3 @@ Notes:
Owl does not support more than one transition on a single node, so the Owl does not support more than one transition on a single node, so the
`t-transition` expression must be a single value (i.e. no space allowed). `t-transition` expression must be a single value (i.e. no space allowed).
## SCSS Mixins
If you use SCSS, you can use mixins to make generic animations. Here is an exemple with a fade in / fade out animation:
```scss
@mixin animation-fade($time, $name) {
.#{$name}_fade-enter-active,
.#{$name}_fade-active {
transition: all $time;
}
.#{$name}_fade-enter {
opacity: 0;
}
.#{$name}_fade-leave-to {
opacity: 0;
}
}
```
Usage:
```scss
@include animation-fade(0.5s, "o_notification");
```
You can now have in your template:
```xml
<SomeTag t-transition="o_notification_fade"/>
```
-33
View File
@@ -1,33 +0,0 @@
# 🦉 Browser 🦉
## Content
- [Overview](#overview)
- [Browser Content](#browser-content)
## Overview
The browser object contains some browser native APIs, such as `setTimeout`, that
are used by Owl and its utility functions. They are exposed with the intent of
making them mockable if necessary.
```js
owl.browser.setTimeout === window.setTimeout; // return true
```
For now, this object contains some functions that are not used by Owl. They
will eventually be removed in Owl 2.0.
## Browser Content
More specifically, the `browser` object contains the following methods and objects:
- `setTimeout`
- `clearTimeout`
- `setInterval`
- `clearInterval`
- `requestAnimationFrame`
- `random`
- `Date`
- `fetch`
- `localStorage`
+249 -69
View File
@@ -10,22 +10,16 @@
- [Static Properties](#static-properties) - [Static Properties](#static-properties)
- [Methods](#methods) - [Methods](#methods)
- [Lifecycle](#lifecycle) - [Lifecycle](#lifecycle)
- [`constructor(parent, props)`](#constructorparent-props)
- [`setup()`](#setup)
- [`willStart()`](#willstart)
- [`mounted()`](#mounted)
- [`willUpdateProps(nextProps)`](#willupdatepropsnextprops)
- [`willPatch()`](#willpatch)
- [`patched(snapshot)`](#patchedsnapshot)
- [`willUnmount()`](#willunmount)
- [`catchError(error)`](#catcherrorerror)
- [Root Component](#root-component) - [Root Component](#root-component)
- [Composition](#composition) - [Composition](#composition)
- [Event Handling](#event-handling)
- [Form Input Bindings](#form-input-bindings) - [Form Input Bindings](#form-input-bindings)
- [References](#references) - [References](#references)
- [Slots](#slots)
- [Dynamic sub components](#dynamic-sub-components) - [Dynamic sub components](#dynamic-sub-components)
- [Error Handling](#error-handling)
- [Functional Components](#functional-components) - [Functional Components](#functional-components)
- [SVG Components](#svg-components) - [SVG components](#svg-components)
## Overview ## Overview
@@ -213,7 +207,7 @@ to be called in the constructor.
class Counter extends owl.Component { class Counter extends owl.Component {
static props = { static props = {
initialValue: Number, initialValue: Number,
optional: true, optional: true
}; };
} }
``` ```
@@ -226,15 +220,11 @@ to be called in the constructor.
```js ```js
class Counter extends owl.Component { class Counter extends owl.Component {
static defaultProps = { static defaultProps = {
initialValue: 0, initialValue: 0
}; };
} }
``` ```
- **`style`** (string, optional): it should be the return value of the [`css` tag](tags.md#css-tag),
which is used to inject stylesheet whenever the component is visible on the
screen.
There is another static property defined on the `Component` class: `current`. There is another static property defined on the `Component` class: `current`.
This property is set to the currently being defined component (in the constructor). This property is set to the currently being defined component (in the constructor).
This is the way [hooks](hooks.md) are able to get a reference to the target This is the way [hooks](hooks.md) are able to get a reference to the target
@@ -244,23 +234,12 @@ component.
We explain here all the public methods of the `Component` class. We explain here all the public methods of the `Component` class.
- **`mount(target, options)`** (async): this is the main way a - **`mount(target)`** (async): this is the main way a
component is added to the DOM: the root component is mounted to a target component is added to the DOM: the root component is mounted to a target
HTMLElement (or document fragment). Obviously, this is asynchronous, since each children need to be HTMLElement (or document fragment). Obviously, this is asynchronous, since each children need to be
created as well. Most applications will need to call `mount` exactly once, on created as well. Most applications will need to call `mount` exactly once, on
the root component. the root component.
The `options` argument is an optional object with a `position` key. The
`position` key can have three possible values: `first-child`, `last-child`, `self`.
- `first-child`: with this option, the component will be prepended inside the target,
- `last-child` (default value): with this option, the component will be
appended in the target element,
- `self`: the target will be used as the root element for the component. This
means that the target has to be an HTMLElement (and not a document fragment).
In this situation, it is possible that the component cannot be unmounted. For
example, if its target is `document.body`.
Note that if a component is mounted, unmounted and remounted, it will be Note that if a component is mounted, unmounted and remounted, it will be
automatically re-rendered to ensure that changes in its state (or something automatically re-rendered to ensure that changes in its state (or something
in the environment, or in the store, or ...) will be taken into account. in the environment, or in the store, or ...) will be taken into account.
@@ -279,34 +258,25 @@ We explain here all the public methods of the `Component` class.
// app is now visible // app is now visible
``` ```
Note that the normal way of mounting an application is by using the `mount` - **`unmount()`**: in case a component needs to be detached/removed from the DOM, this
method on a component class, not by creating the instance by hand. See the
documentation on [mounting applications](mounting.md).
* **`unmount()`**: in case a component needs to be detached/removed from the DOM, this
method can be used. Most applications should not call `unmount`, this is more method can be used. Most applications should not call `unmount`, this is more
useful to the underlying component system. useful to the underlying component system.
* **`render()`** (async): calling this method directly will cause a rerender. Note - **`render()`** (async): calling this method directly will cause a rerender. Note
that this should be very rare to have to do it manually, the Owl framework is that this should be very rare to have to do it manually, the Owl framework is
most of the time responsible for doing that at an appropriate moment. most of the time responsible for doing that at an appropriate moment.
Note that the render method is asynchronous, so one cannot observe the updated Note that the render method is asynchronous, so one cannot observe the updated
DOM in the same stack frame. DOM in the same stack frame.
* **`shouldUpdate(nextProps)`**: this method is called each time a component's props - **`shouldUpdate(nextProps)`**: this method is called each time a component's props
are updated. It returns a boolean, which indicates if the component should are updated. It returns a boolean, which indicates if the component should
ignore a props update. If it returns false, then `willUpdateProps` will not ignore a props update. If it returns false, then `willUpdateProps` will not
be called, and no rendering will occur. Its default implementation is to be called, and no rendering will occur. Its default implementation is to
always return true. Note that this is an optimization, similar to React's `shouldComponentUpdate`. Most of the time, this should not be used, but it always return true. This is an optimization, similar to React's `shouldComponentUpdate`. Most of the time, this should not be used, but it
can be useful if we are handling large number of components. Since this is an can be useful if we are handling large number of components.
optimization, Owl has the freedom to ignore the result of `shouldUpdate` in
some cases (for example, if a component is remounted, or if we want to force
a full rerender of the UI). However, if `shouldUpdate` returns true, then Owl
provides the guarantee that the component will be rendered at some point in
the future (except if the component is destroyed or if some part of the UI crashes).
* **`destroy()`**. As its name suggests, this method will remove the component, - **`destroy()`**. As its name suggests, this method will remove the component,
and perform all necessary cleanup, such as unmounting the component, its children, and perform all necessary cleanup, such as unmounting the component, its children,
removing the parent/children relationship. This method should almost never be removing the parent/children relationship. This method should almost never be
called directly (except maybe on the root component), but should be done by the called directly (except maybe on the root component), but should be done by the
@@ -324,15 +294,15 @@ developers write components. Here is a complete description of the lifecycle of
a owl component: a owl component:
| Method | Description | | Method | Description |
| ------------------------------------------------ | ----------------------------------------------------------- | | ------------------------------------------------ | ------------------------------------------------------------ |
| **[setup](#setup)** | setup | | **[constructor](#constructorparent-props)** | constructor |
| **[willStart](#willstart)** | async, before first rendering | | **[willStart](#willstart)** | async, before first rendering |
| **[mounted](#mounted)** | just after component is rendered and added to the DOM | | **[mounted](#mounted)** | just after component is rendered and added to the DOM |
| **[willUpdateProps](#willupdatepropsnextprops)** | async, before props update | | **[willUpdateProps](#willupdatepropsnextprops)** | async, before props update |
| **[willPatch](#willpatch)** | just before the DOM is patched | | **[willPatch](#willpatch)** | just before the DOM is patched |
| **[patched](#patchedsnapshot)** | just after the DOM is patched | | **[patched](#patchedsnapshot)** | just after the DOM is patched |
| **[willUnmount](#willunmount)** | just before removing component from DOM | | **[willUnmount](#willunmount)** | just before removing component from DOM |
| **[catchError](#catcherrorerror)** | catch errors (see [error handling page](error_handling.md)) | | **[catchError](#catcherrorerror)** | catch errors (see [error handling section](#error-handling)) |
Notes: Notes:
@@ -370,23 +340,6 @@ class ClickCounter extends owl.Component {
} }
``` ```
Hook functions can be called in the constructor.
#### `setup()`
_setup_ is run just after the component is constructed. It is a lifecycle method,
very similar to the _constructor_, except that it does not receive any argument.
It is a valid method to call hook functions. Note that one of the main reason to
have the `setup` hook in the component lifecycle is to make it possible to
monkey patch it. It is a common need in the Odoo ecosystem.
```javascript
setup() {
useSetupAutofocus();
}
```
#### `willStart()` #### `willStart()`
willStart is an asynchronous hook that can be implemented to willStart is an asynchronous hook that can be implemented to
@@ -457,7 +410,7 @@ likely via a change in its state/props or environment).
This method is not called on the initial render. It is useful to interact 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 with the DOM (for example, through an external library) whenever the
component was patched. Note that this hook will not be called if the component is component was patched. Note that this hook will not be called if the compoent is
not in the DOM. not in the DOM.
Updating the component state in this hook is possible, but not encouraged. Updating the component state in this hook is possible, but not encouraged.
@@ -484,8 +437,8 @@ This is the opposite method of `mounted`.
#### `catchError(error)` #### `catchError(error)`
The `catchError` method is useful when we need to intercept and properly react The `catchError` method is useful when we need to intercept and properly react
to (rendering) errors that occur in some sub components. See the page on to (rendering) errors that occur in some sub components. See the section on
[error handling](error_handling.md). [error handling](#error-handling).
### Root Component ### Root Component
@@ -584,6 +537,118 @@ with a class object:
<MyComponent t-att-class="{a: state.flagA, b: state.flagB}" /> <MyComponent t-att-class="{a: state.flagA, b: state.flagB}" />
``` ```
### Event Handling
In a component's template, it is useful to be able to register handlers on DOM
elements to some specific events. This is what makes a template _alive_. There
are four different use cases.
1. Register an event handler on a DOM node (_pure_ DOM event)
2. Register an event handler on a component (_pure_ DOM event)
3. Register an event handler on a DOM node (_business_ DOM event)
4. Register an event handler on a component (_business_ DOM event)
A _pure_ DOM event is directly triggered by a user interaction (e.g. a `click`).
```xml
<button t-on-click="someMethod">Do something</button>
```
This will be roughly translated in javascript like this:
```js
button.addEventListener("click", component.someMethod.bind(component));
```
The suffix (`click` in this example) is simply the name of the actual DOM
event.
A _business_ DOM event is triggered by a call to `trigger` on a component.
```xml
<MyComponent t-on-menu-loaded="someMethod" />
```
```js
class MyComponent {
someWhere() {
const payload = ...;
this.trigger('menu-loaded', payload);
}
}
```
The call to `trigger` generates an `OwlEvent`, a subclass of [_CustomEvent_](https://developer.mozilla.org/docs/Web/Guide/Events/Creating_and_triggering_events)
with an additional attribute `originalComponent` (the component that triggered
the event). The generated event is of type `menu-loaded` and dispatches it on
the component's DOM element (`this.el`). The event bubbles and is cancelable.
The parent component listening to event `menu-loaded` will receive the payload
in its `someMethod` handler (in the `detail` property of the event), whenever
the event is triggered.
```js
class ParentComponent {
someMethod(ev) {
const payload = ev.detail;
...
}
}
```
By convention, we use KebabCase for the name of _business_ events.
The `t-on` directive allows to prebind its arguments. For example,
```xml
<button t-on-click="someMethod(expr)">Do something</button>
```
Here, `expr` is a valid Owl expression, so it could be `true` or some variable
from the rendering context.
One can also directly specify inline statements. For example,
```xml
<button t-on-click="state.counter++">Increment counter</button>
```
Here, `state` must be defined in the rendering context (typically the component)
as it will be translated to:
```js
button.addEventListener("click", () => {
component.state.counter++;
});
```
In order to remove the DOM event details from the event handlers (like calls to
`event.preventDefault`) and let them focus on data logic, _modifiers_ can be
specified as additional suffixes of the `t-on` directive.
| Modifier | Description |
| ---------- | ----------------------------------------------------------------- |
| `.stop` | calls `event.stopPropagation()` before calling the method |
| `.prevent` | calls `event.preventDefault()` before calling the method |
| `.self` | calls the method only if the `event.target` is the element itself |
```xml
<button t-on-click.stop="someMethod">Do something</button>
```
Note that modifiers can be combined (ex: `t-on-click.stop.prevent`), and that
the order may matter. For instance `t-on-click.prevent.self` will prevent all
clicks while `t-on-click.self.prevent` will only prevent clicks on the element
itself.
Finally, empty handlers are tolerated as they could be defined only to apply
modifiers. For example,
```xml
<button t-on-click.stop="">Do something</button>
```
This will simply stop the propagation of the event.
### Form Input Bindings ### Form Input Bindings
It is very common to need to be able to read the value out of an html `input` (or It is very common to need to be able to read the value out of an html `input` (or
@@ -733,6 +798,62 @@ Note that these two examples uses the suffix `ref` to name the reference. This
is not mandatory, but it is a useful convention, so we do not forget to access is not mandatory, but it is a useful convention, so we do not forget to access
it with the `el` or `comp` suffix. it with the `el` or `comp` suffix.
### Slots
To make generic components, it is useful to be able for a parent component to _inject_
some sub template, but still be the owner. For example, a generic dialog component
will need to render some content, some footer, but with the parent as the
rendering context.
This is what _slots_ are for.
```xml
<div t-name="Dialog" class="modal">
<div class="modal-title"><t t-esc="props.title"/></div>
<div class="modal-content">
<t t-slot="content"/>
</div>
<div class="modal-footer">
<t t-slot="footer"/>
</div>
</div>
```
Slots are defined by the caller, with the `t-set` directive:
```xml
<div t-name="SomeComponent">
<div>some component</div>
<Dialog title="Some Dialog">
<t t-set="content">
<div>hey</div>
</t>
<t t-set="footer">
<button t-on-click="doSomething">ok</button>
</t>
</Dialog>
</div>
```
In this example, the component `Dialog` will render the slots `content` and `footer`
with its parent as rendering context. This means that clicking on the button
will execute the `doSomething` method on the parent, not on the dialog.
Default slot: the first element inside the component which is not a named slot will
be considered the `default` slot. For example:
```xml
<div t-name="Parent">
<Child>
<span>some content</span>
</Child>
</div>
<div t-name="Child">
<t t-slot="default"/>
</div>
```
### Dynamic sub components ### Dynamic sub components
It is not common, but sometimes we need a dynamic component name. In this case, It is not common, but sometimes we need a dynamic component name. In this case,
@@ -778,6 +899,65 @@ component class.
Note that the `t-component` directive can only be used on `<t>` nodes. Note that the `t-component` directive can only be used on `<t>` nodes.
### Error Handling
By default, whenever an error occurs in the rendering of an Owl application, we
destroy the whole application. Otherwise, we cannot offer any guarantee on the
state of the resulting component tree. It might be hopelessly corrupted, but
without any user-visible state.
Clearly, it sometimes is a little bit extreme to destroy the application. This
is why we have a builtin mechanism to handle rendering errors (and errors coming
from lifecycle hooks): the `catchError` hook.
Whenever the `catchError` lifecycle hook is implemented, all errors coming from
sub components rendering and/or lifecycle method calls will be caught and given
to the `catchError` method. This allows us to properly handle the error, and to
not break the application.
For example, here is how we could implement an `ErrorBoundary` component:
```xml
<div t-name="ErrorBoundary">
<t t-if="state.error">
Error handled
</t>
<t t-else="1">
<t t-slot="default" />
</t>
</div>
```
```js
class ErrorBoundary extends Component {
state = useState({ error: false });
catchError() {
this.state.error = true;
}
}
```
Using the `ErrorBoundary` is then extremely simple:
```xml
<ErrorBoundary><SomeOtherComponent/></ErrorBoundary>
```
Note that we need to be careful here: the fallback UI should not throw any
error, otherwise we risk going into an infinite loop.
Also, it may be useful to know that whenever an error is caught, it is then
broadcasted to the application by an event on the `qweb` instance. It may be
useful, for example, to log the error somewhere.
```js
env.qweb.on("error", null, function(error) {
// do something
// react to the error
});
```
### Functional Components ### Functional Components
Owl does not exactly have functional components. However, there is an extremely Owl does not exactly have functional components. However, there is an extremely
@@ -789,7 +969,7 @@ template rendered with `props`. In Owl, this can be done by
simply defining a template, that will access the `props` object: simply defining a template, that will access the `props` object:
```js ```js
const Welcome = xml`<h1>Hello, <t t-esc="props.name"/></h1>`; const Welcome = xml`<h1>Hello, {props.name}</h1>`;
class MyComponent extends Component { class MyComponent extends Component {
static template = xml` static template = xml`
@@ -839,8 +1019,8 @@ class RootNode extends Component {
children: [ children: [
{ label: "b" }, { label: "b" },
{ label: "c", children: [{ label: "d" }, { label: "e" }] }, { label: "c", children: [{ label: "d" }, { label: "e" }] },
{ label: "f", children: [{ label: "g" }] }, { label: "f", children: [{ label: "g" }] }
], ]
}; };
} }
``` ```
+3 -3
View File
@@ -23,7 +23,7 @@ a rendering that is no longer relevant, restart it, reuse it in some cases.
But even though using concurrency is quite simple (and is the default behaviour), But even though using concurrency is quite simple (and is the default behaviour),
asynchrony is difficult, because it introduces an additional dimension that asynchrony is difficult, because it introduces an additional dimension that
vastly increase the complexity of an application. This section will explain vastly increase the complexity of an application. This section will explain
how Owl manages this complexity, how concurrent rendering works in a general way. how Owl manages this complexity, how concuurent rendering works in a general way.
## Rendering Components ## Rendering Components
@@ -120,7 +120,7 @@ Here is what Owl will do:
1. hook `willUpdateProps` is called on `D` (async) 1. hook `willUpdateProps` is called on `D` (async)
2. template `D` is rerendered 2. template `D` is rerendered
- component `F` is created: - component `F` is created:
1. hook `willStart` is called on `F` (async) 1. hook `willStart` is called on `E` (async)
2. template `F` is rendered 2. template `F` is rendered
3. `willPatch` hooks are called recursively on components `C`, `D` (not on `F`, 3. `willPatch` hooks are called recursively on components `C`, `D` (not on `F`,
@@ -137,7 +137,7 @@ Here is what Owl will do:
Tags are very small helpers to make it easy to write inline templates. There is Tags are very small helpers to make it easy to write inline templates. There is
only one currently available tag: `xml`, but we plan to add other tags later, only one currently available tag: `xml`, but we plan to add other tags later,
such as a `css` tag, which will be used to write [single file components](../learning/how_to_write_sfc.md). such as a `css` tag, which will be used to write [single file components](../tooling.md#single-file-component).
### Asynchronous Rendering ### Asynchronous Rendering
+2 -19
View File
@@ -2,10 +2,9 @@
The Owl framework is designed to work in many situations. However, it is The Owl framework is designed to work in many situations. However, it is
sometimes necessary to customize some behaviour. This is done by using the sometimes necessary to customize some behaviour. This is done by using the
global `config` object. It provides two settings: global `config` object. It currently has one key:
- [`mode`](#mode) (default value: `prod`), - [`mode`](#mode).
- [`enableTransitions`](#enabletransitions) (default value: `true`).
## Mode ## Mode
@@ -26,19 +25,3 @@ So, changing this setting is best done at startup.
An important job done by the `dev` mode is to validate props for each component An important job done by the `dev` mode is to validate props for each component
creation and update. Also, extra props will cause an error. creation and update. Also, extra props will cause an error.
## `enableTransitions`
Transitions are usually nice, but they can cause issues in some specific cases,
such as automated tests. It is uncomfortable having to wait for a transition
to end before moving to the next step.
To solve this issue, Owl can be configured to ignore the `t-transition` directive.
To do that, one only needs to set the `enableTransitions` flag to false:
```js
owl.config.enableTransitions = false;
```
Note that it suffers from the same drawback as the "dev" mode: all compiled
templates, if any, will keep their current behaviours.
-40
View File
@@ -1,40 +0,0 @@
# 🦉 Owl Content 🦉
Here is a complete visual representation of everything exported by the `owl`
global object.
For example, `Component` is available at `owl.Component` and `EventBus` is
exported as `owl.core.EventBus`.
```
browser
Component misc
Context AsyncRoot
QWeb Portal
mount router
Store Link
useState RouteComponent
config Router
mode
core tags
EventBus css
Observer xml
hooks utils
onWillStart debounce
onMounted escape
onWillUpdateProps loadJS
onWillPatch loadFile
onPatched shallowEqual
onWillUnmount whenReady
useContext
useState
useRef
useComponent
useEnv
useSubEnv
useStore
useDispatch
useGetters
```
Note that for convenience, the `useState` hook is also exported at the root of the `owl` object.
+1 -1
View File
@@ -57,7 +57,7 @@ class SomeComponent extends Component {
<t t-if=device.isMobile> <t t-if=device.isMobile>
some simplified user interface some simplified user interface
</t> </t>
<t t-else=""> <t t-else="1">
a more advanced user interface a more advanced user interface
</t> </t>
</div>`; </div>`;
+11 -20
View File
@@ -6,7 +6,6 @@
- [Setting an Environment](#setting-an-environment) - [Setting an Environment](#setting-an-environment)
- [Using a sub environment](#using-a-sub-environment) - [Using a sub environment](#using-a-sub-environment)
- [Content of an Environment](#content-of-an-environment) - [Content of an Environment](#content-of-an-environment)
- [Special keys](#special-keys)
## Overview ## Overview
@@ -41,22 +40,23 @@ all templates.
Whenever a root component `App` is mounted, Owl will setup a valid environment by Whenever a root component `App` is mounted, Owl will setup a valid environment by
following the next steps: following the next steps:
- take the `env` object defined on `App.env` (if no `env` was explicitly setup, - take the `env` object defined on `App.env` (if no `env` was explicitely setup,
this will return the empty `env` object defined on `Component`) this will be return the empty `env` object defined on `Component`)
- if `env.qweb` is not set, then Owl will create a `QWeb` instance. - if `env.qweb` is not set, then Owl will create a `QWeb` instance.
The correct way to customize an environment is to simply set it up on the root The correct way to customize an environment is to simply set it up on the root
component class, before the first component is created: component class, before the first component is created:
```js ```js
const env = { App.env = {
_t: myTranslateFunction, _t: myTranslateFunction,
user: {...}, user: {...},
services: { services: {
... ...
}, },
}; };
mount(App, { target: document.body, env }); const app = new App();
app.mount(document.body);
``` ```
It is also possible to simply share an environment between all root components, It is also possible to simply share an environment between all root components,
@@ -93,7 +93,7 @@ Some good use cases for additional keys in the environment are:
- some configuration keys, - some configuration keys,
- session information, - session information,
- generic services (such as doing rpcs). - generic services (such as doing rpcs, or accessing local storage).
Doing it this way means that components are easily testable: we can simply Doing it this way means that components are easily testable: we can simply
create a test environment with mock services. create a test environment with mock services.
@@ -112,25 +112,16 @@ async function myEnv() {
qweb: qweb, qweb: qweb,
services: { services: {
localStorage: localStorage, localStorage: localStorage,
rpc: rpc, rpc: rpc
}, },
debug: false, debug: false,
inMobileMode: true, inMobileMode: true
}; };
} }
async function start() { async function start() {
const env = await myEnv(); App.env = await myEnv();
mount(App, { target: document.body, env }); const app = new App();
await app.mount(document.body);
} }
``` ```
## Special Keys
There are two special key/value added by Owl if not provided in the environment:
the `QWeb` instance and a `browser` object:
- `qweb` will be set to an empty `QWeb` instance. This is absolutely necessary
for Owl to be able to render anything
- `browser`: this is an object that contains some common access points to the
browser methods with a side effect. See [browser](browser.md) for more information. Note that the browser object will be removed from the environment in Owl 2.0.
-81
View File
@@ -1,81 +0,0 @@
# 🦉 Error Handling 🦉
## Content
- [Overview](#overview)
- [Example](#example)
- [Reference](#reference)
## Overview
By default, whenever an error occurs in the rendering of an Owl application, we
destroy the whole application. Otherwise, we cannot offer any guarantee on the
state of the resulting component tree. It might be hopelessly corrupted, but
without any user-visible state.
Clearly, it sometimes is a little bit extreme to destroy the application. This
is why we have a builtin mechanism to handle rendering errors (and errors coming
from lifecycle hooks): the `catchError` hook.
## Example
For example, here is how we could implement an `ErrorBoundary` component:
```xml
<div t-name="ErrorBoundary">
<t t-if="state.error">
Error handled
</t>
<t t-else="">
<t t-slot="default" />
</t>
</div>
```
```js
class ErrorBoundary extends Component {
state = useState({ error: false });
catchError() {
this.state.error = true;
}
}
```
Using the `ErrorBoundary` is then extremely simple:
```xml
<ErrorBoundary><SomeOtherComponent/></ErrorBoundary>
```
Note that we need to be careful here: the fallback UI should not throw any
error, otherwise we risk going into an infinite loop (also, see the page on
[slots](slots.md) for more information on the `t-slot` directive).
## Reference
Whenever the `catchError` lifecycle hook is implemented, all errors coming from
sub components rendering and/or lifecycle method calls will be caught and given
to the `catchError` method. This allows us to properly handle the error, and to
not break the application.
There are important things to know:
- If an error that occured in the internal rendering cycle is not caught, then
Owl will destroy the full application. This is done on purpose, because Owl
cannot guarantee that the state is not corrupted from this point on.
- errors coming from event handlers are NOT managed by `catchError` or any other
owl mechanism. This is up to the application developer to properly recover
from an error
Also, it may be useful to know that whenever an error is caught, it is then
broadcasted to the application by an event on the `qweb` instance. It may be
useful, for example, to log the error somewhere.
```js
env.qweb.on("error", null, function (error) {
// do something
// react to the error
});
```
-151
View File
@@ -1,151 +0,0 @@
# 🦉 Event Handling 🦉
## Content
- [Event Handling](#event-handling)
- [Business DOM Events](#business-dom-events)
- [Inline Event Handlers](#inline-event-handlers)
- [Modifiers](#modifiers)
## Event Handling
In a component's template, it is useful to be able to register handlers on DOM
elements to some specific events. This is what makes a template _alive_. There
are four different use cases.
1. Register an event handler on a DOM node (_pure_ DOM event)
2. Register an event handler on a component (_pure_ DOM event)
3. Register an event handler on a DOM node (_business_ DOM event)
4. Register an event handler on a component (_business_ DOM event)
A _pure_ DOM event is directly triggered by a user interaction (e.g. a `click`).
```xml
<button t-on-click="someMethod">Do something</button>
```
This will be roughly translated in javascript like this:
```js
button.addEventListener("click", component.someMethod.bind(component));
```
The suffix (`click` in this example) is simply the name of the actual DOM
event.
## Business DOM Events
A _business_ DOM event is triggered by a call to `trigger` on a component.
```xml
<MyComponent t-on-menu-loaded="someMethod" />
```
```js
class MyComponent {
someWhere() {
const payload = ...;
this.trigger('menu-loaded', payload);
}
}
```
The call to `trigger` generates an `OwlEvent`, a subclass of [_CustomEvent_](https://developer.mozilla.org/docs/Web/Guide/Events/Creating_and_triggering_events)
with an additional attribute `originalComponent` (the component that triggered
the event). The generated event is of type `menu-loaded` and dispatches it on
the component's DOM element (`this.el`). The event bubbles and is cancelable.
The parent component listening to event `menu-loaded` will receive the payload
in its `someMethod` handler (in the `detail` property of the event), whenever
the event is triggered.
```js
class ParentComponent {
someMethod(ev) {
const payload = ev.detail;
...
}
}
```
By convention, we use KebabCase for the name of _business_ events.
The `t-on` directive allows to prebind its arguments. For example,
```xml
<button t-on-click="someMethod(expr)">Do something</button>
```
Here, `expr` is a valid Owl expression, so it could be `true` or some variable
from the rendering context.
### Type Hinting
Note that if you work with Typescript, the `trigger` method is generic on the type of the payload.
You can then describe the type of the event, so you will see typing errors...
```typescript
this.trigger<MyCustomPayload>("my-custom-event", payload);
```
```typescript
myCustomEventHandler(ev: OwlEvent<MyCustomPayload>) { ... }
```
## Inline Event Handlers
One can also directly specify inline statements. For example,
```xml
<button t-on-click="state.counter++">Increment counter</button>
```
Here, `state` must be defined in the rendering context (typically the component)
as it will be translated to:
```js
button.addEventListener("click", () => {
context.state.counter++;
});
```
Warning: inline expressions are evaluated in the context of the template. This
means that they can access the component methods and properties. But if they set
a key, the inline statement will actually not modify the component, but a key in
a sub scope.
```xml
<button t-on-click="value = 1">Set value to 1 (does not work!!!)</button>
<button t-on-click="state.value = 1">Set state.value to 1 (work as expected)</button>
```
## Modifiers
In order to remove the DOM event details from the event handlers (like calls to
`event.preventDefault`) and let them focus on data logic, _modifiers_ can be
specified as additional suffixes of the `t-on` directive.
| Modifier | Description |
| ---------- | ------------------------------------------------------------------------------------------------------------------------ |
| `.stop` | calls `event.stopPropagation()` before calling the method |
| `.prevent` | calls `event.preventDefault()` before calling the method |
| `.self` | calls the method only if the `event.target` is the element itself |
| `.capture` | bind the event handler in [capture](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener) mode. |
```xml
<button t-on-click.stop="someMethod">Do something</button>
```
Note that modifiers can be combined (ex: `t-on-click.stop.prevent`), and that
the order may matter. For instance `t-on-click.prevent.self` will prevent all
clicks while `t-on-click.self.prevent` will only prevent clicks on the element
itself.
Finally, empty handlers are tolerated as they could be defined only to apply
modifiers. For example,
```xml
<button t-on-click.stop="">Do something</button>
```
This will simply stop the propagation of the event.
+6 -35
View File
@@ -17,12 +17,9 @@
- [`useContext`](#usecontext) - [`useContext`](#usecontext)
- [`useRef`](#useref) - [`useRef`](#useref)
- [`useSubEnv`](#usesubenv) - [`useSubEnv`](#usesubenv)
- [`useExternalListener`](#useexternallistener)
- [`useStore`](#usestore) - [`useStore`](#usestore)
- [`useDispatch`](#usedispatch) - [`useDispatch`](#usedispatch)
- [`useGetters`](#usegetters) - [`useGetters`](#usegetters)
- [`useComponent`](#usecomponent)
- [`useEnv`](#useenv)
- [Making customized hooks](#making-customized-hooks) - [Making customized hooks](#making-customized-hooks)
## Overview ## Overview
@@ -131,7 +128,7 @@ class SomeComponent extends Component {
### One rule ### One rule
There is only one rule: every hook for a component has to be called in the There is only one rule: every hook for a component has to be called in the
constructor, in the _setup_ method, or in class fields: constructor (or in class fields):
```js ```js
// ok // ok
@@ -147,13 +144,6 @@ class SomeComponent extends Component {
} }
} }
// also ok
class SomeComponent extends Component {
setup() {
this.state = useState({ value: 0 });
}
}
// not ok: this is executed after the constructor is called // not ok: this is executed after the constructor is called
class SomeComponent extends Component { class SomeComponent extends Component {
async willStart() { async willStart() {
@@ -268,7 +258,7 @@ function useLoader() {
} }
onWillStart(() => updateRecord(component.props.id)); onWillStart(() => updateRecord(component.props.id));
onWillUpdateProps((nextProps) => updateRecord(nextProps.id)); onWillUpdateProps(nextProps => updateRecord(nextProps.id));
return record; return record;
} }
@@ -364,17 +354,6 @@ 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.
### `useExternalListener`
The `useExternalListener` hook helps solve a very common problem: adding and removing
a listener on some target whenever a component is mounted/unmounted. For example,
a dropdown menu (or its parent) may need to listen to a `click` event on `window`
to be closed:
```js
useExternalListener(window, "click", this.closeMenu);
```
### `useStore` ### `useStore`
The `useStore` hook is the entry point for a component to connect to the store. The `useStore` hook is the entry point for a component to connect to the store.
@@ -390,16 +369,6 @@ The `useDispatch` hook is the way for components to get a reference to the store
The `useGetters` hook is the way for components to get a reference to the store 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. getters. See the [store documentation](store.md) for more information.
### `useComponent`
The `useComponent` hook is useful as a building block for some customized hooks,
that may need a reference to the component calling them.
### `useEnv`
The `useEnv` hook is useful as a building block for some customized hooks,
that may need a reference to the env of the component calling them.
### 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
@@ -454,11 +423,13 @@ not the solution to every problem.
```js ```js
function useRouter() { function useRouter() {
const env = useEnv(); return Component.current.env.router;
return env.router;
} }
``` ```
This means that we give control to the application developer to create the This means that we give control to the application developer to create the
router, which is good, so they can set it up, subclass it, ... And then, to router, which is good, so they can set it up, subclass it, ... And then, to
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
way hooks are able to get a reference to the component currently being created.
-110
View File
@@ -1,115 +1,5 @@
# 🦉 Miscellaneous 🦉 # 🦉 Miscellaneous 🦉
## Content
- [Portal](#portal)
- [AsyncRoot](#asyncroot)
## `Portal`
### Overview
The component `Portal` is meant to be used as a transparent way to 'teleport' a piece
of DOM to the node represented by its sole `target` props.
This component aims at helping the implementation of the needed infrastructure
for modals (as in `bootstrap-modal`).
### Usage
The content it will teleport is defined within the `<Portal>` node and
internally uses the `default` [Slot](slots.md).
This slot must contain only **one** node, which in turn can have as many children as necessary.
The element under which the content will be teleported is represented as a selector
by the `target` props which only accepts a string as value.
The `target` props only supports static selector, and is not meant to be passed to `Portal`
as a variable. Namely, `<Portal target="'body'" />` is the intended use.
By contrast, `<Portal target="state.target" />` is not supported.
The component `Portal` has no particular state, rather it is meant to be a slave to its parent,
and ultimately just a way for the parent to teleport a piece of its own DOM elsewhere.
The `Portal`'s root node is always `<portal/>` and is placed where the teleported content
_would have_ been. It is this element that the [teleported events](#expected-behaviors) are re-directed on.
### Example
The canonic use-case is to implement a Dialog, where a Component may choose to break the natural
workflow to help the user put in some data, which it could use later on.
JavaScript:
```js
const { Component, mount } = owl;
const { Portal } = owl.misc;
class TeleportedComponent extends Component {}
class App extends Component {
static components = { Portal, TeleportedComponent };
}
mount(App, { target: document.body });
```
XML:
```xml
<templates>
<div t-name="TeleportedComponent">
<span>I will move soon enough</span>
</div>
<div t-name="App">
<span>I am like the rest of us</span>
<Portal target="'body'">
<TeleportedComponent />
</Portal>
</div>
</templates>
```
In this example, the `Portal` component will teleport the `TeleportedComponent`'s `div` as a child of the `body`.
`TeleportedComponent` is acting as a Dialog here.
The resulting DOM will look like:
```xml
<body>
<div>
<span>I am like the rest of us</span>
<portal></portal>
</div>
<div>
<span>I will move soon enough</span>
</div>
</body>
```
### Expected Behaviors
The teleported piece is updated as any other `Component`'s DOM and in the same sequence.
Namely the teleported piece will be updated in function of its parents components, and patched as
a normal child.
The [_business_ events](event_handling.md#business-dom-events) triggered by a child component will be stopped
to not bubble outside of the `target`. They will, on the other hand, be re-directed onto the
`Portal`'s root node and bubble up the DOM as if it were triggered by a regular child component.
Beware that those re-directed events are copies of the original event.
They have:
- The same payload.
- The same `originalComponent` than their original counterpart,
that is the actual Component that triggered it.
- A **different** `target` property than their original counterpart.
The `target` of a re-directed event is necessarily the `Portal`'s root node.
Pure DOM events do not follow this pattern and are free to bubble their natural, unaltered way
up to the `body`.
## `AsyncRoot` ## `AsyncRoot`
When this component is used, a new rendering sub tree is created, such that the When this component is used, a new rendering sub tree is created, such that the
-60
View File
@@ -1,60 +0,0 @@
# 🦉 Mounting an application 🦉
## Content
- [Overview](#overview)
- [API](#api)
## Overview
Mounting an Owl application is done by using the `mount` method (available in
`owl.mount` if you are using the iife build, or it can be directly imported
from `owl` if you are using a module system):
```js
const mount = { owl }; // if owl is available as an object
const env = { ... };
const app = await mount(MyComponent, { target: document.body, env });
```
Another example:
```js
const config = {
env: ...,
props: ...,
target: document.body,
position: "self",
};
const app = await mount(App, config);
```
A common way to initialize an application is to first setup an environment,
then to call the `mount` method.
## API
Mount takes two parameters:
- `C`, which should be a component class (NOT instance),
- `params`, which is an object with the following keys:
- `target (HTMLElement | DocumentFragment)`: the target of the mount operation
- `env (optional, Env)` an environment
- `position (optional, "first-child" | "last-child" | "self")` the position
where it should be mounted (see below for more informations)
- `props (optional, any)`: some initial values that are given as props. Useful
when the root component is configurable, or when testing sub components
Here are the various positions supported by Owl:
- `first-child`: with this option, the component will be prepended inside the target,
- `last-child` (default value): with this option, the component will be
appended in the target element,
- `self`: the target will be used as the root element for the component. This
means that the target has to be an HTMLElement (and not a document fragment).
In this situation, it is possible that the component cannot be unmounted. For
example, if its target is `document.body`.
The `mount` method returns a promise that resolves to the instance of the created
component.
+5 -5
View File
@@ -15,7 +15,7 @@ use cases, there is no need to directly instantiate an observer.
For example, this code will display `update` in the console: For example, this code will display `update` in the console:
```javascript ```javascript
const observer = new owl.core.Observer(); const observer = new owl.Observer();
observer.notifyCB = () => console.log("update"); observer.notifyCB = () => console.log("update");
const obj = observer.observe({ a: { b: 1 } }); const obj = observer.observe({ a: { b: 1 } });
@@ -39,14 +39,14 @@ is incremented every time the value is observed. Sometimes, it can be useful
to obtain that number: to obtain that number:
```js ```js
const observer = new owl.core.Observer(); const observer = new owl.Observer();
const obj = observer.observe({ a: { b: 1 } }); const obj = observer.observe({ a: { b: 1 } });
observer.revNumber(obj.a); // 1 observer.deepRevNumber(obj.a); // 1
obj.a.b = 2; obj.a.b = 2;
observer.revNumber(obj.a); // 2 observer.deepRevNumber(obj.a); // 2
``` ```
The `revNumber` can also return 0, which indicates that the value is not The `deepRevNumber` can also return 0, which indicates that the value is not
observed. observed.
+1 -1
View File
@@ -18,7 +18,7 @@ class Child extends Component {
} }
class Parent extends Component { class Parent extends Component {
static template = xml`<div><Child a="state.a" b="'string'"/></div>`; static template = xml`<div><ComponentA a="state.a" b="'string'"/></div>`;
static components = { Child }; static components = { Child };
state = useState({ a: "fromparent" }); state = useState({ a: "fromparent" });
} }
+1 -1
View File
@@ -30,7 +30,7 @@ class ComponentB extends owl.Component {
count: {type: Number}, count: {type: Number},
messages: { messages: {
type: Array, type: Array,
element: {type: Object, shape: {id: Boolean, text: String } element: {type: Object, shape: {id: Boolean, text: 'string' }
}, },
date: Date, date: Date,
combinedVal: [Number, Boolean] combinedVal: [Number, Boolean]
+3 -3
View File
@@ -61,7 +61,7 @@ Its API is quite simple:
``` ```
- **`render(name, context, extra)`**: renders a template. This returns a `vnode`, - **`render(name, context, extra)`**: renders a template. This returns a `vnode`,
which is a virtual representation of the DOM (see [vdom doc](../miscellaneous/vdom.md)). which is a virtual representation of the DOM (see [vdom doc](../architecture/vdom.md)).
```js ```js
const vnode = qweb.render("App", component); const vnode = qweb.render("App", component);
@@ -114,9 +114,9 @@ For example:
const translations = { const translations = {
hello: "bonjour", hello: "bonjour",
yes: "oui", yes: "oui",
no: "non", no: "non"
}; };
const translateFn = (str) => translations[str] || str; const translateFn = str => translations[str] || str;
const qweb = new QWeb({ translateFn }); const qweb = new QWeb({ translateFn });
``` ```
+5 -79
View File
@@ -13,11 +13,8 @@
- [Setting Variables](#setting-variables) - [Setting Variables](#setting-variables)
- [Conditionals](#conditionals) - [Conditionals](#conditionals)
- [Dynamic Attributes](#dynamic-attributes) - [Dynamic Attributes](#dynamic-attributes)
- [Dynamic Class Attribute](#dynamic-class-attribute)
- [Dynamic Tag Names](#dynamic-tag-names)
- [Loops](#loops) - [Loops](#loops)
- [Rendering Sub Templates](#rendering-sub-templates) - [Rendering Sub Templates](#rendering-sub-templates)
- [Dynamic Sub Templates](#dynamic-sub-templates)
- [Translations](#translations) - [Translations](#translations)
- [Debugging](#debugging) - [Debugging](#debugging)
@@ -31,7 +28,7 @@ generate a virtual dom representation of the HTML.
```xml ```xml
<div> <div>
<span t-if="somecondition">Some string</span> <span t-if="somecondition">Some string</span>
<ul t-else=""> <ul t-else="1">
<li t-foreach="messages" t-as="message"> <li t-foreach="messages" t-as="message">
<t t-esc="message"/> <t t-esc="message"/>
</li> </li>
@@ -74,11 +71,10 @@ needs. Here is a list of all Owl specific directives:
| `t-component`, `t-props` | [Defining a sub component](component.md#composition) | | `t-component`, `t-props` | [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)](#loops) | | `t-key` | [Defining a key (to help virtual dom reconciliation)](#loops) |
| `t-on-*` | [Event handling](event_handling.md) | | `t-on-*` | [Event handling](component.md#event-handling) |
| `t-transition` | [Defining an animation](animations.md#css-transitions) | | `t-transition` | [Defining an animation](animations.md#css-transitions) |
| `t-slot` | [Rendering a slot](slots.md) | | `t-slot` | [Rendering a slot](component.md#slots) |
| `t-model` | [Form input bindings](component.md#form-input-bindings) | | `t-model` | [Form input bindings](component.md#form-input-bindings) |
| `t-tag` | [Rendering nodes with dynamic tag name](#dynamic-tag-names) |
## Reference ## Reference
@@ -105,7 +101,7 @@ precisely, the result of a template rendering should have a single root node:
<!–– ok: result has one single root node ––> <!–– ok: result has one single root node ––>
<t> <t>
<div t-if="someCondition">foo</div> <div t-if="someCondition">foo</div>
<span t-else="">bar</span> <span t-else="1">bar</span>
</t> </t>
``` ```
@@ -327,40 +323,6 @@ values) or a pair `[key, value]`. For example:
<div t-att="['a', 'b']"/> <!-- <div a="b"></div> --> <div t-att="['a', 'b']"/> <!-- <div a="b"></div> -->
``` ```
### Dynamic class attribute
For convenience, Owl supports a special case for the `t-att-class` case: one can
use an object with keys describing the classes, and values boolean value denoting
if the class is or is not present:
```xml
<div t-att-class="{'a': true, 'b': true}"/> <!-- result: <div class="a b"></div> -->
<div t-att-class="{'a b': true, 'c': true}"/> <!-- result: <div class="a b c"></div> -->
```
Note that it can be combined with normal class attribute:
```xml
<div class="a" t-att-class="{'b': true}"/> <!-- result: <div class="a b"></div> -->
```
### Dynamic tag names
When writing generic components or templates, the specific concrete tag for an
HTML element is not known yet. In those situations, the `t-tag` directive is
useful. It simply evaluates dynamically an expression to use as a tag name. The
template:
```xml
<t t-tag="tag">
<span>content</span>
</t>
```
will be rendered as `<div><span>content</span></div>` if the `tag` context key
is set to `div`.
### Loops ### Loops
QWeb has an iteration directive `t-foreach` which take an expression returning the QWeb has an iteration directive `t-foreach` which take an expression returning the
@@ -490,17 +452,6 @@ are all equivalent:
If there is no `t-key` directive, Owl will use the index as a default key. If there is no `t-key` directive, Owl will use the index as a default key.
Note: the `t-foreach` directive only accepts arrays (lists) or objects. It does
not work with other iterables, such as `Set`. However, it is only a matter of
using the `...` javascript operator. For example:
```xml
<t t-foreach="...items" t-as="item">...</t>
```
The `...` operator will convert the `Set` (or any other iterables) into a list,
which will work with Owl QWeb.
### Rendering Sub Templates ### Rendering Sub Templates
QWeb templates can be used for top level rendering, but they can also be used QWeb templates can be used for top level rendering, but they can also be used
@@ -549,31 +500,6 @@ will result in :
</div> </div>
``` ```
This can be used to define variables scoped to a sub template:
```xml
<t t-call="other-template">
<t t-set="var" t-value="1"/>
</t>
<!-- "var" does not exist here -->
```
### Dynamic sub templates
The `t-call` directive can also be used to dynamically call a sub template,
using string interpolation. For example:
```xml
<div t-name="main-template">
<t t-call="{{template}}">
<em>content</em>
</t>
</div>
```
Here, the name of the template is obtained from the `template` value in the
template rendering context.
### Translations ### Translations
By default, QWeb specify that templates should be translated. If this behaviour By default, QWeb specify that templates should be translated. If this behaviour
@@ -597,7 +523,7 @@ The javascript QWeb implementation provides two useful debugging directives:
```xml ```xml
<t t-if="a_test"> <t t-if="a_test">
<t t-debug=""/> <t t-debug="">
</t> </t>
``` ```
-111
View File
@@ -1,111 +0,0 @@
# 🦉 Slots 🦉
## Content
- [Overview](#overview)
- [Example](#example)
- [Reference](#reference)
## Overview
Owl is a template based component system. There is therefore a need to be able
to make generic components. For example, imagine a generic `Dialog`
component, which is able to display some arbitrary content.
Obviously, we want to use this component everywhere in our application, to
display various different content. The `Dialog` component is technically the
owner of its content, but is only a container. The user of the `Dialog` is
the component that want to _inject_ something inside the `Dialog`. This is
exactly what slots are for.
## Example
To make generic components, it is useful to be able for a parent component to _inject_
some sub template, but still be the owner. For example, a generic dialog component
will need to render some content, some footer, but with the parent as the
rendering context.
Slots are inserted with the `t-slot` directive:
```xml
<div t-name="Dialog" class="modal">
<div class="modal-title"><t t-esc="props.title"/></div>
<div class="modal-content">
<t t-slot="content"/>
</div>
<div class="modal-footer">
<t t-slot="footer"/>
</div>
</div>
```
Slots are defined by the caller, with the `t-set-slot` directive:
```xml
<div t-name="SomeComponent">
<div>some component</div>
<Dialog title="'Some Dialog'">
<t t-set-slot="content">
<div>hey</div>
</t>
<t t-set-slot="footer">
<button t-on-click="doSomething">ok</button>
</t>
</Dialog>
</div>
```
In this example, the component `Dialog` will render the slots `content` and `footer`
with its parent as rendering context. This means that clicking on the button
will execute the `doSomething` method on the parent, not on the dialog.
Note: Owl previously used the `t-set` directive to define the content of a slot.
This is deprecated and should no longer be used in new code.
## Reference
### Default Slot
The first element inside the component which is not a named slot will
be considered the `default` slot. For example:
```xml
<div t-name="Parent">
<Child>
<span>some content</span>
</Child>
</div>
<div t-name="Child">
<t t-slot="default"/>
</div>
```
### Default content
Slots can define a default content, in case the parent did not define them:
```xml
<div t-name="Parent">
<Child/>
</div>
<span t-name="Child">
<t t-slot="default">default content</t>
</span>
<!-- will be rendered as: <div><span>default content</span></div> -->
```
Rendering context: the content of the slots is actually rendered with the
rendering context corresponding to where it was defined, not where it is
positioned. This allows the user to define event handlers that will be bound
to the correct component (usually, the grandparent of the slot content).
### Dynamic Slots
The `t-slot` directive is actually able to use any expressions, using string
interplolation:
```xml
<t t-slot="{{current}}" />
```
+17 -29
View File
@@ -25,8 +25,8 @@ 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 (or all) state, and lets the developer update it in a structured way, with some (or all) state, and lets the developer update it in a structured way, with
`actions`. Owl components can then connect to the store to read their relevant `actions`. Owl components can then connect to the store, and will be updated if
state, and they will be rerendered if the state is updated. necessary.
Note: Owl store is inspired by React Redux and VueX. Note: Owl store is inspired by React Redux and VueX.
@@ -40,14 +40,14 @@ const actions = {
state.todos.push({ state.todos.push({
id: state.nextId++, id: state.nextId++,
message, message,
isCompleted: false, isCompleted: false
}); });
}, }
}; };
const state = { const state = {
todos: [], todos: [],
nextId: 1, nextId: 1
}; };
const store = new owl.Store({ state, actions }); const store = new owl.Store({ state, actions });
@@ -90,7 +90,7 @@ const config = {
state, state,
actions, actions,
getters, getters,
env, env
}; };
const store = new Store(config); const store = new Store(config);
``` ```
@@ -110,7 +110,7 @@ const actions = {
} catch (e) { } catch (e) {
state.loginState = "error"; state.loginState = "error";
} }
}, }
}; };
``` ```
@@ -144,7 +144,7 @@ const actions = {
state.recordId = recordId; state.recordId = recordId;
const data = await doSomeRPC("/read/", recordId); const data = await doSomeRPC("/read/", recordId);
state.recordData = data; state.recordData = data;
}, }
}; };
``` ```
@@ -159,7 +159,7 @@ const actions = {
const data = await doSomeRPC("/read/", recordId); const data = await doSomeRPC("/read/", recordId);
state.recordId = recordId; state.recordId = recordId;
state.recordData = data; state.recordData = data;
}, }
}; };
``` ```
@@ -187,14 +187,14 @@ transform the data contained in the store.
```js ```js
const getters = { const getters = {
getPost({ state }, id) { getPost({ state }, id) {
const post = state.posts.find((p) => p.id === id); const post = state.posts.find(p => p.id === id);
const author = state.authors.find((a) => a.id === post.id); const author = state.authors.find(a => a.id === post.id);
return { return {
id, id,
author, author,
content: post.content, content: post.content
}; };
}, }
}; };
// somewhere else // somewhere else
@@ -208,11 +208,7 @@ Note that getters are not cached.
### Connecting a Component ### Connecting a Component
At some point, we need a way to interact with the store from a component. This At some point, we need a way to interact with the store from a component. This
means that the component needs a reference to the store. By default, it looks can be done with the help of the three store hooks:
for it in the `env.store` key. However, this can be configured with the `useStore`
hook.
Every component-store interactions are done with the help of the three store hooks:
- [`useStore`](#usestore) to subscribe a component to some part of the store state, - [`useStore`](#usestore) to subscribe a component to some part of the store state,
- [`useDispatch`](#usedispatch) to get a reference to a dispatch function, - [`useDispatch`](#usedispatch) to get a reference to a dispatch function,
@@ -224,28 +220,20 @@ Assume we have this store:
const actions = { const actions = {
increment({ state }, val) { increment({ state }, val) {
state.counter.value += val; state.counter.value += val;
}, }
}; };
const state = { const state = {
counter: { value: 0 }, counter: { value: 0 }
}; };
const store = new owl.Store({ state, actions }); const store = new owl.Store({ state, actions });
``` ```
To make it accessible to the complete application, we will put it in the
environment:
```js
// in this example, the root component is App
App.env.store = store;
```
A counter component can then select this value and dispatch an action like this: A counter component can then select this value and dispatch an action like this:
```js ```js
class Counter extends Component { class Counter extends Component {
counter = useStore((state) => state.counter); counter = useStore(state => state.counter);
dispatch = useDispatch(); dispatch = useDispatch();
} }
+4 -134
View File
@@ -4,19 +4,16 @@
- [Overview](#overview) - [Overview](#overview)
- [`xml` tag](#xml-tag) - [`xml` tag](#xml-tag)
- [`css` tag](#css-tag)
## Overview ## Overview
Tags are very small helpers intended to make it easy to write inline templates Tags are very small helpers to make it easy to write inline templates. There is
or styles. There are currently two tags: `css` and `xml`. With these functions, only one currently available tag: `xml`, but we plan to add other tags later,
it is possible to write [single file components](../learning/how_to_write_sfc.md). such as a `css` tag, which will be used to write [single file components](../tooling.md#single-file-component).
## XML tag ## XML tag
The `xml` tag is certainly the most useful tag. It is used to define an inline Without tags, creating a standalone component would look like this:
QWeb template for a component. Without tags, creating a standalone component
would look like this:
```js ```js
import { Component } from 'owl' import { Component } from 'owl'
@@ -55,130 +52,3 @@ class MyComponent extends Component {
... ...
} }
``` ```
## CSS tag
The CSS tag is useful to define a css stylesheet in the javascript file:
```js
class MyComponent extends Component {
static template = xml`
<div class="my-component">some template</div>
`;
static style = css`
.my-component {
color: red;
}
`;
}
```
The `css` tag registers internally the css information. Then, whenever the first
instance of the component is created, will add a `<style>` tag to the document
`<head>`.
Note that to make it more useful, like other css preprocessors, the `css` tag
accepts a small extension of the css specification: css scopes can be nested,
and the rules will then be expanded by the `css` helper:
```scss
.my-component {
display: block;
.sub-component h {
color: red;
}
}
```
will be formatted as:
```css
.my-component {
display: block;
}
.my-component .sub-component h {
color: red;
}
```
This extension brings another useful feature: the `&` selector which refers to
the parent selector. For example, we want our component to be red when hovered.
We would like to write something like:
```scss
.my-component {
display: block;
:hover {
color: red;
}
}
```
but it will be formatted as:
```css
.my-component {
display: block;
}
.my-component :hover {
color: red;
}
```
The `&` selector can be used to solve this problem:
```scss
.my-component {
display: block;
&:hover {
color: red;
}
}
```
will be formatted as:
```css
.my-component {
display: block;
}
.my-component:hover {
color: red;
}
```
Now, there is no additional processing done by the `css` tag. However, since it
is done in javascript at runtime, we actually have more power. For example:
1. sharing values between javascript and css:
```js
import { theme } from "./theme";
class MyComponent extends Component {
static template = xml`<div class="my-component">...</div>`;
static style = css`
.my-component {
color: ${theme.MAIN_COLOR};
background-color: ${theme.SECONDARY_color};
}
`;
}
```
2. scoping rules to the current component:
```js
import { generateUUID } from "./utils";
const uuid = generateUUID();
class MyComponent extends Component {
static template = xml`<div data-o-${uuid}="">...</div>`;
static style = css`
[data-o-${uuid}] {
color: red;
}
`;
}
```
+4 -4
View File
@@ -21,8 +21,8 @@ argument, it executes it as soon as the DOM ready (or directly).
```js ```js
Promise.all([loadFile("templates.xml"), owl.utils.whenReady()]).then(function([templates]) { Promise.all([loadFile("templates.xml"), owl.utils.whenReady()]).then(function([templates]) {
const qweb = new owl.QWeb({ templates }); const qweb = new owl.QWeb({ templates });
const env = { qweb }; const app = new App({ qweb });
await mount(App, { env, target: document.body }); app.mount(document.body);
}); });
``` ```
@@ -31,8 +31,8 @@ or alternatively:
```js ```js
owl.utils.whenReady(function() { owl.utils.whenReady(function() {
const qweb = new owl.QWeb(); const qweb = new owl.QWeb();
const env = { qweb }; const app = new App({ qweb });
await mount(App, { env, target: document.body }); app.mount(document.body);
}); });
``` ```
+107
View File
@@ -0,0 +1,107 @@
# 🦉 Tooling 🦉
## Content
- [Overview](#overview)
- [Playground](#playground)
- [Benchmarks](#benchmarks)
- [Single File Component](#single-file-component)
- [Debugging Script](#debugging-script)
## Overview
To help work with/improve/learn OWL, there are a few extras tools/settings.
- development mode: enable better error reporting for the developer
- a playground application: a space to experiment and learn Owl.
- a benchmarks application: allow comparison with a few common frameworks
The two applications are available in the `tools/` folder, and can be accessed
by using a static http server. A simple python
server is available in `server.py`. There is also a npm script to start it:
`npm run tools` (and its version with a watcher: `npm run tools:watch`).
## Playground
The playground is an important application designed to help learning and
experimenting with Owl. The last published version of Owl can be tested [online](https://odoo.github.io/owl/playground/).
It is an application similar to `jsFiddle`, but specialized for Owl: there are
three tabs (`js`, `css` and `xml`), and a simple button `Run` to execute that
code in an iframe.
## Benchmarks
Note: This is more an internal tool, useful for people working on Owl.
The benchmarks application is a very small application, implemented in different
frameworks, and in different versions of Owl. This is a simple internal tool,
useful to compare various performance metrics on some tasks.
## Single File Component
It is very useful to group code by feature instead of by type of file. It makes
it easier to scale application to larger size.
To do so, Owl currently has a small helper that makes it easy to define a
template inside a javascript (or typescript) file: the [`xml`](reference/tags.md#xml-tag)
helper. With this, a template is automatically registered to [QWeb](reference/qweb_engine.md).
This means that the template and the javascript code can be defined in the same
file. It is not currently possible to add css to the same file, but Owl may
get a `css` tag helper later.
```js
const { Component } = owl;
const { xml } = owl.tags;
// -----------------------------------------------------------------------------
// TEMPLATE
// -----------------------------------------------------------------------------
const TEMPLATE = xml/* xml */ `
<div class="main two-columns">
<Sidebar/>
<Content />
</div>`;
// -----------------------------------------------------------------------------
// CODE
// -----------------------------------------------------------------------------
class MyComponent extends Component {
static template = TEMPLATE;
static components = { Sidebar, Content };
// rest of component...
}
```
Note that the above example has an inline xml comment, just after the `xml` call.
This is useful for some editor plugins, such as the VS Code addon
`Comment tagged template`, which, if installed, add syntax highlighting to the
content of the template string.
## Debugging Script
## 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,
logging useful information is extremely valuable. There is a [javascript file](../tools/debug.js) which can be evaluated in an application.
Once it is executed, it will log a lot of information on each component main hooks. The following code is a minified version to make it easier to copy/paste:
```
let debugSetup = {
// componentBlackList: /App/, // regexp
// componentWhiteList: /SomeComponent/, // regexp
// methodBlackList: ["mounted"], // list of method names
// methodWhiteList: ["willStart"], // list of method names
logScheduler: false, // display/mute scheduler logs
logStore: true, // display/mute store logs
};
{let o,t="[OWL_DEBUG]";function toStr(o){let t=JSON.stringify(o||{});return t.length>200&&(t=t.slice(0,200)+"..."),t}function debugComponent(o,e,n){let l=`${e}<id=${n}>`,r=o=>(!debugSetup.methodBlackList||!debugSetup.methodBlackList.includes(o))&&!(debugSetup.methodWhiteList&&!debugSetup.methodWhiteList.includes(o));r("constructor")&&console.log(`${t} ${l} constructor, props=${toStr(o.props)}`),r("willStart")&&owl.hooks.onWillStart(()=>{console.log(`${t} ${l} willStart`)}),r("mounted")&&owl.hooks.onMounted(()=>{console.log(`${t} ${l} mounted`)}),r("willUpdateProps")&&owl.hooks.onWillUpdateProps(o=>{console.log(`${t} ${l} willUpdateProps, nextprops=${toStr(o)}`)}),r("willPatch")&&owl.hooks.onWillPatch(()=>{console.log(`${t} ${l} willPatch`)}),r("patched")&&owl.hooks.onPatched(()=>{console.log(`${t} ${l} patched`)}),r("willUnmount")&&owl.hooks.onWillUnmount(()=>{console.log(`${t} ${l} willUnmount`)});const s=o.__render.bind(o);o.__render=function(...o){console.log(`${t} ${l} rendering template`),s(...o)};const u=o.render.bind(o);o.render=function(...o){return console.log(`${t} ${l} render`),u(...o)};const c=o.mount.bind(o);o.mount=function(...o){return console.log(`${t} ${l} mount`),c(...o)}}if(Object.defineProperty(owl.Component,"current",{get:()=>o,set(t){o=t;const e=t.constructor.name;if(debugSetup.componentBlackList&&debugSetup.componentBlackList.test(e))return;if(debugSetup.componentWhiteList&&!debugSetup.componentWhiteList.test(e))return;let n;Object.defineProperty(o,"__owl__",{get:()=>n,set(o){debugComponent(t,e,(n=o).id)}})}}),debugSetup.logScheduler){let o;Object.defineProperty(owl.Component.scheduler,"isRunning",{get:()=>o,set(e){e?console.log(`${t} scheduler: start running tasks queue`):console.log(`${t} scheduler: stop running tasks queue`),o=e}})}if(debugSetup.logStore){let o=owl.Store.prototype.dispatch;owl.Store.prototype.dispatch=function(e,...n){return console.log(`${t} store: action '${e}' dispatched. Payload: '${toStr(n)}'`),o.call(this,e,...n)}}}
```
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.
+19 -31
View File
@@ -1,65 +1,54 @@
{ {
"name": "@odoo/owl", "name": "owl-framework",
"version": "1.4.10", "version": "1.0.0-alpha5",
"description": "Odoo Web Library (OWL)", "description": "Odoo Web Library (OWL)",
"main": "dist/owl.cjs.js", "main": "src/index.ts",
"browser": "dist/owl.iife.js",
"module": "dist/owl.es.js",
"types": "dist/types/index.d.ts",
"files": [
"dist"
],
"engines": { "engines": {
"node": ">=12.18.3" "node": ">=10.15.3"
}, },
"scripts": { "scripts": {
"build:js": "tsc --target esnext --module es6 --outDir dist/owl",
"build:bundle": "rollup -c", "build:bundle": "rollup -c",
"build": "npm run build:bundle", "build": "npm run build:js && npm run build:bundle",
"buildcommonjs": "npm run build:js && npm run build:bundle -- -f cjs",
"minify": "uglifyjs dist/owl.js -o dist/owl.min.js --compress --mangle",
"test": "jest", "test": "jest",
"test:watch": "jest --watch", "test:watch": "jest --watch",
"tools:serve": "python3 tools/server.py || python tools/server.py", "tools:serve": "python3 tools/server.py || python tools/server.py",
"tools": "npm run build && npm run tools:serve", "tools": "npm run build && npm run tools:serve",
"pretools:watch": "npm run build", "pretools:watch": "npm run build",
"tools:watch": "npm-run-all --parallel tools:serve \"build:* -- --watch\"", "tools:watch": "npm-run-all --parallel tools:serve \"build:* -- --watch\"",
"prettier": "prettier {src/*.ts,src/**/*.ts,tests/*.ts,tests/**/*.ts,doc/*.md,doc/**/*.md} --write", "prettier": "prettier {src/*.ts,src/**/*.ts,tests/*.ts,tests/**/*.ts,doc/*.md,doc/**/*.md} --write"
"check-formatting": "prettier {src/*.ts,src/**/*.ts,tests/*.ts,tests/**/*.ts,doc/*.md,doc/**/*.md} --check",
"publish": "npm run build && npm publish",
"release": "node tools/release.js"
}, },
"repository": { "repository": {
"type": "git", "type": "git",
"url": "git+https://github.com/odoo/owl.git" "url": "git+https://github.com/odoo/owl.git"
}, },
"author": "Odoo", "author": "Odoo",
"license": "LGPL-3.0-only", "license": "LGPL",
"bugs": { "bugs": {
"url": "https://github.com/odoo/owl/issues" "url": "https://github.com/odoo/owl/issues"
}, },
"homepage": "https://github.com/odoo/owl#readme", "homepage": "https://github.com/odoo/owl#readme",
"dependencies": {},
"devDependencies": { "devDependencies": {
"@types/jest": "^27.0.1", "@types/jest": "^23.3.12",
"@types/node": "^14.11.8",
"chalk": "^3.0.0",
"cpx": "^1.5.0", "cpx": "^1.5.0",
"current-git-branch": "^1.1.0",
"git-rev-sync": "^1.12.0", "git-rev-sync": "^1.12.0",
"github-api": "^3.3.0", "jest": "^23.6.0",
"jest": "^27.1.0", "jest-environment-jsdom": "^24.7.1",
"jest-environment-jsdom": "^27.1.0",
"live-server": "^1.2.1", "live-server": "^1.2.1",
"npm-run-all": "^4.1.5", "npm-run-all": "^4.1.5",
"prettier": "^2.0.4", "prettier": "^1.19.1",
"rollup": "^2.56.3", "rollup": "^1.6.0",
"rollup-plugin-terser": "^7.0.2", "rollup-plugin-typescript2": "^0.20.1",
"rollup-plugin-typescript2": "^0.30.0",
"sass": "^1.16.1", "sass": "^1.16.1",
"source-map-support": "^0.5.10", "source-map-support": "^0.5.10",
"ts-jest": "^27.0.5", "ts-jest": "^23.10.5",
"typescript": "^3.7.2", "typescript": "^3.7.2",
"uglify-es": "^3.3.9" "uglify-es": "^3.3.9"
}, },
"jest": { "jest": {
"testEnvironment": "jsdom",
"roots": [ "roots": [
"<rootDir>/src", "<rootDir>/src",
"<rootDir>/tests" "<rootDir>/tests"
@@ -79,7 +68,6 @@
] ]
}, },
"prettier": { "prettier": {
"printWidth": 100, "printWidth": 100
"endOfLine": "auto"
} }
} }
+27 -7
View File
@@ -1,27 +1,47 @@
# 🦉 OWL Roadmap 🦉 # 🦉 OWL Roadmap 🦉
- Current version: 1.4.10 - Current version: 1.0.0-alpha5
- Status: stable - Status: mostly stable
This roadmap is only an attempt at predicting Owl's future. Everything may This roadmap is only an attempt at predicting Owl's future. Everything may
change! change!
### November 2019
Owl will be used in various Odoo projects. We plan to:
- fix any issues encountered
- maybe cleanup slightly the router API
- improve the documentation
- improve error handling, add more helpful error messages
### December 2019
If all goes well, Owl will be upgraded to beta status. From then, no API change,
even small, is expected.
### End of 2019
Release v1.0
- API should be stable,
- we will use semantic versioning,
- we will maintain a changelog and an upgrade guide.
### 1.x ### 1.x
- add chrome and firefox devtools, - add chrome and firefox devtools,
- add support for single file components,
- fix every bugs, - fix every bugs,
- improve documentation, - improve documentation,
- small backward compatible improvements. - small backward compatible improvements.
### 2.x (2020? 2021? 2022?) ### 2.x (2021? 2022?)
- stop support for `t-set` directive to define the content of a slot
Maybe: Maybe:
- reimplement vdom to use *block* system, like Vue 3, which should make Owl - reimplement vdom to use *block* system, like Vue 3,
much faster
- refactor `QWeb` to use an intermediate representation (some kind of AST) to - refactor `QWeb` to use an intermediate representation (some kind of AST) to
allow additional optimisations. allow additional optimisations.
+10 -66
View File
@@ -1,70 +1,14 @@
import pkg from "./package.json"; import { version } from "./package.json";
import git from "git-rev-sync"; import git from "git-rev-sync";
import typescript from 'rollup-plugin-typescript2';
import { terser } from "rollup-plugin-terser";
const name = "owl";
const extend = true;
/**
* Meta data to be added on the __info__ object.
* Used to let external tools know the current owl version.
*/
const outro = `
__info__.version = '${pkg.version}';
__info__.date = '${new Date().toISOString()}';
__info__.hash = '${git.short()}';
__info__.url = 'https://github.com/odoo/owl';
`;
/**
* Generate from a string depicting a path a new path for the minified version.
* @param {string} pkgFileName file name
*/
function generateMinifiedNameFromPkgName(pkgFileName) {
const parts = pkgFileName.split('.');
parts.splice(parts.length - 1, 0, "min");
return parts.join('.');
}
/**
* Get the rollup config based on the arguments
* @param {string} format format of the bundle
* @param {string} generatedFileName generated file name
* @param {boolean} minified should it be minified
*/
function getConfigForFormat(format, generatedFileName, minified = false) {
return {
file: minified ? generateMinifiedNameFromPkgName(generatedFileName) : generatedFileName,
format: format,
name: name,
extend: extend,
outro: outro,
plugins: minified ? [terser()] : [],
indent: ' ', // indent with 4 spaces
};
}
// rollup.config.js
export default { export default {
input: "src/index.ts", input: "dist/owl/index.js",
output: [ output: {
file: "dist/owl.js",
/** format: "iife",
* Read about module formats: name: "owl",
* https://auth0.com/blog/javascript-module-systems-showdown/ extend: true,
* https://medium.com/@kelin2025/so-you-wanna-use-es6-modules-714f48b3a953 outro: `exports.__info__.version = '${version}';\nexports.__info__.date = '${new Date().toISOString()}';\nexports.__info__.hash = '${git.short()}';\nexports.__info__.url = 'https://github.com/odoo/owl';`
*/ }
getConfigForFormat('esm', pkg.module),
getConfigForFormat('esm', pkg.module, true),
getConfigForFormat('cjs', pkg.main),
getConfigForFormat('cjs', pkg.main, true),
getConfigForFormat('iife', pkg.browser),
getConfigForFormat('iife', pkg.browser, true),
],
plugins: [
typescript({
useTsconfigDeclarationDir: true
}),
]
}; };
-30
View File
@@ -1,30 +0,0 @@
export interface Browser {
setTimeout: Window["setTimeout"];
clearTimeout: Window["clearTimeout"];
setInterval: Window["setInterval"];
clearInterval: Window["clearInterval"];
requestAnimationFrame: Window["requestAnimationFrame"];
random: Math["random"];
Date: typeof Date;
fetch: Window["fetch"];
localStorage: Window["localStorage"];
}
let localStorage: Window["localStorage"] | null = null;
export const browser: Browser = {
setTimeout: window.setTimeout.bind(window),
clearTimeout: window.clearTimeout.bind(window),
setInterval: window.setInterval.bind(window),
clearInterval: window.clearInterval.bind(window),
requestAnimationFrame: window.requestAnimationFrame.bind(window),
random: Math.random,
Date: window.Date,
fetch: (window.fetch || (() => {})).bind(window),
get localStorage() {
return localStorage || window.localStorage;
},
set localStorage(newLocalStorage: Window["localStorage"]) {
localStorage = newLocalStorage;
},
};
+99 -228
View File
@@ -6,8 +6,6 @@ import "./directive";
import { Fiber } from "./fiber"; import { Fiber } from "./fiber";
import "./props_validation"; import "./props_validation";
import { Scheduler, scheduler } from "./scheduler"; import { Scheduler, scheduler } from "./scheduler";
import { activateSheet } from "./styles";
import { Browser, browser } from "../browser";
/** /**
* Owl Component System * Owl Component System
@@ -35,22 +33,7 @@ import { Browser, browser } from "../browser";
*/ */
export interface Env { export interface Env {
qweb: QWeb; qweb: QWeb;
browser: Browser; [key: string]: any;
}
export type MountPosition = "first-child" | "last-child" | "self";
interface MountOptions {
position?: MountPosition;
}
export const enum STATUS {
CREATED,
WILLSTARTED, // willstart has been called
RENDERED, // first render is completed (so, vnode is now defined)
MOUNTED, // is ready, and in DOM. It has a valid el
UNMOUNTED, // has a valid el, but is not in DOM
DESTROYED,
} }
/** /**
@@ -58,19 +41,20 @@ export const enum STATUS {
* useful to typecheck and describe the internal keys used by Owl to manage the * useful to typecheck and describe the internal keys used by Owl to manage the
* component tree. * component tree.
*/ */
interface Internal<T extends Env> { interface Internal<T extends Env, Props> {
// each component has a unique id, useful mostly to handle parent/child // each component has a unique id, useful mostly to handle parent/child
// relationships // relationships
readonly id: number; readonly id: number;
depth: number; depth: number;
vnode: VNode | null; vnode: VNode | null;
pvnode: VNode | null; pvnode: VNode | null;
status: STATUS; isMounted: boolean;
isDestroyed: boolean;
// parent and children keys are obviously useful to setup the parent-children // parent and children keys are obviously useful to setup the parent-children
// relationship. // relationship.
parent: Component<any, T> | null; parent: Component<T, any> | null;
children: { [key: number]: Component<any, T> }; children: { [key: number]: Component<T, any> };
// children mapping: from templateID to componentID. templateID identifies a // children mapping: from templateID to componentID. templateID identifies a
// place in a template. The t-component directive needs it to be able to get // place in a template. The t-component directive needs it to be able to get
// the component instance back whenever the template is rerendered. // the component instance back whenever the template is rerendered.
@@ -82,9 +66,10 @@ interface Internal<T extends Env> {
parentLastFiberId: number; parentLastFiberId: number;
// when a rendering is initiated by a parent, it may set variables in 'scope' // when a rendering is initiated by a parent, it may set variables in 'scope'
// (typically when the component is rendered in a slot). We need to // and 'vars' (typically when the component is rendered in a slot). We need to
// store that information in case the component would be re-rendered later on. // store that information in case the component would be re-rendered later on.
scope: any; scope: any;
vars: any;
boundHandlers: { [key: number]: any }; boundHandlers: { [key: number]: any };
observer: Observer | null; observer: Observer | null;
@@ -96,21 +81,19 @@ interface Internal<T extends Env> {
willStartCB: Function | null; willStartCB: Function | null;
willUpdatePropsCB: Function | null; willUpdatePropsCB: Function | null;
classObj: { [key: string]: boolean } | null; classObj: { [key: string]: boolean } | null;
refs: { [key: string]: Component<any, T> | HTMLElement | undefined } | null; refs: { [key: string]: Component<T, any> | HTMLElement | undefined } | null;
} }
export const portalSymbol = Symbol("portal"); // FIXME
//------------------------------------------------------------------------------ //------------------------------------------------------------------------------
// Component // Component
//------------------------------------------------------------------------------ //------------------------------------------------------------------------------
let nextId = 1; let nextId = 1;
export class Component<Props extends {} = any, T extends Env = Env> { export class Component<T extends Env, Props extends {}> {
readonly __owl__: Internal<T>; readonly __owl__: Internal<Env, Props>;
static template?: string | null = null; static template?: string | null = null;
static _template?: string | null = null; static _template?: string | null = null;
static current: Component | null = null; static current: Component<any, any> | null = null;
static components = {}; static components = {};
static props?: any; static props?: any;
static defaultProps?: any; static defaultProps?: any;
@@ -140,7 +123,7 @@ export class Component<Props extends {} = any, T extends Env = Env> {
* hand. Other components should be created automatically by the framework (with * hand. Other components should be created automatically by the framework (with
* the t-component directive in a template) * the t-component directive in a template)
*/ */
constructor(parent?: Component<any, T> | null, props?: Props) { constructor(parent?: Component<T, any> | null, props?: Props) {
Component.current = this; Component.current = this;
let constr = this.constructor as any; let constr = this.constructor as any;
@@ -167,23 +150,17 @@ export class Component<Props extends {} = any, T extends Env = Env> {
if (!this.env.qweb) { if (!this.env.qweb) {
this.env.qweb = new QWeb(); this.env.qweb = new QWeb();
} }
// TODO: remove this in owl 2.0
if (!this.env.browser) {
this.env.browser = browser;
}
this.env.qweb.on("update", this, () => { this.env.qweb.on("update", this, () => {
switch (this.__owl__.status) { if (this.__owl__.isMounted) {
case STATUS.MOUNTED:
this.render(true); this.render(true);
break; }
case STATUS.DESTROYED: if (this.__owl__.isDestroyed) {
// this is unlikely to happen, but if a root widget is destroyed, // this is unlikely to happen, but if a root widget is destroyed,
// we want to remove our subscription. The usual way to do that // we want to remove our subscription. The usual way to do that
// would be to perform some check in the destroy method, but since // would be to perform some check in the destroy method, but since
// it is very performance sensitive, and since this is a rare event, // it is very performance sensitive, and since this is a rare event,
// we simply do it lazily // we simply do it lazily
this.env.qweb.off("update", this); this.env.qweb.off("update", this);
break;
} }
}); });
depth = 0; depth = 0;
@@ -196,7 +173,8 @@ export class Component<Props extends {} = any, T extends Env = Env> {
depth: depth, depth: depth,
vnode: null, vnode: null,
pvnode: null, pvnode: null,
status: STATUS.CREATED, isMounted: false,
isDestroyed: false,
parent: parent || null, parent: parent || null,
children: {}, children: {},
cmap: {}, cmap: {},
@@ -214,23 +192,9 @@ export class Component<Props extends {} = any, T extends Env = Env> {
classObj: null, classObj: null,
refs: null, refs: null,
scope: null, scope: null,
vars: null
}; };
if (constr.style) {
this.__applyStyles(constr);
} }
this.setup();
}
/**
* setup is run just after the component is constructed. This is the standard
* location where the component can setup its hooks. It has some advantages
* over the constructor:
* - it can be patched (useful in odoo ecosystem)
* - it does not need to propagate the arguments to the super call
*
* Note: this method should not be called manually.
*/
setup() {}
/** /**
* willStart is an asynchronous hook that can be implemented to perform some * willStart is an asynchronous hook that can be implemented to perform some
@@ -325,50 +289,24 @@ export class Component<Props extends {} = any, T extends Env = Env> {
* *
* Note that a component can be mounted an unmounted several times * Note that a component can be mounted an unmounted several times
*/ */
async mount(target: HTMLElement | DocumentFragment, options: MountOptions = {}): Promise<void> { async mount(target: HTMLElement | DocumentFragment): Promise<void> {
const __owl__ = this.__owl__;
if (__owl__.isMounted) {
return Promise.resolve();
}
if (!(target instanceof HTMLElement || target instanceof DocumentFragment)) { if (!(target instanceof HTMLElement || target instanceof DocumentFragment)) {
let message = `Component '${this.constructor.name}' cannot be mounted: the target is not a valid DOM node.`; let message = `Component '${this.constructor.name}' cannot be mounted: the target is not a valid DOM node.`;
message += `\nMaybe the DOM is not ready yet? (in that case, you can use owl.utils.whenReady)`; message += `\nMaybe the DOM is not ready yet? (in that case, you can use owl.utils.whenReady)`;
throw new Error(message); throw new Error(message);
} }
const position = options.position || "last-child"; const fiber = new Fiber(null, this, false, target);
const __owl__ = this.__owl__;
const currentFiber = __owl__.currentFiber;
switch (__owl__.status) {
case STATUS.CREATED: {
const fiber = new Fiber(null, this, true, target, position);
fiber.shouldPatch = false; fiber.shouldPatch = false;
this.__prepareAndRender(fiber, () => {}); if (!__owl__.vnode) {
return scheduler.addFiber(fiber); this.__prepareAndRender(fiber);
}
case STATUS.WILLSTARTED:
case STATUS.RENDERED:
currentFiber.target = target;
currentFiber.position = position;
return scheduler.addFiber(currentFiber);
case STATUS.UNMOUNTED: {
const fiber = new Fiber(null, this, true, target, position);
fiber.shouldPatch = false;
this.__render(fiber);
return scheduler.addFiber(fiber);
}
case STATUS.MOUNTED: {
if (position !== "self" && this.el!.parentNode !== target) {
const fiber = new Fiber(null, this, true, target, position);
fiber.shouldPatch = false;
this.__render(fiber);
return scheduler.addFiber(fiber);
} else { } else {
return Promise.resolve(); this.__render(fiber);
}
}
case STATUS.DESTROYED:
throw new Error("Cannot mount a destroyed component");
} }
return scheduler.addFiber(fiber);
} }
/** /**
@@ -376,7 +314,7 @@ export class Component<Props extends {} = any, T extends Env = Env> {
* to call willUnmount calls and remove the component from the DOM. * to call willUnmount calls and remove the component from the DOM.
*/ */
unmount() { unmount() {
if (this.__owl__.status === STATUS.MOUNTED) { if (this.__owl__.isMounted) {
this.__callWillUnmount(); this.__callWillUnmount();
this.el!.remove(); this.el!.remove();
} }
@@ -393,24 +331,24 @@ export class Component<Props extends {} = any, T extends Env = Env> {
*/ */
async render(force: boolean = false): Promise<void> { async render(force: boolean = false): Promise<void> {
const __owl__ = this.__owl__; const __owl__ = this.__owl__;
const currentFiber = __owl__.currentFiber; if (!__owl__.isMounted && !__owl__.currentFiber) {
if (!__owl__.vnode && !currentFiber) { // if we get here, this means that the component was either never mounted,
// or was unmounted and some state change triggered a render. Either way,
// we do not want to actually render anything in this case.
return; return;
} }
if (currentFiber && !currentFiber.isRendered && !currentFiber.isCompleted) { if (__owl__.currentFiber && !__owl__.currentFiber.isRendered) {
return scheduler.addFiber(currentFiber.root); return scheduler.addFiber(__owl__.currentFiber.root);
} }
// if we aren't mounted at this point, it implies that there is a // if we aren't mounted at this point, it implies that there is a
// currentFiber that is already rendered (isRendered is true), so we are // currentFiber that is already rendered (isRendered is true), so we are
// about to be mounted // about to be mounted
const status = __owl__.status; const isMounted = __owl__.isMounted;
const fiber = new Fiber(null, this, force, null, null); const fiber = new Fiber(null, this, force, null);
Promise.resolve().then(() => { Promise.resolve().then(() => {
if (__owl__.status === STATUS.MOUNTED || status !== STATUS.MOUNTED) { if (__owl__.isMounted || !isMounted) {
if (fiber.isCompleted || fiber.isRendered) { // we are mounted (__owl__.isMounted), or if we are currently being
return; // mounted (!isMounted), so we call __render
}
this.__render(fiber); this.__render(fiber);
} else { } else {
// we were mounted when render was called, but we aren't anymore, so we // we were mounted when render was called, but we aren't anymore, so we
@@ -434,7 +372,7 @@ export class Component<Props extends {} = any, T extends Env = Env> {
*/ */
destroy() { destroy() {
const __owl__ = this.__owl__; const __owl__ = this.__owl__;
if (__owl__.status !== STATUS.DESTROYED) { if (!__owl__.isDestroyed) {
const el = this.el; const el = this.el;
this.__destroy(__owl__.parent); this.__destroy(__owl__.parent);
if (el) { if (el) {
@@ -458,8 +396,15 @@ export class Component<Props extends {} = any, T extends Env = Env> {
* up to the parent DOM nodes. Thus, it must be called between mounted() and * up to the parent DOM nodes. Thus, it must be called between mounted() and
* willUnmount(). * willUnmount().
*/ */
trigger<T = any>(eventType: string, payload?: T) { trigger(eventType: string, payload?: any) {
this.__trigger<T>(this, eventType, payload); if (this.el) {
const ev = new OwlEvent(this, eventType, {
bubbles: true,
cancelable: true,
detail: payload
});
this.el.dispatchEvent(ev);
}
} }
//-------------------------------------------------------------------------- //--------------------------------------------------------------------------
@@ -477,14 +422,15 @@ export class Component<Props extends {} = any, T extends Env = Env> {
* Note that it does not call the __callWillUnmount method to avoid visiting * Note that it does not call the __callWillUnmount method to avoid visiting
* all children many times. * all children many times.
*/ */
__destroy(parent: Component | null) { __destroy(parent: Component<any, any> | null) {
const __owl__ = this.__owl__; const __owl__ = this.__owl__;
if (__owl__.status === STATUS.MOUNTED) { const isMounted = __owl__.isMounted;
if (isMounted) {
if (__owl__.willUnmountCB) { if (__owl__.willUnmountCB) {
__owl__.willUnmountCB(); __owl__.willUnmountCB();
} }
this.willUnmount(); this.willUnmount();
__owl__.status = STATUS.UNMOUNTED; __owl__.isMounted = false;
} }
const children = __owl__.children; const children = __owl__.children;
for (let key in children) { for (let key in children) {
@@ -495,7 +441,7 @@ export class Component<Props extends {} = any, T extends Env = Env> {
delete parent.__owl__.children[id]; delete parent.__owl__.children[id];
__owl__.parent = null; __owl__.parent = null;
} }
__owl__.status = STATUS.DESTROYED; __owl__.isDestroyed = true;
delete __owl__.vnode; delete __owl__.vnode;
if (__owl__.currentFiber) { if (__owl__.currentFiber) {
__owl__.currentFiber.isCompleted = true; __owl__.currentFiber.isCompleted = true;
@@ -505,7 +451,8 @@ export class Component<Props extends {} = any, T extends Env = Env> {
__callMounted() { __callMounted() {
const __owl__ = this.__owl__; const __owl__ = this.__owl__;
__owl__.status = STATUS.MOUNTED; __owl__.isMounted = true;
__owl__.currentFiber = null;
this.mounted(); this.mounted();
if (__owl__.mountedCB) { if (__owl__.mountedCB) {
__owl__.mountedCB(); __owl__.mountedCB();
@@ -518,47 +465,31 @@ export class Component<Props extends {} = any, T extends Env = Env> {
__owl__.willUnmountCB(); __owl__.willUnmountCB();
} }
this.willUnmount(); this.willUnmount();
__owl__.status = STATUS.UNMOUNTED; __owl__.isMounted = false;
if (__owl__.currentFiber) { if (this.__owl__.currentFiber) {
__owl__.currentFiber.isCompleted = true; this.__owl__.currentFiber.isCompleted = true;
__owl__.currentFiber.root.counter = 0; this.__owl__.currentFiber.root.counter = 0;
} }
const children = __owl__.children; const children = __owl__.children;
for (let id in children) { for (let id in children) {
const comp = children[id]; const comp = children[id];
if (comp.__owl__.status === STATUS.MOUNTED) { if (comp.__owl__.isMounted) {
comp.__callWillUnmount(); comp.__callWillUnmount();
} }
} }
} }
/**
* Private trigger method, allows to choose the component which triggered
* the event in the first place
*/
__trigger<T>(component: Component, eventType: string, payload?: T) {
if (this.el) {
const ev = new OwlEvent<T>(component, eventType, {
bubbles: true,
cancelable: true,
detail: payload,
});
const triggerHook = this.env[portalSymbol as any];
if (triggerHook) {
triggerHook(ev);
}
this.el.dispatchEvent(ev);
}
}
/** /**
* The __updateProps method is called by the t-component directive whenever * The __updateProps method is called by the t-component directive whenever
* it updates a component (so, when the parent template is rerendered). * it updates a component (so, when the parent template is rerendered).
*/ */
async __updateProps(nextProps: Props, parentFiber: Fiber, scope: any): Promise<void> { async __updateProps(nextProps: Props, parentFiber: Fiber, scope: any, vars: any): Promise<void> {
this.__owl__.scope = scope; this.__owl__.scope = scope;
this.__owl__.vars = vars;
const shouldUpdate = parentFiber.force || this.shouldUpdate(nextProps); const shouldUpdate = parentFiber.force || this.shouldUpdate(nextProps);
if (shouldUpdate) { if (shouldUpdate) {
const __owl__ = this.__owl__; const __owl__ = this.__owl__;
const fiber = new Fiber(parentFiber, this, parentFiber.force, null, null); const fiber = new Fiber(parentFiber, this, parentFiber.force, null);
if (!parentFiber.child) { if (!parentFiber.child) {
parentFiber.child = fiber; parentFiber.child = fiber;
} else { } else {
@@ -575,7 +506,7 @@ export class Component<Props extends {} = any, T extends Env = Env> {
} }
await Promise.all([ await Promise.all([
this.willUpdateProps(nextProps), this.willUpdateProps(nextProps),
__owl__.willUpdatePropsCB && __owl__.willUpdatePropsCB(nextProps), __owl__.willUpdatePropsCB && __owl__.willUpdatePropsCB(nextProps)
]); ]);
if (fiber.isCompleted) { if (fiber.isCompleted) {
return; return;
@@ -590,18 +521,21 @@ export class Component<Props extends {} = any, T extends Env = Env> {
* Main patching method. We call the virtual dom patch method here to convert * Main patching method. We call the virtual dom patch method here to convert
* a virtual dom vnode into some actual dom. * a virtual dom vnode into some actual dom.
*/ */
__patch(target: HTMLElement | VNode | DocumentFragment, vnode: VNode) { __patch(vnode: VNode) {
this.__owl__.vnode = patch(target as any, vnode); const __owl__ = this.__owl__;
const target = __owl__.vnode || document.createElement(vnode.sel!);
__owl__.vnode = patch(target, vnode);
} }
/** /**
* The __prepare method is only called by the t-component directive, when a * The __prepare method is only called by the t-component directive, when a
* subcomponent is created. It gets its scope, if any, from the * subcomponent is created. It gets its scope and vars, if any, from the
* parent template. * parent template.
*/ */
__prepare(parentFiber: Fiber, scope: any, cb: CallableFunction): Fiber { __prepare(parentFiber: Fiber, scope: any, vars: any) {
this.__owl__.scope = scope; this.__owl__.scope = scope;
const fiber = new Fiber(parentFiber, this, parentFiber.force, null, null); this.__owl__.vars = vars;
const fiber = new Fiber(parentFiber, this, parentFiber.force, null);
fiber.shouldPatch = false; fiber.shouldPatch = false;
if (!parentFiber.child) { if (!parentFiber.child) {
parentFiber.child = fiber; parentFiber.child = fiber;
@@ -609,24 +543,9 @@ export class Component<Props extends {} = any, T extends Env = Env> {
parentFiber.lastChild!.sibling = fiber; parentFiber.lastChild!.sibling = fiber;
} }
parentFiber.lastChild = fiber; parentFiber.lastChild = fiber;
this.__prepareAndRender(fiber, cb); return this.__prepareAndRender(fiber);
return fiber;
} }
/**
* Apply the stylesheets defined by the component. Note that we need to make
* sure all inherited stylesheets are applied as well. We then delete the
* `style` key from the constructor to make sure we do not apply it again.
*/
private __applyStyles(constr) {
while (constr && constr.style) {
if (constr.hasOwnProperty("style")) {
activateSheet(constr.style, constr.name);
delete constr.style;
}
constr = constr.__proto__;
}
}
__getTemplate(qweb: QWeb): string { __getTemplate(qweb: QWeb): string {
let p = (<any>this).constructor; let p = (<any>this).constructor;
if (!p.hasOwnProperty("_template")) { if (!p.hasOwnProperty("_template")) {
@@ -634,10 +553,9 @@ export class Component<Props extends {} = any, T extends Env = Env> {
// key. So we fall back on looking for a template matching its name (or // key. So we fall back on looking for a template matching its name (or
// one of its subclass). // one of its subclass).
let template: string = p.name; let template: string;
while (!(template in qweb.templates) && p !== Component) { while ((template = p.name) && !(template in qweb.templates) && p !== Component) {
p = p.__proto__; p = p.__proto__;
template = p.name;
} }
if (p === Component) { if (p === Component) {
throw new Error(`Could not find template for component "${this.constructor.name}"`); throw new Error(`Could not find template for component "${this.constructor.name}"`);
@@ -647,26 +565,18 @@ export class Component<Props extends {} = any, T extends Env = Env> {
} }
return p._template; return p._template;
} }
async __prepareAndRender(fiber: Fiber) {
async __prepareAndRender(fiber: Fiber, cb: CallableFunction) {
try { try {
const proms = Promise.all([ await Promise.all([this.willStart(), this.__owl__.willStartCB && this.__owl__.willStartCB()]);
this.willStart(),
this.__owl__.willStartCB && this.__owl__.willStartCB(),
]);
this.__owl__.status = STATUS.WILLSTARTED;
await proms;
if (this.__owl__.status === <any>STATUS.DESTROYED) {
return Promise.resolve();
}
} catch (e) { } catch (e) {
fiber.handleError(e); fiber.handleError(e);
return Promise.resolve(); return Promise.resolve();
} }
if (this.__owl__.isDestroyed) {
return Promise.resolve();
}
if (!fiber.isCompleted) { if (!fiber.isCompleted) {
this.__render(fiber); this.__render(fiber);
this.__owl__.status = STATUS.RENDERED;
cb();
} }
} }
@@ -679,35 +589,16 @@ export class Component<Props extends {} = any, T extends Env = Env> {
try { try {
let vnode = __owl__.renderFn!(this, { let vnode = __owl__.renderFn!(this, {
handlers: __owl__.boundHandlers, handlers: __owl__.boundHandlers,
fiber: fiber, fiber: fiber
}); });
// we iterate over the children to detect those that no longer belong to the // we iterate over the children to detect those that no longer belong to the
// current rendering: those ones, if not mounted yet, can (and have to) be // current rendering: those ones, if not mounted yet, can (and have to) be
// destroyed right now, because they are not in the DOM, and thus we won't // destroyed right now, because they are not in the DOM, and thus we won't
// be notified later on (when patching), that they are removed from the DOM // be notified later on (when patching), that they are removed from the DOM
for (let childKey in __owl__.children) { for (let childKey in __owl__.children) {
const child = __owl__.children[childKey]; let child = __owl__.children[childKey];
const childOwl = child.__owl__; if (!child.__owl__.isMounted && child.__owl__.parentLastFiberId < fiber.id) {
if (childOwl.status !== STATUS.MOUNTED && childOwl.parentLastFiberId < fiber.id) { child.destroy();
// we only do here a "soft" destroy, meaning that we leave the child
// dom node alone, without removing it. Most of the time, it does not
// matter, because the child component is already unmounted. However,
// if some of its parent have been unmounted, the child could actually
// still be attached to its parent, and this may be important if we
// want to remount the parent, because the vdom need to match the
// actual DOM
child.__destroy(childOwl.parent);
if (childOwl.pvnode) {
// we remove the key here to make sure that the patching algorithm
// is able to make the difference between this pvnode and an eventual
// other instance of the same component
delete childOwl.pvnode.key;
// Since the component has been unmounted, we do not want to actually
// call a remove hook. This is pretty important, since the t-component
// directive actually disabled it, so the vdom algorithm will just
// not remove the child elm if we don't remove the hook.
delete childOwl.pvnode.data!.hook!.remove;
}
} }
} }
if (!vnode) { if (!vnode) {
@@ -735,6 +626,17 @@ export class Component<Props extends {} = any, T extends Env = Env> {
} }
} }
/**
* Only called by qweb t-component directive (when t-keepalive is set)
*/
__remount() {
const __owl__ = this.__owl__;
if (!__owl__.isMounted) {
__owl__.isMounted = true;
this.mounted();
}
}
/** /**
* Apply default props (only top level). * Apply default props (only top level).
* *
@@ -748,34 +650,3 @@ export class Component<Props extends {} = any, T extends Env = Env> {
} }
} }
} }
interface MountParameters {
env?: Env;
target: HTMLElement | DocumentFragment;
props?: any;
position?: MountOptions["position"];
}
interface Type<T> extends Function {
new (...args: any[]): T;
}
export async function mount<T extends Type<Component>>(
C: T,
params: MountParameters
): Promise<InstanceType<T>> {
const { env, props, target } = params;
let origEnv = C.hasOwnProperty("env") ? (C as any).env : null;
if (env) {
(C as any as typeof Component).env = env;
}
const component: Component = new C(null, props);
if (origEnv) {
(C as any).env = origEnv;
} else {
delete (C as any).env;
}
const position = params.position || "last-child";
await component.mount(target, { position });
return component as any;
}
+83 -129
View File
@@ -1,14 +1,13 @@
import { QWeb } from "../qweb/index"; import { QWeb } from "../qweb/index";
import { INTERP_REGEXP } from "../qweb/compilation_context"; import { INTERP_REGEXP } from "../qweb/compilation_context";
import { makeHandlerCode, MODS_CODE } from "../qweb/extensions"; import { MODS_CODE } from "../qweb/extensions";
import { STATUS } from "./component";
//------------------------------------------------------------------------------ //------------------------------------------------------------------------------
// t-component // t-component
//------------------------------------------------------------------------------ //------------------------------------------------------------------------------
const T_COMPONENT_MODS_CODE = Object.assign({}, MODS_CODE, { const T_COMPONENT_MODS_CODE = Object.assign({}, MODS_CODE, {
self: "if (e.target !== vn.elm) {return}", self: "if (e.target !== vn.elm) {return}"
}); });
QWeb.utils.defineProxy = function defineProxy(target, source) { QWeb.utils.defineProxy = function defineProxy(target, source) {
@@ -19,31 +18,11 @@ QWeb.utils.defineProxy = function defineProxy(target, source) {
}, },
set(val) { set(val) {
source[k] = val; source[k] = val;
}, }
}); });
} }
}; };
QWeb.utils.assignHooks = function assignHooks(dataObj, hooks) {
if ("hook" in dataObj) {
const hookObject = dataObj.hook;
for (let name in hooks) {
const current = hookObject[name];
const fn = hooks[name];
if (current) {
hookObject[name] = (...args) => {
current(...args);
fn(...args);
};
} else {
hookObject[name] = fn;
}
}
} else {
dataObj.hook = hooks;
}
};
/** /**
* The t-component directive is certainly a complicated and hard to maintain piece * The t-component directive is certainly a complicated and hard to maintain piece
* of code. To help you, fellow developer, if you have to maintain it, I offer * of code. To help you, fellow developer, if you have to maintain it, I offer
@@ -210,15 +189,15 @@ QWeb.addDirective({
extraNames: ["props"], extraNames: ["props"],
priority: 100, priority: 100,
atNodeEncounter({ ctx, value, node, qweb }): boolean { atNodeEncounter({ ctx, value, node, qweb }): boolean {
ctx.addLine(`// Component '${value}'`); ctx.addLine("//COMPONENT");
ctx.rootContext.shouldDefineOwner = true;
ctx.rootContext.shouldDefineQWeb = true; ctx.rootContext.shouldDefineQWeb = true;
ctx.rootContext.shouldDefineParent = true; ctx.rootContext.shouldDefineParent = true;
ctx.rootContext.shouldDefineUtils = true; ctx.rootContext.shouldDefineUtils = true;
ctx.rootContext.shouldDefineScope = true;
let hasDynamicProps = node.getAttribute("t-props") ? true : false; let hasDynamicProps = node.getAttribute("t-props") ? true : false;
// t-on- events and t-transition // t-on- events and t-transition
const events: [string, string][] = []; const events: [string, string[], string, string][] = [];
let transition: string = ""; let transition: string = "";
const attributes = (<Element>node).attributes; const attributes = (<Element>node).attributes;
const props: { [key: string]: string } = {}; const props: { [key: string]: string } = {};
@@ -226,41 +205,31 @@ QWeb.addDirective({
const name = attributes[i].name; const name = attributes[i].name;
const value = attributes[i].textContent!; const value = attributes[i].textContent!;
if (name.startsWith("t-on-")) { if (name.startsWith("t-on-")) {
events.push([name, value]); const [eventName, ...mods] = name.slice(5).split(".");
let extraArgs;
let handlerValue = value.replace(/\(.*\)/, function(args) {
extraArgs = args.slice(1, -1);
return "";
});
events.push([eventName, mods, handlerValue, extraArgs]);
} else if (name === "t-transition") { } else if (name === "t-transition") {
if (QWeb.enableTransitions) {
transition = value; transition = value;
}
} else if (!name.startsWith("t-")) { } else if (!name.startsWith("t-")) {
if (name !== "class" && name !== "style") { if (name !== "class" && name !== "style") {
// this is a prop! // this is a prop!
if (value.includes("=>")) { props[name] = ctx.formatExpression(value);
props[name] = ctx.captureExpression(value);
} else {
props[name] = ctx.formatExpression(value) || "undefined";
}
} }
} }
} }
// computing the props string representing the props object // computing the props string representing the props object
let propStr = Object.keys(props) let propStr = Object.keys(props)
.map((k) => k + ":" + props[k]) .map(k => k + ":" + props[k])
.join(","); .join(",");
let defID = ctx.generateID();
let componentID = ctx.generateID(); let componentID = ctx.generateID();
let hasDefinedKey = false; const templateKey = ctx.generateTemplateKey();
let templateKey;
if (node.tagName === "t" && !node.hasAttribute("t-key") && value.match(INTERP_REGEXP)) {
defineComponentKey();
const id = ctx.generateID();
// the ___ is to make sure we have no possible conflict with normal
// template keys
ctx.addLine(`let k${id} = '___' + componentKey${componentID}`);
templateKey = `k${id}`;
} else {
templateKey = ctx.generateTemplateKey();
}
let ref = node.getAttribute("t-ref"); let ref = node.getAttribute("t-ref");
let refExpr = ""; let refExpr = "";
let refKey: string = ""; let refKey: string = "";
@@ -298,7 +267,7 @@ QWeb.addDirective({
let classDef = classAttr let classDef = classAttr
.trim() .trim()
.split(/\s+/) .split(/\s+/)
.map((a) => `'${a}':true`) .map(a => `'${a}':true`)
.join(","); .join(",");
classObj = `_${ctx.generateID()}`; classObj = `_${ctx.generateID()}`;
ctx.addLine(`let ${classObj} = {${classDef}};`); ctx.addLine(`let ${classObj} = {${classDef}};`);
@@ -306,7 +275,7 @@ QWeb.addDirective({
if (tattClass) { if (tattClass) {
let tattExpr = ctx.formatExpression(tattClass); let tattExpr = ctx.formatExpression(tattClass);
if (tattExpr[0] !== "{" || tattExpr[tattExpr.length - 1] !== "}") { if (tattExpr[0] !== "{" || tattExpr[tattExpr.length - 1] !== "}") {
tattExpr = `utils.toClassObj(${tattExpr})`; tattExpr = `utils.toObj(${tattExpr})`;
} }
if (classAttr) { if (classAttr) {
ctx.addLine(`Object.assign(${classObj}, ${tattExpr})`); ctx.addLine(`Object.assign(${classObj}, ${tattExpr})`);
@@ -316,25 +285,37 @@ QWeb.addDirective({
} }
} }
let eventsCode = events let eventsCode = events
.map(function ([name, value]) { .map(function([eventName, mods, handlerValue, extraArgs]) {
const capture = name.match(/\.capture/); let params = "owner";
name = capture ? name.replace(/\.capture/, "") : name; if (extraArgs) {
const { event, handler } = makeHandlerCode( if (ctx.loopNumber) {
ctx, let argId = ctx.generateID();
name, // we need to evaluate the arguments now, because the handler will
value, // be set asynchronously later when the widget is ready, and the
false, // context might be different.
T_COMPONENT_MODS_CODE ctx.addLine(`let arg${argId} = ${ctx.formatExpression(extraArgs)};`);
); params = `owner, arg${argId}`;
if (capture) { } else {
return `vn.elm.addEventListener('${event}', ${handler}, true);`; params = `owner, ${ctx.formatExpression(extraArgs)}`;
} }
return `vn.elm.addEventListener('${event}', ${handler});`; }
let handler = `function (e) {`;
handler += mods
.map(function(mod) {
return T_COMPONENT_MODS_CODE[mod];
})
.join("");
if (handlerValue) {
handler += `const fn = owner['${handlerValue}'];`;
handler += `if (fn) { fn.call(${params}, e); } else { owner.${handlerValue}; }`;
}
handler += `}`;
return `vn.elm.addEventListener('${eventName}', ${handler});`;
}) })
.join(""); .join("");
const styleExpr = tattStyle || (styleAttr ? `'${styleAttr}'` : false); const styleExpr = tattStyle || (styleAttr ? `'${styleAttr}'` : false);
const styleCode = styleExpr ? `vn.elm.style = ${styleExpr};` : ""; const styleCode = styleExpr ? `vn.elm.style = ${styleExpr};` : "";
createHook = `utils.assignHooks(vnode.data, {create(_, vn){${styleCode}${eventsCode}}});`; createHook = `vnode.data.hook = {create(_, vn){${styleCode}${eventsCode}}};`;
} }
ctx.addLine( ctx.addLine(
@@ -351,7 +332,7 @@ QWeb.addDirective({
} }
if (hasDynamicProps) { if (hasDynamicProps) {
const dynamicProp = ctx.formatExpression(node.getAttribute("t-props")!); const dynamicProp = ctx.formatExpression(node.getAttribute("t-props")!);
ctx.addLine(`let props${componentID} = Object.assign({}, ${dynamicProp}, {${propStr}});`); ctx.addLine(`let props${componentID} = Object.assign({${propStr}}, ${dynamicProp});`);
} else { } else {
ctx.addLine(`let props${componentID} = {${propStr}};`); ctx.addLine(`let props${componentID} = {${propStr}};`);
} }
@@ -368,19 +349,36 @@ QWeb.addDirective({
} }
// SLOTS // SLOTS
const varDefs: string[] = [];
const hasSlots = node.childNodes.length; const hasSlots = node.childNodes.length;
if (hasSlots) {
ctx.rootContext.shouldTrackScope = true;
for (let v of Object.values(ctx.variables)) {
if (v["id"]) {
varDefs.push(v["id"]);
}
}
}
let scope = hasSlots ? `utils.combine(context, scope)` : "undefined"; let scopeVars;
if (hasSlots) {
let scope = ctx.scopeVars.length ? `Object.assign({}, scope)` : `{}`;
let vars = varDefs.length ? `{${varDefs.join(",")}}` : "undefined";
scopeVars = `${scope}, ${vars}`;
} else {
scopeVars = "undefined, undefined";
}
ctx.addIf(`w${componentID}`); ctx.addIf(`w${componentID}`);
// need to update component // need to update component
let styleCode = ""; let styleCode = "";
if (tattStyle) { if (tattStyle) {
styleCode = `.then(()=>{if (w${componentID}.__owl__.status === ${STATUS.DESTROYED}) {return};w${componentID}.el.style=${tattStyle};});`; styleCode = `.then(()=>{if (w${componentID}.__owl__.isDestroyed) {return};w${componentID}.el.style=${tattStyle};});`;
} }
ctx.addLine( ctx.addLine(
`w${componentID}.__updateProps(props${componentID}, extra.fiber, ${scope})${styleCode};` `w${componentID}.__updateProps(props${componentID}, extra.fiber${scopeVars &&
", " + scopeVars})${styleCode};`
); );
ctx.addLine(`let pvnode = w${componentID}.__owl__.pvnode;`); ctx.addLine(`let pvnode = w${componentID}.__owl__.pvnode;`);
if (registerCode) { if (registerCode) {
@@ -393,17 +391,14 @@ QWeb.addDirective({
ctx.addElse(); ctx.addElse();
// new component // new component
function defineComponentKey() { let dynamicFallback = "";
if (!hasDefinedKey) { if (!value.match(INTERP_REGEXP)) {
dynamicFallback = `|| ${ctx.formatExpression(value)}`;
}
const interpValue = ctx.interpolate(value); const interpValue = ctx.interpolate(value);
ctx.addLine(`let componentKey${componentID} = ${interpValue};`); ctx.addLine(`let componentKey${componentID} = ${interpValue};`);
hasDefinedKey = true;
}
}
defineComponentKey();
const contextualValue = value.match(INTERP_REGEXP) ? "false" : ctx.formatExpression(value);
ctx.addLine( ctx.addLine(
`let W${componentID} = ${contextualValue} || context.constructor.components[componentKey${componentID}] || QWeb.components[componentKey${componentID}];` `let W${componentID} = context.constructor.components[componentKey${componentID}] || QWeb.components[componentKey${componentID}]${dynamicFallback};`
); );
// maybe only do this in dev mode... // maybe only do this in dev mode...
@@ -414,87 +409,46 @@ QWeb.addDirective({
if (transition) { if (transition) {
ctx.addLine(`const __patch${componentID} = w${componentID}.__patch;`); ctx.addLine(`const __patch${componentID} = w${componentID}.__patch;`);
ctx.addLine( ctx.addLine(
`w${componentID}.__patch = (t, vn) => {__patch${componentID}.call(w${componentID}, t, vn); if(!w${componentID}.__owl__.transitionInserted){w${componentID}.__owl__.transitionInserted = true;utils.transitionInsert(w${componentID}.__owl__.vnode, '${transition}');}};` `w${componentID}.__patch = fiber => {__patch${componentID}.call(w${componentID}, fiber); if(!w${componentID}.__owl__.transitionInserted){w${componentID}.__owl__.transitionInserted = true;utils.transitionInsert(w${componentID}.__owl__.vnode, '${transition}');}};`
); );
} }
ctx.addLine(`parent.__owl__.cmap[${templateKey}] = w${componentID}.__owl__.id;`); ctx.addLine(`parent.__owl__.cmap[${templateKey}] = w${componentID}.__owl__.id;`);
if (hasSlots) { if (hasSlots) {
const clone = <Element>node.cloneNode(true); const clone = <Element>node.cloneNode(true);
const slotNodes = clone.querySelectorAll("[t-set]");
// The next code is a fallback for compatibility reason. It accepts t-set
// elements that are direct children with a non empty body as nodes defining
// the content of a slot.
//
// This is wrong, but is necessary to prevent breaking all existing Owl
// code using slots. This will be removed in v2.0 someday. Meanwhile,
// please use t-set-slot everywhere you need to set the content of a
// slot.
for (let node of clone.children) {
if (node.hasAttribute("t-set") && node.hasChildNodes()) {
node.setAttribute("t-set-slot", node.getAttribute("t-set")!);
node.removeAttribute("t-set");
}
}
const slotNodes = Array.from(clone.querySelectorAll("[t-set-slot]"));
const slotNames = new Set<string>();
const slotId = QWeb.nextSlotId++; const slotId = QWeb.nextSlotId++;
ctx.addLine(`w${componentID}.__owl__.slotId = ${slotId};`); ctx.addLine(`w${componentID}.__owl__.slotId = ${slotId};`);
if (slotNodes.length) { if (slotNodes.length) {
for (let i = 0, length = slotNodes.length; i < length; i++) { for (let i = 0, length = slotNodes.length; i < length; i++) {
const slotNode = slotNodes[i]; const slotNode = slotNodes[i];
// check if this is defined in a sub component (in which case it should
// be ignored)
let el = slotNode.parentElement;
let isInSubComponent = false;
while (el !== clone) {
if (
el!.hasAttribute("t-component") ||
el!.tagName[0] === el!.tagName[0].toUpperCase()
) {
isInSubComponent = true;
break;
}
el = el.parentElement;
}
if (isInSubComponent) {
continue;
}
let key = slotNode.getAttribute("t-set-slot")!;
if (slotNames.has(key)) {
continue;
}
slotNames.add(key);
slotNode.removeAttribute("t-set-slot");
slotNode.parentElement!.removeChild(slotNode); slotNode.parentElement!.removeChild(slotNode);
const key = slotNode.getAttribute("t-set")!;
const slotFn = qweb._compile(`slot_${key}_template`, { elem: slotNode, hasParent: true }); slotNode.removeAttribute("t-set");
const slotFn = qweb._compile(`slot_${key}_template`, slotNode, ctx);
QWeb.slots[`${slotId}_${key}`] = slotFn; QWeb.slots[`${slotId}_${key}`] = slotFn;
} }
} }
if (clone.childNodes.length) { if (clone.childNodes.length) {
let hasContent = false;
const t = clone.ownerDocument!.createElement("t"); const t = clone.ownerDocument!.createElement("t");
for (let child of Object.values(clone.childNodes)) { for (let child of Object.values(clone.childNodes)) {
hasContent =
hasContent || (child instanceof Text ? Boolean(child.textContent.trim().length) : true);
t.appendChild(child); t.appendChild(child);
} }
if (hasContent) { const slotFn = qweb._compile(`slot_default_template`, t, ctx);
const slotFn = qweb._compile(`slot_default_template`, { elem: t, hasParent: true });
QWeb.slots[`${slotId}_default`] = slotFn; QWeb.slots[`${slotId}_default`] = slotFn;
} }
} }
}
ctx.addLine( ctx.addLine(`let def${defID} = w${componentID}.__prepare(extra.fiber, ${scopeVars});`);
`let fiber = w${componentID}.__prepare(extra.fiber, ${scope}, () => { const vnode = fiber.vnode; pvnode.sel = vnode.sel; ${createHook}});`
);
// hack: specify empty remove hook to prevent the node from being removed from the DOM // hack: specify empty remove hook to prevent the node from being removed from the DOM
const insertHook = refExpr ? `insert(vn) {${refExpr}},` : ""; const insertHook = refExpr ? `insert(vn) {${refExpr}},` : "";
ctx.addLine( ctx.addLine(
`let pvnode = h('dummy', {key: ${templateKey}, hook: {${insertHook}remove() {},destroy(vn) {${finalizeComponentCode}}}});` `let pvnode = h('dummy', {key: ${templateKey}, hook: {${insertHook}remove() {},destroy(vn) {${finalizeComponentCode}}}});`
); );
ctx.addLine(`const fiber = w${componentID}.__owl__.currentFiber;`);
ctx.addLine(
`def${defID}.then(function () { if (fiber.isCompleted) { return; } const vnode = fiber.vnode; pvnode.sel = vnode.sel; ${createHook}});`
);
if (registerCode) { if (registerCode) {
ctx.addLine(registerCode); ctx.addLine(registerCode);
} }
@@ -512,5 +466,5 @@ QWeb.addDirective({
ctx.addLine(`w${componentID}.__owl__.parentLastFiberId = extra.fiber.id;`); ctx.addLine(`w${componentID}.__owl__.parentLastFiberId = extra.fiber.id;`);
return true; return true;
}, }
}); });
+32 -96
View File
@@ -1,5 +1,5 @@
import { h, VNode } from "../vdom/index"; import { h, VNode } from "../vdom/index";
import { Component, MountPosition, STATUS } from "./component"; import { Component } from "./component";
import { scheduler } from "./scheduler"; import { scheduler } from "./scheduler";
/** /**
@@ -46,12 +46,12 @@ export class Fiber {
// scheduler. // scheduler.
counter: number = 0; counter: number = 0;
target: HTMLElement | DocumentFragment | null; target: HTMLElement | null;
position: MountPosition | null;
scope: any; scope: any;
vars: any;
component: Component; component: Component<any, any>;
vnode: VNode | null = null; vnode: VNode | null = null;
root: Fiber; root: Fiber;
@@ -62,27 +62,20 @@ export class Fiber {
error?: Error; error?: Error;
constructor( constructor(parent: Fiber | null, component: Component<any, any>, force, target) {
parent: Fiber | null,
component: Component,
force: boolean,
target: HTMLElement | DocumentFragment | null,
position: MountPosition | null
) {
this.component = component; this.component = component;
this.force = force; this.force = force;
this.target = target; this.target = target;
this.position = position;
const __owl__ = component.__owl__; const __owl__ = component.__owl__;
this.scope = __owl__.scope; this.scope = __owl__.scope;
this.vars = __owl__.vars;
this.root = parent ? parent.root : this; this.root = parent ? parent.root : this;
this.parent = parent; this.parent = parent;
let oldFiber = __owl__.currentFiber; let oldFiber = __owl__.currentFiber;
if (oldFiber && !oldFiber.isCompleted) { if (oldFiber && !oldFiber.isCompleted) {
this.force = true;
if (oldFiber.root === oldFiber && !parent) { if (oldFiber.root === oldFiber && !parent) {
// both oldFiber and this fiber are root fibers // both oldFiber and this fiber are root fibers
this._reuseFiber(oldFiber); this._reuseFiber(oldFiber);
@@ -107,8 +100,6 @@ export class Fiber {
*/ */
_reuseFiber(oldFiber: Fiber) { _reuseFiber(oldFiber: Fiber) {
oldFiber.cancel(); // cancel children fibers oldFiber.cancel(); // cancel children fibers
oldFiber.target = this.target || oldFiber.target;
oldFiber.position = this.position || oldFiber.position;
oldFiber.isCompleted = false; // keep the root fiber alive oldFiber.isCompleted = false; // keep the root fiber alive
oldFiber.isRendered = false; // the fiber has to be re-rendered oldFiber.isRendered = false; // the fiber has to be re-rendered
if (oldFiber.child) { if (oldFiber.child) {
@@ -189,26 +180,25 @@ export class Fiber {
*/ */
complete() { complete() {
let component = this.component; let component = this.component;
let fiber: Fiber = this;
this.isCompleted = true; this.isCompleted = true;
const status = component.__owl__.status; if (!this.target && !component.__owl__.isMounted) {
if (status === STATUS.DESTROYED) {
return; return;
} }
// build patchQueue // build patchQueue
const patchQueue: Fiber[] = []; const patchQueue: Fiber[] = [];
const doWork: (Fiber) => Fiber | null = function(f) { const doWork: (Fiber) => Fiber | null = function(f) {
f.component.__owl__.currentFiber = null;
patchQueue.push(f); patchQueue.push(f);
return f.child; return f.child;
}; };
this._walk(doWork); this._walk(doWork);
const patchLen = patchQueue.length; const patchLen = patchQueue.length;
try {
// call willPatch hook on each fiber of patchQueue // call willPatch hook on each fiber of patchQueue
if (status === STATUS.MOUNTED) {
for (let i = 0; i < patchLen; i++) { for (let i = 0; i < patchLen; i++) {
const fiber = patchQueue[i]; fiber = patchQueue[i];
if (fiber.shouldPatch) { if (fiber.shouldPatch) {
component = fiber.component; component = fiber.component;
if (component.__owl__.willPatchCB) { if (component.__owl__.willPatchCB) {
@@ -217,83 +207,46 @@ export class Fiber {
component.willPatch(); component.willPatch();
} }
} }
}
// call __patch on each fiber of (reversed) patchQueue // call __patch on each fiber of (reversed) patchQueue
for (let i = patchLen - 1; i >= 0; i--) { for (let i = patchLen - 1; i >= 0; i--) {
const fiber = patchQueue[i]; fiber = patchQueue[i];
component = fiber.component; component = fiber.component;
if (fiber.target && i === 0) { component.__patch(fiber.vnode!);
let target; if (!fiber.shouldPatch && (!fiber.target || i !== 0)) {
if (fiber.position === "self") {
target = fiber.target;
if ((target as HTMLElement).tagName.toLowerCase() !== fiber.vnode!.sel) {
throw new Error(
`Cannot attach '${component.constructor.name}' to target node (not same tag name)`
);
}
// In self mode, we *know* we are to take possession of the target
// Hence we manually create the corresponding VNode and copy the "key" in data
const selfVnodeData = fiber.vnode!.data ? { key: fiber.vnode!.data.key } : {};
const selfVnode = h(fiber.vnode!.sel, selfVnodeData);
selfVnode.elm = target;
target = selfVnode;
} else {
target = component.__owl__.vnode || document.createElement(fiber.vnode!.sel!);
}
component.__patch(target!, fiber.vnode!);
} else {
const vnode = component.__owl__.vnode;
if (fiber.shouldPatch && vnode) {
component.__patch(vnode, fiber.vnode!);
// When updating a Component's props (in directive),
// the component has a pvnode AND should be patched.
// However, its pvnode.elm may have changed if it is a High Order Component
if (component.__owl__.pvnode) {
component.__owl__.pvnode.elm = component.__owl__.vnode!.elm;
}
} else {
component.__patch(document.createElement(fiber.vnode!.sel!), fiber.vnode!);
component.__owl__.pvnode!.elm = component.__owl__.vnode!.elm; component.__owl__.pvnode!.elm = component.__owl__.vnode!.elm;
} }
} component.__owl__.currentFiber = null;
} }
// insert into the DOM (mount case) // insert into the DOM (mount case)
let inDOM = false; let inDOM = false;
if (this.target) { if (this.target) {
switch (this.position) {
case "first-child":
this.target.prepend(this.component.el!);
break;
case "last-child":
this.target.appendChild(this.component.el!); this.target.appendChild(this.component.el!);
break; inDOM = document.body.contains(this.target);
}
inDOM = document.body.contains(this.component.el);
this.component.env.qweb.trigger("dom-appended");
} }
// call patched/mounted hook on each fiber of (reversed) patchQueue // call patched/mounted hook on each fiber of (reversed) patchQueue
if (status === STATUS.MOUNTED || inDOM) {
for (let i = patchLen - 1; i >= 0; i--) { for (let i = patchLen - 1; i >= 0; i--) {
const fiber = patchQueue[i]; fiber = patchQueue[i];
component = fiber.component; component = fiber.component;
if (fiber.shouldPatch && !this.target) { if (fiber.shouldPatch && !this.target) {
component.patched(); component.patched();
if (component.__owl__.patchedCB) { if (component.__owl__.patchedCB) {
component.__owl__.patchedCB(); component.__owl__.patchedCB();
} }
} else { } else if (this.target ? inDOM : true) {
component.__callMounted(); component.__callMounted();
} }
} }
} else { } catch (e) {
for (let i = patchLen - 1; i >= 0; i--) { // if there is no current fiber on component, we are in the situation where
const fiber = patchQueue[i]; // components were patched to the DOM, but a mounted/patched hook threw an
component = fiber.component; // error. In that case, we cannot manage the error at a lower level than
component.__owl__.status = STATUS.UNMOUNTED; // the root fiber, since some components may not have been properly mounted
} // patched yet.
const errorFiber = component.__owl__.currentFiber ? fiber : this;
errorFiber.handleError(e);
} }
} }
@@ -301,7 +254,7 @@ export class Fiber {
* Cancel a fiber and all its children. * Cancel a fiber and all its children.
*/ */
cancel() { cancel() {
this._walk((f) => { this._walk(f => {
if (!f.isRendered) { if (!f.isRendered) {
f.root.counter--; f.root.counter--;
} }
@@ -324,43 +277,26 @@ export class Fiber {
const qweb = component.env.qweb; const qweb = component.env.qweb;
let root = component; let root = component;
function handle(error) {
let canCatch = false; let canCatch = false;
qweb.trigger("error", error);
while (component && !(canCatch = !!component.catchError)) { while (component && !(canCatch = !!component.catchError)) {
root = component; root = component;
component = component.__owl__.parent!; component = component.__owl__.parent!;
} }
qweb.trigger("error", error);
if (canCatch) { if (canCatch) {
try { // this.root.isCompleted = false
this.root.isCompleted = false;
// component.__owl__.currentFiber!.root.isCompleted = false;
component.catchError!(error); component.catchError!(error);
} catch (e) {
root = component;
component = component.__owl__.parent!;
return handle(e);
}
return true;
}
return false;
}
let isHandled = handle(error); } else {
if (!isHandled) {
// the 3 next lines aim to mark the root fiber as being in error, and // the 3 next lines aim to mark the root fiber as being in error, and
// to force it to end, without waiting for its children // to force it to end, without waiting for its children
this.root.counter = 0; this.root.counter = 0;
this.root.error = error; this.root.error = error;
scheduler.flush(); scheduler.flush();
// at this point, the state of the application is corrupted and we could
// have a lot of issues or crashes. So we destroy the application in a try
// catch and swallow these errors because the fiber is already in error,
// and this is the actual issue that needs to be solved, not those followup
// errors.
try {
root.destroy(); root.destroy();
} catch (e) {}
} }
} }
} }
+1 -1
View File
@@ -37,7 +37,7 @@ QWeb.utils.validateProps = function (Widget, props: Object) {
if (propsDef[propName] && !propsDef[propName].optional) { if (propsDef[propName] && !propsDef[propName].optional) {
throw new Error(`Missing props '${propName}' (component '${Widget.name}')`); throw new Error(`Missing props '${propName}' (component '${Widget.name}')`);
} else { } else {
continue; break;
} }
} }
let isValid; let isValid;
+14 -35
View File
@@ -1,5 +1,4 @@
import { Fiber } from "./fiber"; import { Fiber } from "./fiber";
import { browser } from "../browser";
/** /**
* Owl Scheduler Class * Owl Scheduler Class
@@ -20,22 +19,13 @@ interface Task {
export class Scheduler { export class Scheduler {
tasks: Task[] = []; tasks: Task[] = [];
isRunning: boolean = false; isRunning: boolean = false;
requestAnimationFrame: Window["requestAnimationFrame"]; requestAnimationFrame: typeof window.requestAnimationFrame;
constructor(requestAnimationFrame: Window["requestAnimationFrame"]) { constructor(requestAnimationFrame) {
this.requestAnimationFrame = requestAnimationFrame; this.requestAnimationFrame = requestAnimationFrame;
} }
start() { addFiber(fiber): Promise<void> {
this.isRunning = true;
this.scheduleTasks();
}
stop() {
this.isRunning = false;
}
addFiber(fiber: Fiber): Promise<void> {
// if the fiber was remapped into a larger rendering fiber, it may not be a // if the fiber was remapped into a larger rendering fiber, it may not be a
// root fiber. But we only want to register root fibers // root fiber. But we only want to register root fibers
fiber = fiber.root; fiber = fiber.root;
@@ -50,25 +40,14 @@ export class Scheduler {
return reject(fiber.error); return reject(fiber.error);
} }
resolve(); resolve();
}, }
}); });
if (!this.isRunning) { if (!this.isRunning) {
this.start(); this.scheduleTasks();
} }
}); });
} }
rejectFiber(fiber: Fiber, reason: string) {
fiber = fiber.root;
const index = this.tasks.findIndex((t) => t.fiber === fiber);
if (index >= 0) {
const [task] = this.tasks.splice(index, 1);
fiber.cancel();
fiber.error = new Error(reason);
task.callback();
}
}
/** /**
* Process all current tasks. This only applies to the fibers that are ready. * Process all current tasks. This only applies to the fibers that are ready.
* Other tasks are left unchanged. * Other tasks are left unchanged.
@@ -76,17 +55,16 @@ export class Scheduler {
flush() { flush() {
let tasks = this.tasks; let tasks = this.tasks;
this.tasks = []; this.tasks = [];
tasks = tasks.filter((task) => { tasks = tasks.filter(task => {
if (task.fiber.isCompleted) { if (task.fiber.isCompleted) {
task.callback(); task.callback();
return false; return false;
} }
if (task.fiber.counter === 0) { if (task.fiber.counter === 0) {
if (!task.fiber.error) { if (!task.fiber.error) {
try {
task.fiber.complete(); task.fiber.complete();
} catch (e) { if (!task.fiber.isCompleted) {
task.fiber.handleError(e); return true;
} }
} }
task.callback(); task.callback();
@@ -95,19 +73,20 @@ export class Scheduler {
return true; return true;
}); });
this.tasks = tasks.concat(this.tasks); this.tasks = tasks.concat(this.tasks);
if (this.tasks.length === 0) {
this.stop();
}
} }
scheduleTasks() { scheduleTasks() {
this.isRunning = true;
this.requestAnimationFrame(() => { this.requestAnimationFrame(() => {
this.flush(); this.flush();
if (this.isRunning) { if (this.tasks.length > 0) {
this.scheduleTasks(); this.scheduleTasks();
} else {
this.isRunning = false;
} }
}); });
} }
} }
export const scheduler = new Scheduler(browser.requestAnimationFrame); const raf = window.requestAnimationFrame.bind(window);
export const scheduler = new Scheduler(raf);
-70
View File
@@ -1,70 +0,0 @@
/**
* Owl Style System
*
* This files contains the Owl code related to processing (extended) css strings
* and creating/adding <style> tags to the document head.
*/
export const STYLESHEETS: { [id: string]: HTMLStyleElement } = {};
export function processSheet(str: string): string {
const tokens = str.split(/(\{|\}|;)/).map((s) => s.trim());
const selectorStack: string[][] = [];
const parts: string[] = [];
let rules: string[] = [];
function generateSelector(stackIndex: number, parentSelector?: string) {
const parts: string[] = [];
for (const selector of selectorStack[stackIndex]) {
let part = (parentSelector && parentSelector + " " + selector) || selector;
if (part.includes("&")) {
part = selector.replace(/&/g, parentSelector || "");
}
if (stackIndex < selectorStack.length - 1) {
part = generateSelector(stackIndex + 1, part);
}
parts.push(part);
}
return parts.join(", ");
}
function generateRules() {
if (rules.length) {
parts.push(generateSelector(0) + " {");
parts.push(...rules);
parts.push("}");
rules = [];
}
}
while (tokens.length) {
let token = tokens.shift()!;
if (token === "}") {
generateRules();
selectorStack.pop();
} else {
if (tokens[0] === "{") {
generateRules();
selectorStack.push(token.split(/\s*,\s*/));
tokens.shift();
}
if (tokens[0] === ";") {
rules.push(" " + token + ";");
}
}
}
return parts.join("\n");
}
export function registerSheet(id: string, css: string) {
const sheet = document.createElement("style");
sheet.innerHTML = processSheet(css);
STYLESHEETS[id] = sheet;
}
export function activateSheet(id, name) {
const sheet = STYLESHEETS[id];
if (!sheet) {
throw new Error(
`Invalid css stylesheet for component '${name}'. Did you forget to use the 'css' tag helper?`
);
}
sheet.setAttribute("component", name);
document.head.appendChild(sheet);
}
+8 -20
View File
@@ -1,5 +1,4 @@
import { QWeb } from "./qweb/index"; import { QWeb } from "./qweb/index";
import { TRANSLATABLE_ATTRS } from "./qweb/qweb";
/** /**
* This file creates and exports the OWL 'config' object, with keys: * This file creates and exports the OWL 'config' object, with keys:
@@ -9,13 +8,9 @@ import { TRANSLATABLE_ATTRS } from "./qweb/qweb";
interface Config { interface Config {
mode: string; mode: string;
enableTransitions: boolean;
translatableAttributes: string[];
} }
export const config = { export const config = {} as Config;
translatableAttributes: TRANSLATABLE_ATTRS,
} as Config;
Object.defineProperty(config, "mode", { Object.defineProperty(config, "mode", {
get() { get() {
@@ -24,19 +19,12 @@ Object.defineProperty(config, "mode", {
set(mode: string) { set(mode: string) {
QWeb.dev = mode === "dev"; QWeb.dev = mode === "dev";
if (QWeb.dev) { if (QWeb.dev) {
console.info(`Owl is running in 'dev' mode. const url = `https://github.com/odoo/owl/blob/master/doc/reference/config.md#mode`;
console.warn(
This is not suitable for production use. `Owl is running in 'dev' mode. This is not suitable for production use. See ${url} for more information.`
See https://github.com/odoo/owl/blob/master/doc/reference/config.md#mode for more information.`); );
} else {
console.log(`Owl is now running in 'prod' mode.`);
}
} }
},
});
Object.defineProperty(config, "enableTransitions", {
get() {
return QWeb.enableTransitions;
},
set(value: boolean) {
QWeb.enableTransitions = value;
},
}); });
+16 -6
View File
@@ -79,9 +79,9 @@ export class Context extends EventBus {
async __notifyComponents() { async __notifyComponents() {
const rev = ++this.rev; const rev = ++this.rev;
const subscriptions = this.subscriptions.update; const subscriptions = this.subscriptions.update;
const groups = partitionBy(subscriptions, (s) => (s.owner ? s.owner.__owl__.depth : -1)); const groups = partitionBy(subscriptions, s => (s.owner ? s.owner.__owl__.depth : -1));
for (let group of groups) { for (let group of groups) {
const proms = group.map((sub) => sub.callback.call(sub.owner, rev)); const proms = group.map(sub => sub.callback.call(sub.owner, rev));
// at this point, each component in the current group has registered a // at this point, each component in the current group has registered a
// top level fiber in the scheduler. It could happen that rendering these // top level fiber in the scheduler. It could happen that rendering these
// components is done (if they have no children). This is why we manually // components is done (if they have no children). This is why we manually
@@ -100,11 +100,11 @@ export class Context extends EventBus {
* to context state changes. The `useContext` method returns the context state * to context state changes. The `useContext` method returns the context state
*/ */
export function useContext(ctx: Context): any { export function useContext(ctx: Context): any {
const component: Component = Component.current!; const component: Component<any, any> = Component.current!;
return useContextWithCB(ctx, component, component.render.bind(component)); return useContextWithCB(ctx, component, component.render.bind(component));
} }
export function useContextWithCB(ctx: Context, component: Component, method): any { export function useContextWithCB(ctx: Context, component: Component<any, any>, 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;
@@ -115,6 +115,16 @@ export function useContextWithCB(ctx: Context, component: Component, method): an
__owl__.observer = new Observer(); __owl__.observer = new Observer();
__owl__.observer.notifyCB = component.render.bind(component); __owl__.observer.notifyCB = component.render.bind(component);
} }
const currentCB = __owl__.observer.notifyCB;
__owl__.observer.notifyCB = function() {
if (ctx.rev > mapping[id]) {
// in this case, the context has been updated since we were rendering
// last, and we do not need to render here with the observer. A
// rendering is coming anyway, with the correct props.
return;
}
currentCB();
};
mapping[id] = 0; mapping[id] = 0;
const renderFn = __owl__.renderFn; const renderFn = __owl__.renderFn;
@@ -122,14 +132,14 @@ export function useContextWithCB(ctx: Context, component: Component, method): an
mapping[id] = ctx.rev; mapping[id] = ctx.rev;
return renderFn(comp, params); return renderFn(comp, params);
}; };
ctx.on("update", component, async (contextRev) => { ctx.on("update", component, async contextRev => {
if (mapping[id] < contextRev) { if (mapping[id] < contextRev) {
mapping[id] = contextRev; mapping[id] = contextRev;
await method(); await method();
} }
}); });
const __destroy = component.__destroy; const __destroy = component.__destroy;
component.__destroy = (parent) => { component.__destroy = parent => {
ctx.off("update", component); ctx.off("update", component);
delete mapping[id]; delete mapping[id];
__destroy.call(component, parent); __destroy.call(component, parent);
+2 -2
View File
@@ -44,7 +44,7 @@ export class EventBus {
} }
this.subscriptions[eventType].push({ this.subscriptions[eventType].push({
owner, owner,
callback, callback
}); });
} }
@@ -54,7 +54,7 @@ export class EventBus {
off(eventType: string, owner: any) { off(eventType: string, owner: any) {
const subs = this.subscriptions[eventType]; const subs = this.subscriptions[eventType];
if (subs) { if (subs) {
this.subscriptions[eventType] = subs.filter((s) => s.owner !== owner); this.subscriptions[eventType] = subs.filter(s => s.owner !== owner);
} }
} }
+4 -8
View File
@@ -20,17 +20,13 @@
export class Observer { export class Observer {
rev: number = 1; rev: number = 1;
allowMutations: boolean = true; allowMutations: boolean = true;
dirty: boolean = false;
weakMap: WeakMap<any, any> = new WeakMap(); weakMap: WeakMap<any, any> = new WeakMap();
notifyCB() {} notifyCB() {}
observe<T>(value: T, parent?: any): T { observe<T>(value: T, parent?: any): T {
if ( if (value === null || typeof value !== "object" || value instanceof Date) {
value === null ||
typeof value !== "object" ||
value instanceof Date ||
value instanceof Promise
) {
// fun fact: typeof null === 'object' // fun fact: typeof null === 'object'
return value; return value;
} }
@@ -72,14 +68,14 @@ export class Observer {
self.notifyCB(); self.notifyCB();
} }
return true; return true;
}, }
}); });
const metadata = { const metadata = {
value, value,
proxy, proxy,
rev: this.rev, rev: this.rev,
parent, parent
}; };
this.weakMap.set(value, metadata); this.weakMap.set(value, metadata);
+1 -1
View File
@@ -7,7 +7,7 @@ import { Component } from "../component/component";
*/ */
export class OwlEvent<T> extends CustomEvent<T> { export class OwlEvent<T> extends CustomEvent<T> {
originalComponent: Component; originalComponent: Component<any, any>;
constructor(component, eventType, options) { constructor(component, eventType, options) {
super(eventType, options); super(eventType, options);
this.originalComponent = component; this.originalComponent = component;
+12 -61
View File
@@ -1,4 +1,4 @@
import { Component, Env } from "./component/component"; import { Component } from "./component/component";
import { Observer } from "./core/observer"; import { Observer } from "./core/observer";
/** /**
@@ -22,7 +22,7 @@ import { Observer } from "./core/observer";
* trigger a rerendering of the current component. * trigger a rerendering of the current component.
*/ */
export function useState<T>(state: T): T { export function useState<T>(state: T): T {
const component: Component = Component.current!; const component: Component<any, any> = Component.current!;
const __owl__ = component.__owl__; const __owl__ = component.__owl__;
if (!__owl__.observer) { if (!__owl__.observer) {
__owl__.observer = new Observer(); __owl__.observer = new Observer();
@@ -37,7 +37,7 @@ export function useState<T>(state: T): T {
function makeLifecycleHook(method: string, reverse: boolean = false) { function makeLifecycleHook(method: string, reverse: boolean = false) {
if (reverse) { if (reverse) {
return function(cb) { return function(cb) {
const component: Component = Component.current!; const component: Component<any, any> = Component.current!;
if (component.__owl__[method]) { if (component.__owl__[method]) {
const current = component.__owl__[method]; const current = component.__owl__[method];
component.__owl__[method] = function() { component.__owl__[method] = function() {
@@ -50,7 +50,7 @@ function makeLifecycleHook(method: string, reverse: boolean = false) {
}; };
} else { } else {
return function(cb) { return function(cb) {
const component: Component = Component.current!; const component: Component<any, any> = Component.current!;
if (component.__owl__[method]) { if (component.__owl__[method]) {
const current = component.__owl__[method]; const current = component.__owl__[method];
component.__owl__[method] = function() { component.__owl__[method] = function() {
@@ -66,11 +66,11 @@ function makeLifecycleHook(method: string, reverse: boolean = false) {
function makeAsyncHook(method: string) { function makeAsyncHook(method: string) {
return function(cb) { return function(cb) {
const component: Component = Component.current!; const component: Component<any, any> = Component.current!;
if (component.__owl__[method]) { if (component.__owl__[method]) {
const current = component.__owl__[method]; const current = component.__owl__[method];
component.__owl__[method] = function(...args) { component.__owl__[method] = function(...args) {
return Promise.all([current.call(component, ...args), cb.call(component, ...args)]); return Promise.all[(current.call(component, ...args), cb.call(component, ...args))];
}; };
} else { } else {
component.__owl__[method] = cb; component.__owl__[method] = cb;
@@ -94,12 +94,12 @@ export const onWillUpdateProps = makeAsyncHook("willUpdatePropsCB");
* The purpose of this hook is to allow components to get a reference to a sub * The purpose of this hook is to allow components to get a reference to a sub
* html node or component. * html node or component.
*/ */
interface Ref<C extends Component = Component> { interface Ref {
el: HTMLElement | null; el: HTMLElement | null;
comp: C | null; comp: Component<any, any> | null;
} }
export function useRef<C extends Component = Component>(name: string): Ref<C> { export function useRef(name: string): Ref {
const __owl__ = Component.current!.__owl__; const __owl__ = Component.current!.__owl__;
return { return {
get el(): HTMLElement | null { get el(): HTMLElement | null {
@@ -111,33 +111,13 @@ export function useRef<C extends Component = Component>(name: string): Ref<C> {
} }
return null; return null;
}, },
get comp(): C | null { get comp(): Component<any, any> | null {
const val = __owl__.refs && __owl__.refs[name]; const val = __owl__.refs && __owl__.refs[name];
return val instanceof Component ? (val as C) : null; return val instanceof Component ? val : null;
}, }
}; };
} }
// -----------------------------------------------------------------------------
// "Builder" hooks
// -----------------------------------------------------------------------------
/**
* This hook is useful as a building block for some customized hooks, that may
* need a reference to the component calling them.
*/
export function useComponent<P, E extends Env>(): Component<P, E> {
return Component.current as any;
}
/**
* This hook is useful as a building block for some customized hooks, that may
* need a reference to the env of the component calling them.
*/
export function useEnv<E extends Env>(): E {
return Component.current.env as any;
}
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// useSubEnv // useSubEnv
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
@@ -151,32 +131,3 @@ export function useSubEnv(nextEnv) {
const component = Component.current!; const component = Component.current!;
component.env = Object.assign(Object.create(component.env), nextEnv); component.env = Object.assign(Object.create(component.env), nextEnv);
} }
// -----------------------------------------------------------------------------
// useExternalListener
// -----------------------------------------------------------------------------
/**
* When a component needs to listen to DOM Events on element(s) that are not
* part of his hierarchy, we can use the `useExternalListener` hook.
* It will correctly add and remove the event listener, whenever the
* component is mounted and unmounted.
*
* Example:
* a menu needs to listen to the click on window to be closed automatically
*
* Usage:
* in the constructor of the OWL component that needs to be notified,
* `useExternalListener(window, 'click', this._doSomething);`
* */
export function useExternalListener(
target: HTMLElement | typeof window,
eventName: string,
handler,
eventParams?
) {
const boundHandler = handler.bind(Component.current);
onMounted(() => target.addEventListener(eventName, boundHandler, eventParams));
onWillUnmount(() => target.removeEventListener(eventName, boundHandler, eventParams));
}
+3 -6
View File
@@ -12,17 +12,15 @@ import * as _store from "./store";
import * as _utils from "./utils"; import * as _utils from "./utils";
import * as _tags from "./tags"; import * as _tags from "./tags";
import { AsyncRoot } from "./misc/async_root"; import { AsyncRoot } from "./misc/async_root";
import { Portal } from "./misc/portal";
import * as _hooks from "./hooks"; import * as _hooks from "./hooks";
import * as _context from "./context"; import * as _context from "./context";
import { Link } from "./router/link"; import { Link } from "./router/link";
import { RouteComponent } from "./router/route_component"; import { RouteComponent } from "./router/route_component";
import { Router } from "./router/router"; import { Router } from "./router/router";
export { Component, mount } from "./component/component"; export { Component } from "./component/component";
export { QWeb }; export { QWeb };
export { config }; export { config };
export { browser } from "./browser";
export const Context = _context.Context; export const Context = _context.Context;
export const useState = _hooks.useState; export const useState = _hooks.useState;
@@ -31,12 +29,11 @@ export const router = { Router, RouteComponent, Link };
export const Store = _store.Store; export const Store = _store.Store;
export const utils = _utils; export const utils = _utils;
export const tags = _tags; export const tags = _tags;
export const misc = { AsyncRoot, Portal }; export const misc = { AsyncRoot };
export const hooks = Object.assign({}, _hooks, { export const hooks = Object.assign({}, _hooks, {
useContext: _context.useContext, useContext: _context.useContext,
useDispatch: _store.useDispatch, useDispatch: _store.useDispatch,
useGetters: _store.useGetters, useGetters: _store.useGetters,
useStore: _store.useStore, useStore: _store.useStore
}); });
export const __info__ = {}; export const __info__ = {};
+1 -1
View File
@@ -10,7 +10,7 @@ import { xml } from "../tags";
* from this coordination. This is the goal of the AsyncRoot component. * from this coordination. This is the goal of the AsyncRoot component.
*/ */
export class AsyncRoot extends Component { export class AsyncRoot extends Component<any, any> {
static template = xml`<t t-slot="default"/>`; static template = xml`<t t-slot="default"/>`;
async __updateProps(nextProps, parentFiber) { async __updateProps(nextProps, parentFiber) {
-171
View File
@@ -1,171 +0,0 @@
import { Component, portalSymbol } from "../component/component";
import { VNode, patch } from "../vdom/index";
import { xml } from "../tags";
import { OwlEvent } from "../core/owl_event";
import { useSubEnv } from "../hooks";
/**
* Portal
*
* The Portal component allows to render a part of a component outside it's DOM.
* It is for example useful for dialogs: for css reasons, dialogs are in general
* placed in a specific spot of the DOM (e.g. directly in the body). With the
* Portal, a component can conditionally specify in its tempate that it contains
* a dialog, and where this dialog should be inserted in the DOM.
*
* The Portal component ensures that the communication between the content of
* the Portal and its parent properly works: business events reaching the Portal
* are re-triggered on an empty <portal> node located in the parent's DOM.
*/
interface Props {
target: string;
}
export class Portal extends Component<Props> {
static template = xml`<portal><t t-slot="default"/></portal>`;
static props = {
target: {
type: String,
},
};
// boolean to indicate whether or not we must listen to 'dom-appended' event
// to hook on the moment when the target is inserted into the DOM (because it
// is not when the portal is rendered)
doTargetLookUp: boolean = true;
// set of encountered events that need to be redirected
_handledEvents: Set<string> = new Set();
// function that will be the event's tunnel (needs to be an arrow function to
// avoid having to rebind `this`)
_handlerTunnel: (f: OwlEvent<any>) => void = (ev: OwlEvent<any>) => {
ev.stopPropagation();
this.__trigger(ev.originalComponent, ev.type, ev.detail);
};
// Storing the parent's env
parentEnv: any = null;
// represents the element that is moved somewhere else
portal: VNode | null = null;
// the target where we will move `portal`
target: Element | null = null;
constructor(parent, props) {
super(parent, props);
this.parentEnv = parent ? parent.env : {};
// put a callback in the env that is propagated to children s.t. portal can
// register an handler to those events just before children will trigger them
useSubEnv({
[portalSymbol]: (ev) => {
if (!this._handledEvents.has(ev.type)) {
this.portal!.elm!.addEventListener(ev.type, this._handlerTunnel);
this._handledEvents.add(ev.type);
}
},
});
}
/**
* Override to revert back to a classic Component's structure
*
* @override
*/
__callWillUnmount() {
super.__callWillUnmount();
this.el!.appendChild(this.portal!.elm!);
this.doTargetLookUp = true;
}
/**
* At each DOM change, we must ensure that the portal contains exactly one
* child
*/
__checkVNodeStructure(vnode: VNode) {
const children = vnode.children!;
let countRealNodes = 0;
for (let child of children) {
if ((child as VNode).sel) {
countRealNodes++;
}
}
if (countRealNodes !== 1) {
throw new Error(`Portal must have exactly one non-text child (has ${countRealNodes})`);
}
}
/**
* Ensure the target is still there at whichever time we render
*/
__checkTargetPresence() {
if (!this.target || !document.contains(this.target)) {
throw new Error(`Could not find any match for "${this.props.target}"`);
}
}
/**
* Move the portal's element to the target
*/
__deployPortal() {
this.__checkTargetPresence();
this.target!.appendChild(this.portal!.elm!);
}
/**
* Override to remove from the DOM the element we have teleported
*
* @override
*/
__destroy(parent) {
if (this.portal && this.portal.elm) {
const displacedElm = this.portal.elm!;
const parent = displacedElm.parentNode;
if (parent) {
parent.removeChild(displacedElm);
}
}
super.__destroy(parent);
}
/**
* Override to patch the element that has been teleported
*
* @override
*/
__patch(target, vnode) {
if (this.doTargetLookUp) {
const target = document.querySelector(this.props.target);
if (!target) {
this.env.qweb.on("dom-appended", this, () => {
this.doTargetLookUp = false;
this.env.qweb.off("dom-appended", this);
this.target = document.querySelector(this.props.target);
this.__deployPortal();
});
} else {
this.doTargetLookUp = false;
this.target = target;
}
}
this.__checkVNodeStructure(vnode);
const shouldDeploy =
(!this.portal || this.el!.contains(this.portal.elm!)) && !this.doTargetLookUp;
if (!this.doTargetLookUp && !shouldDeploy) {
// Only on pure patching, provided the
// this.target's parent has not been unmounted
this.__checkTargetPresence();
}
const portalPatch = this.portal ? this.portal : document.createElement(vnode.children[0].sel);
this.portal = patch(portalPatch, vnode.children![0] as VNode);
vnode.children = [];
super.__patch(target, vnode);
if (shouldDeploy) {
this.__deployPortal();
}
}
/**
* Override to set the env
*/
__trigger(component: Component, eventType: string, payload?: any) {
const env = this.env;
this.env = this.parentEnv;
super.__trigger(component, eventType, payload);
this.env = env;
}
}
+129 -182
View File
@@ -1,7 +1,6 @@
import { CompilationContext, INTERP_REGEXP } from "./compilation_context"; import { CompilationContext } from "./compilation_context";
import { QWeb } from "./qweb"; import { QWeb } from "./qweb";
import { htmlToVDOM } from "../vdom/html_to_vdom"; import { htmlToVDOM } from "../vdom/html_to_vdom";
import { QWebVar } from "./expression_parser";
/** /**
* Owl QWeb Directives * Owl QWeb Directives
@@ -20,41 +19,38 @@ import { QWebVar } from "./expression_parser";
//------------------------------------------------------------------------------ //------------------------------------------------------------------------------
// t-esc and t-raw // t-esc and t-raw
//------------------------------------------------------------------------------ //------------------------------------------------------------------------------
QWeb.utils.getFragment = function(str: string): DocumentFragment {
const temp = document.createElement("template");
temp.innerHTML = str;
return temp.content;
};
QWeb.utils.htmlToVDOM = htmlToVDOM; QWeb.utils.htmlToVDOM = htmlToVDOM;
function compileValueNode(value: any, node: Element, qweb: QWeb, ctx: CompilationContext) { function compileValueNode(value: any, node: Element, qweb: QWeb, ctx: CompilationContext) {
ctx.rootContext.shouldDefineScope = true;
if (value === "0") { if (value === "0") {
if (ctx.parentNode) { const caller = ctx.getCaller();
// the 'zero' magical symbol is where we can find the result of the rendering if (caller) {
// of the body of the t-call. qweb._compileNode(caller, ctx.getInliningContext());
ctx.rootContext.shouldDefineUtils = true; return;
const zeroArgs = ctx.escaping }
? `{text: utils.vDomToString(scope[utils.zero])}` }
: `...scope[utils.zero]`;
ctx.addLine(`c${ctx.parentNode}.push(${zeroArgs});`); if (value.xml instanceof NodeList && !value.id) {
for (let node of Array.from(value.xml)) {
qweb._compileNode(<ChildNode>node, ctx);
} }
return; return;
} }
let exprID: string; let exprID: string;
if (typeof value === "string") { if (typeof value === "string") {
exprID = `_${ctx.generateID()}`; exprID = `_${ctx.generateID()}`;
ctx.addLine(`let ${exprID} = ${ctx.formatExpression(value)};`); ctx.addLine(`var ${exprID} = ${ctx.formatExpression(value)};`);
} else { } else {
exprID = `scope.${value.id}`; exprID = value.id;
} }
ctx.addIf(`${exprID} != null`); ctx.addIf(`${exprID} || ${exprID} === 0`);
if (ctx.escaping) { if (ctx.escaping) {
let protectID;
if (value.hasBody) {
ctx.rootContext.shouldDefineUtils = true;
protectID = ctx.startProtectScope();
ctx.addLine(
`${exprID} = ${exprID} instanceof utils.VDomArray ? utils.vDomToString(${exprID}) : ${exprID};`
);
}
if (ctx.parentTextNode) { if (ctx.parentTextNode) {
ctx.addLine(`vn${ctx.parentTextNode}.text += ${exprID};`); ctx.addLine(`vn${ctx.parentTextNode}.text += ${exprID};`);
} else if (ctx.parentNode) { } else if (ctx.parentNode) {
@@ -63,30 +59,26 @@ function compileValueNode(value: any, node: Element, qweb: QWeb, ctx: Compilatio
let nodeID = ctx.generateID(); let nodeID = ctx.generateID();
ctx.rootContext.rootNode = nodeID; ctx.rootContext.rootNode = nodeID;
ctx.rootContext.parentTextNode = nodeID; ctx.rootContext.parentTextNode = nodeID;
ctx.addLine(`let vn${nodeID} = {text: ${exprID}};`); ctx.addLine(`var vn${nodeID} = {text: ${exprID}};`);
if (ctx.rootContext.shouldDefineResult) { if (ctx.rootContext.shouldDefineResult) {
ctx.addLine(`result = vn${nodeID}`); ctx.addLine(`result = vn${nodeID}`);
} }
} }
if (value.hasBody) {
ctx.stopProtectScope(protectID);
}
} else { } else {
ctx.rootContext.shouldDefineUtils = true; ctx.rootContext.shouldDefineUtils = true;
if (value.hasBody) {
ctx.addLine(
`const vnodeArray = ${exprID} instanceof utils.VDomArray ? ${exprID} : utils.htmlToVDOM(${exprID});`
);
ctx.addLine(`c${ctx.parentNode}.push(...vnodeArray);`);
} else {
ctx.addLine(`c${ctx.parentNode}.push(...utils.htmlToVDOM(${exprID}));`); ctx.addLine(`c${ctx.parentNode}.push(...utils.htmlToVDOM(${exprID}));`);
} }
}
if (node.childNodes.length) { if (node.childNodes.length) {
ctx.addElse(); ctx.addElse();
qweb._compileChildren(node, ctx); qweb._compileChildren(node, ctx);
} }
if (value.xml instanceof NodeList && value.id) {
ctx.addElse();
for (let node of Array.from(value.xml)) {
qweb._compileNode(<ChildNode>node, ctx);
}
}
ctx.closeIf(); ctx.closeIf();
} }
@@ -97,7 +89,7 @@ QWeb.addDirective({
let value = ctx.getValue(node.getAttribute("t-esc")!); let value = ctx.getValue(node.getAttribute("t-esc")!);
compileValueNode(value, node, qweb, ctx.subContext("escaping", true)); compileValueNode(value, node, qweb, ctx.subContext("escaping", true));
return true; return true;
}, }
}); });
QWeb.addDirective({ QWeb.addDirective({
@@ -107,7 +99,7 @@ QWeb.addDirective({
let value = ctx.getValue(node.getAttribute("t-raw")!); let value = ctx.getValue(node.getAttribute("t-raw")!);
compileValueNode(value, node, qweb, ctx); compileValueNode(value, node, qweb, ctx);
return true; return true;
}, }
}); });
//------------------------------------------------------------------------------ //------------------------------------------------------------------------------
@@ -117,54 +109,27 @@ QWeb.addDirective({
name: "set", name: "set",
extraNames: ["value"], extraNames: ["value"],
priority: 60, priority: 60,
atNodeEncounter({ node, qweb, ctx }): boolean { atNodeEncounter({ node, ctx }): boolean {
ctx.rootContext.shouldDefineScope = true;
const variable = node.getAttribute("t-set")!; const variable = node.getAttribute("t-set")!;
let value = node.getAttribute("t-value")!; let value = node.getAttribute("t-value")!;
ctx.variables[variable] = ctx.variables[variable] || ({} as QWebVar); ctx.variables[variable] = ctx.variables[variable] || {};
let qwebvar = ctx.variables[variable]; let qwebvar = ctx.variables[variable];
const hasBody = node.hasChildNodes();
qwebvar.id = variable;
qwebvar.expr = `scope.${variable}`;
if (value) { if (value) {
const formattedValue = ctx.formatExpression(value); const formattedValue = ctx.formatExpression(value);
let scopeExpr = `scope`; if (ctx.variables.hasOwnProperty(variable) && qwebvar.id) {
if (ctx.protectedScopeNumber) { ctx.addLine(`${qwebvar.id} = ${formattedValue}`);
ctx.rootContext.shouldDefineUtils = true; } else {
scopeExpr = `utils.getScope(scope, '${variable}')`; const varName = `_${ctx.generateID()}`;
} ctx.addLine(`var ${varName} = ${formattedValue};`);
ctx.addLine(`${scopeExpr}.${variable} = ${formattedValue};`); qwebvar.id = varName;
qwebvar.value = formattedValue; qwebvar.expr = formattedValue;
}
if (hasBody) {
ctx.rootContext.shouldDefineUtils = true;
if (value) {
ctx.addIf(`!(${qwebvar.expr})`);
}
const tempParentNodeID = ctx.generateID();
const _parentNode = ctx.parentNode;
ctx.parentNode = tempParentNodeID;
ctx.addLine(`let c${tempParentNodeID} = new utils.VDomArray();`);
const nodeCopy = node.cloneNode(true) as Element;
for (let attr of ["t-set", "t-value", "t-if", "t-else", "t-elif"]) {
nodeCopy.removeAttribute(attr);
}
qweb._compileNode(nodeCopy, ctx);
ctx.addLine(`${qwebvar.expr} = c${tempParentNodeID}`);
qwebvar.value = `c${tempParentNodeID}`;
qwebvar.hasBody = true;
ctx.parentNode = _parentNode;
if (value) {
ctx.closeIf();
} }
} else {
qwebvar.xml = node.childNodes;
} }
return true; return true;
}, }
}); });
//------------------------------------------------------------------------------ //------------------------------------------------------------------------------
@@ -175,12 +140,12 @@ QWeb.addDirective({
priority: 20, priority: 20,
atNodeEncounter({ node, ctx }): boolean { atNodeEncounter({ node, ctx }): boolean {
let cond = ctx.getValue(node.getAttribute("t-if")!); let cond = ctx.getValue(node.getAttribute("t-if")!);
ctx.addIf(typeof cond === "string" ? ctx.formatExpression(cond) : `scope.${cond.id!}`); ctx.addIf(typeof cond === "string" ? ctx.formatExpression(cond) : cond.id!);
return false; return false;
}, },
finalize({ ctx }) { finalize({ ctx }) {
ctx.closeIf(); ctx.closeIf();
}, }
}); });
QWeb.addDirective({ QWeb.addDirective({
@@ -188,15 +153,13 @@ QWeb.addDirective({
priority: 30, priority: 30,
atNodeEncounter({ node, ctx }): boolean { atNodeEncounter({ node, ctx }): boolean {
let cond = ctx.getValue(node.getAttribute("t-elif")!); let cond = ctx.getValue(node.getAttribute("t-elif")!);
ctx.addLine( ctx.addLine(`else if (${typeof cond === "string" ? ctx.formatExpression(cond) : cond.id}) {`);
`else if (${typeof cond === "string" ? ctx.formatExpression(cond) : `scope.${cond.id}`}) {`
);
ctx.indent(); ctx.indent();
return false; return false;
}, },
finalize({ ctx }) { finalize({ ctx }) {
ctx.closeIf(); ctx.closeIf();
}, }
}); });
QWeb.addDirective({ QWeb.addDirective({
@@ -209,7 +172,7 @@ QWeb.addDirective({
}, },
finalize({ ctx }) { finalize({ ctx }) {
ctx.closeIf(); ctx.closeIf();
}, }
}); });
//------------------------------------------------------------------------------ //------------------------------------------------------------------------------
@@ -219,94 +182,89 @@ QWeb.addDirective({
name: "call", name: "call",
priority: 50, priority: 50,
atNodeEncounter({ node, qweb, ctx }): boolean { atNodeEncounter({ node, qweb, ctx }): boolean {
// Step 1: sanity checks if (node.nodeName !== "t") {
// ------------------------------------------------ throw new Error("Invalid tag for t-call directive (should be 't')");
ctx.rootContext.shouldDefineScope = true; }
ctx.rootContext.shouldDefineUtils = true;
const subTemplate = node.getAttribute("t-call")!; const subTemplate = node.getAttribute("t-call")!;
const isDynamic = INTERP_REGEXP.test(subTemplate);
const nodeTemplate = qweb.templates[subTemplate]; const nodeTemplate = qweb.templates[subTemplate];
if (!isDynamic && !nodeTemplate) { if (!nodeTemplate) {
throw new Error(`Cannot find template "${subTemplate}" (t-call)`); throw new Error(`Cannot find template "${subTemplate}" (t-call)`);
} }
// Step 2: compile target template in sub templates
// ------------------------------------------------
let subIdstr: string;
if (isDynamic) {
const _id = ctx.generateID();
ctx.addLine(`let tname${_id} = ${ctx.interpolate(subTemplate)};`);
ctx.addLine(`let tid${_id} = this.subTemplates[tname${_id}];`);
ctx.addIf(`!tid${_id}`);
ctx.addLine(`tid${_id} = this.constructor.nextId++;`);
ctx.addLine(`this.subTemplates[tname${_id}] = tid${_id};`);
ctx.addLine(
`this.constructor.subTemplates[tid${_id}] = this._compile(tname${_id}, {hasParent: true, defineKey: true});`
);
ctx.closeIf();
subIdstr = `tid${_id}`;
} else {
let subId = qweb.subTemplates[subTemplate];
if (!subId) {
subId = QWeb.nextId++;
qweb.subTemplates[subTemplate] = subId;
const subTemplateFn = qweb._compile(subTemplate, { hasParent: true, defineKey: true });
QWeb.subTemplates[subId] = subTemplateFn;
}
subIdstr = `'${subId}'`;
}
// Step 3: compile t-call body if necessary
// ------------------------------------------------
let hasBody = node.hasChildNodes();
const protectID = ctx.startProtectScope();
if (hasBody) {
// we add a sub scope to protect the ambient scope
ctx.addLine(`{`);
ctx.indent();
const nodeCopy = node.cloneNode(true) as Element; const nodeCopy = node.cloneNode(true) as Element;
for (let attr of ["t-if", "t-else", "t-elif", "t-call"]) { nodeCopy.removeAttribute("t-call");
nodeCopy.removeAttribute(attr);
}
// this local scope is intended to trap c__0
ctx.addLine(`{`);
ctx.indent();
ctx.addLine("let c__0 = [];");
qweb._compileNode(nodeCopy, ctx.subContext("parentNode", "__0"));
ctx.rootContext.shouldDefineUtils = true;
ctx.addLine("scope[utils.zero] = c__0;");
ctx.dedent();
ctx.addLine(`}`);
}
// Step 4: add the appropriate function call to current component // extract variables from nodecopy
// ------------------------------------------------ const tempCtx = new CompilationContext();
const parentComponent = ctx.rootContext.shouldDefineParent tempCtx.allowMultipleRoots = true;
? `parent` qweb._compileNode(nodeCopy, tempCtx);
: `utils.getComponent(context)`; const vars = Object.assign({}, ctx.variables, tempCtx.variables);
const key = ctx.generateTemplateKey();
const parentNode = ctx.parentNode ? `c${ctx.parentNode}` : "result"; const templateMap = Object.create(ctx.templates);
const extra = `Object.assign({}, extra, {parentNode: ${parentNode}, parent: ${parentComponent}, key: ${key}})`; // open new scope, if necessary
if (ctx.parentNode) { const hasNewVariables = Object.keys(tempCtx.variables).length > 0;
ctx.addLine(`this.constructor.subTemplates[${subIdstr}].call(this, scope, ${extra});`);
// compile sub template
let subCtx = ctx.subContext("caller", nodeCopy).subContext("variables", Object.create(vars));
subCtx = subCtx.subContext("templates", templateMap);
if (templateMap[subTemplate]) {
// OUCH, IT IS A RECURSIVE TEMPLATE SITUATION...
// This is a tricky situation... We obviously cannot inline the compiled
// template. So, what we need to do is to compile it, and make sure we
// properly transfer everything from the current scope to the sub template.
ctx.rootContext.shouldTrackScope = true;
ctx.rootContext.shouldDefineOwner = true;
let subTemplateName;
if (ctx.hasParentWidget) {
subTemplateName = ctx.templateName;
} else { } else {
// this is a t-call with no parentnode, we need to extract the result subTemplateName = `__${ctx.generateID()}`;
ctx.rootContext.shouldDefineResult = true; subCtx.variables = {};
ctx.addLine(`result = []`); let id = 0;
ctx.addLine(`this.constructor.subTemplates[${subIdstr}].call(this, scope, ${extra});`); for (let v in vars) {
ctx.addLine(`result = result[0]`); subCtx.variables[v] = vars[v];
(vars[v] as any).id = `_v${id++}`;
} }
const subTemplateFn = qweb._compile(subTemplateName, nodeTemplate.elem, subCtx);
qweb.recursiveFns[subTemplateName] = subTemplateFn;
}
let varCode = `{}`;
if (Object.keys(vars).length) {
let id = 0;
const content = Object.values(vars)
.map((v: any) => `_v${id++}: ${v.expr}`)
.join(",");
varCode = `{${content}}`;
}
ctx.addLine(
`this.recursiveFns['${subTemplateName}'].call(this, context, Object.assign({}, extra, {parentNode: c${ctx.parentNode}, fiber: {vars: ${varCode}, scope}}));`
);
return true;
}
templateMap[subTemplate] = true;
// Step 5: restore previous scope if (hasNewVariables) {
// ------------------------------------------------ ctx.addLine("{");
if (hasBody) { ctx.indent();
ctx.dedent(); // add new variables, if any
ctx.addLine(`}`); for (let key in tempCtx.variables) {
const v = tempCtx.variables[key];
if (v.expr) {
ctx.addLine(`let ${v.id} = ${v.expr};`);
}
// todo: handle XML variables...
}
}
qweb._compileNode(nodeTemplate.elem, subCtx);
// close new scope
if (hasNewVariables) {
ctx.dedent();
ctx.addLine("}");
} }
ctx.stopProtectScope(protectID);
return true; return true;
}, }
}); });
//------------------------------------------------------------------------------ //------------------------------------------------------------------------------
@@ -317,32 +275,29 @@ QWeb.addDirective({
extraNames: ["as"], extraNames: ["as"],
priority: 10, priority: 10,
atNodeEncounter({ node, qweb, ctx }): boolean { atNodeEncounter({ node, qweb, ctx }): boolean {
ctx.rootContext.shouldDefineScope = true; ctx.rootContext.shouldProtectContext = true;
ctx = ctx.subContext("loopNumber", ctx.loopNumber + 1); ctx = ctx.subContext("loopNumber", ctx.loopNumber + 1);
const elems = node.getAttribute("t-foreach")!; const elems = node.getAttribute("t-foreach")!;
const name = node.getAttribute("t-as")!; const name = node.getAttribute("t-as")!;
let arrayID = ctx.generateID(); let arrayID = ctx.generateID();
ctx.addLine(`let _${arrayID} = ${ctx.formatExpression(elems)};`); ctx.addLine(`var _${arrayID} = ${ctx.formatExpression(elems)};`);
ctx.addLine(`if (!_${arrayID}) { throw new Error('QWeb error: Invalid loop expression')}`); ctx.addLine(`if (!_${arrayID}) { throw new Error('QWeb error: Invalid loop expression')}`);
let keysID = ctx.generateID(); let keysID = ctx.generateID();
let valuesID = ctx.generateID(); let valuesID = ctx.generateID();
ctx.addLine(`let _${keysID} = _${arrayID};`); ctx.addLine(`var _${keysID} = _${valuesID} = _${arrayID};`);
ctx.addLine(`let _${valuesID} = _${arrayID};`);
ctx.addIf(`!(_${arrayID} instanceof Array)`); ctx.addIf(`!(_${arrayID} instanceof Array)`);
ctx.addLine(`_${keysID} = Object.keys(_${arrayID});`); ctx.addLine(`_${keysID} = Object.keys(_${arrayID});`);
ctx.addLine(`_${valuesID} = Object.values(_${arrayID});`); ctx.addLine(`_${valuesID} = Object.values(_${arrayID});`);
ctx.closeIf(); ctx.closeIf();
ctx.addLine(`let _length${keysID} = _${keysID}.length;`); ctx.addLine(`var _length${keysID} = _${keysID}.length;`);
let varsID = ctx.startProtectScope(true);
const loopVar = `i${ctx.loopNumber}`; const loopVar = `i${ctx.loopNumber}`;
ctx.addLine(`for (let ${loopVar} = 0; ${loopVar} < _length${keysID}; ${loopVar}++) {`); ctx.addLine(`for (let ${loopVar} = 0; ${loopVar} < _length${keysID}; ${loopVar}++) {`);
ctx.indent(); ctx.indent();
ctx.addToScope(name + "_first", `${loopVar} === 0`);
ctx.addLine(`scope.${name}_first = ${loopVar} === 0`); ctx.addToScope(name + "_last", `${loopVar} === _length${keysID} - 1`);
ctx.addLine(`scope.${name}_last = ${loopVar} === _length${keysID} - 1`); ctx.addToScope(name + "_index", loopVar);
ctx.addLine(`scope.${name}_index = ${loopVar}`); ctx.addToScope(name, `_${keysID}[${loopVar}]`);
ctx.addLine(`scope.${name} = _${keysID}[${loopVar}]`); ctx.addToScope(name + "_value", `_${valuesID}[${loopVar}]`);
ctx.addLine(`scope.${name}_value = _${valuesID}[${loopVar}]`);
const nodeCopy = <Element>node.cloneNode(true); const nodeCopy = <Element>node.cloneNode(true);
let shouldWarn = let shouldWarn =
!nodeCopy.hasAttribute("t-key") && !nodeCopy.hasAttribute("t-key") &&
@@ -354,21 +309,13 @@ QWeb.addDirective({
`Directive t-foreach should always be used with a t-key! (in template: '${ctx.templateName}')` `Directive t-foreach should always be used with a t-key! (in template: '${ctx.templateName}')`
); );
} }
if (nodeCopy.hasAttribute("t-key")) {
const expr = ctx.formatExpression(nodeCopy.getAttribute("t-key")!);
ctx.addLine(`let key${ctx.loopNumber} = ${expr};`);
nodeCopy.removeAttribute("t-key");
} else {
ctx.addLine(`let key${ctx.loopNumber} = i${ctx.loopNumber};`);
}
nodeCopy.removeAttribute("t-foreach"); nodeCopy.removeAttribute("t-foreach");
qweb._compileNode(nodeCopy, ctx); qweb._compileNode(nodeCopy, ctx);
ctx.dedent(); ctx.dedent();
ctx.addLine("}"); ctx.addLine("}");
ctx.stopProtectScope(varsID);
return true; return true;
}, }
}); });
//------------------------------------------------------------------------------ //------------------------------------------------------------------------------
@@ -379,7 +326,7 @@ QWeb.addDirective({
priority: 1, priority: 1,
atNodeEncounter({ ctx }) { atNodeEncounter({ ctx }) {
ctx.addLine("debugger;"); ctx.addLine("debugger;");
}, }
}); });
//------------------------------------------------------------------------------ //------------------------------------------------------------------------------
@@ -391,5 +338,5 @@ QWeb.addDirective({
atNodeEncounter({ ctx, value }) { atNodeEncounter({ ctx, value }) {
const expr = ctx.formatExpression(value); const expr = ctx.formatExpression(value);
ctx.addLine(`console.log(${expr})`); ctx.addLine(`console.log(${expr})`);
}, }
}); });
+74 -75
View File
@@ -1,4 +1,4 @@
import { compileExpr, compileExprToArray, QWebVar } from "./expression_parser"; import { compileExpr, QWebVar } from "./expression_parser";
export const INTERP_REGEXP = /\{\{.*?\}\}/g; export const INTERP_REGEXP = /\{\{.*?\}\}/g;
//------------------------------------------------------------------------------ //------------------------------------------------------------------------------
@@ -10,30 +10,36 @@ export class CompilationContext {
code: string[] = []; code: string[] = [];
variables: { [key: string]: QWebVar } = {}; variables: { [key: string]: QWebVar } = {};
escaping: boolean = false; escaping: boolean = false;
parentNode: number | null | string = null; parentNode: number | null = null;
parentTextNode: number | null = null; parentTextNode: number | null = null;
rootNode: number | null = null; rootNode: number | null = null;
indentLevel: number = 0; indentLevel: number = 0;
rootContext: CompilationContext; rootContext: CompilationContext;
caller: Element | undefined;
shouldDefineOwner: boolean = false;
shouldDefineParent: boolean = false; shouldDefineParent: boolean = false;
shouldDefineScope: boolean = false;
protectedScopeNumber: number = 0;
shouldDefineQWeb: boolean = false; shouldDefineQWeb: boolean = false;
shouldDefineUtils: boolean = false; shouldDefineUtils: boolean = false;
shouldDefineRefs: boolean = false; shouldDefineRefs: boolean = false;
shouldDefineResult: boolean = true; shouldDefineResult: boolean = true;
shouldProtectContext: boolean = false;
shouldTrackScope: boolean = false;
loopNumber: number = 0; loopNumber: number = 0;
inPreTag: boolean = false; inPreTag: boolean = false;
templateName: string; templateName: string;
allowMultipleRoots: boolean = false; allowMultipleRoots: boolean = false;
hasParentWidget: boolean = false; hasParentWidget: boolean = false;
hasKey0: boolean = false; scopeVars: any[] = [];
keyStack: boolean[] = []; currentKey: string = "";
templates: { [key: string]: boolean } = {};
callingLevel: number = 0;
inliningLevel: number = 0;
constructor(name?: string) { constructor(name?: string) {
this.rootContext = this; this.rootContext = this;
this.templateName = name || "noname"; this.templateName = name || "noname";
this.addLine("let h = this.h;"); this.templates[this.templateName] = true;
this.addLine("var h = this.h;");
} }
generateID(): number { generateID(): number {
@@ -48,31 +54,47 @@ export class CompilationContext {
* Such a key is necessary when we need to associate an id to some element * Such a key is necessary when we need to associate an id to some element
* generated by a template (for example, a component) * generated by a template (for example, a component)
*/ */
generateTemplateKey(prefix: string = ""): string { generateTemplateKey(): string {
const id = this.generateID(); const id = this.generateID();
if (this.loopNumber === 0 && !this.hasKey0) { let locationExpr = `\`__${this.generateID()}__`;
return `'${prefix}__${id}__'`; for (let i = 0; i < this.loopNumber - 1; i++) {
locationExpr += `\${i${i + 1}}__`;
} }
let key = `\`${prefix}__${id}__`; if (this.currentKey) {
let start = this.hasKey0 ? 0 : 1; const k = this.currentKey;
for (let i = start; i < this.loopNumber + 1; i++) { this.addLine(`let k${id} = ${locationExpr}\` + ${k};`);
key += `\${key${i}}__`; } else {
locationExpr += this.loopNumber ? `\${i${this.loopNumber}}__\`` : "`";
this.addLine(`let k${id} = ${locationExpr};`);
} }
this.addLine(`let k${id} = ${key}\`;`);
return `k${id}`; return `k${id}`;
} }
generateCode(): string[] { generateCode(): string[] {
const shouldTrackScope = this.shouldTrackScope && this.scopeVars.length;
if (shouldTrackScope) {
// add some vars to scope if needed
for (let scopeVar of this.scopeVars.reverse()) {
let { index, key, indent } = scopeVar;
const prefix = new Array(indent + 2).join(" ");
this.code.splice(index + 1, 0, prefix + `scope.${key} = context.${key};`);
}
this.code.unshift(" const scope = Object.create(null);");
}
if (this.shouldProtectContext) {
this.code.unshift(" context = Object.create(context);");
}
if (this.shouldDefineResult) { if (this.shouldDefineResult) {
this.code.unshift(" let result;"); this.code.unshift(" let result;");
} }
if (this.shouldDefineScope) {
this.code.unshift(" let scope = Object.create(context);");
}
if (this.shouldDefineRefs) { if (this.shouldDefineRefs) {
this.code.unshift(" context.__owl__.refs = context.__owl__.refs || {};"); this.code.unshift(" context.__owl__.refs = context.__owl__.refs || {};");
} }
if (this.shouldDefineOwner) {
// this is necessary to prevent some directives (t-forach for ex) to
// pollute the rendering context by adding some keys in it.
this.code.unshift(" let owner = context;");
}
if (this.shouldDefineParent) { if (this.shouldDefineParent) {
if (this.hasParentWidget) { if (this.hasParentWidget) {
this.code.unshift(" let parent = extra.parent;"); this.code.unshift(" let parent = extra.parent;");
@@ -109,15 +131,19 @@ export class CompilationContext {
subContext(key: keyof CompilationContext, value: any): CompilationContext { subContext(key: keyof CompilationContext, value: any): CompilationContext {
const newContext = Object.create(this); const newContext = Object.create(this);
newContext[key] = value; newContext[key] = value;
if (key === "caller") {
newContext.callingLevel++;
newContext.inliningLevel++;
}
return newContext; return newContext;
} }
indent() { indent() {
this.rootContext.indentLevel++; this.indentLevel++;
} }
dedent() { dedent() {
this.rootContext.indentLevel--; this.indentLevel--;
} }
addLine(line: string): number { addLine(line: string): number {
@@ -126,6 +152,11 @@ export class CompilationContext {
return this.code.length - 1; return this.code.length - 1;
} }
addToScope(key: string, expr: string) {
const index = this.addLine(`context.${key} = ${expr};`);
this.rootContext.scopeVars.push({ index, key, indent: this.indentLevel });
}
addIf(condition: string) { addIf(condition: string) {
this.addLine(`if (${condition}) {`); this.addLine(`if (${condition}) {`);
this.indent(); this.indent();
@@ -141,6 +172,27 @@ export class CompilationContext {
this.dedent(); this.dedent();
this.addLine("}"); this.addLine("}");
} }
/**
* Recursively (inverse) fetches the `caller` of a context
* Useful to determine to which t-call a t-raw="0" refers
*/
getCaller(targetLevel?: number): Element | null {
if (targetLevel === undefined) {
targetLevel = this.inliningLevel;
}
if (targetLevel === this.callingLevel) {
return this.caller || null;
}
const proto = (this as any).__proto__;
return proto ? proto.getCaller(targetLevel) : null;
}
/**
* Marks the context with the current recursive level
* in which we are for inlining archs (t-raw="0")
*/
getInliningContext(): CompilationContext {
return this.subContext("inliningLevel", this.inliningLevel - 1);
}
getValue(val: any): QWebVar | string { getValue(val: any): QWebVar | string {
return val in this.variables ? this.getValue(this.variables[val]) : val; return val in this.variables ? this.getValue(this.variables[val]) : val;
@@ -153,45 +205,8 @@ export class CompilationContext {
* - replace already defined variables by their internal name * - replace already defined variables by their internal name
*/ */
formatExpression(expr: string): string { formatExpression(expr: string): string {
this.rootContext.shouldDefineScope = true;
return compileExpr(expr, this.variables); return compileExpr(expr, this.variables);
} }
captureExpression(expr: string): string {
this.rootContext.shouldDefineScope = true;
const argId = this.generateID();
const tokens = compileExprToArray(expr, this.variables);
const done = new Set();
return tokens
.map((tok, i) => {
// "this" in captured expressions should be the current component
if (tok.value === "this") {
if (!done.has("this")) {
done.add("this");
this.addLine(`const this_${argId} = utils.getComponent(context);`);
}
tok.value = `this_${argId}`;
}
// Variables that should be looked up in the scope. isLocal is for arrow
// function arguments that should stay untouched (eg "ev => ev" should
// not become "const ev_1 = scope['ev']; ev_1 => ev_1")
if (
tok.varName &&
!tok.isLocal &&
// HACK: for backwards compatibility, we don't capture bare methods
// this allows them to be called with the rendering context/scope
// as their this value.
(!tokens[i + 1] || tokens[i + 1].type !== "LEFT_PAREN")
) {
if (!done.has(tok.varName)) {
done.add(tok.varName);
this.addLine(`const ${tok.varName}_${argId} = ${tok.value};`);
}
tok.value = `${tok.varName}_${argId}`;
}
return tok.value;
})
.join("");
}
/** /**
* Perform string interpolation on the given string. Note that if the whole * Perform string interpolation on the given string. Note that if the whole
@@ -207,23 +222,7 @@ export class CompilationContext {
return `(${this.formatExpression(s.slice(2, -2))})`; return `(${this.formatExpression(s.slice(2, -2))})`;
} }
let r = s.replace(/\{\{.*?\}\}/g, (s) => "${" + this.formatExpression(s.slice(2, -2)) + "}"); let r = s.replace(/\{\{.*?\}\}/g, s => "${" + this.formatExpression(s.slice(2, -2)) + "}");
return "`" + r + "`"; return "`" + r + "`";
} }
startProtectScope(codeBlock?: boolean): number {
const protectID = this.generateID();
this.rootContext.protectedScopeNumber++;
this.rootContext.shouldDefineScope = true;
const scopeExpr = `Object.create(scope);`;
this.addLine(`let _origScope${protectID} = scope;`);
this.addLine(`scope = ${scopeExpr}`);
if (!codeBlock) {
this.addLine(`scope.__access_mode__ = 'ro';`);
}
return protectID;
}
stopProtectScope(protectID: number) {
this.rootContext.protectedScopeNumber--;
this.addLine(`scope = _origScope${protectID};`);
}
} }
+25 -112
View File
@@ -25,25 +25,23 @@
// Misc types, constants and helpers // Misc types, constants and helpers
//------------------------------------------------------------------------------ //------------------------------------------------------------------------------
const RESERVED_WORDS = const RESERVED_WORDS = "true,false,NaN,null,undefined,debugger,console,window,in,instanceof,new,function,return,this,eval,void,Math,RegExp,Array,Object,Date".split(
"true,false,NaN,null,undefined,debugger,console,window,in,instanceof,new,function,return,this,eval,void,Math,RegExp,Array,Object,Date".split(
"," ","
); );
const WORD_REPLACEMENT = Object.assign(Object.create(null), { const WORD_REPLACEMENT = {
and: "&&", and: "&&",
or: "||", or: "||",
gt: ">", gt: ">",
gte: ">=", gte: ">=",
lt: "<", lt: "<",
lte: "<=", lte: "<="
}); };
export interface QWebVar { export interface QWebVar {
id: string; // foo id?: string;
expr: string; // scope.foo (local variables => only foo) expr?: string;
value?: string; // 1 + 3 xml?: NodeList;
hasBody?: boolean;
} }
//------------------------------------------------------------------------------ //------------------------------------------------------------------------------
@@ -58,7 +56,6 @@ type TKind =
| "RIGHT_PAREN" | "RIGHT_PAREN"
| "COMMA" | "COMMA"
| "VALUE" | "VALUE"
| "TEMPLATE_STRING"
| "SYMBOL" | "SYMBOL"
| "OPERATOR" | "OPERATOR"
| "COLON"; | "COLON";
@@ -66,14 +63,10 @@ type TKind =
interface Token { interface Token {
type: TKind; type: TKind;
value: string; value: string;
originalValue?: string;
size?: number; size?: number;
varName?: string;
replace?: Function;
isLocal?: boolean;
} }
const STATIC_TOKEN_MAP: { [key: string]: TKind } = Object.assign(Object.create(null), { const STATIC_TOKEN_MAP: { [key: string]: TKind } = {
"{": "LEFT_BRACE", "{": "LEFT_BRACE",
"}": "RIGHT_BRACE", "}": "RIGHT_BRACE",
"[": "LEFT_BRACKET", "[": "LEFT_BRACKET",
@@ -81,19 +74,19 @@ const STATIC_TOKEN_MAP: { [key: string]: TKind } = Object.assign(Object.create(n
":": "COLON", ":": "COLON",
",": "COMMA", ",": "COMMA",
"(": "LEFT_PAREN", "(": "LEFT_PAREN",
")": "RIGHT_PAREN", ")": "RIGHT_PAREN"
}); };
// note that the space after typeof is relevant. It makes sure that the formatted // note that the space after typeof is relevant. It makes sure that the formatted
// expression has a space after typeof // expression has a space after typeof
const OPERATORS = "...,.,===,==,+,!==,!=,!,||,&&,>=,>,<=,<,?,-,*,/,%,typeof ,=>,=,;,in ".split(","); const OPERATORS = ".,===,==,+,!==,!=,!,||,&&,>=,>,<=,<,?,-,*,/,%,typeof ".split(",");
type Tokenizer = (expr: string) => Token | false; type Tokenizer = (expr: string) => Token | false;
let tokenizeString: Tokenizer = function(expr) { let tokenizeString: Tokenizer = function(expr) {
let s = expr[0]; let s = expr[0];
let start = s; let start = s;
if (s !== "'" && s !== '"' && s !== "`") { if (s !== "'" && s !== '"') {
return false; return false;
} }
let i = 1; let i = 1;
@@ -115,17 +108,6 @@ let tokenizeString: Tokenizer = function (expr) {
throw new Error("Invalid expression"); throw new Error("Invalid expression");
} }
s += start; s += start;
if (start === "`") {
return {
type: "TEMPLATE_STRING",
value: s,
replace(replacer) {
return s.replace(/\$\{(.*?)\}/g, (match, group) => {
return "${" + replacer(group) + "}";
});
},
};
}
return { type: "VALUE", value: s }; return { type: "VALUE", value: s };
}; };
@@ -182,7 +164,7 @@ const TOKENIZERS = [
tokenizeNumber, tokenizeNumber,
tokenizeOperator, tokenizeOperator,
tokenizeSymbol, tokenizeSymbol,
tokenizeStatic, tokenizeStatic
]; ];
/** /**
@@ -225,10 +207,6 @@ export function tokenize(expr: string): Token[] {
// Expression "evaluator" // Expression "evaluator"
//------------------------------------------------------------------------------ //------------------------------------------------------------------------------
const isLeftSeparator = (token) => token && (token.type === "LEFT_BRACE" || token.type === "COMMA");
const isRightSeparator = (token) =>
token && (token.type === "RIGHT_BRACE" || token.type === "COMMA");
/** /**
* This is the main function exported by this file. This is the code that will * This is the main function exported by this file. This is the code that will
* process an expression (given as a string) and returns another expression with * process an expression (given as a string) and returns another expression with
@@ -249,100 +227,35 @@ const isRightSeparator = (token) =>
* - unless the previous token is a dot (in that case, this is a property: `a.b`) * - unless the previous token is a dot (in that case, this is a property: `a.b`)
* - or if the previous token is a left brace or a comma, and the next token is * - or if the previous token is a left brace or a comma, and the next token is
* a colon (in that case, this is an object key: `{a: b}`) * a colon (in that case, this is an object key: `{a: b}`)
*
* Some specific code is also required to support arrow functions. If we detect
* the arrow operator, then we add the current (or some previous tokens) token to
* the list of variables so it does not get replaced by a lookup in the context
*/ */
export function compileExprToArray(expr: string, scope: { [key: string]: QWebVar }): Token[] { export function compileExpr(expr: string, vars: { [key: string]: QWebVar }): string {
const localVars = new Set<string>();
scope = Object.create(scope);
const tokens = tokenize(expr); const tokens = tokenize(expr);
let result = "";
let i = 0; for (let i = 0; i < tokens.length; i++) {
let stack = []; // to track last opening [ or {
while (i < tokens.length) {
let token = tokens[i]; let token = tokens[i];
let prevToken = tokens[i - 1];
let nextToken = tokens[i + 1];
let groupType = stack[stack.length - 1];
switch (token.type) {
case "LEFT_BRACE":
case "LEFT_BRACKET":
stack.push(token.type);
break;
case "RIGHT_BRACE":
case "RIGHT_BRACKET":
stack.pop();
}
let isVar = token.type === "SYMBOL" && !RESERVED_WORDS.includes(token.value);
if (token.type === "SYMBOL" && !RESERVED_WORDS.includes(token.value)) { if (token.type === "SYMBOL" && !RESERVED_WORDS.includes(token.value)) {
// we need to find if it is a variable
let isVar = true;
let prevToken = tokens[i - 1];
if (prevToken) { if (prevToken) {
// normalize missing tokens: {a} should be equivalent to {a:a}
if (
groupType === "LEFT_BRACE" &&
isLeftSeparator(prevToken) &&
isRightSeparator(nextToken)
) {
tokens.splice(i + 1, 0, { type: "COLON", value: ":" }, { ...token });
nextToken = tokens[i + 1];
}
if (prevToken.type === "OPERATOR" && prevToken.value === ".") { if (prevToken.type === "OPERATOR" && prevToken.value === ".") {
isVar = false; isVar = false;
} else if (prevToken.type === "LEFT_BRACE" || prevToken.type === "COMMA") { } else if (prevToken.type === "LEFT_BRACE" || prevToken.type === "COMMA") {
let nextToken = tokens[i + 1];
if (nextToken && nextToken.type === "COLON") { if (nextToken && nextToken.type === "COLON") {
isVar = false; isVar = false;
} }
} }
} }
}
if (token.type === "TEMPLATE_STRING") {
token.value = token.replace((expr) => compileExpr(expr, scope));
}
if (nextToken && nextToken.type === "OPERATOR" && nextToken.value === "=>") {
if (token.type === "RIGHT_PAREN") {
let j = i - 1;
while (j > 0 && tokens[j].type !== "LEFT_PAREN") {
if (tokens[j].type === "SYMBOL" && tokens[j].originalValue) {
tokens[j].value = tokens[j].originalValue!;
scope[tokens[j].value] = { id: tokens[j].value, expr: tokens[j].value };
localVars.add(tokens[j].value);
}
j--;
}
} else {
scope[token.value] = { id: token.value, expr: token.value };
localVars.add(token.value);
}
}
if (isVar) { if (isVar) {
token.varName = token.value; if (token.value in vars && "id" in vars[token.value]) {
if (token.value in scope && "id" in scope[token.value]) { token.value = vars[token.value].id!;
token.value = scope[token.value].expr!;
} else { } else {
token.originalValue = token.value; token.value = `context['${token.value}']`;
token.value = `scope['${token.value}']`;
} }
} }
i++;
} }
// Mark all variables that have been used locally. result += token.value;
// This assumes the expression has only one scope (incorrect but "good enough for now")
for (const token of tokens) {
if (token.type === "SYMBOL" && localVars.has(token.value)) {
token.isLocal = true;
} }
} return result;
return tokens;
}
export function compileExpr(expr: string, scope: { [key: string]: QWebVar }): string {
return compileExprToArray(expr, scope)
.map((t) => t.value)
.join("");
} }
+63 -151
View File
@@ -1,8 +1,5 @@
import { STATUS } from "../component/component";
import { VNode } from "../vdom/index"; import { VNode } from "../vdom/index";
import { INTERP_REGEXP } from "./compilation_context";
import { QWeb } from "./qweb"; import { QWeb } from "./qweb";
import { browser } from "../browser";
/** /**
* Owl QWeb Extensions * Owl QWeb Extensions
@@ -26,75 +23,53 @@ import { browser } from "../browser";
export const MODS_CODE = { export const MODS_CODE = {
prevent: "e.preventDefault();", prevent: "e.preventDefault();",
self: "if (e.target !== this.elm) {return}", self: "if (e.target !== this.elm) {return}",
stop: "e.stopPropagation();", stop: "e.stopPropagation();"
}; };
interface HandlerInfo {
event: string;
handler: string;
}
const FNAMEREGEXP = /^[$A-Z_][0-9A-Z_$]*$/i;
export function makeHandlerCode(
ctx,
fullName,
value,
putInCache: boolean,
modcodes = MODS_CODE
): HandlerInfo {
let [event, ...mods] = fullName.slice(5).split(".");
if (mods.includes("capture")) {
event = "!" + event;
}
if (!event) {
throw new Error("Missing event name with t-on directive");
}
let code: string;
// check if it is a method with no args, a method with args or an expression
let args: string = "";
const name: string = value.replace(/\(.*\)/, function (_args) {
args = _args.slice(1, -1);
return "";
});
const isMethodCall = name.match(FNAMEREGEXP);
// then generate code
if (isMethodCall) {
ctx.rootContext.shouldDefineUtils = true;
const comp = `utils.getComponent(context)`;
if (args) {
const argId = ctx.generateID();
ctx.addLine(`let args${argId} = [${ctx.formatExpression(args)}];`);
code = `${comp}['${name}'](...args${argId}, e);`;
putInCache = false;
} else {
code = `${comp}['${name}'](e);`;
}
} else {
// if we get here, then it is an expression
// we need to capture every variable in it
putInCache = false;
code = ctx.captureExpression(value);
code = `const res = (() => { return ${code} })(); if (typeof res === 'function') { res(e) }`;
}
const modCode = mods.map((mod) => modcodes[mod]).join("");
let handler = `function (e) {if (context.__owl__.status === ${STATUS.DESTROYED}){return}${modCode}${code}}`;
if (putInCache) {
const key = ctx.generateTemplateKey(event);
ctx.addLine(`extra.handlers[${key}] = extra.handlers[${key}] || ${handler};`);
handler = `extra.handlers[${key}]`;
}
return { event, handler };
}
QWeb.addDirective({ QWeb.addDirective({
name: "on", name: "on",
priority: 90, priority: 90,
atNodeCreation({ ctx, fullName, value, nodeID }) { atNodeCreation({ ctx, fullName, value, nodeID }) {
const { event, handler } = makeHandlerCode(ctx, fullName, value, true); ctx.rootContext.shouldDefineOwner = true;
ctx.addLine(`p${nodeID}.on['${event}'] = ${handler};`); const [eventName, ...mods] = fullName.slice(5).split(".");
}, if (!eventName) {
throw new Error("Missing event name with t-on directive");
}
let extraArgs;
let handlerName = value.replace(/\(.*\)/, function(args) {
extraArgs = args.slice(1, -1);
return "";
});
let params = extraArgs ? `owner, ${ctx.formatExpression(extraArgs)}` : "owner";
let handler = `function (e) {`;
handler += mods
.map(function(mod) {
return MODS_CODE[mod];
})
.join("");
if (handlerName) {
if (!extraArgs) {
handler += `const fn = context['${handlerName}'];`;
handler += `if (fn) { fn.call(${params}, e); } else { context.${handlerName}; }`;
handler += `}`;
ctx.addLine(
`extra.handlers['${eventName}' + ${nodeID}] = extra.handlers['${eventName}' + ${nodeID}] || ${handler};`
);
ctx.addLine(`p${nodeID}.on['${eventName}'] = extra.handlers['${eventName}' + ${nodeID}];`);
} else {
const handlerKey = `handler${ctx.generateID()}`;
ctx.addLine(
`const ${handlerKey} = context['${handlerName}'] && context['${handlerName}'].bind(${params});`
);
handler += `if (${handlerKey}) { ${handlerKey}(e); } else { context.${value}; }`;
handler += `}`;
ctx.addLine(`p${nodeID}.on['${eventName}'] = ${handler};`);
}
} else {
handler += "}";
ctx.addLine(`p${nodeID}.on['${eventName}'] = ${handler};`);
}
}
}); });
//------------------------------------------------------------------------------ //------------------------------------------------------------------------------
@@ -109,7 +84,7 @@ QWeb.addDirective({
ctx.addLine(`const ${refKey} = ${ctx.interpolate(value)};`); ctx.addLine(`const ${refKey} = ${ctx.interpolate(value)};`);
addNodeHook("create", `context.__owl__.refs[${refKey}] = n.elm;`); addNodeHook("create", `context.__owl__.refs[${refKey}] = n.elm;`);
addNodeHook("destroy", `delete context.__owl__.refs[${refKey}];`); addNodeHook("destroy", `delete context.__owl__.refs[${refKey}];`);
}, }
}); });
//------------------------------------------------------------------------------ //------------------------------------------------------------------------------
@@ -187,26 +162,12 @@ function toMs(s: string): number {
} }
function whenTransitionEnd(elm: HTMLElement, cb) { function whenTransitionEnd(elm: HTMLElement, cb) {
if (!elm.parentNode) {
// if we get here, this means that the element was removed for some other
// reasons, and in that case, we don't want to work on animation since nothing
// will be displayed anyway.
return;
}
const styles = window.getComputedStyle(elm); const styles = window.getComputedStyle(elm);
const delays: Array<string> = (styles.transitionDelay || "").split(", "); const delays: Array<string> = (styles.transitionDelay || "").split(", ");
const durations: Array<string> = (styles.transitionDuration || "").split(", "); const durations: Array<string> = (styles.transitionDuration || "").split(", ");
const timeout: number = getTimeout(delays, durations); const timeout: number = getTimeout(delays, durations);
if (timeout > 0) { if (timeout > 0) {
const transitionEndCB = () => { elm.addEventListener("transitionend", cb, { once: true });
if (!elm.parentNode) return;
cb();
browser.clearTimeout(fallbackTimeout);
elm.removeEventListener("transitionend", transitionEndCB);
};
elm.addEventListener("transitionend", transitionEndCB, { once: true });
const fallbackTimeout = browser.setTimeout(transitionEndCB, timeout + 1);
} else { } else {
cb(); cb();
} }
@@ -216,19 +177,16 @@ QWeb.addDirective({
name: "transition", name: "transition",
priority: 96, priority: 96,
atNodeCreation({ ctx, value, addNodeHook }) { atNodeCreation({ ctx, value, addNodeHook }) {
if (!QWeb.enableTransitions) {
return;
}
ctx.rootContext.shouldDefineUtils = true; ctx.rootContext.shouldDefineUtils = true;
let name = value; let name = value;
const hooks = { const hooks = {
insert: `utils.transitionInsert(vn, '${name}');`, insert: `utils.transitionInsert(vn, '${name}');`,
remove: `utils.transitionRemove(vn, '${name}', rm);`, remove: `utils.transitionRemove(vn, '${name}', rm);`
}; };
for (let hookName in hooks) { for (let hookName in hooks) {
addNodeHook(hookName, hooks[hookName]); addNodeHook(hookName, hooks[hookName]);
} }
}, }
}); });
//------------------------------------------------------------------------------ //------------------------------------------------------------------------------
@@ -237,11 +195,11 @@ QWeb.addDirective({
QWeb.addDirective({ QWeb.addDirective({
name: "slot", name: "slot",
priority: 80, priority: 80,
atNodeEncounter({ ctx, value, node, qweb }): boolean { atNodeEncounter({ ctx, value }): boolean {
const slotKey = ctx.generateID(); const slotKey = ctx.generateID();
const valueExpr = value.match(INTERP_REGEXP) ? ctx.interpolate(value) : `'${value}'`; ctx.rootContext.shouldDefineOwner = true;
ctx.addLine( ctx.addLine(
`const slot${slotKey} = this.constructor.slots[context.__owl__.slotId + '_' + ${valueExpr}];` `const slot${slotKey} = this.constructor.slots[context.__owl__.slotId + '_' + '${value}'];`
); );
ctx.addIf(`slot${slotKey}`); ctx.addIf(`slot${slotKey}`);
let parentNode = `c${ctx.parentNode}`; let parentNode = `c${ctx.parentNode}`;
@@ -253,20 +211,14 @@ QWeb.addDirective({
ctx.addLine(`result = {}`); ctx.addLine(`result = {}`);
} }
ctx.addLine( ctx.addLine(
`slot${slotKey}.call(this, context.__owl__.scope, Object.assign({}, extra, {parentNode: ${parentNode}, parent: extra.parent || context}));` `slot${slotKey}.call(this, context.__owl__.parent, Object.assign({}, extra, {parentNode: ${parentNode}, vars: extra.vars, parent: extra.parent || owner}));`
); );
if (!ctx.parentNode) { if (!ctx.parentNode) {
ctx.addLine(`utils.defineProxy(result, ${parentNode}[0]);`); ctx.addLine(`utils.defineProxy(result, ${parentNode}[0]);`);
} }
if (node.hasChildNodes()) {
ctx.addElse();
const nodeCopy = <Element>node.cloneNode(true);
nodeCopy.removeAttribute("t-slot");
qweb._compileNode(nodeCopy, ctx);
}
ctx.closeIf(); ctx.closeIf();
return true; return true;
}, }
}); });
//------------------------------------------------------------------------------ //------------------------------------------------------------------------------
@@ -277,9 +229,6 @@ QWeb.utils.toNumber = function (val: string): number | string {
return isNaN(n) ? val : n; return isNaN(n) ? val : n;
}; };
const hasDotAtTheEnd = /\.[\w_]+\s*$/;
const hasBracketsAtTheEnd = /\[[^\[]+\]\s*$/;
QWeb.addDirective({ QWeb.addDirective({
name: "model", name: "model",
priority: 42, priority: 42,
@@ -288,41 +237,15 @@ QWeb.addDirective({
let handler; let handler;
let event = fullName.includes(".lazy") ? "change" : "input"; let event = fullName.includes(".lazy") ? "change" : "input";
// First step: we need to understand the structure of the expression, and // we keep here a reference to the "base expression" (if the expression
// from it, extract a base expression (that we can capture, which is // is `t-model="some.expr.value", then the base expression is "some.expr").
// important because it will be used in a handler later) and a formatted // This is necessary so we can capture it in the handler closure.
// expression (which uses the captured base expression) let expr = ctx.formatExpression(value);
// const index = expr.lastIndexOf(".");
// Also, we support 2 kinds of values: some.expr.value or some.expr[value] const baseExpr = expr.slice(0, index);
// For the first one, we have: ctx.addLine(`let expr${nodeID} = ${baseExpr};`);
// - base expression = scope[some].expr
// - expression = exprX.value (where exprX is the var that captures the base expr)
// and for the expression with brackets:
// - base expression = scope[some].expr
// - expression = exprX[keyX] (where exprX is the var that captures the base expr
// and keyX captures scope[value])
let expr: string;
let baseExpr: string;
if (hasDotAtTheEnd.test(value)) {
// we manage the case where the expr has a dot: some.expr.value
const index = value.lastIndexOf(".");
baseExpr = value.slice(0, index);
ctx.addLine(`let expr${nodeID} = ${ctx.formatExpression(baseExpr)};`);
expr = `expr${nodeID}${value.slice(index)}`;
} else if (hasBracketsAtTheEnd.test(value)) {
// we manage here the case where the expr ends in a bracket expression:
// some.expr[value]
const index = value.lastIndexOf("[");
baseExpr = value.slice(0, index);
ctx.addLine(`let expr${nodeID} = ${ctx.formatExpression(baseExpr)};`);
let exprKey = value.trimRight().slice(index + 1, -1);
ctx.addLine(`let exprKey${nodeID} = ${ctx.formatExpression(exprKey)};`);
expr = `expr${nodeID}[exprKey${nodeID}]`;
} else {
throw new Error(`Invalid t-model expression: "${value}" (it should be assignable)`);
}
expr = `expr${nodeID}.${expr.slice(index + 1)}`;
const key = ctx.generateTemplateKey(); const key = ctx.generateTemplateKey();
if (node.tagName === "select") { if (node.tagName === "select") {
ctx.addLine(`p${nodeID}.props = {value: ${expr}};`); ctx.addLine(`p${nodeID}.props = {value: ${expr}};`);
@@ -349,7 +272,7 @@ QWeb.addDirective({
} }
ctx.addLine(`extra.handlers[${key}] = extra.handlers[${key}] || (${handler});`); ctx.addLine(`extra.handlers[${key}] = extra.handlers[${key}] || (${handler});`);
ctx.addLine(`p${nodeID}.on['${event}'] = extra.handlers[${key}];`); ctx.addLine(`p${nodeID}.on['${event}'] = extra.handlers[${key}];`);
}, }
}); });
//------------------------------------------------------------------------------ //------------------------------------------------------------------------------
@@ -358,20 +281,9 @@ QWeb.addDirective({
QWeb.addDirective({ QWeb.addDirective({
name: "key", name: "key",
priority: 45, priority: 45,
atNodeEncounter({ ctx, value, node }) { atNodeEncounter({ ctx, value }) {
if (ctx.loopNumber === 0) { let id = ctx.generateID();
ctx.keyStack.push(ctx.rootContext.hasKey0); ctx.addLine(`const nodeKey${id} = ${ctx.formatExpression(value)};`);
ctx.rootContext.hasKey0 = true; ctx.currentKey = `nodeKey${id}`;
} }
ctx.addLine("{");
ctx.indent();
ctx.addLine(`let key${ctx.loopNumber} = ${ctx.formatExpression(value)};`);
},
finalize({ ctx }) {
ctx.dedent();
ctx.addLine("}");
if (ctx.loopNumber === 0) {
ctx.rootContext.hasKey0 = ctx.keyStack.pop() as boolean;
}
},
}); });
+74 -229
View File
@@ -1,7 +1,7 @@
import { EventBus } from "../core/event_bus"; import { EventBus } from "../core/event_bus";
import { h, patch, VNode } from "../vdom/index"; import { h, patch, VNode } from "../vdom/index";
import { CompilationContext } from "./compilation_context"; import { CompilationContext } from "./compilation_context";
import { shallowEqual, escape } from "../utils"; import { shallowEqual } from "../utils";
import { addNS } from "../vdom/vdom"; import { addNS } from "../vdom/vdom";
/** /**
@@ -66,126 +66,46 @@ interface QWebConfig {
// Const/global stuff/helpers // Const/global stuff/helpers
//------------------------------------------------------------------------------ //------------------------------------------------------------------------------
export const TRANSLATABLE_ATTRS = ["label", "title", "placeholder", "alt"]; const DISABLED_TAGS = ["input", "textarea", "button", "select", "option", "optgroup"];
const TRANSLATABLE_ATTRS = ["label", "title", "placeholder", "alt"];
const lineBreakRE = /[\r\n]/; const lineBreakRE = /[\r\n]/;
const whitespaceRE = /\s+/g; const whitespaceRE = /\s+/g;
const translationRE = /^(\s*)([\s\S]+?)(\s*)$/;
const NODE_HOOKS_PARAMS = { const NODE_HOOKS_PARAMS = {
create: "(_, n)", create: "(_, n)",
insert: "vn", insert: "vn",
remove: "(vn, rm)", remove: "(vn, rm)",
destroy: "()", destroy: "()"
}; };
interface Utils { interface Utils {
toClassObj(expr: any): Object; toObj(expr: any): Object;
shallowEqual(p1: Object, p2: Object): boolean; shallowEqual(p1: Object, p2: Object): boolean;
[key: string]: any; [key: string]: any;
} }
function isComponent(obj): boolean {
return obj && obj.hasOwnProperty("__owl__");
}
class VDomArray extends Array {
toString() {
return vDomToString(this);
}
}
function vDomToString(vdom: VNode[]): string {
return vdom
.map((vnode) => {
if (vnode.sel) {
const node = document.createElement(vnode.sel);
const result = patch(node, vnode);
return (<HTMLElement>result.elm).outerHTML;
} else {
return vnode.text;
}
})
.join("");
}
const UTILS: Utils = { const UTILS: Utils = {
zero: Symbol("zero"), toObj(expr) {
toClassObj(expr) {
const result = {};
if (typeof expr === "string") { if (typeof expr === "string") {
// we transform here a list of classes into an object:
// 'hey you' becomes {hey: true, you: true}
expr = expr.trim(); expr = expr.trim();
if (!expr) { if (!expr) {
return {}; return {};
} }
let words = expr.split(/\s+/); let words = expr.split(/\s+/);
let result = {};
for (let i = 0; i < words.length; i++) { for (let i = 0; i < words.length; i++) {
result[words[i]] = true; result[words[i]] = true;
} }
return result; return result;
} }
// this is already an object, but we may need to split keys: return expr;
// {'a b': true, 'a c': false} should become {a: true, b: true, c: false}
for (let key in expr) {
const value = expr[key];
const words = key.split(/\s+/);
for (let word of words) {
result[word] = result[word] || value;
}
}
return result;
},
/**
* This method combines the current context with the variables defined in a
* scope for use in a slot.
*
* The implementation is kind of tricky because we want to preserve the
* prototype chain structure of the cloned result. So we need to traverse the
* prototype chain, cloning each level respectively.
*/
combine(context, scope) {
let clone = context;
const scopeStack = [];
while (!isComponent(scope)) {
scopeStack.push(scope);
scope = scope.__proto__;
}
while (scopeStack.length) {
let scope = scopeStack.pop();
clone = Object.create(clone);
Object.assign(clone, scope);
}
return clone;
}, },
shallowEqual, shallowEqual,
addNameSpace(vnode) { addNameSpace(vnode) {
addNS(vnode.data, vnode.children, vnode.sel); addNS(vnode.data, vnode.children, vnode.sel);
},
VDomArray,
vDomToString,
getComponent(obj) {
while (obj && !isComponent(obj)) {
obj = obj.__proto__;
} }
return obj;
},
getScope(obj, property: string) {
const obj0 = obj;
while (
obj &&
!obj.hasOwnProperty(property) &&
!(obj.hasOwnProperty("__access_mode__") && obj.__access_mode__ === "ro")
) {
const newObj = obj.__proto__;
if (!newObj || isComponent(newObj)) {
return obj0;
}
obj = newObj;
}
return obj;
},
}; };
function parseXML(xml: string): Document { function parseXML(xml: string): Document {
@@ -218,10 +138,6 @@ function parseXML(xml: string): Document {
return doc; return doc;
} }
function escapeQuotes(str: string): string {
return str.replace(/\'/g, "\\'");
}
//------------------------------------------------------------------------------ //------------------------------------------------------------------------------
// QWeb rendering engine // QWeb rendering engine
//------------------------------------------------------------------------------ //------------------------------------------------------------------------------
@@ -235,8 +151,7 @@ export class QWeb extends EventBus {
name: 1, name: 1,
att: 1, att: 1,
attf: 1, attf: 1,
translation: 1, translation: 1
tag: 1,
}; };
static DIRECTIVES: Directive[] = []; static DIRECTIVES: Directive[] = [];
@@ -247,19 +162,16 @@ export class QWeb extends EventBus {
h = h; h = h;
// dev mode enables better error messages or more costly validations // dev mode enables better error messages or more costly validations
static dev: boolean = false; static dev: boolean = false;
static enableTransitions: boolean = true;
// slots contains sub templates defined with t-set inside t-component nodes, and // slots contains sub templates defined with t-set inside t-component nodes, and
// are meant to be used by the t-slot directive. // are meant to be used by the t-slot directive.
static slots = {}; static slots = {};
static nextSlotId = 1; static nextSlotId = 1;
// subTemplates are stored in two objects: a (local) mapping from a name to an // recursiveTemplates contains sub templates called with t-call, but which
// id, and a (global) mapping from an id to the compiled function. This is // ends up in recursive situations. This is very similar to the slot situation,
// necessary to ensure that global templates can be called with more than one // as in we need to propagate the scope.
// QWeb instance. recursiveFns = {};
subTemplates: { [key: string]: number } = {};
static subTemplates: { [id: number]: Function } = {};
isUpdating: boolean = false; isUpdating: boolean = false;
translateFn?: QWebConfig["translateFn"]; translateFn?: QWebConfig["translateFn"];
@@ -283,7 +195,7 @@ export class QWeb extends EventBus {
QWeb.DIRECTIVE_NAMES[directive.name] = 1; QWeb.DIRECTIVE_NAMES[directive.name] = 1;
QWeb.DIRECTIVES.sort((d1, d2) => d1.priority - d2.priority); QWeb.DIRECTIVES.sort((d1, d2) => d1.priority - d2.priority);
if (directive.extraNames) { if (directive.extraNames) {
directive.extraNames.forEach((n) => (QWeb.DIRECTIVE_NAMES[n] = 1)); directive.extraNames.forEach(n => (QWeb.DIRECTIVE_NAMES[n] = 1));
} }
} }
@@ -329,9 +241,6 @@ export class QWeb extends EventBus {
* template, with the name given by the t-name attribute. * template, with the name given by the t-name attribute.
*/ */
addTemplates(xmlstr: string | Document) { addTemplates(xmlstr: string | Document) {
if (!xmlstr) {
return;
}
const doc = typeof xmlstr === "string" ? parseXML(xmlstr) : xmlstr; const doc = typeof xmlstr === "string" ? parseXML(xmlstr) : xmlstr;
const templates = doc.getElementsByTagName("templates")[0]; const templates = doc.getElementsByTagName("templates")[0];
if (!templates) { if (!templates) {
@@ -351,10 +260,10 @@ export class QWeb extends EventBus {
const template = { const template = {
elem, elem,
fn: function(this: QWeb, context, extra) { fn: function(this: QWeb, context, extra) {
const compiledFunction = this._compile(name); const compiledFunction = this._compile(name, elem);
template.fn = compiledFunction; template.fn = compiledFunction;
return compiledFunction.call(this, context, extra); return compiledFunction.call(this, context, extra);
}, }
}; };
this.templates[name] = template; this.templates[name] = template;
} }
@@ -383,11 +292,10 @@ export class QWeb extends EventBus {
) { ) {
throw new Error("Only one conditional branching directive is allowed per node"); throw new Error("Only one conditional branching directive is allowed per node");
} }
// All text (with only spaces) and comment nodes (nodeType 8) between // All text nodes between branch nodes are removed
// branch nodes are removed
let textNode; let textNode;
while ((textNode = node.previousSibling) !== prevElem) { while ((textNode = node.previousSibling) !== prevElem) {
if (textNode.nodeValue.trim().length && textNode.nodeType !== 8) { if (textNode.nodeValue.trim().length) {
throw new Error("text is not allowed between branching directives"); throw new Error("text is not allowed between branching directives");
} }
textNode.remove(); textNode.remove();
@@ -425,17 +333,8 @@ export class QWeb extends EventBus {
return vnode.text!; return vnode.text!;
} }
const node = document.createElement(vnode.sel); const node = document.createElement(vnode.sel);
const elem = patch(node, vnode).elm as HTMLElement; const result = patch(node, vnode);
function escapeTextNodes(node) { return (<HTMLElement>result.elm).outerHTML;
if (node.nodeType === 3) {
node.textContent = escape(node.textContent);
}
for (let n of node.childNodes) {
escapeTextNodes(n);
}
}
escapeTextNodes(elem);
return elem.outerHTML;
} }
/** /**
@@ -454,36 +353,34 @@ export class QWeb extends EventBus {
}); });
} }
_compile( _compile(name: string, elem: Element, parentContext?: CompilationContext): CompiledTemplate {
name: string,
options: {
elem?: Element;
hasParent?: boolean;
defineKey?: boolean;
} = {}
): CompiledTemplate {
const elem = options.elem || this.templates[name].elem;
const isDebug = elem.attributes.hasOwnProperty("t-debug"); const isDebug = elem.attributes.hasOwnProperty("t-debug");
const ctx = new CompilationContext(name); const ctx = new CompilationContext(name);
if (elem.tagName !== "t") { if (elem.tagName !== "t") {
ctx.shouldDefineResult = false; ctx.shouldDefineResult = false;
} }
if (options.hasParent) { if (parentContext) {
ctx.variables = Object.create(null); ctx.templates = Object.create(parentContext.templates);
ctx.parentNode = ctx.generateID(); ctx.variables = Object.create(parentContext.variables);
ctx.parentNode = parentContext.parentNode || ctx.generateID();
ctx.allowMultipleRoots = true; ctx.allowMultipleRoots = true;
ctx.shouldDefineParent = true;
ctx.hasParentWidget = true; ctx.hasParentWidget = true;
ctx.shouldDefineResult = false; ctx.shouldDefineResult = false;
ctx.addLine(`let c${ctx.parentNode} = extra.parentNode;`); ctx.addLine(`let c${ctx.parentNode} = extra.parentNode;`);
if (options.defineKey) {
ctx.addLine(`let key0 = extra.key || "";`); for (let v in parentContext.variables) {
ctx.hasKey0 = true; let variable = <any>parentContext.variables[v];
if (variable.id) {
ctx.addLine(`let ${variable.id} = extra.fiber.vars.${variable.id}`);
} }
} }
}
if (parentContext) {
ctx.addLine(" Object.assign(context, extra.fiber.scope);");
}
this._compileNode(elem, ctx); this._compileNode(elem, ctx);
if (!options.hasParent) { if (!parentContext) {
if (ctx.shouldDefineResult) { if (ctx.shouldDefineResult) {
ctx.addLine(`return result;`); ctx.addLine(`return result;`);
} else { } else {
@@ -495,13 +392,12 @@ export class QWeb extends EventBus {
} }
let code = ctx.generateCode(); let code = ctx.generateCode();
const templateName = ctx.templateName.replace(/`/g, "'").slice(0, 200);
code.unshift(` // Template name: "${templateName}"`);
let template; let template;
try { try {
template = new Function("context, extra", code.join("\n")) as CompiledTemplate; template = new Function("context", "extra", code.join("\n")) as CompiledTemplate;
} catch (e) { } catch (e) {
const templateName = ctx.templateName.replace(/`/g, "'");
console.groupCollapsed(`Invalid Code generated by ${templateName}`); console.groupCollapsed(`Invalid Code generated by ${templateName}`);
console.warn(code.join("\n")); console.warn(code.join("\n"));
console.groupEnd(); console.groupEnd();
@@ -535,8 +431,7 @@ export class QWeb extends EventBus {
} }
if (this.translateFn) { if (this.translateFn) {
if ((node.parentNode as any).getAttribute("t-translation") !== "off") { if ((node.parentNode as any).getAttribute("t-translation") !== "off") {
const match = translationRE.exec(text); text = this.translateFn(text);
text = match[1] + this.translateFn(match[2]) + match[3];
} }
} }
if (ctx.parentNode) { if (ctx.parentNode) {
@@ -551,7 +446,7 @@ export class QWeb extends EventBus {
// this is an unusual situation: this text node is the result of the // this is an unusual situation: this text node is the result of the
// template rendering. // template rendering.
let nodeID = ctx.generateID(); let nodeID = ctx.generateID();
ctx.addLine(`let vn${nodeID} = {text: \`${text}\`};`); ctx.addLine(`var vn${nodeID} = {text: \`${text}\`};`);
ctx.addLine(`result = vn${nodeID};`); ctx.addLine(`result = vn${nodeID};`);
ctx.rootContext.rootNode = nodeID; ctx.rootContext.rootNode = nodeID;
ctx.rootContext.parentTextNode = nodeID; ctx.rootContext.parentTextNode = nodeID;
@@ -559,21 +454,14 @@ export class QWeb extends EventBus {
return; return;
} }
if (node.tagName !== "t" && node.hasAttribute("t-call")) { if (ctx !== ctx.rootContext) {
const tCallNode = document.implementation.createDocument( ctx = ctx.subContext("currentKey", ctx.currentKey);
"http://www.w3.org/1999/xhtml",
"t",
null
).documentElement;
tCallNode.setAttribute("t-call", node.getAttribute("t-call")!);
node.removeAttribute("t-call");
node.prepend(tCallNode);
} }
const firstLetter = node.tagName[0]; const firstLetter = node.tagName[0];
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 <SomeComponent t-component="SomeComponent" ... /> // <SomeComponent ... /> to <t t-component="SomeComponent" ... />
node.setAttribute("t-component", node.tagName); node.setAttribute("t-component", node.tagName);
} else if (node.tagName !== "t" && node.hasAttribute("t-component")) { } else if (node.tagName !== "t" && node.hasAttribute("t-component")) {
throw new Error( throw new Error(
@@ -600,11 +488,7 @@ export class QWeb extends EventBus {
throw new Error(`Unknown QWeb directive: '${attrName}'`); throw new Error(`Unknown QWeb directive: '${attrName}'`);
} }
if (node.tagName !== "t" && (attrName === "t-esc" || attrName === "t-raw")) { if (node.tagName !== "t" && (attrName === "t-esc" || attrName === "t-raw")) {
const tNode = document.implementation.createDocument( const tNode = document.createElement("t");
"http://www.w3.org/1999/xhtml",
"t",
null
).documentElement;
tNode.setAttribute(attrName, node.getAttribute(attrName)!); tNode.setAttribute(attrName, node.getAttribute(attrName)!);
for (let child of Array.from(node.childNodes)) { for (let child of Array.from(node.childNodes)) {
tNode.appendChild(child); tNode.appendChild(child);
@@ -649,7 +533,7 @@ export class QWeb extends EventBus {
qweb: this, qweb: this,
ctx, ctx,
fullName, fullName,
value, value
}); });
if (isDone) { if (isDone) {
for (let { directive, value, fullName } of finalizers) { for (let { directive, value, fullName } of finalizers) {
@@ -660,22 +544,14 @@ export class QWeb extends EventBus {
} }
} }
if (node.nodeName !== "t" || node.hasAttribute("t-tag")) { if (node.nodeName !== "t") {
let nodeID = this._compileGenericNode(node, ctx, withHandlers);
ctx = ctx.withParent(nodeID);
let nodeHooks = {}; let nodeHooks = {};
let addNodeHook = function(hook, handler) { let addNodeHook = function(hook, handler) {
nodeHooks[hook] = nodeHooks[hook] || []; nodeHooks[hook] = nodeHooks[hook] || [];
nodeHooks[hook].push(handler); nodeHooks[hook].push(handler);
}; };
if (node.tagName === "select" && node.hasAttribute("t-att-value")) {
const value = node.getAttribute("t-att-value");
let exprId = ctx.generateID();
ctx.addLine(`let expr${exprId} = ${ctx.formatExpression(value)};`);
let expr = `expr${exprId}`;
node.setAttribute("t-att-value", expr);
addNodeHook("create", `n.elm.value=${expr};`);
}
let nodeID = this._compileGenericNode(node, ctx, withHandlers);
ctx = ctx.withParent(nodeID);
for (let { directive, value, fullName } of validDirectives) { for (let { directive, value, fullName } of validDirectives) {
if (directive.atNodeCreation) { if (directive.atNodeCreation) {
@@ -686,7 +562,7 @@ export class QWeb extends EventBus {
fullName, fullName,
value, value,
nodeID, nodeID,
addNodeHook, addNodeHook
}); });
} }
} }
@@ -739,36 +615,25 @@ export class QWeb extends EventBus {
const props: string[] = []; const props: string[] = [];
const tattrs: number[] = []; const tattrs: number[] = [];
function handleProperties(key, val) { function handleBooleanProps(key, val) {
let isProp = false; let isProp = false;
switch (node.nodeName) { if (node.nodeName === "input" && key === "checked") {
case "input":
let type = (<Element>node).getAttribute("type"); let type = (<Element>node).getAttribute("type");
if (type === "checkbox" || type === "radio") { if (type === "checkbox" || type === "radio") {
if (key === "checked" || key === "indeterminate") {
isProp = true; isProp = true;
} }
} }
if (key === "value" || key === "readonly" || key === "disabled") { if (node.nodeName === "option" && key === "selected") {
isProp = true; isProp = true;
} }
break; if (key === "disabled" && DISABLED_TAGS.indexOf(node.nodeName) > -1) {
case "option": isProp = true;
isProp = key === "selected" || key === "disabled"; }
break; if ((key === "readonly" && node.nodeName === "input") || node.nodeName === "textarea") {
case "textarea": isProp = true;
isProp = key === "readonly" || key === "disabled" || key === "value";
break;
case "select":
isProp = key === "disabled" || key === "value";
break;
case "button":
case "optgroup":
isProp = key === "disabled";
break;
} }
if (isProp) { if (isProp) {
props.push(`${key}: ${val}`); props.push(`${key}: _${val}`);
} }
} }
let classObj = ""; let classObj = "";
@@ -785,26 +650,21 @@ export class QWeb extends EventBus {
if (!name.startsWith("t-") && !(<Element>node).getAttribute("t-attf-" + name)) { if (!name.startsWith("t-") && !(<Element>node).getAttribute("t-attf-" + name)) {
const attID = ctx.generateID(); const attID = ctx.generateID();
if (name === "class") { if (name === "class") {
if ((value = value.trim())) {
let classDef = value let classDef = value
.trim()
.split(/\s+/) .split(/\s+/)
.map((a) => `'${escapeQuotes(a)}':true`) .map(a => `'${a}':true`)
.join(","); .join(",");
if (classObj) {
ctx.addLine(`Object.assign(${classObj}, {${classDef}})`);
} else {
classObj = `_${ctx.generateID()}`; classObj = `_${ctx.generateID()}`;
ctx.addLine(`let ${classObj} = {${classDef}};`); ctx.addLine(`let ${classObj} = {${classDef}};`);
}
}
} else { } else {
ctx.addLine(`let _${attID} = '${escapeQuotes(value)}';`); ctx.addLine(`var _${attID} = '${value}';`);
if (!name.match(/^[a-zA-Z]+$/)) { if (!name.match(/^[a-zA-Z]+$/)) {
// attribute contains 'non letters' => we want to quote it // attribute contains 'non letters' => we want to quote it
name = '"' + name + '"'; name = '"' + name + '"';
} }
attrs.push(`${name}: _${attID}`); attrs.push(`${name}: _${attID}`);
handleProperties(name, `_${attID}`); handleBooleanProps(name, attID);
} }
} }
@@ -812,11 +672,11 @@ export class QWeb extends EventBus {
if (name.startsWith("t-att-")) { if (name.startsWith("t-att-")) {
let attName = name.slice(6); let attName = name.slice(6);
const v = ctx.getValue(value); const v = ctx.getValue(value);
let formattedValue = typeof v === "string" ? ctx.formatExpression(v) : `scope.${v.id}`; let formattedValue = typeof v === "string" ? ctx.formatExpression(v) : v.id;
if (attName === "class") { if (attName === "class") {
ctx.rootContext.shouldDefineUtils = true; ctx.rootContext.shouldDefineUtils = true;
formattedValue = `utils.toClassObj(${formattedValue})`; formattedValue = `utils.toObj(${formattedValue})`;
if (classObj) { if (classObj) {
ctx.addLine(`Object.assign(${classObj}, ${formattedValue})`); ctx.addLine(`Object.assign(${classObj}, ${formattedValue})`);
} else { } else {
@@ -834,19 +694,14 @@ export class QWeb extends EventBus {
const attValue = (<Element>node).getAttribute(attName); const attValue = (<Element>node).getAttribute(attName);
if (attValue) { if (attValue) {
const attValueID = ctx.generateID(); const attValueID = ctx.generateID();
ctx.addLine(`let _${attValueID} = ${formattedValue};`); ctx.addLine(`var _${attValueID} = ${formattedValue};`);
formattedValue = `'${attValue}' + (_${attValueID} ? ' ' + _${attValueID} : '')`; formattedValue = `'${attValue}' + (_${attValueID} ? ' ' + _${attValueID} : '')`;
const attrIndex = attrs.findIndex((att) => att.startsWith(attName + ":")); const attrIndex = attrs.findIndex(att => att.startsWith(attName + ":"));
attrs.splice(attrIndex, 1); attrs.splice(attrIndex, 1);
} }
if (node.nodeName === "select" && attName === "value") { ctx.addLine(`var _${attID} = ${formattedValue};`);
attrs.push(`${attName}: ${v}`);
handleProperties(attName, v);
} else {
ctx.addLine(`let _${attID} = ${formattedValue};`);
attrs.push(`${attName}: _${attID}`); attrs.push(`${attName}: _${attID}`);
handleProperties(attName, "_" + attID); handleBooleanProps(attName, attID);
}
} }
} }
@@ -860,9 +715,9 @@ export class QWeb extends EventBus {
const attID = ctx.generateID(); const attID = ctx.generateID();
let staticVal = (<Element>node).getAttribute(attName); let staticVal = (<Element>node).getAttribute(attName);
if (staticVal) { if (staticVal) {
ctx.addLine(`let _${attID} = '${staticVal} ' + ${formattedExpr};`); ctx.addLine(`var _${attID} = '${staticVal} ' + ${formattedExpr};`);
} else { } else {
ctx.addLine(`let _${attID} = ${formattedExpr};`); ctx.addLine(`var _${attID} = ${formattedExpr};`);
} }
attrs.push(`${attName}: _${attID}`); attrs.push(`${attName}: _${attID}`);
} }
@@ -870,13 +725,13 @@ export class QWeb extends EventBus {
// t-att= attributes // t-att= attributes
if (name === "t-att") { if (name === "t-att") {
let id = ctx.generateID(); let id = ctx.generateID();
ctx.addLine(`let _${id} = ${ctx.formatExpression(value!)};`); ctx.addLine(`var _${id} = ${ctx.formatExpression(value!)};`);
tattrs.push(id); tattrs.push(id);
} }
} }
let nodeID = ctx.generateID(); let nodeID = ctx.generateID();
let key = ctx.loopNumber || ctx.hasKey0 ? `\`\${key${ctx.loopNumber}}_${nodeID}\`` : nodeID; let nodeKey = ctx.currentKey || nodeID;
const parts = [`key:${key}`]; const parts = [`key:${nodeKey}`];
if (attrs.length + tattrs.length > 0) { if (attrs.length + tattrs.length > 0) {
parts.push(`attrs:{${attrs.join(",")}}`); parts.push(`attrs:{${attrs.join(",")}}`);
} }
@@ -902,19 +757,9 @@ export class QWeb extends EventBus {
ctx.addLine(`}`); ctx.addLine(`}`);
ctx.closeIf(); ctx.closeIf();
} }
let nodeName = `'${node.nodeName}'`; ctx.addLine(`var vn${nodeID} = h('${node.nodeName}', p${nodeID}, c${nodeID});`);
if ((<Element>node).hasAttribute("t-tag")) {
const tagExpr = (<Element>node).getAttribute("t-tag");
(<Element>node).removeAttribute("t-tag");
nodeName = `tag${ctx.generateID()}`;
ctx.addLine(`let ${nodeName} = ${ctx.formatExpression(tagExpr)};`);
}
ctx.addLine(`let vn${nodeID} = h(${nodeName}, p${nodeID}, c${nodeID});`);
if (ctx.parentNode) { if (ctx.parentNode) {
ctx.addLine(`c${ctx.parentNode}.push(vn${nodeID});`); ctx.addLine(`c${ctx.parentNode}.push(vn${nodeID});`);
} else if (ctx.loopNumber || ctx.hasKey0) {
ctx.rootContext.shouldDefineResult = true;
ctx.addLine(`result = vn${nodeID};`);
} }
return nodeID; return nodeID;
+1 -1
View File
@@ -4,7 +4,7 @@ import { Destination, RouterEnv } from "./router";
type Props = Destination; type Props = Destination;
export class Link<Env extends RouterEnv> extends Component<Props, Env> { export class Link<Env extends RouterEnv> extends Component<Env, Props> {
static template = xml` static template = xml`
<a t-att-class="{'router-link-active': isActive }" <a t-att-class="{'router-link-active': isActive }"
t-att-href="href" t-att-href="href"
+1 -2
View File
@@ -1,8 +1,7 @@
import { Component } from "../component/component"; import { Component } from "../component/component";
import { xml } from "../tags"; import { xml } from "../tags";
import { EnvWithRouter } from "./router";
export class RouteComponent extends Component<{}, EnvWithRouter> { export class RouteComponent extends Component<any, {}> {
static template = xml` static template = xml`
<t> <t>
<t <t
+35 -54
View File
@@ -11,7 +11,6 @@ type NavigationGuard = (info: {
export interface Route { export interface Route {
name: string; name: string;
path: string; path: string;
extractionRegExp: RegExp;
component?: any; component?: any;
redirect?: Destination; redirect?: Destination;
params: string[]; params: string[];
@@ -50,12 +49,7 @@ interface Options {
mode: Router["mode"]; mode: Router["mode"];
} }
export interface EnvWithRouter extends Env {
router: Router;
}
const paramRegexp = /\{\{(.*?)\}\}/; const paramRegexp = /\{\{(.*?)\}\}/;
const globalParamRegexp = new RegExp(paramRegexp.source, "g");
export class Router { export class Router {
currentRoute: Route | null = null; currentRoute: Route | null = null;
@@ -66,11 +60,7 @@ export class Router {
routeIds: string[]; routeIds: string[];
env: RouterEnv; env: RouterEnv;
constructor( constructor(env: Env, routes: Partial<Route>[], options: Options = { mode: "history" }) {
env: Partial<EnvWithRouter>,
routes: Partial<Route>[],
options: Options = { mode: "history" }
) {
env.router = this; env.router = this;
this.mode = options.mode; this.mode = options.mode;
this.env = env as RouterEnv; this.env = env as RouterEnv;
@@ -89,7 +79,6 @@ export class Router {
this.validateDestination(partialRoute.redirect); this.validateDestination(partialRoute.redirect);
} }
partialRoute.params = partialRoute.path ? findParams(partialRoute.path) : []; partialRoute.params = partialRoute.path ? findParams(partialRoute.path) : [];
partialRoute.extractionRegExp = makeExtractionRegExp(partialRoute.path);
this.routes[partialRoute.name] = partialRoute as Route; this.routes[partialRoute.name] = partialRoute as Route;
this.routeIds.push(partialRoute.name); this.routeIds.push(partialRoute.name);
} }
@@ -100,7 +89,7 @@ export class Router {
//-------------------------------------------------------------------------- //--------------------------------------------------------------------------
async start() { async start() {
(this as any)._listener = (ev) => this._navigate(this.currentPath(), ev); (this as any)._listener = ev => this._navigate(this.currentPath(), ev);
window.addEventListener("popstate", (this as any)._listener); window.addEventListener("popstate", (this as any)._listener);
if (this.mode === "hash") { if (this.mode === "hash") {
window.addEventListener("hashchange", (this as any)._listener); window.addEventListener("hashchange", (this as any)._listener);
@@ -125,10 +114,7 @@ export class Router {
const initialParams = this.currentParams; const initialParams = this.currentParams;
const result = await this.matchAndApplyRules(path); const result = await this.matchAndApplyRules(path);
if (result.type === "match") { if (result.type === "match") {
let finalPath = this.routeToPath(result.route, result.params); const finalPath = this.routeToPath(result.route, result.params);
if (path.indexOf("?") > -1) {
finalPath += "?" + path.split("?")[1];
}
const isPopStateEvent = ev && ev instanceof PopStateEvent; const isPopStateEvent = ev && ev instanceof PopStateEvent;
if (!isPopStateEvent) { if (!isPopStateEvent) {
this.setUrlFromPath(finalPath); this.setUrlFromPath(finalPath);
@@ -176,14 +162,19 @@ export class Router {
} }
private routeToPath(route: Route, params: RouteParams): string { private routeToPath(route: Route, params: RouteParams): string {
const path = route.path;
const parts = path.split("/");
const l = parts.length;
for (let i = 0; i < l; i++) {
const part = parts[i];
const match = part.match(paramRegexp);
if (match) {
const key = match[1].split(".")[0];
parts[i] = <string>params[key];
}
}
const prefix = this.mode === "hash" ? "#" : ""; const prefix = this.mode === "hash" ? "#" : "";
return ( return prefix + parts.join("/");
prefix +
route.path.replace(globalParamRegexp, (match, param) => {
const [key] = param.split(".");
return <string>params[key];
})
);
} }
private currentPath(): string { private currentPath(): string {
@@ -199,7 +190,7 @@ export class Router {
return { return {
type: "match", type: "match",
route: route, route: route,
params: params, params: params
}; };
} }
} }
@@ -224,7 +215,7 @@ export class Router {
const result = await route.beforeRouteEnter({ const result = await route.beforeRouteEnter({
env: this.env, env: this.env,
from: this.currentRoute, from: this.currentRoute,
to: route, to: route
}); });
if (result === false) { if (result === false) {
return { type: "cancelled" }; return { type: "cancelled" };
@@ -242,53 +233,43 @@ export class Router {
if (route.path === "*") { if (route.path === "*") {
return {}; return {};
} }
if (path.indexOf("?") > -1) {
path = path.split("?")[0];
}
if (path.startsWith("#")) { if (path.startsWith("#")) {
path = path.slice(1); path = path.slice(1);
} }
const paramsMatch = path.match(route.extractionRegExp); const descrParts = route.path.split("/");
if (!paramsMatch) { const targetParts = path.split("/");
const l = descrParts.length;
if (l !== targetParts.length) {
return false; return false;
} }
const result = {}; const result = {};
route.params.forEach((param, index) => { for (let i = 0; i < l; i++) {
const [key, suffix] = param.split("."); const descr = descrParts[i];
const paramValue = paramsMatch[index + 1]; let target: string | number = targetParts[i];
const match = descr.match(paramRegexp);
if (match) {
const [key, suffix] = match[1].split(".");
if (suffix === "number") { if (suffix === "number") {
return (result[key] = parseInt(paramValue, 10)); target = parseInt(target, 10);
}
result[key] = target;
} else if (descr !== target) {
return false;
}
} }
return (result[key] = paramValue);
});
return result; return result;
} }
} }
function findParams(str: string): string[] { function findParams(str: string): string[] {
const globalParamRegexp = /\{\{(.*?)\}\}/g;
const result: string[] = []; const result: string[] = [];
let m; let m;
do { do {
m = globalParamRegexp.exec(str); m = globalParamRegexp.exec(str);
if (m) { if (m) {
result.push(m[1]); result.push(m[1].split(".")[0]);
} }
} while (m); } while (m);
return result; return result;
} }
function escapeRegExp(str: string) {
return str.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&");
}
function makeExtractionRegExp(path: string) {
// replace param strings with capture groups so that we can build a regex to match over the path
const extractionString = path
.split(paramRegexp)
.map((part, index) => {
return index % 2 ? "(.*)" : escapeRegExp(part);
})
.join("");
// Example: /home/{{param1}}/{{param2}} => ^\/home\/(.*)\/(.*)$
return new RegExp(`^${extractionString}$`);
}
+14 -31
View File
@@ -1,4 +1,5 @@
import { Component, Env } from "./component/component"; import { Component } from "./component/component";
import { Env } from "./component/component";
import { Context, useContextWithCB } from "./context"; import { Context, useContextWithCB } from "./context";
import { onWillUpdateProps } from "./hooks"; import { onWillUpdateProps } from "./hooks";
@@ -23,10 +24,6 @@ import { onWillUpdateProps } from "./hooks";
// Store Definition // Store Definition
//------------------------------------------------------------------------------ //------------------------------------------------------------------------------
export interface EnvWithStore extends Env {
store: Store;
}
export type Action = ({ state, dispatch, env, getters }, ...payload: any) => any; export type Action = ({ state, dispatch, env, getters }, ...payload: any) => any;
export type Getter = ({ state: any, getters }, payload?) => any; export type Getter = ({ state: any, getters }, payload?) => any;
@@ -52,7 +49,7 @@ export class Store extends Context {
if (config.getters) { if (config.getters) {
const firstArg = { const firstArg = {
state: this.state, state: this.state,
getters: this.getters, getters: this.getters
}; };
for (let g in config.getters) { for (let g in config.getters) {
this.getters[g] = config.getters[g].bind(this, firstArg); this.getters[g] = config.getters[g].bind(this, firstArg);
@@ -69,17 +66,12 @@ export class Store extends Context {
dispatch: this.dispatch.bind(this), dispatch: this.dispatch.bind(this),
env: this.env, env: this.env,
state: this.state, state: this.state,
getters: this.getters, getters: this.getters
}, },
...payload ...payload
); );
return result; return result;
} }
__notifyComponents(): Promise<void> {
this.trigger("before-update");
return super.__notifyComponents();
}
} }
interface SelectorOptions { interface SelectorOptions {
@@ -91,7 +83,7 @@ interface SelectorOptions {
const isStrictEqual = (a, b) => a === b; const isStrictEqual = (a, b) => a === b;
export function useStore(selector, options: SelectorOptions = {}): any { export function useStore(selector, options: SelectorOptions = {}): any {
const component = Component.current as Component<any, EnvWithStore>; const component: Component<any, any> = Component.current!;
const componentId = component.__owl__.id; const componentId = component.__owl__.id;
const store = options.store || (component.env.store as Store); const store = options.store || (component.env.store as Store);
if (!(store instanceof Store)) { if (!(store instanceof Store)) {
@@ -110,16 +102,13 @@ export function useStore(selector, options: SelectorOptions = {}): any {
const newRevNumber = hashFn(result); const newRevNumber = hashFn(result);
if ((newRevNumber > 0 && revNumber !== newRevNumber) || !isEqual(oldResult, result)) { if ((newRevNumber > 0 && revNumber !== newRevNumber) || !isEqual(oldResult, result)) {
revNumber = newRevNumber; revNumber = newRevNumber;
if (options.onUpdate) {
options.onUpdate(result);
}
return true; return true;
} }
return false; return false;
} }
if (options.onUpdate) {
store.on("before-update", component, () => {
const newValue = selector(store!.state, component.props!);
options.onUpdate(newValue);
});
}
store.updateFunctions[componentId].push(function(): boolean { store.updateFunctions[componentId].push(function(): boolean {
return selectCompareUpdate(store!.state, component.props); return selectCompareUpdate(store!.state, component.props);
}); });
@@ -133,20 +122,17 @@ export function useStore(selector, options: SelectorOptions = {}): any {
return component.render(); return component.render();
} }
}); });
onWillUpdateProps((props) => { onWillUpdateProps(props => {
selectCompareUpdate(store.state, props); selectCompareUpdate(store.state, props);
}); });
const __destroy = component.__destroy; const __destroy = component.__destroy;
component.__destroy = (parent) => { component.__destroy = parent => {
delete store.updateFunctions[componentId]; delete store.updateFunctions[componentId];
if (options.onUpdate) {
store.off("before-update", component);
}
__destroy.call(component, parent); __destroy.call(component, parent);
}; };
if (typeof result !== "object" || result === null) { if (typeof result !== "object") {
return result; return result;
} }
return new Proxy(result, { return new Proxy(result, {
@@ -155,19 +141,16 @@ export function useStore(selector, options: SelectorOptions = {}): any {
}, },
set(target, k, v) { set(target, k, v) {
throw new Error("Store state should only be modified through actions"); throw new Error("Store state should only be modified through actions");
}, }
has(target, k) {
return k in result;
},
}); });
} }
export function useDispatch(store?: Store): Store["dispatch"] { export function useDispatch(store?: Store): Store["dispatch"] {
store = store || (Component.current!.env as EnvWithStore).store; store = store || (Component.current!.env.store as Store);
return store.dispatch.bind(store); return store.dispatch.bind(store);
} }
export function useGetters(store?: Store): Store["getters"] { export function useGetters(store?: Store): Store["getters"] {
store = store || (Component.current!.env as EnvWithStore).store; store = store || (Component.current!.env.store as Store);
return store.getters; return store.getters;
} }
-17
View File
@@ -1,5 +1,4 @@
import { QWeb } from "./qweb/index"; import { QWeb } from "./qweb/index";
import { registerSheet } from "./component/styles";
/** /**
* Owl Tags * Owl Tags
@@ -26,19 +25,3 @@ export function xml(strings, ...args) {
QWeb.registerTemplate(name, value); QWeb.registerTemplate(name, value);
return name; return name;
} }
/**
* CSS tag helper for defining inline stylesheets. With this, one can simply define
* an inline stylesheet with just the following code:
* ```js
* class A extends Component {
* static style = css`.component-a { color: red; }`;
* }
* ```
*/
export function css(strings, ...args) {
const name = `__sheet__${QWeb.nextId++}`;
const value = String.raw(strings, ...args);
registerSheet(name, value);
return name;
}
+9 -8
View File
@@ -10,8 +10,6 @@
* - debounce * - debounce
*/ */
import { browser } from "./browser";
export function whenReady(fn?: any) { export function whenReady(fn?: any) {
return new Promise(function(resolve) { return new Promise(function(resolve) {
if (document.readyState !== "loading") { if (document.readyState !== "loading") {
@@ -46,7 +44,7 @@ export function loadJS(url: string): Promise<void> {
} }
export async function loadFile(url: string): Promise<string> { export async function loadFile(url: string): Promise<string> {
const result = await browser.fetch(url); const result = await fetch(url);
if (!result.ok) { if (!result.ok) {
throw new Error("Error while fetching xml templates"); throw new Error("Error while fetching xml templates");
} }
@@ -60,9 +58,12 @@ export function escape(str: string | number | undefined): string {
if (typeof str === "number") { if (typeof str === "number") {
return String(str); return String(str);
} }
const p = document.createElement("p"); return str
p.textContent = str; .replace(/&/g, "&amp;")
return p.innerHTML; .replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&#x27;")
.replace(/`/g, "&#x60;");
} }
/** /**
@@ -85,8 +86,8 @@ export function debounce(func: Function, wait: number, immediate?: boolean): Fun
} }
} }
const callNow = immediate && !timeout; const callNow = immediate && !timeout;
browser.clearTimeout(timeout); clearTimeout(timeout);
timeout = browser.setTimeout(later, wait); timeout = setTimeout(later, wait);
if (callNow) { if (callNow) {
func.apply(context, args); func.apply(context, args);
} }
+2 -9
View File
@@ -1,4 +1,4 @@
import { VNode, h, addNS } from "./vdom"; import { VNode, h } from "./vdom";
const parser = new DOMParser(); const parser = new DOMParser();
@@ -13,9 +13,6 @@ export function htmlToVDOM(html: string): VNode[] {
function htmlToVNode(node: ChildNode): VNode { function htmlToVNode(node: ChildNode): VNode {
if (!(node instanceof Element)) { if (!(node instanceof Element)) {
if (node instanceof Comment) {
return h("!", node.textContent);
}
return { text: node.textContent! } as VNode; return { text: node.textContent! } as VNode;
} }
const attrs = {}; const attrs = {};
@@ -26,9 +23,5 @@ function htmlToVNode(node: ChildNode): VNode {
for (let c of node.childNodes) { for (let c of node.childNodes) {
children.push(htmlToVNode(c)); children.push(htmlToVNode(c));
} }
const vnode = h((node as Element).tagName, { attrs }, children); return h((node as Element).tagName, { attrs }, children);
if (vnode.sel === "svg") {
addNS(vnode.data, (vnode as any).children, vnode.sel);
}
return vnode;
} }
+9 -22
View File
@@ -33,7 +33,7 @@ function updateProps(oldVnode: VNode, vnode: VNode): void {
export const propsModule = { export const propsModule = {
create: updateProps, create: updateProps,
update: updateProps, update: updateProps
} as Module; } as Module;
//------------------------------------------------------------------------------ //------------------------------------------------------------------------------
@@ -70,12 +70,8 @@ function handleEvent(event: Event, vnode: VNode) {
on = (vnode.data as VNodeData).on; on = (vnode.data as VNodeData).on;
// call event handler(s) if exists // call event handler(s) if exists
if (on) { if (on && on[name]) {
if (on[name]) {
invokeHandler(on[name], vnode, event); invokeHandler(on[name], vnode, event);
} else if (on["!" + name]) {
invokeHandler(on["!" + name], vnode, event);
}
} }
} }
@@ -104,17 +100,13 @@ function updateEventListeners(oldVnode: VNode, vnode?: VNode): void {
if (!on) { if (!on) {
for (name in oldOn) { for (name in oldOn) {
// remove listener if element was changed or existing listeners removed // remove listener if element was changed or existing listeners removed
const capture = name.charAt(0) === "!"; oldElm.removeEventListener(name, oldListener, false);
name = capture ? name.slice(1) : name;
oldElm.removeEventListener(name, oldListener, capture);
} }
} else { } else {
for (name in oldOn) { for (name in oldOn) {
// remove listener if existing listener removed // remove listener if existing listener removed
if (!on[name]) { if (!on[name]) {
const capture = name.charAt(0) === "!"; oldElm.removeEventListener(name, oldListener, false);
name = capture ? name.slice(1) : name;
oldElm.removeEventListener(name, oldListener, capture);
} }
} }
} }
@@ -131,17 +123,13 @@ function updateEventListeners(oldVnode: VNode, vnode?: VNode): void {
if (!oldOn) { if (!oldOn) {
for (name in on) { for (name in on) {
// add listener if element was changed or new listeners added // add listener if element was changed or new listeners added
const capture = name.charAt(0) === "!"; elm.addEventListener(name, listener, false);
name = capture ? name.slice(1) : name;
elm.addEventListener(name, listener, capture);
} }
} else { } else {
for (name in on) { for (name in on) {
// add listener if new listener added // add listener if new listener added
if (!oldOn[name]) { if (!oldOn[name]) {
const capture = name.charAt(0) === "!"; elm.addEventListener(name, listener, false);
name = capture ? name.slice(1) : name;
elm.addEventListener(name, listener, capture);
} }
} }
} }
@@ -151,7 +139,7 @@ function updateEventListeners(oldVnode: VNode, vnode?: VNode): void {
export const eventListenersModule = { export const eventListenersModule = {
create: updateEventListeners, create: updateEventListeners,
update: updateEventListeners, update: updateEventListeners,
destroy: updateEventListeners, destroy: updateEventListeners
} as Module; } as Module;
//------------------------------------------------------------------------------ //------------------------------------------------------------------------------
@@ -210,7 +198,7 @@ function updateAttrs(oldVnode: VNode, vnode: VNode): void {
export const attrsModule = { export const attrsModule = {
create: updateAttrs, create: updateAttrs,
update: updateAttrs, update: updateAttrs
} as Module; } as Module;
//------------------------------------------------------------------------------ //------------------------------------------------------------------------------
@@ -231,8 +219,7 @@ function updateClass(oldVnode: VNode, vnode: VNode): void {
elm = vnode.elm as Element; elm = vnode.elm as Element;
for (name in oldClass) { for (name in oldClass) {
if (name && !klass[name] && !Object.prototype.hasOwnProperty.call(klass, name)) { if (!klass[name]) {
// was `true` and now not provided
elm.classList.remove(name); elm.classList.remove(name);
} }
} }
+1 -1
View File
@@ -520,7 +520,7 @@ const htmlDomApi = {
parentNode, parentNode,
nextSibling, nextSibling,
tagName, tagName,
setTextContent, setTextContent
} as DOMAPI; } as DOMAPI;
//------------------------------------------------------------------------------ //------------------------------------------------------------------------------
+90 -107
View File
@@ -3,43 +3,45 @@
exports[`animations t-transition combined with component 1`] = ` exports[`animations t-transition combined with component 1`] = `
"function anonymous(context,extra "function anonymous(context,extra
) { ) {
// Template name: \\"Parent\\"
let utils = this.constructor.utils; let utils = this.constructor.utils;
let QWeb = this.constructor; let QWeb = this.constructor;
let parent = context; let parent = context;
let scope = Object.create(context); let owner = context;
let h = this.h; var h = this.h;
let c1 = [], p1 = {key:1}; let c1 = [], p1 = {key:1};
let vn1 = h('div', p1, c1); var vn1 = h('div', p1, c1);
// Component 'Child' //COMPONENT
let w2 = '__3__' in parent.__owl__.cmap ? parent.__owl__.children[parent.__owl__.cmap['__3__']] : false; let k4 = \`__5__\`;
let props2 = {}; let w3 = k4 in parent.__owl__.cmap ? parent.__owl__.children[parent.__owl__.cmap[k4]] : false;
if (w2 && w2.__owl__.currentFiber && !w2.__owl__.vnode) { let props3 = {};
w2.destroy(); if (w3 && w3.__owl__.currentFiber && !w3.__owl__.vnode) {
w2 = false; w3.destroy();
w3 = false;
} }
if (w2) { if (w3) {
w2.__updateProps(props2, extra.fiber, undefined); w3.__updateProps(props3, extra.fiber, undefined, undefined);
let pvnode = w2.__owl__.pvnode; let pvnode = w3.__owl__.pvnode;
c1.push(pvnode); c1.push(pvnode);
} else { } else {
let componentKey2 = \`Child\`; let componentKey3 = \`Child\`;
let W2 = scope['Child'] || context.constructor.components[componentKey2] || QWeb.components[componentKey2]; let W3 = context.constructor.components[componentKey3] || QWeb.components[componentKey3]|| context['Child'];
if (!W2) {throw new Error('Cannot find the definition of component \\"' + componentKey2 + '\\"')} if (!W3) {throw new Error('Cannot find the definition of component \\"' + componentKey3 + '\\"')}
w2 = new W2(parent, props2); w3 = new W3(parent, props3);
const __patch2 = w2.__patch; const __patch3 = w3.__patch;
w2.__patch = (t, vn) => {__patch2.call(w2, t, vn); if(!w2.__owl__.transitionInserted){w2.__owl__.transitionInserted = true;utils.transitionInsert(w2.__owl__.vnode, 'chimay');}}; w3.__patch = fiber => {__patch3.call(w3, fiber); if(!w3.__owl__.transitionInserted){w3.__owl__.transitionInserted = true;utils.transitionInsert(w3.__owl__.vnode, 'chimay');}};
parent.__owl__.cmap['__3__'] = w2.__owl__.id; parent.__owl__.cmap[k4] = w3.__owl__.id;
let fiber = w2.__prepare(extra.fiber, undefined, () => { const vnode = fiber.vnode; pvnode.sel = vnode.sel; }); let def2 = w3.__prepare(extra.fiber, undefined, undefined);
let pvnode = h('dummy', {key: '__3__', hook: {remove() {},destroy(vn) {let finalize = () => { let pvnode = h('dummy', {key: k4, hook: {remove() {},destroy(vn) {let finalize = () => {
w2.destroy(); w3.destroy();
}; };
delete w2.__owl__.transitionInserted; delete w3.__owl__.transitionInserted;
utils.transitionRemove(vn, 'chimay', finalize);}}}); utils.transitionRemove(vn, 'chimay', finalize);}}});
const fiber = w3.__owl__.currentFiber;
def2.then(function () { if (fiber.isCompleted) { return; } const vnode = fiber.vnode; pvnode.sel = vnode.sel; });
c1.push(pvnode); c1.push(pvnode);
w2.__owl__.pvnode = pvnode; w3.__owl__.pvnode = pvnode;
} }
w2.__owl__.parentLastFiberId = extra.fiber.id; w3.__owl__.parentLastFiberId = extra.fiber.id;
return vn1; return vn1;
}" }"
`; `;
@@ -47,44 +49,46 @@ exports[`animations t-transition combined with component 1`] = `
exports[`animations t-transition combined with t-component and t-if 1`] = ` exports[`animations t-transition combined with t-component and t-if 1`] = `
"function anonymous(context,extra "function anonymous(context,extra
) { ) {
// Template name: \\"Parent\\"
let utils = this.constructor.utils; let utils = this.constructor.utils;
let QWeb = this.constructor; let QWeb = this.constructor;
let parent = context; let parent = context;
let scope = Object.create(context); let owner = context;
let h = this.h; var h = this.h;
let c1 = [], p1 = {key:1}; let c1 = [], p1 = {key:1};
let vn1 = h('div', p1, c1); var vn1 = h('div', p1, c1);
if (scope['state'].display) { if (context['state'].display) {
// Component 'Child' //COMPONENT
let w2 = '__3__' in parent.__owl__.cmap ? parent.__owl__.children[parent.__owl__.cmap['__3__']] : false; let k4 = \`__5__\`;
let props2 = {}; let w3 = k4 in parent.__owl__.cmap ? parent.__owl__.children[parent.__owl__.cmap[k4]] : false;
if (w2 && w2.__owl__.currentFiber && !w2.__owl__.vnode) { let props3 = {};
w2.destroy(); if (w3 && w3.__owl__.currentFiber && !w3.__owl__.vnode) {
w2 = false; w3.destroy();
w3 = false;
} }
if (w2) { if (w3) {
w2.__updateProps(props2, extra.fiber, undefined); w3.__updateProps(props3, extra.fiber, undefined, undefined);
let pvnode = w2.__owl__.pvnode; let pvnode = w3.__owl__.pvnode;
c1.push(pvnode); c1.push(pvnode);
} else { } else {
let componentKey2 = \`Child\`; let componentKey3 = \`Child\`;
let W2 = scope['Child'] || context.constructor.components[componentKey2] || QWeb.components[componentKey2]; let W3 = context.constructor.components[componentKey3] || QWeb.components[componentKey3]|| context['Child'];
if (!W2) {throw new Error('Cannot find the definition of component \\"' + componentKey2 + '\\"')} if (!W3) {throw new Error('Cannot find the definition of component \\"' + componentKey3 + '\\"')}
w2 = new W2(parent, props2); w3 = new W3(parent, props3);
const __patch2 = w2.__patch; const __patch3 = w3.__patch;
w2.__patch = (t, vn) => {__patch2.call(w2, t, vn); if(!w2.__owl__.transitionInserted){w2.__owl__.transitionInserted = true;utils.transitionInsert(w2.__owl__.vnode, 'chimay');}}; w3.__patch = fiber => {__patch3.call(w3, fiber); if(!w3.__owl__.transitionInserted){w3.__owl__.transitionInserted = true;utils.transitionInsert(w3.__owl__.vnode, 'chimay');}};
parent.__owl__.cmap['__3__'] = w2.__owl__.id; parent.__owl__.cmap[k4] = w3.__owl__.id;
let fiber = w2.__prepare(extra.fiber, undefined, () => { const vnode = fiber.vnode; pvnode.sel = vnode.sel; }); let def2 = w3.__prepare(extra.fiber, undefined, undefined);
let pvnode = h('dummy', {key: '__3__', hook: {remove() {},destroy(vn) {let finalize = () => { let pvnode = h('dummy', {key: k4, hook: {remove() {},destroy(vn) {let finalize = () => {
w2.destroy(); w3.destroy();
}; };
delete w2.__owl__.transitionInserted; delete w3.__owl__.transitionInserted;
utils.transitionRemove(vn, 'chimay', finalize);}}}); utils.transitionRemove(vn, 'chimay', finalize);}}});
const fiber = w3.__owl__.currentFiber;
def2.then(function () { if (fiber.isCompleted) { return; } const vnode = fiber.vnode; pvnode.sel = vnode.sel; });
c1.push(pvnode); c1.push(pvnode);
w2.__owl__.pvnode = pvnode; w3.__owl__.pvnode = pvnode;
} }
w2.__owl__.parentLastFiberId = extra.fiber.id; w3.__owl__.parentLastFiberId = extra.fiber.id;
} }
return vn1; return vn1;
}" }"
@@ -93,44 +97,46 @@ exports[`animations t-transition combined with t-component and t-if 1`] = `
exports[`animations t-transition combined with t-component, remove and re-add before transitionend 1`] = ` exports[`animations t-transition combined with t-component, remove and re-add before transitionend 1`] = `
"function anonymous(context,extra "function anonymous(context,extra
) { ) {
// Template name: \\"__template__2\\"
let utils = this.constructor.utils; let utils = this.constructor.utils;
let QWeb = this.constructor; let QWeb = this.constructor;
let parent = context; let parent = context;
let scope = Object.create(context); let owner = context;
let h = this.h; var h = this.h;
let c1 = [], p1 = {key:1}; let c1 = [], p1 = {key:1};
let vn1 = h('div', p1, c1); var vn1 = h('div', p1, c1);
if (scope['state'].flag) { if (context['state'].flag) {
// Component 'Child' //COMPONENT
let w2 = '__3__' in parent.__owl__.cmap ? parent.__owl__.children[parent.__owl__.cmap['__3__']] : false; let k4 = \`__5__\`;
let props2 = {}; let w3 = k4 in parent.__owl__.cmap ? parent.__owl__.children[parent.__owl__.cmap[k4]] : false;
if (w2 && w2.__owl__.currentFiber && !w2.__owl__.vnode) { let props3 = {};
w2.destroy(); if (w3 && w3.__owl__.currentFiber && !w3.__owl__.vnode) {
w2 = false; w3.destroy();
w3 = false;
} }
if (w2) { if (w3) {
w2.__updateProps(props2, extra.fiber, undefined); w3.__updateProps(props3, extra.fiber, undefined, undefined);
let pvnode = w2.__owl__.pvnode; let pvnode = w3.__owl__.pvnode;
c1.push(pvnode); c1.push(pvnode);
} else { } else {
let componentKey2 = \`Child\`; let componentKey3 = \`Child\`;
let W2 = scope['Child'] || context.constructor.components[componentKey2] || QWeb.components[componentKey2]; let W3 = context.constructor.components[componentKey3] || QWeb.components[componentKey3]|| context['Child'];
if (!W2) {throw new Error('Cannot find the definition of component \\"' + componentKey2 + '\\"')} if (!W3) {throw new Error('Cannot find the definition of component \\"' + componentKey3 + '\\"')}
w2 = new W2(parent, props2); w3 = new W3(parent, props3);
const __patch2 = w2.__patch; const __patch3 = w3.__patch;
w2.__patch = (t, vn) => {__patch2.call(w2, t, vn); if(!w2.__owl__.transitionInserted){w2.__owl__.transitionInserted = true;utils.transitionInsert(w2.__owl__.vnode, 'chimay');}}; w3.__patch = fiber => {__patch3.call(w3, fiber); if(!w3.__owl__.transitionInserted){w3.__owl__.transitionInserted = true;utils.transitionInsert(w3.__owl__.vnode, 'chimay');}};
parent.__owl__.cmap['__3__'] = w2.__owl__.id; parent.__owl__.cmap[k4] = w3.__owl__.id;
let fiber = w2.__prepare(extra.fiber, undefined, () => { const vnode = fiber.vnode; pvnode.sel = vnode.sel; }); let def2 = w3.__prepare(extra.fiber, undefined, undefined);
let pvnode = h('dummy', {key: '__3__', hook: {remove() {},destroy(vn) {let finalize = () => { let pvnode = h('dummy', {key: k4, hook: {remove() {},destroy(vn) {let finalize = () => {
w2.destroy(); w3.destroy();
}; };
delete w2.__owl__.transitionInserted; delete w3.__owl__.transitionInserted;
utils.transitionRemove(vn, 'chimay', finalize);}}}); utils.transitionRemove(vn, 'chimay', finalize);}}});
const fiber = w3.__owl__.currentFiber;
def2.then(function () { if (fiber.isCompleted) { return; } const vnode = fiber.vnode; pvnode.sel = vnode.sel; });
c1.push(pvnode); c1.push(pvnode);
w2.__owl__.pvnode = pvnode; w3.__owl__.pvnode = pvnode;
} }
w2.__owl__.parentLastFiberId = extra.fiber.id; w3.__owl__.parentLastFiberId = extra.fiber.id;
} }
return vn1; return vn1;
}" }"
@@ -139,11 +145,10 @@ exports[`animations t-transition combined with t-component, remove and re-add be
exports[`animations t-transition with no delay/duration 1`] = ` exports[`animations t-transition with no delay/duration 1`] = `
"function anonymous(context,extra "function anonymous(context,extra
) { ) {
// Template name: \\"test\\"
let utils = this.constructor.utils; let utils = this.constructor.utils;
let h = this.h; var h = this.h;
let c1 = [], p1 = {key:1}; let c1 = [], p1 = {key:1};
let vn1 = h('span', p1, c1); var vn1 = h('span', p1, c1);
p1.hook = { p1.hook = {
insert: vn => { insert: vn => {
utils.transitionInsert(vn, 'jupiler'); utils.transitionInsert(vn, 'jupiler');
@@ -160,32 +165,10 @@ exports[`animations t-transition with no delay/duration 1`] = `
exports[`animations t-transition, on a simple node (insert) 1`] = ` exports[`animations t-transition, on a simple node (insert) 1`] = `
"function anonymous(context,extra "function anonymous(context,extra
) { ) {
// Template name: \\"test\\"
let utils = this.constructor.utils; let utils = this.constructor.utils;
let h = this.h; var h = this.h;
let c1 = [], p1 = {key:1}; let c1 = [], p1 = {key:1};
let vn1 = h('span', p1, c1); var vn1 = h('span', p1, c1);
p1.hook = {
insert: vn => {
utils.transitionInsert(vn, 'chimay');
},
remove: (vn, rm) => {
utils.transitionRemove(vn, 'chimay', rm);
},
};
c1.push({text: \`blue\`});
return vn1;
}"
`;
exports[`animations t-transition, on a simple node, not in the DOM 1`] = `
"function anonymous(context, extra
) {
// Template name: \\"test\\"
let utils = this.constructor.utils;
let h = this.h;
let c1 = [], p1 = {key:1};
let vn1 = h('span', p1, c1);
p1.hook = { p1.hook = {
insert: vn => { insert: vn => {
utils.transitionInsert(vn, 'chimay'); utils.transitionInsert(vn, 'chimay');
+25 -48
View File
@@ -1,15 +1,15 @@
import { Component, Env } from "../src/component/component"; import { Component, Env } from "../src/component/component";
import { useRef, useState } from "../src/hooks";
import { QWeb } from "../src/qweb/index"; import { QWeb } from "../src/qweb/index";
import { useState, useRef } from "../src/hooks";
import { xml } from "../src/tags"; import { xml } from "../src/tags";
import { import {
makeDeferred, makeDeferred,
makeTestEnv,
makeTestFixture, makeTestFixture,
nextFrame, makeTestEnv,
patchNextFrame, patchNextFrame,
renderToDOM, renderToDOM,
unpatchNextFrame, unpatchNextFrame,
nextTick
} from "./helpers"; } from "./helpers";
//------------------------------------------------------------------------------ //------------------------------------------------------------------------------
@@ -71,14 +71,13 @@ describe("animations", () => {
qweb.addTemplate("test", `<span t-transition="chimay">blue</span>`); qweb.addTemplate("test", `<span t-transition="chimay">blue</span>`);
let def = makeDeferred(); let def = makeDeferred();
patchNextFrame((cb) => { patchNextFrame(cb => {
expect(node.className).toBe("chimay-enter chimay-enter-active"); expect(node.className).toBe("chimay-enter chimay-enter-active");
cb(); cb();
expect(node.className).toBe("chimay-enter-active chimay-enter-to"); expect(node.className).toBe("chimay-enter-active chimay-enter-to");
def.resolve(); def.resolve();
}); });
let node: HTMLElement = <HTMLElement>renderToDOM(qweb, "test"); let node: HTMLElement = <HTMLElement>renderToDOM(qweb, "test");
fixture.appendChild(node);
expect(node.className).toBe("chimay-enter chimay-enter-active"); expect(node.className).toBe("chimay-enter chimay-enter-active");
await def; // wait for the mocked repaint to be done await def; // wait for the mocked repaint to be done
@@ -86,40 +85,18 @@ describe("animations", () => {
expect(node.className).toBe(""); expect(node.className).toBe("");
}); });
test("t-transition, on a simple node, not in the DOM", async () => {
expect.assertions(5);
qweb.addTemplate("test", `<span t-transition="chimay">blue</span>`);
let def = makeDeferred();
patchNextFrame((cb) => {
expect(node.className).toBe("chimay-enter chimay-enter-active");
cb();
expect(node.className).toBe("chimay-enter-active chimay-enter-to");
def.resolve();
});
let node: HTMLElement = <HTMLElement>renderToDOM(qweb, "test");
expect(node.className).toBe("chimay-enter chimay-enter-active");
await def; // wait for the mocked repaint to be done
node.dispatchEvent(new Event("transitionend"));
// we check here that the css classes have not been removed, since the
// element is not in the dom, we actually do not want to do anything.
expect(node.className).toBe("chimay-enter-active chimay-enter-to");
});
test("t-transition with no delay/duration", async () => { test("t-transition with no delay/duration", async () => {
expect.assertions(4); expect.assertions(4);
qweb.addTemplate("test", `<span t-transition="jupiler">blue</span>`); qweb.addTemplate("test", `<span t-transition="jupiler">blue</span>`);
let def = makeDeferred(); let def = makeDeferred();
patchNextFrame((cb) => { patchNextFrame(cb => {
expect(node.className).toBe("jupiler-enter jupiler-enter-active"); expect(node.className).toBe("jupiler-enter jupiler-enter-active");
cb(); cb();
expect(node.className).toBe(""); expect(node.className).toBe("");
def.resolve(); def.resolve();
}); });
let node: HTMLElement = <HTMLElement>renderToDOM(qweb, "test"); let node: HTMLElement = <HTMLElement>renderToDOM(qweb, "test");
fixture.appendChild(node);
expect(node.className).toBe("jupiler-enter jupiler-enter-active"); expect(node.className).toBe("jupiler-enter jupiler-enter-active");
await def; await def;
}); });
@@ -139,7 +116,7 @@ describe("animations", () => {
// insert widget into the DOM // insert widget into the DOM
let def = makeDeferred(); let def = makeDeferred();
var spanNode; var spanNode;
patchNextFrame((cb) => { patchNextFrame(cb => {
expect(spanNode.className).toBe("chimay-enter chimay-enter-active"); expect(spanNode.className).toBe("chimay-enter chimay-enter-active");
cb(); cb();
expect(spanNode.className).toBe("chimay-enter-active chimay-enter-to"); expect(spanNode.className).toBe("chimay-enter-active chimay-enter-to");
@@ -155,7 +132,7 @@ describe("animations", () => {
// remove span from the DOM // remove span from the DOM
def = makeDeferred(); def = makeDeferred();
widget.state.hide = true; widget.state.hide = true;
patchNextFrame((cb) => { patchNextFrame(cb => {
expect(spanNode.className).toBe("chimay-leave chimay-leave-active"); expect(spanNode.className).toBe("chimay-leave chimay-leave-active");
cb(); cb();
expect(spanNode.className).toBe("chimay-leave-active chimay-leave-to"); expect(spanNode.className).toBe("chimay-leave-active chimay-leave-to");
@@ -182,7 +159,7 @@ describe("animations", () => {
// insert widget into the DOM // insert widget into the DOM
let def = makeDeferred(); let def = makeDeferred();
var spanNode; var spanNode;
patchNextFrame((cb) => { patchNextFrame(cb => {
expect(spanNode.className).toBe("chimay-enter chimay-enter-active"); expect(spanNode.className).toBe("chimay-enter chimay-enter-active");
cb(); cb();
expect(spanNode.className).toBe("chimay-enter-active chimay-enter-to"); expect(spanNode.className).toBe("chimay-enter-active chimay-enter-to");
@@ -210,7 +187,7 @@ describe("animations", () => {
let def = makeDeferred(); let def = makeDeferred();
var spanNode; var spanNode;
patchNextFrame((cb) => { patchNextFrame(cb => {
expect(fixture.innerHTML).toBe( expect(fixture.innerHTML).toBe(
'<div><span class="chimay-enter chimay-enter-active">blue</span></div>' '<div><span class="chimay-enter chimay-enter-active">blue</span></div>'
); );
@@ -250,7 +227,7 @@ describe("animations", () => {
let def = makeDeferred(); let def = makeDeferred();
var spanNode; var spanNode;
patchNextFrame((cb) => { patchNextFrame(cb => {
expect(fixture.innerHTML).toBe( expect(fixture.innerHTML).toBe(
'<div><span class="chimay-enter chimay-enter-active">blue</span></div>' '<div><span class="chimay-enter chimay-enter-active">blue</span></div>'
); );
@@ -275,13 +252,13 @@ describe("animations", () => {
// remove span from the DOM // remove span from the DOM
def = makeDeferred(); def = makeDeferred();
widget.state.display = false; widget.state.display = false;
patchNextFrame((cb) => { patchNextFrame(cb => {
expect(fixture.innerHTML).toBe( expect(fixture.innerHTML).toBe(
'<div><span class="chimay-leave chimay-leave-active" data-owl-key="__3__">blue</span></div>' '<div><span class="chimay-leave chimay-leave-active" data-owl-key="__5__">blue</span></div>'
); );
cb(); cb();
expect(fixture.innerHTML).toBe( expect(fixture.innerHTML).toBe(
'<div><span class="chimay-leave-active chimay-leave-to" data-owl-key="__3__">blue</span></div>' '<div><span class="chimay-leave-active chimay-leave-to" data-owl-key="__5__">blue</span></div>'
); );
def.resolve(); def.resolve();
}); });
@@ -315,7 +292,7 @@ describe("animations", () => {
let def = makeDeferred(); let def = makeDeferred();
let phase = "enter"; let phase = "enter";
patchNextFrame((cb) => { patchNextFrame(cb => {
let spans = fixture.querySelectorAll("span"); let spans = fixture.querySelectorAll("span");
expect(spans.length).toBe(1); expect(spans.length).toBe(1);
expect(spans[0].className).toBe(`chimay-${phase} chimay-${phase}-active`); expect(spans[0].className).toBe(`chimay-${phase} chimay-${phase}-active`);
@@ -366,7 +343,7 @@ describe("animations", () => {
let def = makeDeferred(); let def = makeDeferred();
let phase = "enter"; let phase = "enter";
patchNextFrame((cb) => { patchNextFrame(cb => {
let spans = fixture.querySelectorAll("span"); let spans = fixture.querySelectorAll("span");
expect(spans.length).toBe(1); expect(spans.length).toBe(1);
expect(spans[0].className).toBe(`chimay-${phase} chimay-${phase}-active`); expect(spans[0].className).toBe(`chimay-${phase} chimay-${phase}-active`);
@@ -394,7 +371,7 @@ describe("animations", () => {
await def; // wait for the mocked repaint to be done await def; // wait for the mocked repaint to be done
widget.el!.querySelector("span")!.dispatchEvent(new Event("transitionend")); // mock end of css transition widget.el!.querySelector("span")!.dispatchEvent(new Event("transitionend")); // mock end of css transition
expect(fixture.innerHTML).toBe('<div><span class="" data-owl-key="__3__">blue</span></div>'); expect(fixture.innerHTML).toBe('<div><span class="" data-owl-key="__5__">blue</span></div>');
}); });
test("transitionInsert is called the correct amount of times", async () => { test("transitionInsert is called the correct amount of times", async () => {
@@ -413,40 +390,40 @@ describe("animations", () => {
state = useState({ flag: false }); state = useState({ flag: false });
} }
patchNextFrame((cb) => cb()); patchNextFrame(cb => cb());
const widget = new Parent(); const widget = new Parent();
await widget.mount(fixture); await widget.mount(fixture);
widget.state.flag = true; widget.state.flag = true;
await nextFrame(); await nextTick();
widget.el!.querySelector("span")!.dispatchEvent(new Event("transitionend")); widget.el!.querySelector("span")!.dispatchEvent(new Event("transitionend"));
expect(fixture.innerHTML).toBe('<div><span class="">blue</span></div>'); expect(fixture.innerHTML).toBe('<div><span class="">blue</span></div>');
expect(QWeb.utils.transitionInsert).toBeCalledTimes(1); expect(QWeb.utils.transitionInsert).toBeCalledTimes(1);
widget.state.flag = false; widget.state.flag = false;
await nextFrame(); await nextTick();
expect(fixture.innerHTML).toBe( expect(fixture.innerHTML).toBe(
'<div><span class="chimay-leave-active chimay-leave-to" data-owl-key="__3__">blue</span></div>' '<div><span class="chimay-leave-active chimay-leave-to" data-owl-key="__5__">blue</span></div>'
); );
expect(QWeb.utils.transitionInsert).toBeCalledTimes(1); expect(QWeb.utils.transitionInsert).toBeCalledTimes(1);
widget.state.flag = true; widget.state.flag = true;
await nextFrame(); await nextTick();
expect(fixture.innerHTML).toBe( expect(fixture.innerHTML).toBe(
'<div><span class="chimay-enter-active chimay-enter-to" data-owl-key="__3__">blue</span></div>' '<div><span class="chimay-enter-active chimay-enter-to" data-owl-key="__5__">blue</span></div>'
); );
expect(QWeb.utils.transitionInsert).toBeCalledTimes(2); expect(QWeb.utils.transitionInsert).toBeCalledTimes(2);
widget.state.flag = false; widget.state.flag = false;
await nextFrame(); await nextTick();
widget.state.flag = true; widget.state.flag = true;
await nextFrame(); await nextTick();
expect(QWeb.utils.transitionInsert).toBeCalledTimes(3); expect(QWeb.utils.transitionInsert).toBeCalledTimes(3);
widget.el!.querySelector("span")!.dispatchEvent(new Event("transitionend")); widget.el!.querySelector("span")!.dispatchEvent(new Event("transitionend"));
expect(fixture.innerHTML).toBe('<div><span class="" data-owl-key="__3__">blue</span></div>'); expect(fixture.innerHTML).toBe('<div><span class="" data-owl-key="__5__">blue</span></div>');
QWeb.utils.transitionInsert = oldTransitionInsert; QWeb.utils.transitionInsert = oldTransitionInsert;
}); });
}); });
@@ -1,156 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`class and style attributes with t-component dynamic t-att-style is properly added and updated on widget root el 1`] = `
"function anonymous(context, extra
) {
// Template name: \\"ParentWidget\\"
let utils = this.constructor.utils;
let QWeb = this.constructor;
let parent = context;
let scope = Object.create(context);
let h = this.h;
let c1 = [], p1 = {key:1};
let vn1 = h('div', p1, c1);
// Component 'child'
const _4 = scope['state'].style;
let w2 = '__3__' in parent.__owl__.cmap ? parent.__owl__.children[parent.__owl__.cmap['__3__']] : false;
let props2 = {};
if (w2 && w2.__owl__.currentFiber && !w2.__owl__.vnode) {
w2.destroy();
w2 = false;
}
if (w2) {
w2.__updateProps(props2, extra.fiber, undefined).then(()=>{if (w2.__owl__.status === 5) {return};w2.el.style=_4;});;
let pvnode = w2.__owl__.pvnode;
c1.push(pvnode);
} else {
let componentKey2 = \`child\`;
let W2 = scope['child'] || context.constructor.components[componentKey2] || QWeb.components[componentKey2];
if (!W2) {throw new Error('Cannot find the definition of component \\"' + componentKey2 + '\\"')}
w2 = new W2(parent, props2);
parent.__owl__.cmap['__3__'] = w2.__owl__.id;
let fiber = w2.__prepare(extra.fiber, undefined, () => { const vnode = fiber.vnode; pvnode.sel = vnode.sel; utils.assignHooks(vnode.data, {create(_, vn){vn.elm.style = _4;}});});
let pvnode = h('dummy', {key: '__3__', hook: {remove() {},destroy(vn) {w2.destroy();}}});
c1.push(pvnode);
w2.__owl__.pvnode = pvnode;
}
w2.__owl__.parentLastFiberId = extra.fiber.id;
return vn1;
}"
`;
exports[`class and style attributes with t-component t-att-class is properly added/removed on widget root el (v2) 1`] = `
"function anonymous(context, extra
) {
// Template name: \\"ParentWidget\\"
let utils = this.constructor.utils;
let QWeb = this.constructor;
let parent = context;
context.__owl__.refs = context.__owl__.refs || {};
let scope = Object.create(context);
let h = this.h;
let c1 = [], p1 = {key:1};
let vn1 = h('div', p1, c1);
// Component 'Child'
const ref4 = \`child\`;
let _5 = {'a':true};
Object.assign(_5, {b:scope['state'].b})
let w2 = '__3__' in parent.__owl__.cmap ? parent.__owl__.children[parent.__owl__.cmap['__3__']] : false;
let props2 = {};
if (w2 && w2.__owl__.currentFiber && !w2.__owl__.vnode) {
w2.destroy();
w2 = false;
}
if (w2) {
w2.__updateProps(props2, extra.fiber, undefined);
let pvnode = w2.__owl__.pvnode;
c1.push(pvnode);
} else {
let componentKey2 = \`Child\`;
let W2 = scope['Child'] || context.constructor.components[componentKey2] || QWeb.components[componentKey2];
if (!W2) {throw new Error('Cannot find the definition of component \\"' + componentKey2 + '\\"')}
w2 = new W2(parent, props2);
parent.__owl__.cmap['__3__'] = w2.__owl__.id;
let fiber = w2.__prepare(extra.fiber, undefined, () => { const vnode = fiber.vnode; pvnode.sel = vnode.sel; utils.assignHooks(vnode.data, {create(_, vn){}});});
let pvnode = h('dummy', {key: '__3__', hook: {insert(vn) {context.__owl__.refs[ref4] = w2;},remove() {},destroy(vn) {w2.destroy();delete context.__owl__.refs[ref4];}}});
c1.push(pvnode);
w2.__owl__.pvnode = pvnode;
}
w2.__owl__.classObj=_5;
w2.__owl__.parentLastFiberId = extra.fiber.id;
return vn1;
}"
`;
exports[`class and style attributes with t-component t-att-class is properly added/removed on widget root el (v2) 2`] = `
"function anonymous(context, extra
) {
// Template name: \\"Child\\"
let utils = this.constructor.utils;
let scope = Object.create(context);
let h = this.h;
let _7 = {'c':true};
Object.assign(_7, utils.toClassObj({d:scope['state'].d}))
let c8 = [], p8 = {key:8,class:_7};
let vn8 = h('span', p8, c8);
return vn8;
}"
`;
exports[`class and style attributes with t-component t-att-class is properly added/removed on widget root el (v3) 1`] = `
"function anonymous(context, extra
) {
// Template name: \\"ParentWidget\\"
let utils = this.constructor.utils;
let QWeb = this.constructor;
let parent = context;
context.__owl__.refs = context.__owl__.refs || {};
let scope = Object.create(context);
let h = this.h;
let c1 = [], p1 = {key:1};
let vn1 = h('div', p1, c1);
// Component 'Child'
const ref4 = \`child\`;
let _5 = {'a':true};
Object.assign(_5, utils.toClassObj(scope['state'].b?'b':''))
let w2 = '__3__' in parent.__owl__.cmap ? parent.__owl__.children[parent.__owl__.cmap['__3__']] : false;
let props2 = {};
if (w2 && w2.__owl__.currentFiber && !w2.__owl__.vnode) {
w2.destroy();
w2 = false;
}
if (w2) {
w2.__updateProps(props2, extra.fiber, undefined);
let pvnode = w2.__owl__.pvnode;
c1.push(pvnode);
} else {
let componentKey2 = \`Child\`;
let W2 = scope['Child'] || context.constructor.components[componentKey2] || QWeb.components[componentKey2];
if (!W2) {throw new Error('Cannot find the definition of component \\"' + componentKey2 + '\\"')}
w2 = new W2(parent, props2);
parent.__owl__.cmap['__3__'] = w2.__owl__.id;
let fiber = w2.__prepare(extra.fiber, undefined, () => { const vnode = fiber.vnode; pvnode.sel = vnode.sel; utils.assignHooks(vnode.data, {create(_, vn){}});});
let pvnode = h('dummy', {key: '__3__', hook: {insert(vn) {context.__owl__.refs[ref4] = w2;},remove() {},destroy(vn) {w2.destroy();delete context.__owl__.refs[ref4];}}});
c1.push(pvnode);
w2.__owl__.pvnode = pvnode;
}
w2.__owl__.classObj=_5;
w2.__owl__.parentLastFiberId = extra.fiber.id;
return vn1;
}"
`;
exports[`class and style attributes with t-component t-att-class is properly added/removed on widget root el (v3) 2`] = `
"function anonymous(context, extra
) {
// Template name: \\"Child\\"
let utils = this.constructor.utils;
let scope = Object.create(context);
let h = this.h;
let _7 = {'c':true};
Object.assign(_7, utils.toClassObj(scope['state'].d?'d':''))
let c8 = [], p8 = {key:8,class:_7};
let vn8 = h('span', p8, c8);
return vn8;
}"
`;
File diff suppressed because it is too large Load Diff
@@ -3,37 +3,39 @@
exports[`props validation props are validated in dev mode (code snapshot) 1`] = ` exports[`props validation props are validated in dev mode (code snapshot) 1`] = `
"function anonymous(context,extra "function anonymous(context,extra
) { ) {
// Template name: \\"App\\"
let utils = this.constructor.utils; let utils = this.constructor.utils;
let QWeb = this.constructor; let QWeb = this.constructor;
let parent = context; let parent = context;
let scope = Object.create(context); let owner = context;
let h = this.h; var h = this.h;
let c1 = [], p1 = {key:1}; let c1 = [], p1 = {key:1};
let vn1 = h('div', p1, c1); var vn1 = h('div', p1, c1);
// Component 'Child' //COMPONENT
let w2 = '__3__' in parent.__owl__.cmap ? parent.__owl__.children[parent.__owl__.cmap['__3__']] : false; let k4 = \`__5__\`;
let props2 = {message:1}; let w3 = k4 in parent.__owl__.cmap ? parent.__owl__.children[parent.__owl__.cmap[k4]] : false;
if (w2 && w2.__owl__.currentFiber && !w2.__owl__.vnode) { let props3 = {message:1};
w2.destroy(); if (w3 && w3.__owl__.currentFiber && !w3.__owl__.vnode) {
w2 = false; w3.destroy();
w3 = false;
} }
if (w2) { if (w3) {
w2.__updateProps(props2, extra.fiber, undefined); w3.__updateProps(props3, extra.fiber, undefined, undefined);
let pvnode = w2.__owl__.pvnode; let pvnode = w3.__owl__.pvnode;
c1.push(pvnode); c1.push(pvnode);
} else { } else {
let componentKey2 = \`Child\`; let componentKey3 = \`Child\`;
let W2 = scope['Child'] || context.constructor.components[componentKey2] || QWeb.components[componentKey2]; let W3 = context.constructor.components[componentKey3] || QWeb.components[componentKey3]|| context['Child'];
if (!W2) {throw new Error('Cannot find the definition of component \\"' + componentKey2 + '\\"')} if (!W3) {throw new Error('Cannot find the definition of component \\"' + componentKey3 + '\\"')}
w2 = new W2(parent, props2); w3 = new W3(parent, props3);
parent.__owl__.cmap['__3__'] = w2.__owl__.id; parent.__owl__.cmap[k4] = w3.__owl__.id;
let fiber = w2.__prepare(extra.fiber, undefined, () => { const vnode = fiber.vnode; pvnode.sel = vnode.sel; }); let def2 = w3.__prepare(extra.fiber, undefined, undefined);
let pvnode = h('dummy', {key: '__3__', hook: {remove() {},destroy(vn) {w2.destroy();}}}); let pvnode = h('dummy', {key: k4, hook: {remove() {},destroy(vn) {w3.destroy();}}});
const fiber = w3.__owl__.currentFiber;
def2.then(function () { if (fiber.isCompleted) { return; } const vnode = fiber.vnode; pvnode.sel = vnode.sel; });
c1.push(pvnode); c1.push(pvnode);
w2.__owl__.pvnode = pvnode; w3.__owl__.pvnode = pvnode;
} }
w2.__owl__.parentLastFiberId = extra.fiber.id; w3.__owl__.parentLastFiberId = extra.fiber.id;
return vn1; return vn1;
}" }"
`; `;
@@ -1,758 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`t-slot directive can define and call slots 1`] = `
"function anonymous(context, extra
) {
// Template name: \\"Parent\\"
let utils = this.constructor.utils;
let QWeb = this.constructor;
let parent = context;
let scope = Object.create(context);
let h = this.h;
let c1 = [], p1 = {key:1};
let vn1 = h('div', p1, c1);
// Component 'Dialog'
let w2 = '__3__' in parent.__owl__.cmap ? parent.__owl__.children[parent.__owl__.cmap['__3__']] : false;
let props2 = {};
if (w2 && w2.__owl__.currentFiber && !w2.__owl__.vnode) {
w2.destroy();
w2 = false;
}
if (w2) {
w2.__updateProps(props2, extra.fiber, utils.combine(context, scope));
let pvnode = w2.__owl__.pvnode;
c1.push(pvnode);
} else {
let componentKey2 = \`Dialog\`;
let W2 = scope['Dialog'] || context.constructor.components[componentKey2] || QWeb.components[componentKey2];
if (!W2) {throw new Error('Cannot find the definition of component \\"' + componentKey2 + '\\"')}
w2 = new W2(parent, props2);
parent.__owl__.cmap['__3__'] = w2.__owl__.id;
w2.__owl__.slotId = 1;
let fiber = w2.__prepare(extra.fiber, utils.combine(context, scope), () => { const vnode = fiber.vnode; pvnode.sel = vnode.sel; });
let pvnode = h('dummy', {key: '__3__', hook: {remove() {},destroy(vn) {w2.destroy();}}});
c1.push(pvnode);
w2.__owl__.pvnode = pvnode;
}
w2.__owl__.parentLastFiberId = extra.fiber.id;
return vn1;
}"
`;
exports[`t-slot directive can define and call slots 2`] = `
"function anonymous(context, extra
) {
// Template name: \\"Dialog\\"
let h = this.h;
let c8 = [], p8 = {key:8};
let vn8 = h('div', p8, c8);
let c9 = [], p9 = {key:9};
let vn9 = h('div', p9, c9);
c8.push(vn9);
const slot10 = this.constructor.slots[context.__owl__.slotId + '_' + 'header'];
if (slot10) {
slot10.call(this, context.__owl__.scope, Object.assign({}, extra, {parentNode: c9, parent: extra.parent || context}));
}
let c11 = [], p11 = {key:11};
let vn11 = h('div', p11, c11);
c8.push(vn11);
const slot12 = this.constructor.slots[context.__owl__.slotId + '_' + 'footer'];
if (slot12) {
slot12.call(this, context.__owl__.scope, Object.assign({}, extra, {parentNode: c11, parent: extra.parent || context}));
}
return vn8;
}"
`;
exports[`t-slot directive can define and call slots 3`] = `
"function anonymous(context, extra
) {
// Template name: \\"slot_header_template\\"
let parent = extra.parent;
let h = this.h;
let c4 = extra.parentNode;
let c5 = [], p5 = {key:5};
let vn5 = h('span', p5, c5);
c4.push(vn5);
c5.push({text: \`header\`});
}"
`;
exports[`t-slot directive can define and call slots 4`] = `
"function anonymous(context, extra
) {
// Template name: \\"slot_footer_template\\"
let parent = extra.parent;
let h = this.h;
let c6 = extra.parentNode;
let c7 = [], p7 = {key:7};
let vn7 = h('span', p7, c7);
c6.push(vn7);
c7.push({text: \`footer\`});
}"
`;
exports[`t-slot directive can define and call slots using old t-set keyword 1`] = `
"function anonymous(context, extra
) {
// Template name: \\"__template__2\\"
let utils = this.constructor.utils;
let QWeb = this.constructor;
let parent = context;
let scope = Object.create(context);
let h = this.h;
let c1 = [], p1 = {key:1};
let vn1 = h('div', p1, c1);
// Component 'Dialog'
let w2 = '__3__' in parent.__owl__.cmap ? parent.__owl__.children[parent.__owl__.cmap['__3__']] : false;
let props2 = {};
if (w2 && w2.__owl__.currentFiber && !w2.__owl__.vnode) {
w2.destroy();
w2 = false;
}
if (w2) {
w2.__updateProps(props2, extra.fiber, utils.combine(context, scope));
let pvnode = w2.__owl__.pvnode;
c1.push(pvnode);
} else {
let componentKey2 = \`Dialog\`;
let W2 = scope['Dialog'] || context.constructor.components[componentKey2] || QWeb.components[componentKey2];
if (!W2) {throw new Error('Cannot find the definition of component \\"' + componentKey2 + '\\"')}
w2 = new W2(parent, props2);
parent.__owl__.cmap['__3__'] = w2.__owl__.id;
w2.__owl__.slotId = 1;
let fiber = w2.__prepare(extra.fiber, utils.combine(context, scope), () => { const vnode = fiber.vnode; pvnode.sel = vnode.sel; });
let pvnode = h('dummy', {key: '__3__', hook: {remove() {},destroy(vn) {w2.destroy();}}});
c1.push(pvnode);
w2.__owl__.pvnode = pvnode;
}
w2.__owl__.parentLastFiberId = extra.fiber.id;
return vn1;
}"
`;
exports[`t-slot directive can define and call slots using old t-set keyword 2`] = `
"function anonymous(context, extra
) {
// Template name: \\"__template__1\\"
let h = this.h;
let c8 = [], p8 = {key:8};
let vn8 = h('div', p8, c8);
let c9 = [], p9 = {key:9};
let vn9 = h('div', p9, c9);
c8.push(vn9);
const slot10 = this.constructor.slots[context.__owl__.slotId + '_' + 'header'];
if (slot10) {
slot10.call(this, context.__owl__.scope, Object.assign({}, extra, {parentNode: c9, parent: extra.parent || context}));
}
let c11 = [], p11 = {key:11};
let vn11 = h('div', p11, c11);
c8.push(vn11);
const slot12 = this.constructor.slots[context.__owl__.slotId + '_' + 'footer'];
if (slot12) {
slot12.call(this, context.__owl__.scope, Object.assign({}, extra, {parentNode: c11, parent: extra.parent || context}));
}
return vn8;
}"
`;
exports[`t-slot directive can define and call slots using old t-set keyword 3`] = `
"function anonymous(context, extra
) {
// Template name: \\"slot_header_template\\"
let parent = extra.parent;
let h = this.h;
let c4 = extra.parentNode;
let c5 = [], p5 = {key:5};
let vn5 = h('span', p5, c5);
c4.push(vn5);
c5.push({text: \`header\`});
}"
`;
exports[`t-slot directive can define and call slots using old t-set keyword 4`] = `
"function anonymous(context, extra
) {
// Template name: \\"slot_footer_template\\"
let parent = extra.parent;
let h = this.h;
let c6 = extra.parentNode;
let c7 = [], p7 = {key:7};
let vn7 = h('span', p7, c7);
c6.push(vn7);
c7.push({text: \`footer\`});
}"
`;
exports[`t-slot directive content is the default slot 1`] = `
"function anonymous(context, extra
) {
// Template name: \\"slot_default_template\\"
let parent = extra.parent;
let h = this.h;
let c4 = extra.parentNode;
let c5 = [], p5 = {key:5};
let vn5 = h('span', p5, c5);
c4.push(vn5);
c5.push({text: \`sts rocks\`});
}"
`;
exports[`t-slot directive dafault slots can define a default content 1`] = `
"function anonymous(context, extra
) {
// Template name: \\"__template__1\\"
let h = this.h;
let c4 = [], p4 = {key:4};
let vn4 = h('span', p4, c4);
const slot5 = this.constructor.slots[context.__owl__.slotId + '_' + 'default'];
if (slot5) {
slot5.call(this, context.__owl__.scope, Object.assign({}, extra, {parentNode: c4, parent: extra.parent || context}));
} else {
c4.push({text: \`default content\`});
}
return vn4;
}"
`;
exports[`t-slot directive default slot next to named slot, with default content 1`] = `
"function anonymous(context, extra
) {
// Template name: \\"__template__2\\"
let utils = this.constructor.utils;
let QWeb = this.constructor;
let parent = context;
let scope = Object.create(context);
let h = this.h;
let c1 = [], p1 = {key:1};
let vn1 = h('div', p1, c1);
// Component 'Dialog'
let w2 = '__3__' in parent.__owl__.cmap ? parent.__owl__.children[parent.__owl__.cmap['__3__']] : false;
let props2 = {};
if (w2 && w2.__owl__.currentFiber && !w2.__owl__.vnode) {
w2.destroy();
w2 = false;
}
if (w2) {
w2.__updateProps(props2, extra.fiber, utils.combine(context, scope));
let pvnode = w2.__owl__.pvnode;
c1.push(pvnode);
} else {
let componentKey2 = \`Dialog\`;
let W2 = scope['Dialog'] || context.constructor.components[componentKey2] || QWeb.components[componentKey2];
if (!W2) {throw new Error('Cannot find the definition of component \\"' + componentKey2 + '\\"')}
w2 = new W2(parent, props2);
parent.__owl__.cmap['__3__'] = w2.__owl__.id;
w2.__owl__.slotId = 1;
let fiber = w2.__prepare(extra.fiber, utils.combine(context, scope), () => { const vnode = fiber.vnode; pvnode.sel = vnode.sel; });
let pvnode = h('dummy', {key: '__3__', hook: {remove() {},destroy(vn) {w2.destroy();}}});
c1.push(pvnode);
w2.__owl__.pvnode = pvnode;
}
w2.__owl__.parentLastFiberId = extra.fiber.id;
return vn1;
}"
`;
exports[`t-slot directive default slot work with text nodes 1`] = `
"function anonymous(context, extra
) {
// Template name: \\"slot_default_template\\"
let parent = extra.parent;
let h = this.h;
let c4 = extra.parentNode;
c4.push({text: \`sts rocks\`});
}"
`;
exports[`t-slot directive dynamic t-slot call 1`] = `
"function anonymous(context, extra
) {
// Template name: \\"__template__1\\"
let utils = this.constructor.utils;
let scope = Object.create(context);
let h = this.h;
let c9 = [], p9 = {key:9,on:{}};
let vn9 = h('button', p9, c9);
extra.handlers['click__10__'] = extra.handlers['click__10__'] || function (e) {if (context.__owl__.status === 5){return}utils.getComponent(context)['toggle'](e);};
p9.on['click'] = extra.handlers['click__10__'];
const slot11 = this.constructor.slots[context.__owl__.slotId + '_' + (scope['current'].slot)];
if (slot11) {
slot11.call(this, context.__owl__.scope, Object.assign({}, extra, {parentNode: c9, parent: extra.parent || context}));
}
return vn9;
}"
`;
exports[`t-slot directive multiple roots are allowed in a default slot 1`] = `
"function anonymous(context, extra
) {
// Template name: \\"slot_default_template\\"
let parent = extra.parent;
let h = this.h;
let c4 = extra.parentNode;
let c5 = [], p5 = {key:5};
let vn5 = h('span', p5, c5);
c4.push(vn5);
c5.push({text: \`sts\`});
let c6 = [], p6 = {key:6};
let vn6 = h('span', p6, c6);
c4.push(vn6);
c6.push({text: \`rocks\`});
}"
`;
exports[`t-slot directive multiple roots are allowed in a named slot 1`] = `
"function anonymous(context, extra
) {
// Template name: \\"slot_content_template\\"
let parent = extra.parent;
let h = this.h;
let c4 = extra.parentNode;
let c5 = [], p5 = {key:5};
let vn5 = h('span', p5, c5);
c4.push(vn5);
c5.push({text: \`sts\`});
let c6 = [], p6 = {key:6};
let vn6 = h('span', p6, c6);
c4.push(vn6);
c6.push({text: \`rocks\`});
}"
`;
exports[`t-slot directive named slots can define a default content 1`] = `
"function anonymous(context, extra
) {
// Template name: \\"__template__1\\"
let h = this.h;
let c4 = [], p4 = {key:4};
let vn4 = h('span', p4, c4);
const slot5 = this.constructor.slots[context.__owl__.slotId + '_' + 'header'];
if (slot5) {
slot5.call(this, context.__owl__.scope, Object.assign({}, extra, {parentNode: c4, parent: extra.parent || context}));
} else {
c4.push({text: \`default content\`});
}
return vn4;
}"
`;
exports[`t-slot directive refs are properly bound in slots 1`] = `
"function anonymous(context, extra
) {
// Template name: \\"slot_footer_template\\"
let utils = this.constructor.utils;
let parent = extra.parent;
context.__owl__.refs = context.__owl__.refs || {};
let h = this.h;
let c8 = extra.parentNode;
let c9 = [], p9 = {key:9,on:{}};
let vn9 = h('button', p9, c9);
c8.push(vn9);
extra.handlers['click__10__'] = extra.handlers['click__10__'] || function (e) {if (context.__owl__.status === 5){return}utils.getComponent(context)['doSomething'](e);};
p9.on['click'] = extra.handlers['click__10__'];
const ref11 = \`myButton\`;
p9.hook = {
create: (_, n) => {
context.__owl__.refs[ref11] = n.elm;
},
destroy: () => {
delete context.__owl__.refs[ref11];
},
};
c9.push({text: \`do something\`});
}"
`;
exports[`t-slot directive slots are rendered with proper context 1`] = `
"function anonymous(context, extra
) {
// Template name: \\"slot_footer_template\\"
let utils = this.constructor.utils;
let parent = extra.parent;
let h = this.h;
let c8 = extra.parentNode;
let c9 = [], p9 = {key:9,on:{}};
let vn9 = h('button', p9, c9);
c8.push(vn9);
extra.handlers['click__10__'] = extra.handlers['click__10__'] || function (e) {if (context.__owl__.status === 5){return}utils.getComponent(context)['doSomething'](e);};
p9.on['click'] = extra.handlers['click__10__'];
c9.push({text: \`do something\`});
}"
`;
exports[`t-slot directive slots are rendered with proper context, part 2 1`] = `
"function anonymous(context, extra
) {
// Template name: \\"Link\\"
let scope = Object.create(context);
let h = this.h;
let _12 = scope['props'].to;
let c13 = [], p13 = {key:13,attrs:{href: _12}};
let vn13 = h('a', p13, c13);
const slot14 = this.constructor.slots[context.__owl__.slotId + '_' + 'default'];
if (slot14) {
slot14.call(this, context.__owl__.scope, Object.assign({}, extra, {parentNode: c13, parent: extra.parent || context}));
}
return vn13;
}"
`;
exports[`t-slot directive slots are rendered with proper context, part 2 2`] = `
"function anonymous(context, extra
) {
// Template name: \\"App\\"
let utils = this.constructor.utils;
let QWeb = this.constructor;
let parent = context;
let scope = Object.create(context);
let h = this.h;
let c1 = [], p1 = {key:1};
let vn1 = h('div', p1, c1);
let c2 = [], p2 = {key:2};
let vn2 = h('u', p2, c2);
c1.push(vn2);
let _3 = scope['state'].users;
if (!_3) { throw new Error('QWeb error: Invalid loop expression')}
let _4 = _3;
let _5 = _3;
if (!(_3 instanceof Array)) {
_4 = Object.keys(_3);
_5 = Object.values(_3);
}
let _length4 = _4.length;
let _origScope6 = scope;
scope = Object.create(scope);
for (let i1 = 0; i1 < _length4; i1++) {
scope.user_first = i1 === 0
scope.user_last = i1 === _length4 - 1
scope.user_index = i1
scope.user = _4[i1]
scope.user_value = _5[i1]
let key1 = scope['user'].id;
let c7 = [], p7 = {key:\`\${key1}_7\`};
let vn7 = h('li', p7, c7);
c2.push(vn7);
// Component 'Link'
let k9 = \`__9__\${key1}__\`;
let w8 = k9 in parent.__owl__.cmap ? parent.__owl__.children[parent.__owl__.cmap[k9]] : false;
let props8 = {to:'/user/'+scope['user'].id};
if (w8 && w8.__owl__.currentFiber && !w8.__owl__.vnode) {
w8.destroy();
w8 = false;
}
if (w8) {
w8.__updateProps(props8, extra.fiber, utils.combine(context, scope));
let pvnode = w8.__owl__.pvnode;
c7.push(pvnode);
} else {
let componentKey8 = \`Link\`;
let W8 = scope['Link'] || context.constructor.components[componentKey8] || QWeb.components[componentKey8];
if (!W8) {throw new Error('Cannot find the definition of component \\"' + componentKey8 + '\\"')}
w8 = new W8(parent, props8);
parent.__owl__.cmap[k9] = w8.__owl__.id;
w8.__owl__.slotId = 1;
let fiber = w8.__prepare(extra.fiber, utils.combine(context, scope), () => { const vnode = fiber.vnode; pvnode.sel = vnode.sel; });
let pvnode = h('dummy', {key: k9, hook: {remove() {},destroy(vn) {w8.destroy();}}});
c7.push(pvnode);
w8.__owl__.pvnode = pvnode;
}
w8.__owl__.parentLastFiberId = extra.fiber.id;
}
scope = _origScope6;
return vn1;
}"
`;
exports[`t-slot directive slots are rendered with proper context, part 2 3`] = `
"function anonymous(context, extra
) {
// Template name: \\"slot_default_template\\"
let parent = extra.parent;
let scope = Object.create(context);
let h = this.h;
let c10 = extra.parentNode;
c10.push({text: \`User \`});
let _11 = scope['user'].name;
if (_11 != null) {
c10.push({text: _11});
}
}"
`;
exports[`t-slot directive slots are rendered with proper context, part 3 1`] = `
"function anonymous(context, extra
) {
// Template name: \\"Link\\"
let scope = Object.create(context);
let h = this.h;
let _12 = scope['props'].to;
let c13 = [], p13 = {key:13,attrs:{href: _12}};
let vn13 = h('a', p13, c13);
const slot14 = this.constructor.slots[context.__owl__.slotId + '_' + 'default'];
if (slot14) {
slot14.call(this, context.__owl__.scope, Object.assign({}, extra, {parentNode: c13, parent: extra.parent || context}));
}
return vn13;
}"
`;
exports[`t-slot directive slots are rendered with proper context, part 3 2`] = `
"function anonymous(context, extra
) {
// Template name: \\"App\\"
let utils = this.constructor.utils;
let QWeb = this.constructor;
let parent = context;
let scope = Object.create(context);
let h = this.h;
let c1 = [], p1 = {key:1};
let vn1 = h('div', p1, c1);
let c2 = [], p2 = {key:2};
let vn2 = h('u', p2, c2);
c1.push(vn2);
let _3 = scope['state'].users;
if (!_3) { throw new Error('QWeb error: Invalid loop expression')}
let _4 = _3;
let _5 = _3;
if (!(_3 instanceof Array)) {
_4 = Object.keys(_3);
_5 = Object.values(_3);
}
let _length4 = _4.length;
let _origScope6 = scope;
scope = Object.create(scope);
for (let i1 = 0; i1 < _length4; i1++) {
scope.user_first = i1 === 0
scope.user_last = i1 === _length4 - 1
scope.user_index = i1
scope.user = _4[i1]
scope.user_value = _5[i1]
let key1 = scope['user'].id;
let c7 = [], p7 = {key:\`\${key1}_7\`};
let vn7 = h('li', p7, c7);
c2.push(vn7);
utils.getScope(scope, 'userdescr').userdescr = 'User '+scope['user'].name;
// Component 'Link'
let k9 = \`__9__\${key1}__\`;
let w8 = k9 in parent.__owl__.cmap ? parent.__owl__.children[parent.__owl__.cmap[k9]] : false;
let props8 = {to:'/user/'+scope['user'].id};
if (w8 && w8.__owl__.currentFiber && !w8.__owl__.vnode) {
w8.destroy();
w8 = false;
}
if (w8) {
w8.__updateProps(props8, extra.fiber, utils.combine(context, scope));
let pvnode = w8.__owl__.pvnode;
c7.push(pvnode);
} else {
let componentKey8 = \`Link\`;
let W8 = scope['Link'] || context.constructor.components[componentKey8] || QWeb.components[componentKey8];
if (!W8) {throw new Error('Cannot find the definition of component \\"' + componentKey8 + '\\"')}
w8 = new W8(parent, props8);
parent.__owl__.cmap[k9] = w8.__owl__.id;
w8.__owl__.slotId = 1;
let fiber = w8.__prepare(extra.fiber, utils.combine(context, scope), () => { const vnode = fiber.vnode; pvnode.sel = vnode.sel; });
let pvnode = h('dummy', {key: k9, hook: {remove() {},destroy(vn) {w8.destroy();}}});
c7.push(pvnode);
w8.__owl__.pvnode = pvnode;
}
w8.__owl__.parentLastFiberId = extra.fiber.id;
}
scope = _origScope6;
return vn1;
}"
`;
exports[`t-slot directive slots are rendered with proper context, part 3 3`] = `
"function anonymous(context, extra
) {
// Template name: \\"slot_default_template\\"
let parent = extra.parent;
let scope = Object.create(context);
let h = this.h;
let c10 = extra.parentNode;
let _11 = scope['userdescr'];
if (_11 != null) {
c10.push({text: _11});
}
}"
`;
exports[`t-slot directive slots are rendered with proper context, part 4 1`] = `
"function anonymous(context, extra
) {
// Template name: \\"App\\"
let utils = this.constructor.utils;
let QWeb = this.constructor;
let parent = context;
let scope = Object.create(context);
let h = this.h;
let c1 = [], p1 = {key:1};
let vn1 = h('div', p1, c1);
scope.userdescr = 'User '+scope['state'].user.name;
// Component 'Link'
let w2 = '__3__' in parent.__owl__.cmap ? parent.__owl__.children[parent.__owl__.cmap['__3__']] : false;
let props2 = {to:'/user/'+scope['state'].user.id};
if (w2 && w2.__owl__.currentFiber && !w2.__owl__.vnode) {
w2.destroy();
w2 = false;
}
if (w2) {
w2.__updateProps(props2, extra.fiber, utils.combine(context, scope));
let pvnode = w2.__owl__.pvnode;
c1.push(pvnode);
} else {
let componentKey2 = \`Link\`;
let W2 = scope['Link'] || context.constructor.components[componentKey2] || QWeb.components[componentKey2];
if (!W2) {throw new Error('Cannot find the definition of component \\"' + componentKey2 + '\\"')}
w2 = new W2(parent, props2);
parent.__owl__.cmap['__3__'] = w2.__owl__.id;
w2.__owl__.slotId = 1;
let fiber = w2.__prepare(extra.fiber, utils.combine(context, scope), () => { const vnode = fiber.vnode; pvnode.sel = vnode.sel; });
let pvnode = h('dummy', {key: '__3__', hook: {remove() {},destroy(vn) {w2.destroy();}}});
c1.push(pvnode);
w2.__owl__.pvnode = pvnode;
}
w2.__owl__.parentLastFiberId = extra.fiber.id;
return vn1;
}"
`;
exports[`t-slot directive slots are rendered with proper context, part 4 2`] = `
"function anonymous(context, extra
) {
// Template name: \\"slot_default_template\\"
let parent = extra.parent;
let scope = Object.create(context);
let h = this.h;
let c4 = extra.parentNode;
let _5 = scope['userdescr'];
if (_5 != null) {
c4.push({text: _5});
}
}"
`;
exports[`t-slot directive slots in t-foreach in t-foreach 1`] = `
"function anonymous(context, extra
) {
// Template name: \\"__template__2\\"
let utils = this.constructor.utils;
let QWeb = this.constructor;
let parent = context;
let scope = Object.create(context);
let h = this.h;
let c1 = [], p1 = {key:1};
let vn1 = h('div', p1, c1);
let _2 = scope['tree'];
if (!_2) { throw new Error('QWeb error: Invalid loop expression')}
let _3 = _2;
let _4 = _2;
if (!(_2 instanceof Array)) {
_3 = Object.keys(_2);
_4 = Object.values(_2);
}
let _length3 = _3.length;
let _origScope5 = scope;
scope = Object.create(scope);
for (let i1 = 0; i1 < _length3; i1++) {
scope.node1_first = i1 === 0
scope.node1_last = i1 === _length3 - 1
scope.node1_index = i1
scope.node1 = _3[i1]
scope.node1_value = _4[i1]
let key1 = scope['node1'].key;
let c6 = [], p6 = {key:\`\${key1}_6\`};
let vn6 = h('div', p6, c6);
c1.push(vn6);
let _7 = scope['node1'].value;
if (_7 != null) {
c6.push({text: _7});
}
let c8 = [], p8 = {key:\`\${key1}_8\`};
let vn8 = h('ul', p8, c8);
c1.push(vn8);
let _9 = scope['node1'].nodes;
if (!_9) { throw new Error('QWeb error: Invalid loop expression')}
let _10 = _9;
let _11 = _9;
if (!(_9 instanceof Array)) {
_10 = Object.keys(_9);
_11 = Object.values(_9);
}
let _length10 = _10.length;
let _origScope12 = scope;
scope = Object.create(scope);
for (let i2 = 0; i2 < _length10; i2++) {
scope.node2_first = i2 === 0
scope.node2_last = i2 === _length10 - 1
scope.node2_index = i2
scope.node2 = _10[i2]
scope.node2_value = _11[i2]
let key2 = scope['node2'].key;
// Component 'Child'
let k14 = \`__14__\${key1}__\${key2}__\`;
let w13 = k14 in parent.__owl__.cmap ? parent.__owl__.children[parent.__owl__.cmap[k14]] : false;
let props13 = {};
if (w13 && w13.__owl__.currentFiber && !w13.__owl__.vnode) {
w13.destroy();
w13 = false;
}
if (w13) {
w13.__updateProps(props13, extra.fiber, utils.combine(context, scope));
let pvnode = w13.__owl__.pvnode;
c8.push(pvnode);
} else {
let componentKey13 = \`Child\`;
let W13 = scope['Child'] || context.constructor.components[componentKey13] || QWeb.components[componentKey13];
if (!W13) {throw new Error('Cannot find the definition of component \\"' + componentKey13 + '\\"')}
w13 = new W13(parent, props13);
parent.__owl__.cmap[k14] = w13.__owl__.id;
w13.__owl__.slotId = 1;
let fiber = w13.__prepare(extra.fiber, utils.combine(context, scope), () => { const vnode = fiber.vnode; pvnode.sel = vnode.sel; });
let pvnode = h('dummy', {key: k14, hook: {remove() {},destroy(vn) {w13.destroy();}}});
c8.push(pvnode);
w13.__owl__.pvnode = pvnode;
}
w13.__owl__.parentLastFiberId = extra.fiber.id;
}
scope = _origScope12;
}
scope = _origScope5;
return vn1;
}"
`;
exports[`t-slot directive t-set t-value in a slot 1`] = `
"function anonymous(context, extra
) {
// Template name: \\"__template__1\\"
let h = this.h;
let c5 = [], p5 = {key:5};
let vn5 = h('span', p5, c5);
const slot6 = this.constructor.slots[context.__owl__.slotId + '_' + 'default'];
if (slot6) {
slot6.call(this, context.__owl__.scope, Object.assign({}, extra, {parentNode: c5, parent: extra.parent || context}));
}
return vn5;
}"
`;
exports[`t-slot directive template can just return a slot 1`] = `
"function anonymous(context, extra
) {
// Template name: \\"__template__2\\"
let utils = this.constructor.utils;
let result;
let h = this.h;
const slot7 = this.constructor.slots[context.__owl__.slotId + '_' + 'default'];
if (slot7) {
let children8= []
result = {}
slot7.call(this, context.__owl__.scope, Object.assign({}, extra, {parentNode: children8, parent: extra.parent || context}));
utils.defineProxy(result, children8[0]);
}
return result;
}"
`;
File diff suppressed because it is too large Load Diff
-273
View File
@@ -1,273 +0,0 @@
import { Component, Env } from "../../src/component/component";
import { QWeb } from "../../src/qweb/qweb";
import { xml } from "../../src/tags";
import { useState, useRef } from "../../src/hooks";
import { makeTestFixture, makeTestEnv, nextTick } from "../helpers";
//------------------------------------------------------------------------------
// Setup and helpers
//------------------------------------------------------------------------------
// We create before each test:
// - fixture: a div, appended to the DOM, intended to be the target of dom
// manipulations. Note that it is removed after each test.
// - env: a WEnv, necessary to create new components
let fixture: HTMLElement;
let env: Env;
beforeEach(() => {
fixture = makeTestFixture();
env = makeTestEnv();
Component.env = env;
});
afterEach(() => {
fixture.remove();
});
describe("class and style attributes with t-component", () => {
test("class is properly added on widget root el", async () => {
class Child extends Component {
static template = xml`<div class="c"/>`;
}
class ParentWidget extends Component {
static template = xml`<div><Child class="a b"/></div>`;
static components = { Child };
}
const widget = new ParentWidget();
await widget.mount(fixture);
expect(fixture.innerHTML).toBe(`<div><div class="c a b"></div></div>`);
});
test("empty class attribute is not added on widget root el", async () => {
class Child extends Component {
static template = xml`<span/>`;
}
class Parent extends Component {
static template = xml`<div><Child class=""/></div>`;
static components = { Child };
}
const widget = new Parent();
await widget.mount(fixture);
expect(fixture.innerHTML).toBe(`<div><span></span></div>`);
});
test("t-att-class is properly added/removed on widget root el", async () => {
class Child extends Component {
static template = xml`<div class="c"/>`;
}
class ParentWidget extends Component {
static template = xml`<div><Child t-att-class="{a:state.a, b:state.b}"/></div>`;
static components = { Child };
state = useState({ a: true, b: false });
}
const widget = new ParentWidget();
await widget.mount(fixture);
expect(fixture.innerHTML).toBe(`<div><div class="c a"></div></div>`);
expect(QWeb.TEMPLATES[ParentWidget.template].fn.toString());
widget.state.a = false;
widget.state.b = true;
await nextTick();
expect(fixture.innerHTML).toBe(`<div><div class="c b"></div></div>`);
});
test("class with extra whitespaces", async () => {
env.qweb.addTemplate(
"ParentWidget",
`<div>
<Child class="a b c d"/>
</div>`
);
class Child extends Component {}
class ParentWidget extends Component {
static components = { Child };
}
env.qweb.addTemplate("Child", `<div/>`);
const widget = new ParentWidget();
await widget.mount(fixture);
expect(fixture.innerHTML).toBe(`<div><div class="a b c d"></div></div>`);
});
test("t-att-class is properly added/removed on widget root el (v2)", async () => {
env.qweb.addTemplates(`
<templates>
<div t-name="ParentWidget">
<Child class="a" t-att-class="{ b: state.b }" t-ref="child"/>
</div>
<span t-name="Child" class="c" t-att-class="{ d: state.d }"/>
</templates>`);
class Child extends Component {
state = useState({ d: true });
}
class ParentWidget extends Component {
static components = { Child };
state = useState({ b: true });
child = useRef("child");
}
const widget = new ParentWidget();
await widget.mount(fixture);
const span = fixture.querySelector("span")!;
expect(span.className).toBe("c d a b");
widget.state.b = false;
await nextTick();
expect(span.className).toBe("c d a");
(widget.child.comp as Child).state.d = false;
await nextTick();
expect(span.className).toBe("c a");
widget.state.b = true;
await nextTick();
expect(span.className).toBe("c a b");
(widget.child.comp as Child).state.d = true;
await nextTick();
expect(span.className).toBe("c a b d");
expect(env.qweb.templates.ParentWidget.fn.toString()).toMatchSnapshot();
expect(env.qweb.templates.Child.fn.toString()).toMatchSnapshot();
});
test("t-att-class is properly added/removed on widget root el (v3)", async () => {
env.qweb.addTemplates(`
<templates>
<div t-name="ParentWidget">
<Child class="a" t-att-class="state.b ? 'b' : ''" t-ref="child"/>
</div>
<span t-name="Child" class="c" t-att-class="state.d ? 'd' : ''"/>
</templates>`);
class Child extends Component {
state = useState({ d: true });
}
class ParentWidget extends Component {
static components = { Child };
state = useState({ b: true });
child = useRef("child");
}
const widget = new ParentWidget();
await widget.mount(fixture);
const span = fixture.querySelector("span")!;
expect(span.className).toBe("c d a b");
widget.state.b = false;
await nextTick();
expect(span.className).toBe("c d a");
(widget.child.comp as Child).state.d = false;
await nextTick();
expect(span.className).toBe("c a");
widget.state.b = true;
await nextTick();
expect(span.className).toBe("c a b");
(widget.child.comp as Child).state.d = true;
await nextTick();
expect(span.className).toBe("c a b d");
expect(env.qweb.templates.ParentWidget.fn.toString()).toMatchSnapshot();
expect(env.qweb.templates.Child.fn.toString()).toMatchSnapshot();
});
test("class on components do not interfere with user defined classes", async () => {
env.qweb.addTemplates(`
<templates>
<div t-name="App" t-att-class="{ c: state.c }" />
</templates>`);
class App extends Component {
state = useState({ c: true });
mounted() {
this.el!.classList.add("user");
}
}
const widget = new App();
await widget.mount(fixture);
expect(fixture.innerHTML).toBe('<div class="c user"></div>');
widget.state.c = false;
await nextTick();
expect(fixture.innerHTML).toBe('<div class="user"></div>');
});
test("style is properly added on widget root el", async () => {
env.qweb.addTemplate(
"ParentWidget",
`
<div>
<t t-component="child" style="font-weight: bold;"/>
</div>`
);
class SomeComponent extends Component {
static template = xml`<div/>`;
}
class ParentWidget extends Component {
static components = { child: SomeComponent };
}
const widget = new ParentWidget();
await widget.mount(fixture);
expect(fixture.innerHTML).toBe(`<div><div style="font-weight: bold;"></div></div>`);
});
test("dynamic t-att-style is properly added and updated on widget root el", async () => {
env.qweb.addTemplate(
"ParentWidget",
`
<div>
<t t-component="child" t-att-style="state.style"/>
</div>`
);
class SomeComponent extends Component {
static template = xml`<div/>`;
}
class ParentWidget extends Component {
static components = { child: SomeComponent };
state = useState({ style: "font-size: 20px" });
}
const widget = new ParentWidget();
await widget.mount(fixture);
expect(env.qweb.templates.ParentWidget.fn.toString()).toMatchSnapshot();
expect(fixture.innerHTML).toBe(`<div><div style="font-size: 20px;"></div></div>`);
widget.state.style = "font-size: 30px";
await nextTick();
expect(fixture.innerHTML).toBe(`<div><div style="font-size: 30px;"></div></div>`);
});
test("error in subcomponent with class", async () => {
class Child extends Component {
static template = xml`<div t-esc="this.will.crash"/>`;
}
class ParentWidget extends Component {
static template = xml`<div><Child class="a"/></div>`;
static components = { Child };
}
const widget = new ParentWidget();
let error;
try {
await widget.mount(fixture);
} catch (e) {
error = e;
}
expect(error).toBeDefined();
const regexp =
/Cannot read properties of undefined \(reading 'crash'\)|Cannot read property 'crash' of undefined/g;
expect(error.message).toMatch(regexp);
expect(fixture.innerHTML).toBe("");
});
});
File diff suppressed because it is too large Load Diff
-620
View File
@@ -1,620 +0,0 @@
import { Component, Env, mount, STATUS } from "../../src/component/component";
import { useState } from "../../src/hooks";
import { xml } from "../../src/tags";
import { makeTestEnv, makeTestFixture, nextTick } from "../helpers";
//------------------------------------------------------------------------------
// Setup and helpers
//------------------------------------------------------------------------------
// We create before each test:
// - fixture: a div, appended to the DOM, intended to be the target of dom
// manipulations. Note that it is removed after each test.
// - env: a WEnv, necessary to create new components
let fixture: HTMLElement;
let env: Env;
beforeEach(() => {
fixture = makeTestFixture();
env = makeTestEnv();
Component.env = env;
});
afterEach(() => {
fixture.remove();
});
describe("component error handling (catchError)", () => {
/**
* This test suite requires often to wait for 3 ticks. Here is why:
* - First tick is to let the app render and crash.
* - When we crash, we call the catchError handler in a setTimeout (because we
* need to wait for the previous rendering to be completely stopped). So, we
* need to wait for the second tick.
* - Then, when the handler changes the state, we need to wait for the interface
* to be rerendered.
* */
test("can catch an error in a component render function", async () => {
const consoleError = console.error;
console.error = jest.fn();
const handler = jest.fn();
env.qweb.on("error", null, handler);
class ErrorComponent extends Component {
static template = xml`<div>hey<t t-esc="props.flag and state.this.will.crash"/></div>`;
}
class ErrorBoundary extends Component {
static template = xml`
<div>
<t t-if="state.error">Error handled</t>
<t t-else=""><t t-slot="default" /></t>
</div>`;
state = useState({ error: false });
catchError() {
this.state.error = true;
}
}
class App extends Component {
static template = xml`
<div>
<ErrorBoundary><ErrorComponent flag="state.flag"/></ErrorBoundary>
</div>`;
state = useState({ flag: false });
static components = { ErrorBoundary, ErrorComponent };
}
const app = new App();
await app.mount(fixture);
expect(fixture.innerHTML).toBe("<div><div><div>heyfalse</div></div></div>");
app.state.flag = true;
await nextTick();
expect(fixture.innerHTML).toBe("<div><div>Error handled</div></div>");
expect(console.error).toBeCalledTimes(0);
console.error = consoleError;
expect(handler).toBeCalledTimes(1);
});
test("no component catching error lead to full app destruction", async () => {
expect.assertions(6);
const handler = jest.fn();
env.qweb.on("error", null, handler);
const consoleError = console.error;
console.error = jest.fn();
class ErrorComponent extends Component {
static template = xml`<div>hey<t t-esc="props.flag and state.this.will.crash"/></div>`;
}
class App extends Component {
static template = xml`<div><ErrorComponent flag="state.flag"/></div>`;
static components = { ErrorComponent };
state = useState({ flag: false });
async render() {
try {
await super.render();
} catch (e) {
expect(e.message).toMatch(
/Cannot read properties of undefined \(reading 'this'\)|Cannot read property 'this' of undefined/
);
}
}
}
const app = new App();
await app.mount(fixture);
expect(fixture.innerHTML).toBe("<div><div>heyfalse</div></div>");
app.state.flag = true;
await nextTick();
expect(fixture.innerHTML).toBe("");
expect(console.error).toBeCalledTimes(0);
console.error = consoleError;
expect(app.__owl__.status).toBe(STATUS.DESTROYED);
expect(handler).toBeCalledTimes(1);
});
test("can catch an error in the initial call of a component render function (parent mounted)", async () => {
const handler = jest.fn();
env.qweb.on("error", null, handler);
const consoleError = console.error;
console.error = jest.fn();
class ErrorComponent extends Component {
static template = xml`<div>hey<t t-esc="state.this.will.crash"/></div>`;
}
class ErrorBoundary extends Component {
static template = xml`
<div>
<t t-if="state.error">Error handled</t>
<t t-else=""><t t-slot="default" /></t>
</div>`;
state = useState({ error: false });
catchError() {
this.state.error = true;
}
}
class App extends Component {
static template = xml`
<div>
<ErrorBoundary><ErrorComponent /></ErrorBoundary>
</div>`;
static components = { ErrorBoundary, ErrorComponent };
}
const app = new App();
await app.mount(fixture);
expect(fixture.innerHTML).toBe("<div><div>Error handled</div></div>");
expect(console.error).toBeCalledTimes(0);
console.error = consoleError;
expect(handler).toBeCalledTimes(1);
});
test("can catch an error in the initial call of a component render function (parent updated)", async () => {
const handler = jest.fn();
env.qweb.on("error", null, handler);
const consoleError = console.error;
console.error = jest.fn();
class ErrorComponent extends Component {
static template = xml`<div>hey<t t-esc="state.this.will.crash"/></div>`;
}
class ErrorBoundary extends Component {
static template = xml`
<div>
<t t-if="state.error">Error handled</t>
<t t-else=""><t t-slot="default" /></t>
</div>`;
state = useState({ error: false });
catchError() {
this.state.error = true;
}
}
class App extends Component {
static template = xml`
<div>
<ErrorBoundary t-if="state.flag"><ErrorComponent /></ErrorBoundary>
</div>`;
state = useState({ flag: false });
static components = { ErrorBoundary, ErrorComponent };
}
const app = new App();
await app.mount(fixture);
app.state.flag = true;
await nextTick();
expect(fixture.innerHTML).toBe("<div><div>Error handled</div></div>");
expect(console.error).toBeCalledTimes(0);
console.error = consoleError;
expect(handler).toBeCalledTimes(1);
});
test("can catch an error in the constructor call of a component render function", async () => {
const handler = jest.fn();
env.qweb.on("error", null, handler);
const consoleError = console.error;
console.error = jest.fn();
env.qweb.addTemplates(`
<templates>
<div t-name="ErrorBoundary">
<t t-if="state.error">Error handled</t>
<t t-else=""><t t-slot="default" /></t>
</div>
<div t-name="ErrorComponent">Some text</div>
<div t-name="App">
<ErrorBoundary><ErrorComponent /></ErrorBoundary>
</div>
</templates>`);
class ErrorComponent extends Component {
constructor(parent) {
super(parent);
throw new Error("NOOOOO");
}
}
class ErrorBoundary extends Component {
state = useState({ error: false });
catchError() {
this.state.error = true;
}
}
class App extends Component {
static components = { ErrorBoundary, ErrorComponent };
}
const app = new App();
await app.mount(fixture);
expect(fixture.innerHTML).toBe("<div><div>Error handled</div></div>");
expect(console.error).toBeCalledTimes(0);
console.error = consoleError;
expect(handler).toBeCalledTimes(1);
});
test("can catch an error in the willStart call", async () => {
const consoleError = console.error;
console.error = jest.fn();
class ErrorComponent extends Component {
static template = xml`<div t-name="ErrorComponent">Some text</div>`;
async willStart() {
// we wait a little bit to be in a different stack frame
await nextTick();
throw new Error("NOOOOO");
}
}
class ErrorBoundary extends Component {
static template = xml`
<div>
<t t-if="state.error">Error handled</t>
<t t-else=""><t t-slot="default" /></t>
</div>`;
state = useState({ error: false });
catchError() {
this.state.error = true;
}
}
class App extends Component {
static template = xml`<div><ErrorBoundary><ErrorComponent /></ErrorBoundary></div>`;
static components = { ErrorBoundary, ErrorComponent };
}
const app = new App();
await app.mount(fixture);
expect(fixture.innerHTML).toBe("<div><div>Error handled</div></div>");
expect(console.error).toBeCalledTimes(0);
console.error = consoleError;
});
test.skip("can catch an error in the mounted call", async () => {
// we do not catch error in mounted anymore
console.error = jest.fn();
env.qweb.addTemplates(`
<templates>
<div t-name="ErrorBoundary">
<t t-if="state.error">Error handled</t>
<t t-else=""><t t-slot="default" /></t>
</div>
<div t-name="ErrorComponent">Some text</div>
<div t-name="App">
<ErrorBoundary><ErrorComponent /></ErrorBoundary>
</div>
</templates>`);
class ErrorComponent extends Component {
mounted() {
throw new Error("NOOOOO");
}
}
class ErrorBoundary extends Component {
state = useState({ error: false });
catchError() {
this.state.error = true;
}
}
class App extends Component {
static components = { ErrorBoundary, ErrorComponent };
}
const app = new App();
await app.mount(fixture);
await nextTick();
await nextTick();
await nextTick();
expect(fixture.innerHTML).toBe("<div><div>Error handled</div></div>");
});
test.skip("can catch an error in the willPatch call", async () => {
// we do not catch error in willPatch anymore
const consoleError = console.error;
console.error = jest.fn();
class ErrorComponent extends Component {
static template = xml`<div><t t-esc="props.message"/></div>`;
willPatch() {
throw new Error("NOOOOO");
}
}
class ErrorBoundary extends Component {
static template = xml`
<div>
<t t-if="state.error">Error handled</t>
<t t-else=""><t t-slot="default" /></t>
</div>`;
state = useState({ error: false });
catchError() {
this.state.error = true;
}
}
class App extends Component {
static template = xml`
<div>
<span><t t-esc="state.message"/></span>
<ErrorBoundary><ErrorComponent message="state.message" /></ErrorBoundary>
</div>`;
state = useState({ message: "abc" });
static components = { ErrorBoundary, ErrorComponent };
}
const app = new App();
await app.mount(fixture);
expect(fixture.innerHTML).toBe("<div><span>abc</span><div><div>abc</div></div></div>");
app.state.message = "def";
await nextTick();
await nextTick();
await nextTick();
expect(fixture.innerHTML).toBe("<div><span>def</span><div>Error handled</div></div>");
expect(console.error).toHaveBeenCalledTimes(1);
console.error = consoleError;
});
test("a rendering error will reject the mount promise", async () => {
const consoleError = console.error;
console.error = jest.fn(() => {});
// we do not catch error in willPatch anymore
class App extends Component {
static template = xml`<div><t t-esc="this.will.crash"/></div>`;
}
const app = new App();
let error;
try {
await app.mount(fixture);
} catch (e) {
error = e;
}
expect(error).toBeDefined();
const regexp =
/Cannot read properties of undefined \(reading 'crash'\)|Cannot read property 'crash' of undefined/g;
expect(error.message).toMatch(regexp);
expect(console.error).toBeCalledTimes(0);
console.error = consoleError;
});
test("an error in mounted call will reject the mount promise", async () => {
const consoleError = console.error;
console.error = jest.fn(() => {});
class App extends Component {
static template = xml`<div>abc</div>`;
mounted() {
throw new Error("boom");
}
}
const app = new App();
let error;
try {
await app.mount(fixture);
} catch (e) {
error = e;
}
expect(error).toBeDefined();
expect(error.message).toBe("boom");
expect(fixture.innerHTML).toBe("");
expect(console.error).toBeCalledTimes(0);
console.error = consoleError;
});
test("an error in willPatch call will reject the render promise", async () => {
const consoleError = console.error;
console.error = jest.fn(() => {});
class App extends Component {
static template = xml`<div><t t-esc="val"/></div>`;
val = 3;
willPatch() {
throw new Error("boom");
}
}
const app = new App();
await app.mount(fixture);
app.val = 4;
let error;
try {
await app.render();
} catch (e) {
error = e;
}
expect(error).toBeDefined();
expect(error.message).toBe("boom");
expect(fixture.innerHTML).toBe("");
expect(console.error).toBeCalledTimes(0);
console.error = consoleError;
});
test("an error in patched call will reject the render promise", async () => {
const consoleError = console.error;
console.error = jest.fn(() => {});
class App extends Component {
static template = xml`<div><t t-esc="val"/></div>`;
val = 3;
patched() {
throw new Error("boom");
}
}
const app = new App();
await app.mount(fixture);
app.val = 4;
let error;
try {
await app.render();
} catch (e) {
error = e;
}
expect(error).toBeDefined();
expect(error.message).toBe("boom");
expect(fixture.innerHTML).toBe("");
expect(console.error).toBeCalledTimes(0);
console.error = consoleError;
});
test("a rendering error in a sub component will reject the mount promise", async () => {
const consoleError = console.error;
console.error = jest.fn(() => {});
// we do not catch error in willPatch anymore
class Child extends Component {
static template = xml`<div><t t-esc="this.will.crash"/></div>`;
}
class App extends Component {
static template = xml`<div><Child/></div>`;
static components = { Child };
}
const app = new App();
let error;
try {
await app.mount(fixture);
} catch (e) {
error = e;
}
expect(error).toBeDefined();
const regexp =
/Cannot read properties of undefined \(reading 'crash'\)|Cannot read property 'crash' of undefined/g;
expect(error.message).toMatch(regexp);
expect(console.error).toBeCalledTimes(0);
console.error = consoleError;
});
test("a rendering error will reject the render promise", async () => {
const consoleError = console.error;
console.error = jest.fn(() => {});
// we do not catch error in willPatch anymore
class App extends Component {
static template = xml`<div><t t-if="flag" t-esc="this.will.crash"/></div>`;
flag = false;
}
const app = new App();
await app.mount(fixture);
expect(fixture.innerHTML).toBe("<div></div>");
app.flag = true;
let error;
try {
await app.render();
} catch (e) {
error = e;
}
expect(error).toBeDefined();
const regexp =
/Cannot read properties of undefined \(reading 'crash'\)|Cannot read property 'crash' of undefined/g;
expect(error.message).toMatch(regexp);
expect(console.error).toBeCalledTimes(0);
console.error = consoleError;
});
test("a rendering error will reject the render promise (with sub components)", async () => {
class Child extends Component {
static template = xml`<span></span>`;
}
class Parent extends Component {
static template = xml`<div><Child/><t t-esc="x.y"/></div>`;
static components = { Child };
}
let error;
try {
const parent = new Parent();
await parent.mount(fixture);
} catch (e) {
error = e;
}
expect(error).toBeDefined();
const regexp =
/Cannot read properties of undefined \(reading 'y'\)|Cannot read property 'y' of undefined/g;
expect(error.message).toMatch(regexp);
});
test("simple catchError", async () => {
class Boom extends Component {
static template = xml`<div t-esc="a.b.c"/>`;
}
class Parent extends Component {
static template = xml`
<div>
<t t-if="error">Error</t>
<t t-else="">
<Boom />
</t>
</div>`;
static components = { Boom };
error = false;
catchError(error) {
this.error = error;
this.render();
}
}
await mount(Parent, { target: fixture });
expect(fixture.innerHTML).toBe("<div>Error</div>");
});
test("catchError in catchError", async () => {
class Boom extends Component {
static template = xml`<div t-esc="a.b.c"/>`;
}
class Child extends Component {
static template = xml`
<div>
<Boom />
</div>`;
static components = { Boom };
catchError(error) {
throw error;
}
}
class Parent extends Component {
static template = xml`
<div>
<t t-if="error">Error</t>
<t t-else="">
<Child />
</t>
</div>`;
static components = { Child };
error = false;
catchError(error) {
this.error = error;
this.render();
}
}
await mount(Parent, { target: fixture });
expect(fixture.innerHTML).toBe("<div>Error</div>");
});
test("errors in mounted and in willUnmount", async () => {
expect.assertions(1);
class Example extends Component {
static template = xml`<div/>`;
val;
mounted() {
throw new Error("Error in mounted");
this.val = { foo: "bar" };
}
willUnmount() {
console.log(this.val.foo);
}
}
try {
await mount(Example, { target: fixture });
} catch (e) {
expect(e.message).toBe("Error in mounted");
}
});
});
+40 -65
View File
@@ -25,7 +25,7 @@ afterEach(() => {
QWeb.dev = dev; QWeb.dev = dev;
}); });
class Widget extends Component {} class Widget extends Component<any, any> {}
//------------------------------------------------------------------------------ //------------------------------------------------------------------------------
// Tests // Tests
@@ -92,11 +92,11 @@ describe("props validation", () => {
{ type: String, ok: "1", ko: 1 }, { type: String, ok: "1", ko: 1 },
{ type: Object, ok: {}, ko: "1" }, { type: Object, ok: {}, ko: "1" },
{ type: Date, ok: new Date(), ko: "1" }, { type: Date, ok: new Date(), ko: "1" },
{ type: Function, ok: () => {}, ko: "1" }, { type: Function, ok: () => {}, ko: "1" }
]; ];
let props; let props;
class Parent extends Component { class Parent extends Component<any, any> {
static template = xml`<div><TestWidget p="p"/></div>`; static template = xml`<div><TestWidget p="p"/></div>`;
get p() { get p() {
return props.p; return props.p;
@@ -149,18 +149,18 @@ describe("props validation", () => {
{ type: String, ok: "1", ko: 1 }, { type: String, ok: "1", ko: 1 },
{ type: Object, ok: {}, ko: "1" }, { type: Object, ok: {}, ko: "1" },
{ type: Date, ok: new Date(), ko: "1" }, { type: Date, ok: new Date(), ko: "1" },
{ type: Function, ok: () => {}, ko: "1" }, { type: Function, ok: () => {}, ko: "1" }
]; ];
let props; let props;
class Parent extends Component { class Parent extends Component<any, any> {
static template = xml`<div><TestWidget p="p"/></div>`; static template = xml`<div><TestWidget p="p"/></div>`;
get p() { get p() {
return props.p; return props.p;
} }
} }
for (let test of Tests) { for (let test of Tests) {
let TestWidget = class extends Component { let TestWidget = class extends Component<any, any> {
static props = { p: { type: test.type } }; static props = { p: { type: test.type } };
static template = xml`<div>hey</div>`; static template = xml`<div>hey</div>`;
}; };
@@ -200,11 +200,11 @@ describe("props validation", () => {
}); });
test("can validate a prop with multiple types", async () => { test("can validate a prop with multiple types", async () => {
class TestWidget extends Component { class TestWidget extends Component<any, any> {
static template = xml`<div>hey</div>`; static template = xml`<div>hey</div>`;
static props = { p: [String, Boolean] }; static props = { p: [String, Boolean] };
} }
class Parent extends Component { class Parent extends Component<any, any> {
static template = xml`<div><TestWidget p="p"/></div>`; static template = xml`<div><TestWidget p="p"/></div>`;
static components = { TestWidget }; static components = { TestWidget };
get p() { get p() {
@@ -244,11 +244,11 @@ describe("props validation", () => {
}); });
test("can validate an optional props", async () => { test("can validate an optional props", async () => {
class TestWidget extends Component { class TestWidget extends Component<any, any> {
static template = xml`<div>hey</div>`; static template = xml`<div>hey</div>`;
static props = { p: { type: String, optional: true } }; static props = { p: { type: String, optional: true } };
} }
class Parent extends Component { class Parent extends Component<any, any> {
static template = xml`<div><TestWidget p="p"/></div>`; static template = xml`<div><TestWidget p="p"/></div>`;
static components = { TestWidget }; static components = { TestWidget };
get p() { get p() {
@@ -288,11 +288,11 @@ describe("props validation", () => {
}); });
test("can validate an array with given primitive type", async () => { test("can validate an array with given primitive type", async () => {
class TestWidget extends Component { class TestWidget extends Component<any, any> {
static template = xml`<div>hey</div>`; static template = xml`<div>hey</div>`;
static props = { p: { type: Array, element: String } }; static props = { p: { type: Array, element: String } };
} }
class Parent extends Component { class Parent extends Component<any, any> {
static template = xml`<div><TestWidget p="p"/></div>`; static template = xml`<div><TestWidget p="p"/></div>`;
static components = { TestWidget }; static components = { TestWidget };
get p() { get p() {
@@ -340,11 +340,11 @@ describe("props validation", () => {
}); });
test("can validate an array with multiple sub element types", async () => { test("can validate an array with multiple sub element types", async () => {
class TestWidget extends Component { class TestWidget extends Component<any, any> {
static template = xml`<div>hey</div>`; static template = xml`<div>hey</div>`;
static props = { p: { type: Array, element: [String, Boolean] } }; static props = { p: { type: Array, element: [String, Boolean] } };
} }
class Parent extends Component { class Parent extends Component<any, any> {
static template = xml`<div><TestWidget p="p"/></div>`; static template = xml`<div><TestWidget p="p"/></div>`;
static components = { TestWidget }; static components = { TestWidget };
get p() { get p() {
@@ -393,13 +393,13 @@ describe("props validation", () => {
}); });
test("can validate an object with simple shape", async () => { test("can validate an object with simple shape", async () => {
class TestWidget extends Component { class TestWidget extends Component<any, any> {
static template = xml`<div>hey</div>`; static template = xml`<div>hey</div>`;
static props = { static props = {
p: { type: Object, shape: { id: Number, url: String } }, p: { type: Object, shape: { id: Number, url: String } }
}; };
} }
class Parent extends Component { class Parent extends Component<any, any> {
static template = xml`<div><TestWidget p="p"/></div>`; static template = xml`<div><TestWidget p="p"/></div>`;
static components = { TestWidget }; static components = { TestWidget };
get p() { get p() {
@@ -451,19 +451,19 @@ describe("props validation", () => {
}); });
test("can validate recursively complicated prop def", async () => { test("can validate recursively complicated prop def", async () => {
class TestWidget extends Component { class TestWidget extends Component<any, any> {
static template = xml`<div>hey</div>`; static template = xml`<div>hey</div>`;
static props = { static props = {
p: { p: {
type: Object, type: Object,
shape: { shape: {
id: Number, id: Number,
url: [Boolean, { type: Array, element: Number }], url: [Boolean, { type: Array, element: Number }]
}, }
}, }
}; };
} }
class Parent extends Component { class Parent extends Component<any, any> {
static template = xml`<div><TestWidget p="p"/></div>`; static template = xml`<div><TestWidget p="p"/></div>`;
static components = { TestWidget }; static components = { TestWidget };
get p() { get p() {
@@ -503,17 +503,17 @@ describe("props validation", () => {
}); });
test("can validate optional attributes in nested sub props", () => { test("can validate optional attributes in nested sub props", () => {
class TestComponent extends Component { class TestComponent extends Component<any, any> {
static props = { static props = {
myprop: { myprop: {
type: Array, type: Array,
element: { element: {
type: Object, type: Object,
shape: { shape: {
num: { type: Number, optional: true }, num: { type: Number, optional: true }
}, }
}, }
}, }
}; };
} }
let error; let error;
@@ -536,11 +536,11 @@ describe("props validation", () => {
}); });
test("can validate with a custom validator", () => { test("can validate with a custom validator", () => {
class TestComponent extends Component { class TestComponent extends Component<any, any> {
static props = { static props = {
size: { size: {
validate: (e) => ["small", "medium", "large"].includes(e), validate: e => ["small", "medium", "large"].includes(e)
}, }
}; };
} }
let error; let error;
@@ -561,13 +561,13 @@ describe("props validation", () => {
}); });
test("can validate with a custom validator, and a type", () => { test("can validate with a custom validator, and a type", () => {
const validator = jest.fn((n) => 0 <= n && n <= 10); const validator = jest.fn(n => 0 <= n && n <= 10);
class TestComponent extends Component { class TestComponent extends Component<any, any> {
static props = { static props = {
n: { n: {
type: Number, type: Number,
validate: validator, validate: validator
}, }
}; };
} }
let error; let error;
@@ -738,7 +738,7 @@ describe("props validation", () => {
test("props are validated whenever component is updated", async () => { test("props are validated whenever component is updated", async () => {
let error; let error;
class TestWidget extends Component { class TestWidget extends Component<any, any> {
static props = { p: { type: Number } }; static props = { p: { type: Number } };
static template = xml`<div><t t-esc="props.p"/></div>`; static template = xml`<div><t t-esc="props.p"/></div>`;
@@ -750,7 +750,7 @@ describe("props validation", () => {
} }
} }
} }
class Parent extends Component { class Parent extends Component<any, any> {
static template = xml`<div><TestWidget p="state.p"/></div>`; static template = xml`<div><TestWidget p="state.p"/></div>`;
static components = { TestWidget }; static components = { TestWidget };
state: any = useState({ p: 1 }); state: any = useState({ p: 1 });
@@ -767,12 +767,12 @@ describe("props validation", () => {
}); });
test("default values are applied before validating props at update", async () => { test("default values are applied before validating props at update", async () => {
class TestWidget extends Component { class TestWidget extends Component<any, any> {
static props = { p: { type: Number } }; static props = { p: { type: Number } };
static template = xml`<div><t t-esc="props.p"/></div>`; static template = xml`<div><t t-esc="props.p"/></div>`;
static defaultProps = { p: 4 }; static defaultProps = { p: 4 };
} }
class Parent extends Component { class Parent extends Component<any, any> {
static template = xml`<div><TestWidget p="state.p"/></div>`; static template = xml`<div><TestWidget p="state.p"/></div>`;
static components = { TestWidget }; static components = { TestWidget };
state: any = useState({ p: 1 }); state: any = useState({ p: 1 });
@@ -786,40 +786,15 @@ describe("props validation", () => {
await nextTick(); await nextTick();
expect(fixture.innerHTML).toBe("<div><div>4</div></div>"); expect(fixture.innerHTML).toBe("<div><div>4</div></div>");
}); });
test("mix of optional and mandatory", async () => {
class Child extends Component {
static props = {
optional: { type: String, optional: true },
mandatory: Number,
};
static template = xml` <div><t t-esc="props.mandatory"/></div>`;
}
class App extends Component {
static components = { Child };
static template = xml`<div><Child/></div>`;
}
const w = new App(undefined, {});
let error;
try {
await w.mount(fixture);
} catch (e) {
error = e;
}
expect(error).toBeDefined();
expect(error.message).toBe("Missing props 'mandatory' (component 'Child')");
});
}); });
describe("default props", () => { describe("default props", () => {
test("can set default values", async () => { test("can set default values", async () => {
class TestWidget extends Component { class TestWidget extends Component<any, any> {
static defaultProps = { p: 4 }; static defaultProps = { p: 4 };
static template = xml`<div><t t-esc="props.p"/></div>`; static template = xml`<div><t t-esc="props.p"/></div>`;
} }
class Parent extends Component { class Parent extends Component<any, any> {
static template = xml`<div><TestWidget /></div>`; static template = xml`<div><TestWidget /></div>`;
static components = { TestWidget }; static components = { TestWidget };
} }
File diff suppressed because it is too large Load Diff
-170
View File
@@ -1,170 +0,0 @@
import { Component, Env } from "../../src/component/component";
import { processSheet } from "../../src/component/styles";
import { xml, css } from "../../src/tags";
import { makeTestFixture, makeTestEnv } from "../helpers";
//------------------------------------------------------------------------------
// Setup and helpers
//------------------------------------------------------------------------------
// We create before each test:
// - fixture: a div, appended to the DOM, intended to be the target of dom
// manipulations. Note that it is removed after each test.
// - env: an Env, necessary to create new components
let fixture: HTMLElement;
let env: Env;
beforeEach(() => {
fixture = makeTestFixture();
env = makeTestEnv();
Component.env = env;
document.head.innerHTML = "";
});
afterEach(() => {
fixture.remove();
});
//------------------------------------------------------------------------------
// Tests
//------------------------------------------------------------------------------
describe("styles and component", () => {
test("can define an inline stylesheet", async () => {
class App extends Component {
static template = xml`<div class="app">text</div>`;
static style = css`
.app {
color: red;
}
`;
}
expect(document.head.innerHTML).toBe("");
const app = new App();
expect(document.head.innerHTML).toBe(`<style component=\"App\">.app {
color: red;
}</style>`);
await app.mount(fixture);
const style = getComputedStyle(app.el!);
expect(style.color).toBe("red");
expect(fixture.innerHTML).toBe('<div class="app">text</div>');
});
test("inherited components properly apply css", async () => {
class App extends Component {
static template = xml`<div class="app">text</div>`;
static style = css`
.app {
color: red;
}
`;
}
class SubApp extends App {
static style = css`
.app {
font-weight: bold;
}
`;
}
expect(document.head.innerHTML).toBe("");
const app = new SubApp();
expect(document.head.innerHTML).toBe(`<style component=\"SubApp\">.app {
font-weight: bold;
}</style><style component=\"App\">.app {
color: red;
}</style>`);
await app.mount(fixture);
const style = getComputedStyle(app.el!);
expect(style.color).toBe("red");
expect(style.fontWeight).toBe("bold");
expect(fixture.innerHTML).toBe('<div class="app">text</div>');
});
test("get a meaningful error message if css helper is missing", async () => {
class App extends Component {
static template = xml`<div class="app">text</div>`;
static style = `.app {color: red;}`;
}
let error;
try {
new App();
} catch (e) {
error = e;
}
expect(error).toBeDefined();
expect(error.message).toBe(
"Invalid css stylesheet for component 'App'. Did you forget to use the 'css' tag helper?"
);
});
test("inline stylesheets are processed", async () => {
class App extends Component {
static template = xml`<div class="app">text</div>`;
static style = css`
.app {
color: red;
.some-class {
font-weight: bold;
width: 40px;
}
display: block;
}
`;
}
new App();
expect(document.head.querySelector("style")!.innerHTML).toBe(`.app {
color: red;
}
.app .some-class {
font-weight: bold;
width: 40px;
}
.app {
display: block;
}`);
});
test("properly handle rules with commas", async () => {
const sheet = processSheet(`.parent-a, .parent-b {
.child-a, .child-b {
color: red;
}
}`);
expect(sheet)
.toBe(`.parent-a .child-a, .parent-a .child-b, .parent-b .child-a, .parent-b .child-b {
color: red;
}`);
});
test("handle & selector", async () => {
let sheet = processSheet(`.btn {
&.danger {
color: red;
}
}`);
expect(sheet).toBe(`.btn.danger {
color: red;
}`);
sheet = processSheet(`.some-class {
&.btn {
.other-class ~ & {
color: red;
}
}
}`);
expect(sheet).toBe(`.other-class ~ .some-class.btn {
color: red;
}`);
});
});
-732
View File
@@ -1,732 +0,0 @@
import { Component, Env, mount } from "../../src/component/component";
import { useState } from "../../src/hooks";
import { xml } from "../../src/tags";
import { makeDeferred, makeTestEnv, makeTestFixture, nextTick, nextMicroTick } from "../helpers";
import { scheduler } from "../../src/component/scheduler";
//------------------------------------------------------------------------------
// Setup and helpers
//------------------------------------------------------------------------------
// We create before each test:
// - fixture: a div, appended to the DOM, intended to be the target of dom
// manipulations. Note that it is removed after each test.
// - env: a WEnv, necessary to create new components
let fixture: HTMLElement;
let env: Env;
beforeEach(() => {
fixture = makeTestFixture();
env = makeTestEnv();
Component.env = env;
});
afterEach(() => {
fixture.remove();
});
describe("mount targets", () => {
test("can attach a component to an existing node (if same tagname)", async () => {
class App extends Component {
static template = xml`<div t-att-class="state.customClass">app<p>another tag</p></div>`;
state = useState({ customClass: "custom" });
}
const div = document.createElement("div");
div.classList.add("arbitrary");
div.innerHTML = `<p>pre-existing</p>`;
fixture.appendChild(div);
const app = await mount(App, { target: div, position: "self" });
expect(fixture.innerHTML).toBe(
`<div class="arbitrary custom"><p>pre-existing</p>app<p>another tag</p></div>`
);
expect(div).toBe(app.el);
app.state.customClass = "custom2";
await nextTick();
expect(fixture.innerHTML).toBe(
`<div class="arbitrary custom2"><p>pre-existing</p>app<p>another tag</p></div>`
);
expect(div).toBe(app.el);
app.unmount();
// This assert is a best guess
// The use case it covers was not really thought through
// and may change in the future
expect(fixture.innerHTML).toBe("");
});
test("cannot attach a component to an existing node (if not same tagname)", async () => {
class App extends Component {
static template = xml`<span>app</span>`;
}
const div = document.createElement("div");
fixture.appendChild(div);
let error;
try {
await mount(App, { target: div, position: "self" });
} catch (e) {
error = e;
}
expect(error).toBeDefined();
expect(error.message).toBe("Cannot attach 'App' to target node (not same tag name)");
});
test("can mount a component (with position='first-child')", async () => {
class App extends Component {
static template = xml`<div>app</div>`;
}
const span = document.createElement("span");
fixture.appendChild(span);
await mount(App, { target: fixture, position: "first-child" });
expect(fixture.innerHTML).toBe("<div>app</div><span></span>");
});
test("can mount a component (with position='last-child')", async () => {
class App extends Component {
static template = xml`<div>app</div>`;
}
const span = document.createElement("span");
fixture.appendChild(span);
await mount(App, { target: fixture, position: "last-child" });
expect(fixture.innerHTML).toBe("<span></span><div>app</div>");
});
test("default mount option is 'last-child'", async () => {
class App extends Component {
static template = xml`<div>app</div>`;
}
const span = document.createElement("span");
fixture.appendChild(span);
await mount(App, { target: fixture });
expect(fixture.innerHTML).toBe("<span></span><div>app</div>");
});
});
describe("unmounting and remounting", () => {
test("widget can be unmounted and remounted", async () => {
const steps: string[] = [];
class MyWidget extends Component {
static template = xml`<div>Hey</div>`;
async willStart() {
steps.push("willstart");
}
mounted() {
steps.push("mounted");
}
willUnmount() {
steps.push("willunmount");
}
patched() {
throw new Error("patched should not be called");
}
}
const w = await mount(MyWidget, { target: fixture });
expect(fixture.innerHTML).toBe("<div>Hey</div>");
expect(steps).toEqual(["willstart", "mounted"]);
w.unmount();
expect(fixture.innerHTML).toBe("");
expect(steps).toEqual(["willstart", "mounted", "willunmount"]);
await w.mount(fixture);
expect(fixture.innerHTML).toBe("<div>Hey</div>");
expect(steps).toEqual(["willstart", "mounted", "willunmount", "mounted"]);
});
test("widget can be mounted twice without ill effect", async () => {
const steps: string[] = [];
class MyWidget extends Component {
static template = xml`<div>Hey</div>`;
async willStart() {
steps.push("willstart");
}
mounted() {
steps.push("mounted");
}
willUnmount() {
steps.push("willunmount");
}
}
const w = await mount(MyWidget, { target: fixture });
await w.mount(fixture);
expect(fixture.innerHTML).toBe("<div>Hey</div>");
expect(steps).toEqual(["willstart", "mounted"]);
});
test("state changes in willUnmount do not trigger rerender", async () => {
const steps: string[] = [];
class Child extends Component {
static template = xml`
<span><t t-esc="props.val"/><t t-esc="state.n"/></span>
`;
state = useState({ n: 2 });
__render(f) {
steps.push("render");
return super.__render(f);
}
willPatch() {
steps.push("willPatch");
}
patched() {
steps.push("patched");
}
willUnmount() {
steps.push("willUnmount");
this.state.n = 3;
}
}
class Parent extends Component {
static template = xml`
<div>
<Child t-if="state.flag" val="state.val"/>
</div>
`;
static components = { Child };
state = useState({ val: 1, flag: true });
}
const widget = await mount(Parent, { target: fixture });
expect(steps).toEqual(["render"]);
expect(fixture.innerHTML).toBe("<div><span>12</span></div>");
widget.state.flag = false;
await nextTick();
// we make sure here that no call to __render is done
expect(steps).toEqual(["render", "willUnmount"]);
});
test("state changes in willUnmount will be applied on remount", async () => {
class TestWidget extends Component {
static template = xml`
<div><t t-esc="state.val"/></div>
`;
state = useState({ val: 1 });
willUnmount() {
this.state.val = 3;
}
}
const widget = new TestWidget();
await widget.mount(fixture);
expect(fixture.innerHTML).toBe("<div>1</div>");
widget.unmount();
expect(fixture.innerHTML).toBe("");
await nextTick(); // wait for changes to be detected before remounting
await widget.mount(fixture);
expect(fixture.innerHTML).toBe("<div>3</div>");
// we want to make sure that there are no remaining tasks left at this point.
expect(Component.scheduler.tasks.length).toBe(0);
});
test("sub component is still active after being unmounted and remounted", async () => {
class Child extends Component {
static template = xml`
<p t-on-click="state.value++">
<t t-esc="state.value"/>
</p>`;
state = useState({ value: 1 });
}
class Parent extends Component {
static components = { Child };
static template = xml`<div><Child/></div>`;
}
const w = new Parent();
await w.mount(fixture);
expect(fixture.innerHTML).toBe("<div><p>1</p></div>");
fixture.querySelector("p")!.click();
await nextTick();
expect(fixture.innerHTML).toBe("<div><p>2</p></div>");
w.unmount();
await nextTick();
await w.mount(fixture);
expect(fixture.innerHTML).toBe("<div><p>2</p></div>");
fixture.querySelector("p")!.click();
await nextTick();
expect(fixture.innerHTML).toBe("<div><p>3</p></div>");
});
test("change state just before mounting component", async () => {
const steps: number[] = [];
class TestWidget extends Component {
static template = xml`
<div><t t-esc="state.val"/></div>
`;
state = useState({ val: 1 });
__render(f) {
steps.push(this.state.val);
return super.__render(f);
}
}
TestWidget.prototype.__render = jest.fn(TestWidget.prototype.__render);
const widget = new TestWidget();
widget.state.val = 2;
await widget.mount(fixture);
expect(fixture.innerHTML).toBe("<div>2</div>");
expect(TestWidget.prototype.__render).toHaveBeenCalledTimes(1);
// unmount and re-mount, as in this case, willStart won't be called, so it's
// slightly different
widget.unmount();
widget.state.val = 3;
await widget.mount(fixture);
expect(fixture.innerHTML).toBe("<div>3</div>");
expect(TestWidget.prototype.__render).toHaveBeenCalledTimes(2);
expect(steps).toEqual([2, 3]);
});
test("change state while mounting component", async () => {
const steps: number[] = [];
class TestWidget extends Component {
static template = xml`
<div><t t-esc="state.val"/></div>
`;
state = useState({ val: 1 });
__render(f) {
steps.push(this.state.val);
return super.__render(f);
}
}
TestWidget.prototype.__render = jest.fn(TestWidget.prototype.__render);
TestWidget.prototype.__patch = jest.fn(TestWidget.prototype.__patch);
const widget = new TestWidget();
let prom = widget.mount(fixture);
widget.state.val = 2;
await prom;
expect(fixture.innerHTML).toBe("<div>2</div>");
expect(TestWidget.prototype.__render).toHaveBeenCalledTimes(1);
// unmount and re-mount, as in this case, willStart won't be called, so it's
// slightly different
widget.unmount();
prom = widget.mount(fixture);
widget.state.val = 3;
await prom;
expect(fixture.innerHTML).toBe("<div>3</div>");
expect(TestWidget.prototype.__render).toHaveBeenCalledTimes(3);
expect(TestWidget.prototype.__patch).toHaveBeenCalledTimes(2);
expect(steps).toEqual([2, 2, 3]);
});
test("change state and render while mounted in detached dom", async () => {
class App extends Component {
static template = xml`<div><t t-esc="state.val"/></div>`;
state = useState({ val: 1 });
}
const detachedDiv = document.createElement("div");
const app = await mount(App, { target: detachedDiv });
expect(detachedDiv.innerHTML).toBe("<div>1</div>");
app.state.val = 2;
await nextTick();
expect(detachedDiv.innerHTML).toBe("<div>2</div>");
});
test("change state and render while not mounted ", async () => {
class App extends Component {
static template = xml`<div><t t-esc="state.val"/></div>`;
state = useState({ val: 1 });
}
const app = new App(null);
app.state.val = 2; // will call the render method (before being mounted)
await nextTick();
await app.mount(fixture);
expect(fixture.innerHTML).toBe("<div>2</div>");
});
test("destroy and change state after mounted in detached dom", async () => {
class App extends Component {
static template = xml`<div><t t-esc="state.val"/></div>`;
state = useState({ val: 1 });
}
const detachedDiv = document.createElement("div");
const app = await mount(App, { target: detachedDiv });
expect(detachedDiv.innerHTML).toBe("<div>1</div>");
app.destroy();
app.state.val = 2;
await nextTick();
expect(detachedDiv.innerHTML).toBe("");
});
test("change state while component is unmounted", async () => {
let child;
class Child extends Component {
static template = xml`<span t-esc="state.val"/>`;
state = useState({
val: "C1",
});
constructor(parent, props) {
super(parent, props);
child = this;
}
}
class Parent extends Component {
static components = { Child };
static template = xml`<div><t t-esc="state.val"/><Child/></div>`;
state = useState({ val: "P1" });
}
const parent = new Parent();
await parent.mount(fixture);
expect(fixture.innerHTML).toBe("<div>P1<span>C1</span></div>");
parent.unmount();
expect(fixture.innerHTML).toBe("");
parent.state.val = "P2";
child.state.val = "C2";
await parent.mount(fixture);
expect(fixture.innerHTML).toBe("<div>P2<span>C2</span></div>");
});
test("change state while component is mounted in a fragment", async () => {
class Child1 extends Component {
static template = xml`<span>C1</span>`;
}
class Child2 extends Component {
static template = xml`<span>C2</span>`;
}
class Parent extends Component {
static components = { Child1, Child2 };
static template = xml`
<div>
<Child1 t-if="child == 'c1'"/>
<Child2 t-if="child == 'c2'"/>
</div>`;
child: string | false = false;
}
const fragment = document.createDocumentFragment();
const parent = new Parent();
await parent.mount(fragment);
expect(parent.el.outerHTML).toBe("<div></div>");
parent.child = "c1";
parent.render();
await Promise.resolve();
parent.child = "c2";
await parent.mount(fixture);
expect(fixture.innerHTML).toBe("<div><span>C2</span></div>");
});
test("unmount component during a re-rendering", async () => {
const def = makeDeferred();
class Child extends Component {
static template = xml`<span><t t-esc="props.val"/></span>`;
willUpdateProps() {
return def;
}
}
Child.prototype.__render = jest.fn(Child.prototype.__render);
class Parent extends Component {
static template = xml`<div><Child val="state.val"/></div>`;
static components = { Child };
state = useState({ val: 1 });
}
const parent = new Parent();
await parent.mount(fixture);
expect(fixture.innerHTML).toBe("<div><span>1</span></div>");
expect(Child.prototype.__render).toBeCalledTimes(1);
parent.state.val = 2;
await nextTick();
expect(fixture.innerHTML).toBe("<div><span>1</span></div>");
parent.unmount();
expect(fixture.innerHTML).toBe("");
def.resolve();
await nextTick();
expect(fixture.innerHTML).toBe("");
expect(Child.prototype.__render).toBeCalledTimes(1);
});
test("widget can be mounted on different target", async () => {
class MyWidget extends Component {
static template = xml`<div>Hey</div>`;
patched() {
throw new Error("patched should not be called");
}
}
const div = document.createElement("div");
const span = document.createElement("span");
fixture.appendChild(div);
fixture.appendChild(span);
const w = new MyWidget();
await w.mount(div);
expect(fixture.innerHTML).toBe("<div><div>Hey</div></div><span></span>");
await w.mount(span);
expect(fixture.innerHTML).toBe("<div></div><span><div>Hey</div></span>");
});
test("widget can be mounted on different target, another situation", async () => {
const def = makeDeferred();
const steps: string[] = [];
class MyWidget extends Component {
static template = xml`<div>Hey</div>`;
async willStart() {
return def;
}
patched() {
throw new Error("patched should not be called");
}
}
const div = document.createElement("div");
const span = document.createElement("span");
fixture.appendChild(div);
fixture.appendChild(span);
const w = new MyWidget();
w.mount(div).catch(() => steps.push("1 catch"));
await nextTick();
expect(fixture.innerHTML).toBe("<div></div><span></span>");
w.mount(span).then(() => steps.push("2 resolved"));
// we wait two microticks because this is the number of internal promises
// that need to be resolved/rejected, and because we want to prove here
// that the first mount operation is cancelled immediately, and not after
// one full tick.
await nextMicroTick();
await nextMicroTick();
expect(steps).toEqual([]);
await nextTick();
expect(fixture.innerHTML).toBe("<div></div><span></span>");
def.resolve();
await nextTick();
expect(steps).toEqual(["2 resolved"]);
expect(fixture.innerHTML).toBe("<div></div><span><div>Hey</div></span>");
});
test("component can be mounted on same target, another situation", async () => {
const def = makeDeferred();
const steps: string[] = [];
class MyWidget extends Component {
static template = xml`<div>Hey</div>`;
async willStart() {
return def;
}
patched() {
throw new Error("patched should not be called");
}
}
const w = new MyWidget();
w.mount(fixture).then(() => steps.push("1 resolved"));
await nextTick();
expect(fixture.innerHTML).toBe("");
w.mount(fixture).then(() => steps.push("2 resolved"));
await nextTick();
expect(steps).toEqual([]);
expect(fixture.innerHTML).toBe("");
def.resolve();
await nextTick();
expect(fixture.innerHTML).toBe("<div>Hey</div>");
expect(steps).toEqual(["1 resolved", "2 resolved"]);
});
test("mounting a destroyed widget", async () => {
class MyWidget extends Component {
static template = xml`<div>Hey</div>`;
}
const w = new MyWidget();
w.destroy(); // because, why not
let error;
try {
await w.mount(fixture);
} catch (e) {
error = e;
}
expect(scheduler.tasks.length).toBe(0);
expect(error).toBeDefined();
expect(error.message).toBe("Cannot mount a destroyed component");
});
test("destroying a sub-component cleans itself from parent's vnode", async () => {
class C1 extends Component {
static template = xml`<div><div><t t-esc="props.a"/></div></div>`;
}
class P extends Component {
static components = { C1 };
static template = xml`<div><div><C1 t-props="state" t-if="state.a"/></div></div>`;
state = {
a: "first",
};
}
const parent = new P();
await parent.mount(fixture);
expect(fixture.textContent).toBe("first");
parent.unmount();
parent.state.a = "";
parent.mount(fixture);
parent.state.a = "fixed";
await parent.render();
expect(fixture.textContent).toBe("fixed");
});
test("destroying a sub-component cleans itself from parent's vnode, part 2", async () => {
class C1 extends Component {
static template = xml`<div><div><t t-esc="props.a"/></div></div>`;
}
class P extends Component {
static components = { C1 };
static template = xml`<div><div><C1 t-props="state" t-if="state.a"/>some text</div></div>`;
state = {
a: "first",
};
}
const parent = new P();
await parent.mount(fixture);
expect(fixture.textContent).toBe("firstsome text");
parent.unmount();
parent.state.a = "";
parent.mount(fixture);
parent.state.a = "fixed";
await parent.render();
expect(fixture.textContent).toBe("fixedsome text");
});
test("destroying a sub-component cleans itself from parent's vnode, part 3", async () => {
class C1 extends Component {
static template = xml`<div><div><t t-esc="props.a"/></div></div>`;
}
class C2 extends Component {
static template = xml`<C1 a="props.a"/>`;
static components = { C1 };
}
class P extends Component {
static components = { C2 };
static template = xml`<div><div><C2 t-props="state" t-if="state.a"/></div></div>`;
state = {
a: "first",
};
}
const parent = new P();
await parent.mount(fixture);
expect(fixture.textContent).toBe("first");
parent.unmount();
parent.state.a = "";
parent.mount(fixture);
parent.state.a = "fixed";
await parent.render();
expect(fixture.textContent).toBe("fixed");
});
test("destroying a sub-component cleans itself from parent's vnode, part 4", async () => {
class C1 extends Component {
static template = xml`<div><div><t t-esc="props.a"/></div></div>`;
}
class C2 extends Component {
static template = xml`<C1 a="props.a"/>`;
static components = { C1 };
}
class P extends Component {
static components = { C2 };
static template = xml`<div><div><C2 t-props="state" t-if="state.a"/>some text</div></div>`;
state = {
a: "first",
};
}
const parent = new P();
await parent.mount(fixture);
expect(fixture.textContent).toBe("firstsome text");
parent.unmount();
parent.state.a = "";
parent.mount(fixture);
parent.state.a = "fixed";
await parent.render();
expect(fixture.textContent).toBe("fixedsome text");
});
test("remounting component tree where a component implement shouldupdate", async () => {
let state: any;
const steps = [];
class Child extends Component {
static template = xml`<div><t t-esc="state.word"/><t t-esc="props.name"/></div>`;
state = useState({ word: "hello" });
constructor(parent, props) {
super(parent, props);
state = this.state;
}
patched() {
steps.push("patched");
}
mounted() {
steps.push("mounted");
}
willUnmount() {
steps.push("willUnmount");
}
shouldUpdate() {
return false;
}
}
class Parent extends Component {
static template = xml`<div><Child name="state.name"/></div>`;
static components = { Child };
state = useState({ name: "World" });
}
const parent = await mount(Parent, { target: fixture });
expect(fixture.innerHTML).toBe("<div><div>helloWorld</div></div>");
parent.unmount();
expect(fixture.innerHTML).toBe("");
await parent.mount(fixture);
expect(fixture.innerHTML).toBe("<div><div>helloWorld</div></div>");
state.word = "test";
await nextTick();
expect(fixture.innerHTML).toBe("<div><div>testWorld</div></div>");
expect(steps).toEqual(["mounted", "willUnmount", "mounted", "patched"]);
});
});
+22 -41
View File
@@ -32,7 +32,7 @@ describe("Context", () => {
test("very simple use, with initial value", async () => { test("very simple use, with initial value", async () => {
const testContext = new Context({ value: 123 }); const testContext = new Context({ value: 123 });
class Test extends Component { class Test extends Component<any, any> {
static template = xml`<div><t t-esc="contextObj.value"/></div>`; static template = xml`<div><t t-esc="contextObj.value"/></div>`;
contextObj = useContext(testContext); contextObj = useContext(testContext);
} }
@@ -44,7 +44,7 @@ describe("Context", () => {
test("useContext hook is reactive, for one component", async () => { test("useContext hook is reactive, for one component", async () => {
const testContext = new Context({ value: 123 }); const testContext = new Context({ value: 123 });
class Test extends Component { class Test extends Component<any, any> {
static template = xml`<div><t t-esc="contextObj.value"/></div>`; static template = xml`<div><t t-esc="contextObj.value"/></div>`;
contextObj = useContext(testContext); contextObj = useContext(testContext);
} }
@@ -59,11 +59,11 @@ describe("Context", () => {
test("two components can subscribe to same context", async () => { test("two components can subscribe to same context", async () => {
const testContext = new Context({ value: 123 }); const testContext = new Context({ value: 123 });
class Child extends Component { class Child extends Component<any, any> {
static template = xml`<span><t t-esc="contextObj.value"/></span>`; static template = xml`<span><t t-esc="contextObj.value"/></span>`;
contextObj = useContext(testContext); contextObj = useContext(testContext);
} }
class Parent extends Component { class Parent extends Component<any, any> {
static template = xml`<div><Child /><Child /></div>`; static template = xml`<div><Child /><Child /></div>`;
static components = { Child }; static components = { Child };
} }
@@ -80,7 +80,7 @@ describe("Context", () => {
const def = makeDeferred(); const def = makeDeferred();
const steps: string[] = []; const steps: string[] = [];
class Child extends Component { class Child extends Component<any, any> {
static template = xml`<span><t t-esc="contextObj.value"/></span>`; static template = xml`<span><t t-esc="contextObj.value"/></span>`;
contextObj = useContext(testContext); contextObj = useContext(testContext);
async render() { async render() {
@@ -90,7 +90,7 @@ describe("Context", () => {
} }
} }
class Parent extends Component { class Parent extends Component<any, any> {
static template = xml`<div><Child /><Child /></div>`; static template = xml`<div><Child /><Child /></div>`;
static components = { Child }; static components = { Child };
} }
@@ -111,13 +111,13 @@ describe("Context", () => {
const def = makeDeferred(); const def = makeDeferred();
const steps: string[] = []; const steps: string[] = [];
class SlowComp extends Component { class SlowComp extends Component<any, any> {
static template = xml`<p><t t-esc="props.value"/></p>`; static template = xml`<p><t t-esc="props.value"/></p>`;
willUpdateProps() { willUpdateProps() {
return def; return def;
} }
} }
class Child extends Component { class Child extends Component<any, any> {
static template = xml`<span><SlowComp value="contextObj.value"/></span>`; static template = xml`<span><SlowComp value="contextObj.value"/></span>`;
static components = { SlowComp }; static components = { SlowComp };
contextObj = useContext(testContext); contextObj = useContext(testContext);
@@ -128,12 +128,12 @@ describe("Context", () => {
} }
} }
class Parent extends Component { class Parent extends Component<any, any> {
static template = xml`<div><Child /><Child /></div>`; static template = xml`<div><Child /><Child /></div>`;
static components = { Child }; static components = { Child };
} }
class App extends Component { class App extends Component<any, any> {
static template = xml`<div><Child /><Parent /></div>`; static template = xml`<div><Child /><Parent /></div>`;
static components = { Child, Parent }; static components = { Child, Parent };
} }
@@ -166,7 +166,7 @@ describe("Context", () => {
const testContext = new Context({ a: 1, b: 2 }); const testContext = new Context({ a: 1, b: 2 });
const steps: string[] = []; const steps: string[] = [];
class Child extends Component { class Child extends Component<any, any> {
static template = xml`<span><t t-esc="contextObj1.a"/><t t-esc="contextObj2.b"/></span>`; static template = xml`<span><t t-esc="contextObj1.a"/><t t-esc="contextObj2.b"/></span>`;
contextObj1 = useContext(testContext); contextObj1 = useContext(testContext);
contextObj2 = useContext(testContext); contextObj2 = useContext(testContext);
@@ -175,7 +175,7 @@ describe("Context", () => {
return super.__render(fiber); return super.__render(fiber);
} }
} }
class Parent extends Component { class Parent extends Component<any, any> {
static template = xml`<div><Child /></div>`; static template = xml`<div><Child /></div>`;
static components = { Child }; static components = { Child };
} }
@@ -193,7 +193,7 @@ describe("Context", () => {
const testContext = new Context({ a: 123, b: 321 }); const testContext = new Context({ a: 123, b: 321 });
const steps: string[] = []; const steps: string[] = [];
class Child extends Component { class Child extends Component<any, any> {
static template = xml`<span><t t-esc="contextObj.a"/></span>`; static template = xml`<span><t t-esc="contextObj.a"/></span>`;
contextObj = useContext(testContext); contextObj = useContext(testContext);
__render(fiber) { __render(fiber) {
@@ -201,7 +201,7 @@ describe("Context", () => {
return super.__render(fiber); return super.__render(fiber);
} }
} }
class Parent extends Component { class Parent extends Component<any, any> {
static template = xml`<div><Child /><t t-esc="contextObj.b"/></div>`; static template = xml`<div><Child /><t t-esc="contextObj.b"/></div>`;
static components = { Child }; static components = { Child };
contextObj = useContext(testContext); contextObj = useContext(testContext);
@@ -227,7 +227,7 @@ describe("Context", () => {
const testContext = new Context({ a: 123 }); const testContext = new Context({ a: 123 });
const steps: string[] = []; const steps: string[] = [];
class Child extends Component { class Child extends Component<any, any> {
static template = xml`<span><t t-esc="contextObj.a"/></span>`; static template = xml`<span><t t-esc="contextObj.a"/></span>`;
contextObj = useContext(testContext); contextObj = useContext(testContext);
__render(fiber) { __render(fiber) {
@@ -235,7 +235,7 @@ describe("Context", () => {
return super.__render(fiber); return super.__render(fiber);
} }
} }
class Parent extends Component { class Parent extends Component<any, any> {
static template = xml`<div><Child t-if="state.flag"/></div>`; static template = xml`<div><Child t-if="state.flag"/></div>`;
static components = { Child }; static components = { Child };
state = useState({ flag: true }); state = useState({ flag: true });
@@ -263,14 +263,14 @@ describe("Context", () => {
test("destroyed component before being mounted is inactive", async () => { test("destroyed component before being mounted is inactive", async () => {
const testContext = new Context({ a: 123 }); const testContext = new Context({ a: 123 });
class Child extends Component { class Child extends Component<any, any> {
static template = xml`<span><t t-esc="contextObj.a"/></span>`; static template = xml`<span><t t-esc="contextObj.a"/></span>`;
contextObj = useContext(testContext); contextObj = useContext(testContext);
willStart() { willStart() {
return makeDeferred(); return makeDeferred();
} }
} }
class Parent extends Component { class Parent extends Component<any, any> {
static template = xml`<div><Child t-if="state.flag"/></div>`; static template = xml`<div><Child t-if="state.flag"/></div>`;
static components = { Child }; static components = { Child };
state = useState({ flag: true }); state = useState({ flag: true });
@@ -289,30 +289,11 @@ describe("Context", () => {
expect(testContext.subscriptions.update.length).toBe(0); expect(testContext.subscriptions.update.length).toBe(0);
}); });
test.skip("concurrent renderings", async () => { test("concurrent renderings", async () => {
/**
* Note: this test is interesting, but sadly just an incomplete attempt at
* protecting users against themselves. With the context API, it is not
* possible for the framework to protect completely against crashes. Maybe
* like in this case, when a component is in a simple hierarchy where all
* renderings come from the context changes, but in a real case, where some
* code can trigger a rendering independently, it is insufficient.
*
* The main problem is that the sub component depends on some external state,
* which may be modified, and then incompatible with the component actual
* state (for example, if the sub component has an id key related to some
* object that has been removed from the context).
*
* For now, sadly, the only solution is that components that depends on external
* state should guarantee their own integrity themselves. Then maybe this
* could be solved at the level of a state management solution that has a
* more advanced API, to let components determine if they should be updated
* or not (so, something slightly more advanced that the useStore hook).
*/
const testContext = new Context({ x: { n: 1 }, key: "x" }); const testContext = new Context({ x: { n: 1 }, key: "x" });
const def = makeDeferred(); const def = makeDeferred();
let stateC; let stateC;
class ComponentC extends Component { class ComponentC extends Component<any, any> {
static template = xml`<span><t t-esc="context[props.key].n"/><t t-esc="state.x"/></span>`; static template = xml`<span><t t-esc="context[props.key].n"/><t t-esc="state.x"/></span>`;
context = useContext(testContext); context = useContext(testContext);
state = useState({ x: "a" }); state = useState({ x: "a" });
@@ -322,7 +303,7 @@ describe("Context", () => {
stateC = this.state; stateC = this.state;
} }
} }
class ComponentB extends Component { class ComponentB extends Component<any, any> {
static components = { ComponentC }; static components = { ComponentC };
static template = xml`<p><ComponentC key="props.key"/></p>`; static template = xml`<p><ComponentC key="props.key"/></p>`;
@@ -330,7 +311,7 @@ describe("Context", () => {
return def; return def;
} }
} }
class ComponentA extends Component { class ComponentA extends Component<any, any> {
static components = { ComponentB }; static components = { ComponentB };
static template = xml`<div><ComponentB key="context.key"/></div>`; static template = xml`<div><ComponentB key="context.key"/></div>`;
context = useContext(testContext); context = useContext(testContext);
+2 -19
View File
@@ -22,7 +22,7 @@ describe("observer", () => {
expect(observer.rev).toBe(2); expect(observer.rev).toBe(2);
expect(obj2).toEqual({ expect(obj2).toEqual({
a: 2, a: 2
}); });
}); });
@@ -47,7 +47,7 @@ describe("observer", () => {
expect(observer.rev).toBe(5); expect(observer.rev).toBe(5);
expect(obj).toEqual({ expect(obj).toEqual({
a: null, a: null,
b: undefined, b: undefined
}); });
}); });
@@ -68,23 +68,6 @@ describe("observer", () => {
expect(obj.date).not.toBe(date); expect(obj.date).not.toBe(date);
}); });
test("properly handle promises (i.e.: treat them like primitive values", async () => {
const observer = new Observer();
let resolved = false;
const prom = new Promise((r) => r());
const obj: any = observer.observe({ prom });
expect(obj.prom).toBeInstanceOf(Promise);
obj.prom.then(() => (resolved = true));
expect(observer.revNumber(obj)).toBe(1);
expect(resolved).toBe(false);
await Promise.resolve();
expect(resolved).toBe(true);
expect(observer.revNumber(obj)).toBe(1);
});
test("can change values in array", () => { test("can change values in array", () => {
const observer = new Observer(); const observer = new Observer();
const obj: any = observer.observe({ arr: [1, 2] }); const obj: any = observer.observe({ arr: [1, 2] });
@@ -60,14 +60,14 @@ function getFiles(path: string[] = []): FileData[] {
if (path.length === 0) { if (path.length === 0) {
const baseFiles: FileData[] = [ const baseFiles: FileData[] = [
{ name: "README.md", path: [], links: [], sections: [], fullName: "README.md" }, { name: "README.md", path: [], links: [], sections: [], fullName: "README.md" },
{ name: "roadmap.md", path: [], links: [], sections: [], fullName: "roadmap.md" }, { name: "roadmap.md", path: [], links: [], sections: [], fullName: "roadmap.md" }
]; ];
const rest = getFiles(["doc"]); const rest = getFiles(["doc"]);
const result = baseFiles.concat(rest); const result = baseFiles.concat(rest);
result.forEach(addMardownData); result.forEach(addMardownData);
return result; return result;
} }
const files = fs.readdirSync(path.join("/"), { withFileTypes: true }).map((f) => { const files = fs.readdirSync(path.join("/"), { withFileTypes: true }).map(f => {
if (f.isDirectory()) { if (f.isDirectory()) {
return getFiles(path.concat(f.name)); return getFiles(path.concat(f.name));
} }
@@ -78,8 +78,8 @@ function getFiles(path: string[] = []): FileData[] {
path, path,
links: [], links: [],
sections: [], sections: [],
fullName, fullName
}, }
]; ];
}); });
return Array.prototype.concat(...files); return Array.prototype.concat(...files);
@@ -127,7 +127,7 @@ export function isLinkValid(link: MarkDownLink, current: FileData, files: FileDa
} }
// Step 4: check if there is a matching file // Step 4: check if there is a matching file
let target: FileData | undefined = files.find((f) => f.fullName === linkFullName); let target: FileData | undefined = files.find(f => f.fullName === linkFullName);
if (!target) { if (!target) {
return false; return false;
} }
@@ -135,7 +135,7 @@ export function isLinkValid(link: MarkDownLink, current: FileData, files: FileDa
// Step 5: if necessary, check if there is a corresponding link inside the target // Step 5: if necessary, check if there is a corresponding link inside the target
// link name // link name
if (hash) { if (hash) {
if (!target.sections.find((s) => s.slug === hash)) { if (!target.sections.find(s => s.slug === hash)) {
return false; return false;
} }
} }
@@ -153,7 +153,7 @@ function slugify(str) {
.toLowerCase() .toLowerCase()
.replace(/\//g, "") // remove / .replace(/\//g, "") // remove /
.replace(/\s+/g, "-") // Replace spaces with - .replace(/\s+/g, "-") // Replace spaces with -
.replace(p, (c) => b.charAt(a.indexOf(c))) // Replace special characters .replace(p, c => b.charAt(a.indexOf(c))) // Replace special characters
.replace(/&/g, "-and-") // Replace & with and .replace(/&/g, "-and-") // Replace & with and
.replace(/[^\w\-]+/g, "") // Remove all non-word characters .replace(/[^\w\-]+/g, "") // Remove all non-word characters
.replace(/\-\-+/g, "-") // Replace multiple - with single - .replace(/\-\-+/g, "-") // Replace multiple - with single -
+18 -22
View File
@@ -1,4 +1,4 @@
import { Env, Component, STATUS } from "../src/component/component"; import { Env } from "../src/component/component";
import { scheduler } from "../src/component/scheduler"; import { scheduler } from "../src/component/scheduler";
import { EvalContext, QWeb } from "../src/qweb/qweb"; import { EvalContext, QWeb } from "../src/qweb/qweb";
import { CompilationContext } from "../src/qweb/compilation_context"; import { CompilationContext } from "../src/qweb/compilation_context";
@@ -6,7 +6,12 @@ import { patch } from "../src/vdom";
import "../src/qweb/base_directives"; import "../src/qweb/base_directives";
import "../src/qweb/extensions"; import "../src/qweb/extensions";
import "../src/component/directive"; import "../src/component/directive";
import { browser } from "../src/browser";
// modifies scheduler to make it faster to test components
scheduler.requestAnimationFrame = function(callback: FrameRequestCallback) {
setTimeout(callback, 1);
return 1;
};
// Some static cleanup // Some static cleanup
let nextSlotId; let nextSlotId;
@@ -20,7 +25,6 @@ beforeEach(() => {
slots = Object.assign({}, QWeb.slots); slots = Object.assign({}, QWeb.slots);
nextId = QWeb.nextId; nextId = QWeb.nextId;
TEMPLATES = Object.assign({}, QWeb.TEMPLATES); TEMPLATES = Object.assign({}, QWeb.TEMPLATES);
Component.scheduler.tasks = [];
}); });
afterEach(() => { afterEach(() => {
@@ -28,7 +32,6 @@ afterEach(() => {
QWeb.slots = slots; QWeb.slots = slots;
QWeb.nextId = nextId; QWeb.nextId = nextId;
QWeb.TEMPLATES = TEMPLATES; QWeb.TEMPLATES = TEMPLATES;
Component.scheduler.tasks = [];
}); });
// helpers // helpers
@@ -37,13 +40,9 @@ export function nextMicroTick(): Promise<void> {
} }
export async function nextTick(): Promise<void> { export async function nextTick(): Promise<void> {
await new Promise((resolve) => setTimeout(resolve)); return new Promise(function(resolve) {
await new Promise((resolve) => scheduler.requestAnimationFrame(resolve)); setTimeout(() => scheduler.requestAnimationFrame(() => resolve()));
} });
export async function nextFrame(): Promise<void> {
await new Promise((resolve) => scheduler.requestAnimationFrame(resolve));
await new Promise((resolve) => scheduler.requestAnimationFrame(resolve));
} }
export function makeTestFixture() { export function makeTestFixture() {
@@ -74,8 +73,7 @@ export function makeDeferred(): Deferred {
export function makeTestEnv(): Env { export function makeTestEnv(): Env {
return { return {
qweb: new QWeb(), qweb: new QWeb()
browser: browser,
}; };
} }
@@ -89,11 +87,6 @@ export function renderToDOM(
context: EvalContext = {}, context: EvalContext = {},
extra?: any extra?: any
): HTMLElement | Text { ): HTMLElement | Text {
if (!context.__owl__) {
// we add `__owl__` to better simulate a component as context. This is
// particularly important for event handlers added with the `t-on` directive.
context.__owl__ = { status: STATUS.MOUNTED };
}
const vnode = qweb.render(template, context, extra); const vnode = qweb.render(template, context, extra);
// we snapshot here the compiled code. This is useful to prevent unwanted code // we snapshot here the compiled code. This is useful to prevent unwanted code
@@ -123,8 +116,11 @@ export function renderToString(
context: EvalContext = {}, context: EvalContext = {},
extra?: any extra?: any
): string { ): string {
const result = qweb.renderToString(t, context, extra); const node = renderToDOM(qweb, t, context, extra);
expect(qweb.templates[t].fn.toString()).toMatchSnapshot(); const result = node instanceof Text ? node.textContent! : node.outerHTML;
if (result !== qweb.renderToString(t, context, extra)) {
throw new Error("HTML string returned by renderToString helper does not match QWeb render");
}
return result; return result;
} }
@@ -132,7 +128,7 @@ export function renderToString(
// is useful for animations tests, as we hook before repaints to trigger // is useful for animations tests, as we hook before repaints to trigger
// animations (thanks to requestAnimationFrame). Patching nextFrame allows to // animations (thanks to requestAnimationFrame). Patching nextFrame allows to
// simulate calls to this hook. One must not forget to unpatch afterwards. // simulate calls to this hook. One must not forget to unpatch afterwards.
let _nextFrame = QWeb.utils.nextFrame; let nextFrame = QWeb.utils.nextFrame;
export function patchNextFrame(f: Function) { export function patchNextFrame(f: Function) {
QWeb.utils.nextFrame = (cb: () => void) => { QWeb.utils.nextFrame = (cb: () => void) => {
setTimeout(() => f(cb)); setTimeout(() => f(cb));
@@ -140,7 +136,7 @@ export function patchNextFrame(f: Function) {
} }
export function unpatchNextFrame() { export function unpatchNextFrame() {
QWeb.utils.nextFrame = _nextFrame; QWeb.utils.nextFrame = nextFrame;
} }
export async function editInput(input: HTMLInputElement | HTMLTextAreaElement, value: string) { export async function editInput(input: HTMLInputElement | HTMLTextAreaElement, value: string) {
+39 -130
View File
@@ -9,10 +9,7 @@ import {
onWillPatch, onWillPatch,
onWillStart, onWillStart,
onWillUpdateProps, onWillUpdateProps,
useEnv, useSubEnv
useSubEnv,
useExternalListener,
useComponent,
} from "../src/hooks"; } from "../src/hooks";
import { xml } from "../src/tags"; import { xml } from "../src/tags";
@@ -44,7 +41,7 @@ afterEach(() => {
describe("hooks", () => { describe("hooks", () => {
test("can use a state hook", async () => { test("can use a state hook", async () => {
class Counter extends Component { class Counter extends Component<any, any> {
static template = xml`<div><t t-esc="counter.value"/></div>`; static template = xml`<div><t t-esc="counter.value"/></div>`;
counter = useState({ value: 42 }); counter = useState({ value: 42 });
} }
@@ -66,7 +63,7 @@ describe("hooks", () => {
steps.push("willunmount"); steps.push("willunmount");
}); });
} }
class MyComponent extends Component { class MyComponent extends Component<any, any> {
static template = xml`<div>hey</div>`; static template = xml`<div>hey</div>`;
constructor() { constructor() {
super(); super();
@@ -75,6 +72,8 @@ describe("hooks", () => {
} }
const component = new MyComponent(); const component = new MyComponent();
await component.mount(fixture); await component.mount(fixture);
expect(component).not.toHaveProperty("mounted");
expect(component).not.toHaveProperty("willUnmount");
expect(fixture.innerHTML).toBe("<div>hey</div>"); expect(fixture.innerHTML).toBe("<div>hey</div>");
expect(steps).toEqual(["mounted"]); expect(steps).toEqual(["mounted"]);
component.unmount(); component.unmount();
@@ -92,7 +91,7 @@ describe("hooks", () => {
steps.push("willunmount"); steps.push("willunmount");
}); });
} }
class MyComponent extends Component { class MyComponent extends Component<any, any> {
static template = xml`<div>hey</div>`; static template = xml`<div>hey</div>`;
constructor(parent, props) { constructor(parent, props) {
super(parent, props); super(parent, props);
@@ -100,7 +99,7 @@ describe("hooks", () => {
} }
} }
class Parent extends Component { class Parent extends Component<any, any> {
static template = xml`<div><MyComponent t-if="state.flag"/></div>`; static template = xml`<div><MyComponent t-if="state.flag"/></div>`;
static components = { MyComponent }; static components = { MyComponent };
state = useState({ flag: true }); state = useState({ flag: true });
@@ -126,7 +125,7 @@ describe("hooks", () => {
steps.push("hook:willunmount"); steps.push("hook:willunmount");
}); });
} }
class MyComponent extends Component { class MyComponent extends Component<any, any> {
static template = xml`<div>hey</div>`; static template = xml`<div>hey</div>`;
constructor() { constructor() {
super(); super();
@@ -157,7 +156,7 @@ describe("hooks", () => {
steps.push("hook:willunmount"); steps.push("hook:willunmount");
}); });
} }
class MyComponent extends Component { class MyComponent extends Component<any, any> {
static template = xml`<div>hey</div>`; static template = xml`<div>hey</div>`;
constructor(parent, props) { constructor(parent, props) {
super(parent, props); super(parent, props);
@@ -171,7 +170,7 @@ describe("hooks", () => {
} }
} }
class Parent extends Component { class Parent extends Component<any, any> {
static template = xml`<div><MyComponent t-if="state.flag"/></div>`; static template = xml`<div><MyComponent t-if="state.flag"/></div>`;
static components = { MyComponent }; static components = { MyComponent };
state = useState({ flag: true }); state = useState({ flag: true });
@@ -197,7 +196,7 @@ describe("hooks", () => {
steps.push("hook:willunmount" + i); steps.push("hook:willunmount" + i);
}); });
} }
class MyComponent extends Component { class MyComponent extends Component<any, any> {
static template = xml`<div>hey</div>`; static template = xml`<div>hey</div>`;
constructor() { constructor() {
super(); super();
@@ -214,12 +213,12 @@ describe("hooks", () => {
"hook:mounted1", "hook:mounted1",
"hook:mounted2", "hook:mounted2",
"hook:willunmount2", "hook:willunmount2",
"hook:willunmount1", "hook:willunmount1"
]); ]);
}); });
test("useRef hook", async () => { test("useRef hook", async () => {
class Counter extends Component { class Counter extends Component<any, any> {
static template = xml`<div><button t-ref="button"><t t-esc="value"/></button></div>`; static template = xml`<div><button t-ref="button"><t t-esc="value"/></button></div>`;
button = useRef("button"); button = useRef("button");
value = 0; value = 0;
@@ -241,7 +240,7 @@ describe("hooks", () => {
test("useRef hook is null if ref is removed ", async () => { test("useRef hook is null if ref is removed ", async () => {
expect.assertions(4); expect.assertions(4);
class TestRef extends Component { class TestRef extends Component<any, any> {
static template = xml`<div><span t-if="state.flag" t-ref="span">owl</span></div>`; static template = xml`<div><span t-if="state.flag" t-ref="span">owl</span></div>`;
spanRef = useRef("span"); spanRef = useRef("span");
state = useState({ flag: true }); state = useState({ flag: true });
@@ -261,13 +260,13 @@ describe("hooks", () => {
}); });
test("t-refs on widget are components", async () => { test("t-refs on widget are components", async () => {
class WidgetB extends Component { class WidgetB extends Component<any, any> {
static template = xml`<div>b</div>`; static template = xml`<div>b</div>`;
} }
class WidgetC extends Component { class WidgetC extends Component<any, any> {
static template = xml`<div class="outer-div">Hello<WidgetB t-ref="mywidgetb" /></div>`; static template = xml`<div class="outer-div">Hello<WidgetB t-ref="mywidgetb" /></div>`;
static components = { WidgetB }; static components = { WidgetB };
ref = useRef<WidgetB>("mywidgetb"); ref = useRef("mywidgetb");
} }
const widget = new WidgetC(); const widget = new WidgetC();
@@ -280,11 +279,11 @@ describe("hooks", () => {
test("t-refs are bound at proper timing", async () => { test("t-refs are bound at proper timing", async () => {
expect.assertions(2); expect.assertions(2);
class Widget extends Component { class Widget extends Component<any, any> {
static template = xml`<div>widget</div>`; static template = xml`<div>widget</div>`;
} }
class ParentWidget extends Component { class ParentWidget extends Component<any, any> {
static template = xml` static template = xml`
<div> <div>
<t t-foreach="state.list" t-as="elem" t-ref="child" t-key="elem" t-component="Widget"/> <t t-foreach="state.list" t-as="elem" t-ref="child" t-key="elem" t-component="Widget"/>
@@ -309,10 +308,10 @@ describe("hooks", () => {
test("t-refs are bound at proper timing (2)", async () => { test("t-refs are bound at proper timing (2)", async () => {
expect.assertions(10); expect.assertions(10);
class Widget extends Component { class Widget extends Component<any, any> {
static template = xml`<div>widget</div>`; static template = xml`<div>widget</div>`;
} }
class ParentWidget extends Component { class ParentWidget extends Component<any, any> {
static template = xml` static template = xml`
<div> <div>
<t t-if="state.child1" t-ref="child1" t-component="Widget"/> <t t-if="state.child1" t-ref="child1" t-component="Widget"/>
@@ -369,7 +368,7 @@ describe("hooks", () => {
}); });
} }
class MyComponent extends Component { class MyComponent extends Component<any, any> {
static template = xml`<div><t t-if="state.flag">hey</t></div>`; static template = xml`<div><t t-if="state.flag">hey</t></div>`;
state = useState({ flag: true }); state = useState({ flag: true });
@@ -381,6 +380,8 @@ describe("hooks", () => {
const component = new MyComponent(); const component = new MyComponent();
await component.mount(fixture); await component.mount(fixture);
expect(component).not.toHaveProperty("patched");
expect(component).not.toHaveProperty("willPatch");
expect(steps).toEqual([]); expect(steps).toEqual([]);
expect(fixture.innerHTML).toBe("<div>hey</div>"); expect(fixture.innerHTML).toBe("<div>hey</div>");
@@ -401,7 +402,7 @@ describe("hooks", () => {
steps.push("hook:willPatch"); steps.push("hook:willPatch");
}); });
} }
class MyComponent extends Component { class MyComponent extends Component<any, any> {
static template = xml`<div><t t-if="state.flag">hey</t></div>`; static template = xml`<div><t t-if="state.flag">hey</t></div>`;
state = useState({ flag: true }); state = useState({ flag: true });
@@ -435,7 +436,7 @@ describe("hooks", () => {
steps.push("hook:willPatch" + i); steps.push("hook:willPatch" + i);
}); });
} }
class MyComponent extends Component { class MyComponent extends Component<any, any> {
static template = xml`<div>hey<t t-esc="state.value"/></div>`; static template = xml`<div>hey<t t-esc="state.value"/></div>`;
state = useState({ value: 1 }); state = useState({ value: 1 });
constructor() { constructor() {
@@ -471,7 +472,7 @@ describe("hooks", () => {
} }
test("simple input", async () => { test("simple input", async () => {
class SomeComponent extends Component { class SomeComponent extends Component<any, any> {
static template = xml` static template = xml`
<div> <div>
<input t-ref="input1"/> <input t-ref="input1"/>
@@ -492,7 +493,7 @@ describe("hooks", () => {
}); });
test("input in a t-if", async () => { test("input in a t-if", async () => {
class SomeComponent extends Component { class SomeComponent extends Component<any, any> {
static template = xml` static template = xml`
<div> <div>
<input t-ref="input1"/> <input t-ref="input1"/>
@@ -518,21 +519,8 @@ describe("hooks", () => {
}); });
}); });
test("can use useEnv", async () => {
expect.assertions(1);
class TestComponent extends Component {
static template = xml`<div><t t-esc="env.val"/></div>`;
constructor() {
super();
expect(useEnv()).toBe(env);
}
}
const component = new TestComponent();
await component.mount(fixture);
});
test("can use sub env", async () => { test("can use sub env", async () => {
class TestComponent extends Component { class TestComponent extends Component<any, any> {
static template = xml`<div><t t-esc="env.val"/></div>`; static template = xml`<div><t t-esc="env.val"/></div>`;
constructor() { constructor() {
super(); super();
@@ -546,21 +534,8 @@ describe("hooks", () => {
expect(component.env).toHaveProperty("val"); expect(component.env).toHaveProperty("val");
}); });
test("can use useComponent", async () => {
expect.assertions(1);
class TestComponent extends Component {
static template = xml`<div></div>`;
constructor() {
super();
expect(useComponent()).toBe(this);
}
}
const component = new TestComponent();
await component.mount(fixture);
});
test("parent and child env", async () => { test("parent and child env", async () => {
class Child extends Component { class Child extends Component<any, any> {
static template = xml`<div><t t-esc="env.val"/></div>`; static template = xml`<div><t t-esc="env.val"/></div>`;
constructor(parent, props) { constructor(parent, props) {
super(parent, props); super(parent, props);
@@ -568,7 +543,7 @@ describe("hooks", () => {
} }
} }
class Parent extends Component { class Parent extends Component<any, any> {
static template = xml`<div><t t-esc="env.val"/><Child/></div>`; static template = xml`<div><t t-esc="env.val"/><Child/></div>`;
static components = { Child }; static components = { Child };
constructor() { constructor() {
@@ -583,42 +558,23 @@ describe("hooks", () => {
test("can use onWillStart, onWillUpdateProps", async () => { test("can use onWillStart, onWillUpdateProps", async () => {
const steps: string[] = []; const steps: string[] = [];
async function slow(): Promise<string> {
return new Promise((resolve) => {
setTimeout(() => {
resolve("slow");
}, 0);
});
}
function useMyHook() { function useMyHook() {
onWillStart(async () => { onWillStart(() => {
steps.push(await slow());
steps.push("onWillStart"); steps.push("onWillStart");
}); });
onWillUpdateProps(async (nextProps) => { onWillUpdateProps(nextProps => {
expect(nextProps).toEqual({ value: 2 }); expect(nextProps).toEqual({ value: 2 });
steps.push(await slow());
steps.push("onWillUpdateProps"); steps.push("onWillUpdateProps");
}); });
} }
function use2ndHook() { class MyComponent extends Component<any, any> {
onWillStart(() => {
steps.push("on2ndStart");
});
onWillUpdateProps((nextProps) => {
expect(nextProps).toEqual({ value: 2 });
steps.push("on2ndUpdate");
});
}
class MyComponent extends Component {
static template = xml`<span><t t-esc="props.value"/></span>`; static template = xml`<span><t t-esc="props.value"/></span>`;
constructor(parent, props) { constructor(parent, props) {
super(parent, props); super(parent, props);
useMyHook(); useMyHook();
use2ndHook();
} }
} }
class App extends Component { class App extends Component<any, any> {
static template = xml`<div><MyComponent value="state.value"/></div>`; static template = xml`<div><MyComponent value="state.value"/></div>`;
static components = { MyComponent }; static components = { MyComponent };
state = useState({ value: 1 }); state = useState({ value: 1 });
@@ -626,61 +582,14 @@ describe("hooks", () => {
const app = new App(); const app = new App();
await app.mount(fixture); await app.mount(fixture);
expect(app).not.toHaveProperty("willStart");
expect(app).not.toHaveProperty("willUpdateProps");
expect(fixture.innerHTML).toBe("<div><span>1</span></div>"); expect(fixture.innerHTML).toBe("<div><span>1</span></div>");
expect(steps).toEqual(["onWillStart"]);
// NOTE: 'on2ndStart' appears first in the list even though
// the 'use2ndHook' is declared after 'useMyHook'. This is
// because Promise.all is used to call the callbacks specified
// in the hooks, which runs them simultaneously.
// Additionally, 'slow' should be listed before 'onWillStart'
// because call to `slow` is awaited.
expect(steps).toEqual(["on2ndStart", "slow", "onWillStart"]);
app.state.value = 2; app.state.value = 2;
await nextTick(); await nextTick();
expect(fixture.innerHTML).toBe("<div><span>2</span></div>"); expect(fixture.innerHTML).toBe("<div><span>2</span></div>");
expect(steps).toEqual([ expect(steps).toEqual(["onWillStart", "onWillUpdateProps"]);
"on2ndStart",
"slow",
"onWillStart",
"on2ndUpdate",
"slow",
"onWillUpdateProps",
]);
});
test("useExternalListener", async () => {
let n = 0;
class MyComponent extends Component {
static template = xml`<span><t t-esc="props.value"/></span>`;
constructor(parent, props) {
super(parent, props);
useExternalListener(window as any, "click", this.increment);
}
increment() {
n++;
}
}
class App extends Component {
static template = xml`<div><MyComponent t-if="state.flag"/></div>`;
static components = { MyComponent };
state = useState({ flag: false });
}
const app = new App();
await app.mount(fixture);
expect(n).toBe(0);
window.dispatchEvent(new Event("click"));
expect(n).toBe(0);
app.state.flag = true;
await nextTick();
window.dispatchEvent(new Event("click"));
expect(n).toBe(1);
app.state.flag = false;
await nextTick();
window.dispatchEvent(new Event("click"));
expect(n).toBe(1);
}); });
}); });
+6 -6
View File
@@ -27,7 +27,7 @@ afterEach(() => {
describe("Asyncroot", () => { describe("Asyncroot", () => {
test("delayed component with AsyncRoot component", async () => { test("delayed component with AsyncRoot component", async () => {
let def; let def;
class Child extends Component { class Child extends Component<any, any> {
static template = xml`<span><t t-esc="props.val"/></span>`; static template = xml`<span><t t-esc="props.val"/></span>`;
} }
class AsyncChild extends Child { class AsyncChild extends Child {
@@ -35,7 +35,7 @@ describe("Asyncroot", () => {
return def; return def;
} }
} }
class Parent extends Component { class Parent extends Component<any, any> {
static template = xml` static template = xml`
<div> <div>
<button t-on-click="updateApp">Update App State</button> <button t-on-click="updateApp">Update App State</button>
@@ -74,7 +74,7 @@ describe("Asyncroot", () => {
test("fast component with AsyncRoot", async () => { test("fast component with AsyncRoot", async () => {
let def; let def;
class Child extends Component { class Child extends Component<any, any> {
static template = xml`<span><t t-esc="props.val"/></span>`; static template = xml`<span><t t-esc="props.val"/></span>`;
} }
class AsyncChild extends Child { class AsyncChild extends Child {
@@ -83,7 +83,7 @@ describe("Asyncroot", () => {
} }
} }
class Parent extends Component { class Parent extends Component<any, any> {
static template = xml` static template = xml`
<div> <div>
<button t-on-click="updateApp">Update App State</button> <button t-on-click="updateApp">Update App State</button>
@@ -122,7 +122,7 @@ describe("Asyncroot", () => {
test("asyncroot component: mixed re-renderings", async () => { test("asyncroot component: mixed re-renderings", async () => {
let def; let def;
class Child extends Component { class Child extends Component<any, any> {
static template = xml` static template = xml`
<span t-on-click="increment"> <span t-on-click="increment">
<t t-esc="state.val"/>/<t t-esc="props.val"/> <t t-esc="state.val"/>/<t t-esc="props.val"/>
@@ -138,7 +138,7 @@ describe("Asyncroot", () => {
return def; return def;
} }
} }
class Parent extends Component { class Parent extends Component<any, any> {
static template = xml` static template = xml`
<div> <div>
<button t-on-click="updateApp">Update App State</button> <button t-on-click="updateApp">Update App State</button>
-892
View File
@@ -1,892 +0,0 @@
import { Portal } from "../../src/misc/portal";
import { xml } from "../../src/tags";
import { makeTestFixture, makeTestEnv, nextTick } from "../helpers";
import { Component } from "../../src/component/component";
import { useState } from "../../src/hooks";
import { QWeb } from "../../src/qweb";
//------------------------------------------------------------------------------
// Setup and helpers
//------------------------------------------------------------------------------
// We create before each test:
// - fixture: a div, appended to the DOM, intended to be the target of dom
// manipulations. Note that it is removed after each test.
// - outside: a div with id #outside appended into fixture, meant to be used as
// target by Portal component
// - a test env, necessary to create components, that is set on Component
let fixture: HTMLElement;
let outside: HTMLElement;
beforeEach(() => {
fixture = makeTestFixture();
outside = document.createElement("div");
outside.setAttribute("id", "outside");
fixture.appendChild(outside);
Component.env = makeTestEnv();
});
afterEach(() => {
fixture.remove();
});
describe("Portal: Props validation", () => {
test("target is mandatory", async () => {
const dev = QWeb.dev;
QWeb.dev = true;
class Parent extends Component {
static components = { Portal };
static template = xml`
<div>
<Portal>
<div>2</div>
</Portal>
</div>`;
}
let error;
try {
const parent = new Parent();
await parent.mount(fixture);
} catch (e) {
error = e;
}
expect(error).toBeDefined();
expect(error.message).toBe(`Missing props 'target' (component 'Portal')`);
QWeb.dev = dev;
});
test("target is not list", async () => {
const dev = QWeb.dev;
QWeb.dev = true;
class Parent extends Component {
static components = { Portal };
static template = xml`
<div>
<Portal target="['body']">
<div>2</div>
</Portal>
</div>`;
}
let error;
try {
const parent = new Parent();
await parent.mount(fixture);
} catch (e) {
error = e;
}
expect(error).toBeDefined();
expect(error.message).toBe(`Invalid Prop 'target' in component 'Portal'`);
QWeb.dev = dev;
});
});
describe("Portal: Basic use and DOM placement", () => {
test("basic use of portal", async () => {
const dev = QWeb.dev;
QWeb.dev = true;
class Parent extends Component {
static components = { Portal };
static template = xml`
<div>
<span>1</span>
<Portal target="'#outside'">
<div>2</div>
</Portal>
</div>`;
}
let error;
let parent;
try {
parent = new Parent();
} catch (e) {
error = e;
}
expect(error).toBeUndefined();
await parent.mount(fixture);
expect(outside.innerHTML).toBe("<div>2</div>");
expect(parent.el!.outerHTML).toBe("<div><span>1</span><portal></portal></div>");
QWeb.dev = dev;
});
test("conditional use of Portal", async () => {
class Parent extends Component {
static components = { Portal };
static template = xml`
<div>
<span>1</span>
<Portal target="'#outside'" t-if="state.hasPortal">
<div>2</div>
</Portal>
</div>`;
state = useState({ hasPortal: false });
}
const parent = new Parent();
await parent.mount(fixture);
expect(outside.innerHTML).toBe("");
expect(parent.el!.outerHTML).toBe("<div><span>1</span></div>");
parent.state.hasPortal = true;
await nextTick();
expect(outside.innerHTML).toBe("<div>2</div>");
expect(parent.el!.outerHTML).toBe("<div><span>1</span><portal></portal></div>");
parent.state.hasPortal = false;
await nextTick();
expect(outside.innerHTML).toBe("");
expect(parent.el!.outerHTML).toBe("<div><span>1</span></div>");
parent.state.hasPortal = true;
await nextTick();
expect(outside.innerHTML).toBe("<div>2</div>");
expect(parent.el!.outerHTML).toBe("<div><span>1</span><portal></portal></div>");
});
test("conditional use of Portal (with sub Component)", async () => {
class Child extends Component {
static template = xml`<div><t t-esc="props.val"/></div>`;
}
class Parent extends Component {
static components = { Portal, Child };
static template = xml`
<div>
<span>1</span>
<Portal t-if="state.hasPortal" target="'#outside'">
<Child val="state.val"/>
</Portal>
</div>`;
state = useState({ hasPortal: false, val: 1 });
}
const parent = new Parent();
await parent.mount(fixture);
expect(outside.innerHTML).toBe("");
expect(parent.el!.outerHTML).toBe("<div><span>1</span></div>");
parent.state.hasPortal = true;
await nextTick();
expect(outside.innerHTML).toBe("<div>1</div>");
expect(parent.el!.outerHTML).toBe("<div><span>1</span><portal></portal></div>");
parent.state.hasPortal = false;
await nextTick();
expect(outside.innerHTML).toBe("");
expect(parent.el!.outerHTML).toBe("<div><span>1</span></div>");
parent.state.val = 2;
await nextTick();
expect(outside.innerHTML).toBe("");
expect(parent.el!.outerHTML).toBe("<div><span>1</span></div>");
parent.state.hasPortal = true;
await nextTick();
expect(outside.innerHTML).toBe("<div>2</div>");
expect(parent.el!.outerHTML).toBe("<div><span>1</span><portal></portal></div>");
});
test("with target in template (before portal)", async () => {
class Parent extends Component {
static components = { Portal };
static template = xml`
<div>
<div id="local-target"></div>
<span>1</span>
<Portal target="'#local-target'">
<p>2</p>
</Portal>
</div>`;
}
const parent = new Parent();
await parent.mount(fixture);
expect(parent.el!.innerHTML).toBe(
'<div id="local-target"><p>2</p></div><span>1</span><portal></portal>'
);
});
test("with target in template (after portal)", async () => {
class Parent extends Component {
static components = { Portal };
static template = xml`
<div>
<span>1</span>
<Portal target="'#local-target'">
<p>2</p>
</Portal>
<div id="local-target"></div>
</div>`;
}
const parent = new Parent();
await parent.mount(fixture);
expect(parent.el!.innerHTML).toBe(
'<span>1</span><portal></portal><div id="local-target"><p>2</p></div>'
);
});
test("portal with target not in dom", async () => {
const consoleError = console.error;
console.error = jest.fn(() => {});
class Parent extends Component {
static components = { Portal };
static template = xml`
<div>
<Portal target="'#does-not-exist'">
<div>2</div>
</Portal>
</div>`;
}
const parent = new Parent();
let error;
try {
await parent.mount(fixture);
} catch (e) {
error = e;
}
expect(error).toBeDefined();
expect(error.message).toBe('Could not find any match for "#does-not-exist"');
expect(console.error).toBeCalledTimes(0);
expect(fixture.innerHTML).toBe(`<div id="outside"></div>`);
console.error = consoleError;
});
test("portal with child and props", async () => {
const steps: string[] = [];
class Child extends Component {
static template = xml`<span><t t-esc="props.val"/></span>`;
mounted() {
steps.push("mounted");
expect(outside.innerHTML).toBe("<span>1</span>");
}
patched() {
steps.push("patched");
expect(outside.innerHTML).toBe("<span>2</span>");
}
}
class Parent extends Component {
static components = { Portal, Child };
static template = xml`
<div>
<Portal target="'#outside'">
<Child val="state.val"/>
</Portal>
</div>`;
state = useState({ val: 1 });
}
const parent = new Parent();
await parent.mount(fixture);
expect(outside.innerHTML).toBe("<span>1</span>");
expect(parent.el!.innerHTML).toBe("<portal></portal>");
parent.state.val = 2;
await nextTick();
expect(outside.innerHTML).toBe("<span>2</span>");
expect(parent.el!.innerHTML).toBe("<portal></portal>");
expect(steps).toEqual(["mounted", "patched"]);
});
test("portal with only text as content", async () => {
const consoleError = console.error;
console.error = jest.fn(() => {});
class Parent extends Component {
static components = { Portal };
static template = xml`
<div>
<Portal target="'#outside'">
<t t-esc="'only text'"/>
</Portal>
</div>`;
}
const parent = new Parent();
let error;
try {
await parent.mount(fixture);
} catch (e) {
error = e;
}
expect(error).toBeDefined();
expect(error.message).toBe("Portal must have exactly one non-text child (has 0)");
expect(console.error).toBeCalledTimes(0);
expect(fixture.innerHTML).toBe(`<div id="outside"></div>`);
console.error = consoleError;
});
test("portal with no content", async () => {
const consoleError = console.error;
console.error = jest.fn(() => {});
class Parent extends Component {
static components = { Portal };
static template = xml`
<div>
<Portal target="'#outside'">
<t t-if="false" t-esc="'ABC'"/>
</Portal>
</div>`;
}
const parent = new Parent();
let error;
try {
await parent.mount(fixture);
} catch (e) {
error = e;
}
expect(error).toBeDefined();
expect(error.message).toBe("Portal must have exactly one non-text child (has 0)");
expect(console.error).toBeCalledTimes(0);
expect(fixture.innerHTML).toBe(`<div id="outside"></div>`);
console.error = consoleError;
});
test("portal with many children", async () => {
const consoleError = console.error;
console.error = jest.fn(() => {});
class Parent extends Component {
static components = { Portal };
static template = xml`
<div>
<Portal target="'#outside'">
<div>1</div>
<p>2</p>
</Portal>
</div>`;
}
const parent = new Parent();
let error;
try {
await parent.mount(fixture);
} catch (e) {
error = e;
}
expect(error).toBeDefined();
expect(error.message).toBe("Portal must have exactly one non-text child (has 2)");
expect(console.error).toBeCalledTimes(0);
expect(fixture.innerHTML).toBe(`<div id="outside"></div>`);
console.error = consoleError;
});
test("portal with dynamic body", async () => {
class Parent extends Component {
static components = { Portal };
static template = xml`
<div>
<Portal target="'#outside'">
<span t-if="state.val" t-esc="state.val"/>
<div t-else=""/>
</Portal>
</div>`;
state = useState({ val: "ab" });
}
const parent = new Parent();
await parent.mount(fixture);
expect(outside.innerHTML).toBe(`<span>ab</span>`);
parent.state.val = "";
await nextTick();
expect(outside.innerHTML).toBe(`<div></div>`);
});
test("portal could have dynamically no content", async () => {
const consoleError = console.error;
console.error = jest.fn(() => {});
class Parent extends Component {
static components = { Portal };
static template = xml`
<div>
<Portal target="'#outside'">
<span t-if="state.val" t-esc="state.val"/>
</Portal>
</div>`;
state = { val: "ab" };
}
const parent = new Parent();
await parent.mount(fixture);
expect(outside.innerHTML).toBe(`<span>ab</span>`);
let error;
try {
parent.state.val = "";
await parent.render();
} catch (e) {
error = e;
}
expect(outside.innerHTML).toBe(``);
expect(error).toBeDefined();
expect(error.message).toBe("Portal must have exactly one non-text child (has 0)");
expect(console.error).toBeCalledTimes(0);
console.error = consoleError;
});
test("lifecycle hooks of portal sub component are properly called", async () => {
const steps: any[] = [];
class Child extends Component {
static template = xml`<span t-esc="props.val"/>`;
mounted() {
steps.push("child:mounted");
}
willPatch() {
steps.push("child:willPatch");
}
patched() {
steps.push("child:patched");
}
willUnmount() {
steps.push("child:willUnmount");
}
}
class Parent extends Component {
static components = { Portal, Child };
static template = xml`
<div>
<Portal t-if="state.hasChild" target="'#outside'">
<Child val="state.val"/>
</Portal>
</div>`;
state = useState({ hasChild: false, val: 1 });
mounted() {
steps.push("parent:mounted");
}
willPatch() {
steps.push("parent:willPatch");
}
patched() {
steps.push("parent:patched");
}
willUnmount() {
steps.push("parent:willUnmount");
}
}
const parent = new Parent();
await parent.mount(fixture);
expect(steps).toEqual(["parent:mounted"]);
parent.state.hasChild = true;
await nextTick();
expect(steps).toEqual([
"parent:mounted",
"parent:willPatch",
"child:mounted",
"parent:patched",
]);
parent.state.val = 2;
await nextTick();
expect(steps).toEqual([
"parent:mounted",
"parent:willPatch",
"child:mounted",
"parent:patched",
"parent:willPatch",
"child:willPatch",
"child:patched",
"parent:patched",
]);
parent.state.hasChild = false;
await nextTick();
expect(steps).toEqual([
"parent:mounted",
"parent:willPatch",
"child:mounted",
"parent:patched",
"parent:willPatch",
"child:willPatch",
"child:patched",
"parent:patched",
"parent:willPatch",
"child:willUnmount",
"parent:patched",
]);
});
test("portal destroys on crash", async () => {
class Child extends Component {
static template = xml`<span t-esc="props.error and this.will.crash" />`;
state = {};
}
class Parent extends Component {
static components = { Portal, Child };
static template = xml`
<div>
<Portal target="'#outside'" >
<Child error="state.error"/>
</Portal>
</div>`;
state = { error: false };
}
const parent = new Parent();
await parent.mount(fixture);
parent.state.error = true;
let error;
try {
await parent.render();
} catch (e) {
error = e;
}
expect(error).toBeDefined();
const regexp =
/Cannot read properties of undefined \(reading 'crash'\)|Cannot read property 'crash' of undefined/g;
expect(error.message).toMatch(regexp);
});
test("portal manual unmount", async () => {
class Parent extends Component {
static components = { Portal };
static template = xml`
<div>
<Portal target="'#outside'">
<span>gloria</span>
</Portal>
</div>`;
}
const parent = new Parent();
await parent.mount(fixture);
expect(outside.innerHTML).toBe("<span>gloria</span>");
expect(parent.el!.innerHTML).toBe("<portal></portal>");
parent.unmount();
expect(outside.innerHTML).toBe("");
expect(parent.el!.innerHTML).toBe("<portal><span>gloria</span></portal>");
await parent.mount(fixture);
expect(outside.innerHTML).toBe("<span>gloria</span>");
expect(parent.el!.innerHTML).toBe("<portal></portal>");
});
test("portal manual unmount with subcomponent", async () => {
expect.assertions(9);
class Child extends Component {
static template = xml`<span>gloria</span>`;
mounted() {
expect(outside.contains(this.el)).toBeTruthy();
}
willUnmount() {
expect(outside.contains(this.el)).toBeTruthy();
}
}
class Parent extends Component {
static components = { Portal, Child };
static template = xml`
<div>
<Portal target="'#outside'">
<Child />
</Portal>
</div>`;
}
const parent = new Parent();
await parent.mount(fixture);
expect(outside.innerHTML).toBe("<span>gloria</span>");
expect(parent.el!.innerHTML).toBe("<portal></portal>");
parent.unmount();
expect(outside.innerHTML).toBe("");
expect(parent.el!.innerHTML).toBe("<portal><span>gloria</span></portal>");
await parent.mount(fixture);
expect(outside.innerHTML).toBe("<span>gloria</span>");
expect(parent.el!.innerHTML).toBe("<portal></portal>");
});
});
describe("Portal: Events handling", () => {
test("events triggered on movable pure node are handled", async () => {
class Parent extends Component {
static components = { Portal };
static template = xml`
<div>
<Portal target="'#outside'">
<span id="trigger-me" t-on-custom="_onCustom" t-esc="state.val"/>
</Portal>
</div>`;
state = useState({ val: "ab" });
_onCustom() {
this.state.val = "triggered";
}
}
const parent = new Parent();
await parent.mount(fixture);
expect(outside.innerHTML).toBe(`<span id="trigger-me">ab</span>`);
outside.querySelector("#trigger-me")!.dispatchEvent(new Event("custom"));
await nextTick();
expect(outside.innerHTML).toBe(`<span id="trigger-me">triggered</span>`);
});
test("events triggered on movable owl components are redirected", async () => {
let childInst: Component | null = null;
class Child extends Component {
static template = xml`
<span t-on-custom="_onCustom" t-esc="props.val"/>`;
constructor(parent, props) {
super(parent, props);
childInst = this;
}
_onCustom() {
this.trigger("custom-portal");
}
}
class Parent extends Component {
static components = { Portal, Child };
static template = xml`
<div t-on-custom-portal="_onCustomPortal">
<Portal target="'#outside'">
<Child val="state.val"/>
</Portal>
</div>`;
state = useState({ val: "ab" });
_onCustomPortal() {
this.state.val = "triggered";
}
}
const parent = new Parent();
await parent.mount(fixture);
expect(outside.innerHTML).toBe(`<span>ab</span>`);
childInst!.trigger("custom");
await nextTick();
expect(outside.innerHTML).toBe(`<span>triggered</span>`);
});
test("events triggered on contained movable owl components are redirected", async () => {
const steps: string[] = [];
let childInst: Component | null = null;
class Child extends Component {
static template = xml`
<span t-on-custom="_onCustom"/>`;
constructor(parent, props) {
super(parent, props);
childInst = this;
}
_onCustom() {
this.trigger("custom-portal");
}
}
class Parent extends Component {
static components = { Portal, Child };
static template = xml`
<div t-on-custom="_handled" t-on-custom-portal="_handled">
<Portal target="'#outside'">
<div>
<Child/>
</div>
</Portal>
</div>`;
_handled(ev) {
steps.push(ev.type);
}
}
const parent = new Parent();
await parent.mount(fixture);
childInst!.trigger("custom");
await nextTick();
// This is expected because trigger is synchronous
expect(steps).toMatchObject(["custom-portal", "custom"]);
});
test("Dom events are not mapped", async () => {
let childInst: Component | null = null;
const steps: string[] = [];
class Child extends Component {
static template = xml`
<button>child</button>`;
constructor(parent, props) {
super(parent, props);
childInst = this;
}
}
class Parent extends Component {
static components = { Portal, Child };
static template = xml`
<div t-on-click="_handled">
<Portal target="'#outside'">
<Child />
</Portal>
</div>`;
_handled(ev) {
steps.push(ev.type as string);
}
}
const bodyListener = (ev) => {
steps.push(`body: ${ev.type}`);
};
document.body.addEventListener("click", bodyListener);
const parent = new Parent();
await parent.mount(fixture);
childInst!.el!.click();
expect(steps).toEqual(["body: click"]);
document.body.removeEventListener("click", bodyListener);
});
test("Nested portals event propagation", async () => {
const outside2 = document.createElement("div");
outside2.setAttribute("id", "outside2");
fixture.appendChild(outside2);
const steps: Array<string> = [];
let childInst: Component | null = null;
class Child2 extends Component {
static template = xml`<div>child2</div>`;
constructor(parent, props) {
super(parent, props);
childInst = this;
}
}
class Child extends Component {
static components = { Portal, Child2 };
static template = xml`
<Portal target="'#outside2'">
<Child2 />
</Portal>`;
}
class Parent extends Component {
static components = { Portal, Child };
static template = xml`
<div t-on-custom='_handled'>
<Portal target="'#outside'">
<Child/>
</Portal>
</div>`;
_handled(ev) {
steps.push(`${ev.type} from ${ev.originalComponent.constructor.name}`);
}
}
const parent = new Parent();
await parent.mount(fixture);
childInst!.trigger("custom");
expect(steps).toEqual(["custom from Child2"]);
});
test("portal's parent's env is not polluted", async () => {
class Child extends Component {
static template = xml`
<button>child</button>`;
}
class Parent extends Component {
static components = { Portal, Child };
static template = xml`
<div>
<Portal target="'#outside'">
<Child />
</Portal>
</div>`;
}
const parent = new Parent();
const parentEnv = Object.assign({}, parent.env);
await parent.mount(fixture);
expect(parentEnv).toStrictEqual(parent.env);
});
test("Portal composed with t-slot", async () => {
const steps: Array<string> = [];
let childInst: Component | null = null;
class Child2 extends Component {
static template = xml`<div>child2</div>`;
constructor(parent, props) {
super(parent, props);
childInst = this;
}
}
class Child extends Component {
static components = { Portal, Child2 };
static template = xml`
<Portal target="'#outside'">
<t t-slot="default"/>
</Portal>`;
}
class Parent extends Component {
static components = { Child, Child2 };
static template = xml`
<div t-on-custom='_handled'>
<Child>
<Child2/>
</Child>
</div>`;
_handled(ev) {
steps.push(ev.type as string);
}
}
const parent = new Parent();
await parent.mount(fixture);
childInst!.trigger("custom");
expect(steps).toEqual(["custom"]);
});
});
describe("Portal: UI/UX", () => {
test("focus is kept across re-renders", async () => {
class Child extends Component {
static template = xml`
<input id="target-me" t-att-placeholder="props.val"/>`;
}
class Parent extends Component {
static components = { Portal, Child };
static template = xml`
<div>
<Portal target="'#outside'">
<Child val="state.val"/>
</Portal>
</div>`;
state = useState({ val: "ab" });
}
const parent = new Parent();
await parent.mount(fixture);
const input = document.querySelector("#target-me");
expect(input!.nodeName).toBe("INPUT");
expect((input as HTMLInputElement).placeholder).toBe("ab");
(input as HTMLInputElement).focus();
expect(document.activeElement === input).toBeTruthy();
parent.state.val = "bc";
await nextTick();
const inputReRendered = document.querySelector("#target-me");
expect(inputReRendered!.nodeName).toBe("INPUT");
expect((inputReRendered as HTMLInputElement).placeholder).toBe("bc");
expect(document.activeElement === inputReRendered).toBeTruthy();
});
});
File diff suppressed because it is too large Load Diff
@@ -1,37 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`memory t-foreach does not leak stuff in global scope 1`] = `
"function anonymous(context, extra
) {
// Template name: \\"test\\"
let scope = Object.create(context);
let h = this.h;
let c1 = [], p1 = {key:1};
let vn1 = h('p', p1, c1);
let _2 = [3,2,1];
if (!_2) { throw new Error('QWeb error: Invalid loop expression')}
let _3 = _2;
let _4 = _2;
if (!(_2 instanceof Array)) {
_3 = Object.keys(_2);
_4 = Object.values(_2);
}
let _length3 = _3.length;
let _origScope5 = scope;
scope = Object.create(scope);
for (let i1 = 0; i1 < _length3; i1++) {
scope.item_first = i1 === 0
scope.item_last = i1 === _length3 - 1
scope.item_index = i1
scope.item = _3[i1]
scope.item_value = _4[i1]
let key1 = i1;
let _6 = scope['item'];
if (_6 != null) {
c1.push({text: _6});
}
}
scope = _origScope5;
return vn1;
}"
`;
@@ -1,57 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`qweb t-att t-att-class with multiple classes 1`] = `
"function anonymous(context, extra
) {
// Template name: \\"test\\"
let utils = this.constructor.utils;
let scope = Object.create(context);
let h = this.h;
let _1 = utils.toClassObj({'a b c':scope['value']});
let c2 = [], p2 = {key:2,class:_1};
let vn2 = h('div', p2, c2);
return vn2;
}"
`;
exports[`qweb t-att t-att-class with multiple classes 2`] = `
"function anonymous(context, extra
) {
// Template name: \\"test\\"
let utils = this.constructor.utils;
let scope = Object.create(context);
let h = this.h;
let _3 = utils.toClassObj({['a b c']:scope['value']});
let c4 = [], p4 = {key:4,class:_3};
let vn4 = h('div', p4, c4);
return vn4;
}"
`;
exports[`qweb t-att t-att-class with multiple classes, some of which are duplicate 1`] = `
"function anonymous(context, extra
) {
// Template name: \\"test\\"
let utils = this.constructor.utils;
let scope = Object.create(context);
let h = this.h;
let _1 = utils.toClassObj({'a b c':scope['value'],'a b d':!scope['value']});
let c2 = [], p2 = {key:2,class:_1};
let vn2 = h('div', p2, c2);
return vn2;
}"
`;
exports[`qweb t-att t-att-class with multiple classes, some of which are duplicate 2`] = `
"function anonymous(context, extra
) {
// Template name: \\"test\\"
let utils = this.constructor.utils;
let scope = Object.create(context);
let h = this.h;
let _3 = utils.toClassObj({'a b c':scope['value'],'a b d':!scope['value']});
let c4 = [], p4 = {key:4,class:_3};
let vn4 = h('div', p4, c4);
return vn4;
}"
`;
@@ -1,71 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`qweb t-tag simple usecases 1`] = `
"function anonymous(context, extra
) {
// Template name: \\"test\\"
let scope = Object.create(context);
let result;
let h = this.h;
let c1 = [], p1 = {key:1};
let tag2 = 'div';
let vn1 = h(tag2, p1, c1);
result = vn1;
return result;
}"
`;
exports[`qweb t-tag simple usecases 2`] = `
"function anonymous(context, extra
) {
// Template name: \\"test\\"
let scope = Object.create(context);
let result;
let h = this.h;
let c3 = [], p3 = {key:3};
let tag4 = scope['tag'];
let vn3 = h(tag4, p3, c3);
result = vn3;
c3.push({text: \`text\`});
return result;
}"
`;
exports[`qweb t-tag with multiple attributes 1`] = `
"function anonymous(context, extra
) {
// Template name: \\"test\\"
let scope = Object.create(context);
let result;
let h = this.h;
let _2 = {'blueberry':true};
let _3 = 'raspberry';
let c4 = [], p4 = {key:4,attrs:{taste: _3},class:_2};
let tag5 = scope['tag'];
let vn4 = h(tag5, p4, c4);
result = vn4;
c4.push({text: \`gooseberry\`});
return result;
}"
`;
exports[`qweb t-tag with multiple child nodes 1`] = `
"function anonymous(context, extra
) {
// Template name: \\"test\\"
let scope = Object.create(context);
let result;
let h = this.h;
let c1 = [], p1 = {key:1};
let tag2 = scope['tag'];
let vn1 = h(tag2, p1, c1);
result = vn1;
c1.push({text: \` pear \`});
let c3 = [], p3 = {key:3};
let vn3 = h('span', p3, c3);
c1.push(vn3);
c3.push({text: \`apple\`});
c1.push({text: \` strawberry \`});
return result;
}"
`;
+52 -650
View File
File diff suppressed because it is too large Load Diff
+37 -97
View File
@@ -6,12 +6,12 @@ describe("tokenizer", () => {
expect(tokenize("{}")).toEqual([ expect(tokenize("{}")).toEqual([
{ type: "LEFT_BRACE", value: "{" }, { type: "LEFT_BRACE", value: "{" },
{ type: "RIGHT_BRACE", value: "}" }, { type: "RIGHT_BRACE", value: "}" }
]); ]);
expect(tokenize("{ }}")).toEqual([ expect(tokenize("{ }}")).toEqual([
{ type: "LEFT_BRACE", value: "{" }, { type: "LEFT_BRACE", value: "{" },
{ type: "RIGHT_BRACE", value: "}" }, { type: "RIGHT_BRACE", value: "}" },
{ type: "RIGHT_BRACE", value: "}" }, { type: "RIGHT_BRACE", value: "}" }
]); ]);
expect(tokenize("a")).toEqual([{ type: "SYMBOL", value: "a" }]); expect(tokenize("a")).toEqual([{ type: "SYMBOL", value: "a" }]);
expect(tokenize("true")).toEqual([{ type: "SYMBOL", value: "true" }]); expect(tokenize("true")).toEqual([{ type: "SYMBOL", value: "true" }]);
@@ -25,15 +25,15 @@ describe("tokenizer", () => {
{ type: "SYMBOL", value: "a" }, { type: "SYMBOL", value: "a" },
{ type: "COLON", value: ":" }, { type: "COLON", value: ":" },
{ type: "VALUE", value: "2" }, { type: "VALUE", value: "2" },
{ type: "RIGHT_BRACE", value: "}" }, { type: "RIGHT_BRACE", value: "}" }
]); ]);
expect(tokenize("a,")).toEqual([ expect(tokenize("a,")).toEqual([
{ type: "SYMBOL", value: "a" }, { type: "SYMBOL", value: "a" },
{ type: "COMMA", value: "," }, { type: "COMMA", value: "," }
]); ]);
expect(tokenize("][")).toEqual([ expect(tokenize("][")).toEqual([
{ type: "RIGHT_BRACKET", value: "]" }, { type: "RIGHT_BRACKET", value: "]" },
{ type: "LEFT_BRACKET", value: "[" }, { type: "LEFT_BRACKET", value: "[" }
]); ]);
}); });
@@ -44,23 +44,11 @@ describe("tokenizer", () => {
{ type: "OPERATOR", value: "<" }, { type: "OPERATOR", value: "<" },
{ type: "OPERATOR", value: ">" }, { type: "OPERATOR", value: ">" },
{ type: "OPERATOR", value: "!==" }, { type: "OPERATOR", value: "!==" },
{ type: "OPERATOR", value: "!=" }, { type: "OPERATOR", value: "!=" }
]); ]);
expect(tokenize("typeof a")).toEqual([ expect(tokenize("typeof a")).toEqual([
{ type: "OPERATOR", value: "typeof " }, { type: "OPERATOR", value: "typeof " },
{ type: "SYMBOL", value: "a" }, { type: "SYMBOL", value: "a" }
]);
expect(tokenize("a...1")).toEqual([
{ type: "SYMBOL", value: "a" },
{ type: "OPERATOR", value: "..." },
{ type: "VALUE", value: "1" },
]);
expect(tokenize("a in b")).toEqual([
{ type: "SYMBOL", value: "a" },
{ type: "OPERATOR", value: "in " },
{ type: "SYMBOL", value: "b" },
]); ]);
}); });
@@ -76,7 +64,7 @@ describe("tokenizer", () => {
expect(tokenize('"hello ged"')).toEqual([{ type: "VALUE", value: '"hello ged"' }]); expect(tokenize('"hello ged"')).toEqual([{ type: "VALUE", value: '"hello ged"' }]);
expect(tokenize('"hello ged"}')).toEqual([ expect(tokenize('"hello ged"}')).toEqual([
{ type: "VALUE", value: '"hello ged"' }, { type: "VALUE", value: '"hello ged"' },
{ type: "RIGHT_BRACE", value: "}" }, { type: "RIGHT_BRACE", value: "}" }
]); ]);
expect(tokenize('"hello \\"ged\\""')).toEqual([{ type: "VALUE", value: '"hello \\"ged\\""' }]); expect(tokenize('"hello \\"ged\\""')).toEqual([{ type: "VALUE", value: '"hello \\"ged\\""' }]);
}); });
@@ -104,7 +92,7 @@ describe("expression evaluation", () => {
test("parenthesis", () => { test("parenthesis", () => {
expect(compileExpr("(1)", {})).toBe("(1)"); expect(compileExpr("(1)", {})).toBe("(1)");
expect(compileExpr("a*(1 +3)", {})).toBe("scope['a']*(1+3)"); expect(compileExpr("a*(1 +3)", {})).toBe("context['a']*(1+3)");
}); });
test("objects and sub objects", () => { test("objects and sub objects", () => {
@@ -112,8 +100,8 @@ describe("expression evaluation", () => {
}); });
test("replacing variables", () => { test("replacing variables", () => {
expect(compileExpr("a", {})).toBe("scope['a']"); expect(compileExpr("a", {})).toBe("context['a']");
expect(compileExpr("a", { a: { id: "_3", expr: "scope._3" } })).toBe("scope._3"); expect(compileExpr("a", { a: { id: "_3", expr: "" } })).toBe("_3");
}); });
test("arrays and objects", () => { test("arrays and objects", () => {
@@ -123,104 +111,56 @@ describe("expression evaluation", () => {
}); });
test("dot operator", () => { test("dot operator", () => {
expect(compileExpr("a.b", {})).toBe("scope['a'].b"); expect(compileExpr("a.b", {})).toBe("context['a'].b");
expect(compileExpr("a.b.c", {})).toBe("scope['a'].b.c"); expect(compileExpr("a.b.c", {})).toBe("context['a'].b.c");
}); });
test("various unary operators", () => { test("various unary operators", () => {
expect(compileExpr("!flag", {})).toBe("!scope['flag']"); expect(compileExpr("!flag", {})).toBe("!context['flag']");
expect(compileExpr("-3", {})).toBe("-3"); expect(compileExpr("-3", {})).toBe("-3");
expect(compileExpr("-a", {})).toBe("-scope['a']"); expect(compileExpr("-a", {})).toBe("-context['a']");
expect(compileExpr("typeof a", {})).toBe("typeof scope['a']"); expect(compileExpr("typeof a", {})).toBe("typeof context['a']");
}); });
test("various binary operators", () => { test("various binary operators", () => {
expect(compileExpr("color == 'black'", {})).toBe("scope['color']=='black'"); expect(compileExpr("color == 'black'", {})).toBe("context['color']=='black'");
expect(compileExpr("a || b", {})).toBe("scope['a']||scope['b']"); expect(compileExpr("a || b", {})).toBe("context['a']||context['b']");
expect(compileExpr("color === 'black'", {})).toBe("scope['color']==='black'"); expect(compileExpr("color === 'black'", {})).toBe("context['color']==='black'");
expect(compileExpr("'li_'+item", {})).toBe("'li_'+scope['item']"); expect(compileExpr("'li_'+item", {})).toBe("'li_'+context['item']");
expect(compileExpr("state.val > 1", {})).toBe("scope['state'].val>1"); expect(compileExpr("state.val > 1", {})).toBe("context['state'].val>1");
expect(compileExpr("a in b", {})).toBe("scope['a']in scope['b']");
}); });
test("boolean operations", () => { test("boolean operations", () => {
expect(compileExpr("a && b", {})).toBe("scope['a']&&scope['b']"); expect(compileExpr("a && b", {})).toBe("context['a']&&context['b']");
}); });
test("ternary operators", () => { test("ternary operators", () => {
expect(compileExpr("a ? b: '2'", {})).toBe("scope['a']?scope['b']:'2'"); expect(compileExpr("a ? b: '2'", {})).toBe("context['a']?context['b']:'2'");
expect(compileExpr("a ? b: (c or '2') ", {})).toBe("scope['a']?scope['b']:(scope['c']||'2')"); expect(compileExpr("a ? b: (c or '2') ", {})).toBe(
"context['a']?context['b']:(context['c']||'2')"
);
expect(compileExpr("a ? {test:c}: [1,u]", {})).toBe( expect(compileExpr("a ? {test:c}: [1,u]", {})).toBe(
"scope['a']?{test:scope['c']}:[1,scope['u']]" "context['a']?{test:context['c']}:[1,context['u']]"
); );
}); });
test("word replacement", () => { test("word replacement", () => {
expect(compileExpr("a or b", {})).toBe("scope['a']||scope['b']"); expect(compileExpr("a or b", {})).toBe("context['a']||context['b']");
expect(compileExpr("a and b", {})).toBe("scope['a']&&scope['b']"); expect(compileExpr("a and b", {})).toBe("context['a']&&context['b']");
}); });
test("function calls", () => { test("function calls", () => {
expect(compileExpr("a()", {})).toBe("scope['a']()"); expect(compileExpr("a()", {})).toBe("context['a']()");
expect(compileExpr("a(1)", {})).toBe("scope['a'](1)"); expect(compileExpr("a(1)", {})).toBe("context['a'](1)");
expect(compileExpr("a(1,2)", {})).toBe("scope['a'](1,2)"); expect(compileExpr("a(1,2)", {})).toBe("context['a'](1,2)");
expect(compileExpr("a(1,2,{a:[a]})", {})).toBe("scope['a'](1,2,{a:[scope['a']]})"); expect(compileExpr("a(1,2,{a:[a]})", {})).toBe("context['a'](1,2,{a:[context['a']]})");
expect(compileExpr("'x'.toUpperCase()", {})).toBe("'x'.toUpperCase()"); expect(compileExpr("'x'.toUpperCase()", {})).toBe("'x'.toUpperCase()");
expect(compileExpr("'x'.toUpperCase({a: 3})", {})).toBe("'x'.toUpperCase({a:3})"); expect(compileExpr("'x'.toUpperCase({a: 3})", {})).toBe("'x'.toUpperCase({a:3})");
expect(compileExpr("'x'.toUpperCase(a)", { a: { id: "_v5", expr: "scope._v5" } })).toBe( expect(compileExpr("'x'.toUpperCase(a)", { a: { id: "_v5", expr: "" } })).toBe(
"'x'.toUpperCase(scope._v5)" "'x'.toUpperCase(_v5)"
); );
expect(compileExpr("'x'.toUpperCase({b: a})", { a: { id: "_v5", expr: "scope._v5" } })).toBe( expect(compileExpr("'x'.toUpperCase({b: a})", { a: { id: "_v5", expr: "" } })).toBe(
"'x'.toUpperCase({b:scope._v5})" "'x'.toUpperCase({b:_v5})"
); );
}); });
test("arrow functions", () => {
expect(compileExpr("list.map(e => e.val)", {})).toBe("scope['list'].map(e=>e.val)");
expect(compileExpr("list.map(e => a + e)", {})).toBe("scope['list'].map(e=>scope['a']+e)");
expect(compileExpr("list.map((e) => e)", {})).toBe("scope['list'].map((e)=>e)");
expect(compileExpr("list.map((elem, index) => elem + index)", {})).toBe(
"scope['list'].map((elem,index)=>elem+index)"
);
});
test("assignation", () => {
expect(compileExpr("a = b", {})).toBe("scope['a']=scope['b']");
expect(compileExpr("a += b", {})).toBe("scope['a']+=scope['b']");
expect(compileExpr("a -= b", {})).toBe("scope['a']-=scope['b']");
expect(compileExpr("a.b = !a.b", {})).toBe("scope['a'].b=!scope['a'].b");
});
test("spread operator", () => {
expect(compileExpr("[...state.list]", {})).toBe("[...scope['state'].list]");
expect(compileExpr("f(...state.list)", {})).toBe("scope['f'](...scope['state'].list)");
expect(compileExpr("f([...list])", {})).toBe("scope['f']([...scope['list']])");
});
test("works with builtin properties", () => {
expect(compileExpr("state.constructor.name", {})).toBe("scope['state'].constructor.name");
});
test("works with shortcut object key description", () => {
expect(compileExpr("{a}", {})).toBe("{a:scope['a']}");
expect(compileExpr("{a,b}", {})).toBe("{a:scope['a'],b:scope['b']}");
expect(compileExpr("{a,b:3,c}", {})).toBe("{a:scope['a'],b:3,c:scope['c']}");
});
test("works with short object description and lists ", () => {
expect(compileExpr("[a, b]", {})).toBe("[scope['a'],scope['b']]");
expect(compileExpr("[a, b, c]", {})).toBe("[scope['a'],scope['b'],scope['c']]");
expect(compileExpr("[a, {b, c},d]", {})).toBe(
"[scope['a'],{b:scope['b'],c:scope['c']},scope['d']]"
);
expect(compileExpr("{a:[b, {c, d: e}]}", {})).toBe(
"{a:[scope['b'],{c:scope['c'],d:scope['e']}]}"
);
});
test("template strings", () => {
expect(compileExpr("`hey`", {})).toBe("`hey`");
expect(compileExpr("`hey ${you}`", {})).toBe("`hey ${scope['you']}`");
expect(compileExpr("`hey ${1 + 2}`", {})).toBe("`hey ${1+2}`");
});
}); });
-14
View File
@@ -1,14 +0,0 @@
import { QWeb } from "../../src/qweb/index";
import { renderToString } from "../helpers";
describe("memory", () => {
test("t-foreach does not leak stuff in global scope", () => {
let qweb = new QWeb();
const initialNumberOfGlobals = Object.keys(window).length;
qweb.addTemplate("test", `<p><t t-foreach="[3, 2, 1]" t-as="item"><t t-esc="item"/></t></p>`);
const result = renderToString(qweb, "test");
const expected = `<p>321</p>`;
expect(result).toBe(expected);
expect(Object.keys(window).length).toBe(initialNumberOfGlobals);
});
});
-36
View File
@@ -1,36 +0,0 @@
import { QWeb } from "../../src/qweb/index";
import { renderToString } from "../helpers";
//------------------------------------------------------------------------------
// Setup and helpers
//------------------------------------------------------------------------------
function render(template, context = {}) {
const qweb = new QWeb();
qweb.addTemplate("test", template);
return renderToString(qweb, "test", context);
}
//------------------------------------------------------------------------------
// Tests
//------------------------------------------------------------------------------
describe("qweb t-att", () => {
test("t-att-class with multiple classes", () => {
expect(render(`<div t-att-class="{'a b c': value}" />`, { value: true })).toBe(
'<div class="a b c"></div>'
);
expect(render(`<div t-att-class="{['a b c']: value}" />`, { value: true })).toBe(
'<div class="a b c"></div>'
);
});
test("t-att-class with multiple classes, some of which are duplicate", () => {
expect(render(`<div t-att-class="{'a b c': value, 'a b d': !value}" />`, { value: true })).toBe(
'<div class="a b c"></div>'
);
expect(
render(`<div t-att-class="{'a b c': value, 'a b d': !value}" />`, { value: false })
).toBe('<div class="a b d"></div>');
});
});
-42
View File
@@ -1,42 +0,0 @@
import { QWeb } from "../../src/qweb/index";
import { renderToString } from "../helpers";
//------------------------------------------------------------------------------
// Setup and helpers
//------------------------------------------------------------------------------
function render(template, context = {}) {
const qweb = new QWeb();
qweb.addTemplate("test", template);
return renderToString(qweb, "test", context);
}
//------------------------------------------------------------------------------
// Tests
//------------------------------------------------------------------------------
describe("qweb t-tag", () => {
test("simple usecases", () => {
expect(render(`<t t-tag="'div'"></t>`)).toBe("<div></div>");
expect(render(`<t t-tag="tag">text</t>`, { tag: "span" })).toBe("<span>text</span>");
});
test("with multiple child nodes", () => {
const template = `
<t t-tag="tag">
pear
<span>apple</span>
strawberry
</t>`;
expect(render(template, { tag: "div" })).toBe(
"<div> pear <span>apple</span> strawberry </div>"
);
});
test("with multiple attributes", () => {
const template = `
<t t-tag="tag" class="blueberry" taste="raspberry">gooseberry</t>`;
const expected = `<div taste=\"raspberry\" class=\"blueberry\">gooseberry</div>`;
expect(render(template, { tag: "div" })).toBe(expected);
});
});

Some files were not shown because too many files have changed in this diff Show More