mirror of
https://github.com/odoo/owl.git
synced 2025-10-06 19:59:41 +07:00
Compare commits
245 Commits
v0.18.0
...
v1.0.0-beta2
| Author | SHA1 | Date | |
|---|---|---|---|
| 0762eeb010 | |||
| bc455be720 | |||
| 9145799ae9 | |||
| ce9c2f8613 | |||
| b890e7ceae | |||
| 124211c18b | |||
| be8c3bbf6f | |||
| e162a6fb6c | |||
| 7ec05ff8cf | |||
| a0bfbf6dd0 | |||
| b67cb71048 | |||
| f5b3ef5cf3 | |||
| ecfccf2448 | |||
| f12d3373c9 | |||
| 286090efca | |||
| 2b4ff7b2fd | |||
| c43178a45b | |||
| 033a431e18 | |||
| 42aa9d3ae1 | |||
| 85f26a0286 | |||
| ed4ab51c17 | |||
| 556a4644f0 | |||
| 821bd0b4b8 | |||
| 06a6d890d7 | |||
| c1269288f5 | |||
| 8c17bb0411 | |||
| 7f6782d009 | |||
| 6e5d6aa226 | |||
| 9400c0adad | |||
| 85d4393242 | |||
| 6c8b401092 | |||
| 12be815342 | |||
| 65344dbf1f | |||
| a6f9b26057 | |||
| 6c753bb49f | |||
| 1f079b883e | |||
| 8fb35ed969 | |||
| a3f2d07b40 | |||
| c993278c80 | |||
| 3aa586db43 | |||
| 14db513f3b | |||
| 7bb04185c7 | |||
| 48744cfa87 | |||
| ff76747a05 | |||
| 7e6b1a28a0 | |||
| 58f6724194 | |||
| 3c0f8ac76f | |||
| cb11c0118c | |||
| 27b4eece66 | |||
| 2e3e8cd603 | |||
| 28ee790b3e | |||
| 2922cee6ea | |||
| 7749fd3b96 | |||
| f44b9a38ae | |||
| c7773bfd2a | |||
| bd39797f17 | |||
| 94a595ef5b | |||
| f83846e054 | |||
| a97144366d | |||
| 79983de4ed | |||
| 54e1734f2f | |||
| 4aea093ed9 | |||
| 56087b95cd | |||
| e5940b4b6b | |||
| bbdc9d90d7 | |||
| f2b3ebd1ec | |||
| 55bb09ba1a | |||
| d249f50d09 | |||
| 749f0063ea | |||
| 263f31fea9 | |||
| b891ae7de4 | |||
| c5a80497ac | |||
| e47f604449 | |||
| 7e721a96b5 | |||
| af4f372506 | |||
| 5731358607 | |||
| 1226f015d7 | |||
| b211e75140 | |||
| 8d1dd06340 | |||
| bd3c1265d3 | |||
| 2413c98f50 | |||
| d74b5a03db | |||
| 83532db48f | |||
| 534152eff7 | |||
| 05a678c039 | |||
| 8fbf2172c5 | |||
| ea1376d0ca | |||
| ba483b6e2c | |||
| 2fc71cfb62 | |||
| 08cb83149e | |||
| 7d249d6f09 | |||
| 0addca63a0 | |||
| 5ba73cc09d | |||
| 9106c19066 | |||
| 9f93da4765 | |||
| 9e37b968e8 | |||
| fa6801b523 | |||
| 9edf29a3a1 | |||
| 6a434310ee | |||
| e7967d0779 | |||
| b0e2ef82f7 | |||
| c9c2b3fa6d | |||
| 62608cbb0d | |||
| 3035a9f009 | |||
| c2adb429bd | |||
| da9dda5eca | |||
| 5614c85b04 | |||
| 5d57a7bf13 | |||
| 4ebe419c56 | |||
| 9779cd196c | |||
| 9cee12d7b4 | |||
| 2c563ee380 | |||
| 2aa705f5c8 | |||
| 5911c8e3f6 | |||
| 2279dafbec | |||
| f07ec21a07 | |||
| b5b6b7342e | |||
| 97b914b9cd | |||
| 9dfcfda365 | |||
| e026f537ae | |||
| f0b5a55ad1 | |||
| 690d8edf67 | |||
| 3c38bbc076 | |||
| 9c5cad15c1 | |||
| 2bed1cfbd1 | |||
| caa9ac5cb2 | |||
| 67349eb0f0 | |||
| 1ca401811f | |||
| 2beb12678e | |||
| 0ea1091692 | |||
| 71827c3ba8 | |||
| c2f42d3b82 | |||
| a3317ab997 | |||
| 403935a41e | |||
| 39af9ec938 | |||
| 13128ed425 | |||
| be556a970e | |||
| da6c24bbca | |||
| f6d6da8393 | |||
| 918945c11e | |||
| 29c2c5b9c9 | |||
| 1bb4577ec1 | |||
| 0aeebd7b6e | |||
| 4bc49e7241 | |||
| 2b5783d9bd | |||
| 1f40f113aa | |||
| 9e65b0686f | |||
| 1d72164015 | |||
| 2825a5a55d | |||
| d40ff53f5a | |||
| 23246c42aa | |||
| 895fe7c60f | |||
| 19b10f7c1d | |||
| 0788d9fe75 | |||
| 4a346d0615 | |||
| 1337aa0107 | |||
| 3e8f60cefa | |||
| 41b0618c93 | |||
| 0928c189f4 | |||
| 2f27768be2 | |||
| aa1b0f502d | |||
| da1824f4dd | |||
| 07dac970f7 | |||
| 5f6f081ae2 | |||
| 1c3c2e5c51 | |||
| 17ce73e6a9 | |||
| cedb1f411f | |||
| e7516be95c | |||
| 683993dbc4 | |||
| 50d569411c | |||
| c3453d35b9 | |||
| b0dd0252a4 | |||
| e2b2e54e29 | |||
| 766c378ac9 | |||
| 022b29b6a0 | |||
| 367725d194 | |||
| e82ed7dc8c | |||
| 63c3d4f6b0 | |||
| b859fcb8f0 | |||
| 3005756d89 | |||
| f6820bb8ac | |||
| 090880f478 | |||
| 5cdaa7b473 | |||
| 91b9e78182 | |||
| 5335fb8fba | |||
| 4513e3f31c | |||
| ecf12f6eee | |||
| 82a6961b3a | |||
| 78a4550ebb | |||
| cc82b3ffcd | |||
| bbbaf95c8e | |||
| caa63628dc | |||
| 7a49b9d94e | |||
| 01eb338e69 | |||
| 3048c6f961 | |||
| 8edc637033 | |||
| c9910077a4 | |||
| 6bf70f016c | |||
| 905619f80f | |||
| 14ceb382ef | |||
| 9846b2e997 | |||
| cfc2fb2ba2 | |||
| d57017b6ed | |||
| 5bf7a5c671 | |||
| 9b589af75a | |||
| 471fd49063 | |||
| 4fc3423682 | |||
| ffb3263c79 | |||
| e1acf66143 | |||
| 2a40907e35 | |||
| 1c0dd79955 | |||
| 9fad164c79 | |||
| 53a8841914 | |||
| 19f0863aa3 | |||
| 28f7b44f9e | |||
| fe46a6ccee | |||
| 3c23a7b246 | |||
| c52799de2c | |||
| 4edb90f44b | |||
| 0c1a7aabae | |||
| cfdce29ce7 | |||
| b344d73e07 | |||
| 4cd08d25c8 | |||
| f9861040c8 | |||
| 40d8090232 | |||
| 55be6437f1 | |||
| 6ff4dd74bf | |||
| 3628b49c37 | |||
| f289cb17ec | |||
| e6a3ede4a6 | |||
| 10cfe0b740 | |||
| a3d563ebb4 | |||
| 3b1247d476 | |||
| beb6c2bc82 | |||
| f0cd6f61cd | |||
| c950b385a0 | |||
| c172b31481 | |||
| 9b3a97e732 | |||
| 670c98916c | |||
| c59abef374 | |||
| bb4c2506c0 | |||
| 9843d16585 | |||
| 34e463ad0a | |||
| 2294c1967f | |||
| 4b53e48e91 |
@@ -0,0 +1,14 @@
|
|||||||
|
|
||||||
|
Most of the files are
|
||||||
|
|
||||||
|
Copyright (c) 2004-2015 Odoo S.A.
|
||||||
|
|
||||||
|
Many files also contain contributions from third
|
||||||
|
parties. In this case the original copyright of
|
||||||
|
the contributions can be traced through the
|
||||||
|
history of the source version control system.
|
||||||
|
|
||||||
|
When that is not the case, the files contain a prominent
|
||||||
|
notice stating the original copyright and applicable
|
||||||
|
license, or come with their own dedicated COPYRIGHT
|
||||||
|
and/or LICENSE file.
|
||||||
@@ -1,19 +1,70 @@
|
|||||||
<h1 align="center">🦉 <a href="https://odoo.github.io/owl/">Odoo Web Library</a> 🦉</h1>
|
<h1 align="center">🦉 <a href="https://odoo.github.io/owl/">Odoo Web Library</a> 🦉</h1>
|
||||||
|
|
||||||
_A web framework for structured, dynamic and maintainable applications_
|
_Class based components with hooks, reactive state and concurrent mode_
|
||||||
|
|
||||||
## Project Overview
|
## Project Overview
|
||||||
|
|
||||||
The Odoo Web Library (OWL) is a small
|
The Odoo Web Library (OWL) is a smallish (~<20kb gzipped) UI framework intended to
|
||||||
UI framework intended to be the basis for the [Odoo](https://www.odoo.com/) Web Client, and hopefully many
|
be the basis for the [Odoo](https://www.odoo.com/) Web Client. Owl is a modern
|
||||||
other Odoo related projects. OWL's main feature is a _declarative component system_, with QWeb as a template engine, asynchronous rendering, and an underlying virtual dom.
|
framework, written in Typescript, taking the best ideas from React and Vue in a
|
||||||
|
simple and consistent way. Owl's main features are:
|
||||||
|
|
||||||
**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.
|
- a declarative component system,
|
||||||
|
- a reactivity system based on hooks,
|
||||||
|
- a store implementation (for state management),
|
||||||
|
- a small frontend router
|
||||||
|
|
||||||
## OWL's Design Principles
|
Owl components are defined with ES6 classes, they use QWeb templates, an underlying
|
||||||
|
virtual dom, integrates beautifully with hooks, and the rendering is asynchronous.
|
||||||
|
|
||||||
|
**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
|
||||||
|
are some code examples to showcase some interesting features.
|
||||||
|
|
||||||
|
Owl is currently mostly stable. Possible future changes are explained in the
|
||||||
|
[roadmap](roadmap.md).
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
Here is a short example to illustrate interactive components:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const { Component, useState } = owl;
|
||||||
|
const { xml } = owl.tags;
|
||||||
|
|
||||||
|
class Counter extends Component {
|
||||||
|
static template = xml`
|
||||||
|
<button t-on-click="state.value++">
|
||||||
|
Click Me! [<t t-esc="state.value"/>]
|
||||||
|
</button>`;
|
||||||
|
|
||||||
|
state = useState({ value: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
class App extends Component {
|
||||||
|
static template = xml`
|
||||||
|
<div>
|
||||||
|
<span>Hello Owl</span>
|
||||||
|
<Counter />
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
static components = { Counter };
|
||||||
|
}
|
||||||
|
|
||||||
|
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).
|
||||||
|
Also, all examples here uses the [`xml` helper](doc/reference/tags.md#xml-tag) to define inline templates.
|
||||||
|
But this is not mandatory, many applications will load templates separately.
|
||||||
|
|
||||||
|
More interesting examples can be found on the
|
||||||
|
[playground](https://odoo.github.io/owl/playground) application.
|
||||||
|
|
||||||
|
## Design Principles
|
||||||
|
|
||||||
OWL is designed to be used in highly dynamic applications where changing
|
OWL is designed to be used in highly dynamic applications where changing
|
||||||
requirements are common, code needs to be maintained by large teams.
|
requirements are common and code needs to be maintained by large teams.
|
||||||
|
|
||||||
- **XML based**: templates are based on the XML format, which allows interesting
|
- **XML based**: templates are based on the XML format, which allows interesting
|
||||||
applications. For example, they could be stored in a database and modified
|
applications. For example, they could be stored in a database and modified
|
||||||
@@ -26,70 +77,176 @@ requirements are common, code needs to be maintained by large teams.
|
|||||||
for various reasons (security/deployment/dynamic modules/specific assets tools),
|
for various reasons (security/deployment/dynamic modules/specific assets tools),
|
||||||
it is not ok to use standard web tools based on `npm`.
|
it is not ok to use standard web tools based on `npm`.
|
||||||
|
|
||||||
Owl is not designed to be fast or 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). If you are interested in a comparison with React or Vue, you will
|
two topics). It is a no nonsense framework to build applications. There is only
|
||||||
|
one way to define components (with classes).
|
||||||
|
|
||||||
|
If you are interested in a comparison with React or Vue, you will
|
||||||
find some more information [here](doc/comparison.md).
|
find some more information [here](doc/comparison.md).
|
||||||
|
|
||||||
## Example
|
## Documentation
|
||||||
|
|
||||||
Here is a short example to illustrate interactive components:
|
A complete documentation for Owl can be found here:
|
||||||
|
|
||||||
```xml
|
- [Main documentation page](doc/readme.md).
|
||||||
<templates>
|
|
||||||
<button t-name="Counter" t-on-click="increment">
|
|
||||||
Click Me! [<t t-esc="state.value"/>]
|
|
||||||
</button>
|
|
||||||
</templates>
|
|
||||||
```
|
|
||||||
|
|
||||||
```javascript
|
The most important sections are:
|
||||||
class Counter extends owl.Component {
|
|
||||||
state = { value: 0 };
|
|
||||||
|
|
||||||
increment() {
|
- [Tutorial: TodoList application](doc/learning/tutorial_todoapp.md)
|
||||||
this.state.value++;
|
- [QWeb templating language](doc/reference/qweb_templating_language.md)
|
||||||
}
|
- [Component](doc/reference/component.md)
|
||||||
}
|
- [Hooks](doc/reference/hooks.md)
|
||||||
|
|
||||||
const qweb = new owl.QWeb(TEMPLATES);
|
Found an issue in the documentation? A broken link? Some outdated information?
|
||||||
const counter = new Counter({ qweb });
|
Submit a PR!
|
||||||
counter.mount(document.body);
|
|
||||||
```
|
|
||||||
|
|
||||||
Note that we assume here that the xml templates are available in the `TEMPLATES`
|
|
||||||
string. More interesting examples can be found on the
|
|
||||||
[playground](https://odoo.github.io/owl/playground) application.
|
|
||||||
|
|
||||||
## Installing/Building
|
## Installing/Building
|
||||||
|
|
||||||
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-0.18.0.js](https://github.com/odoo/owl/releases/download/v0.18.0/owl.js)
|
- [owl-1.0.0-beta2.js](https://github.com/odoo/owl/releases/download/v1.0.0-beta2/owl.js)
|
||||||
- [owl-0.18.0.min.js](https://github.com/odoo/owl/releases/download/v0.18.0/owl.min.js)
|
- [owl-1.0.0-beta2.min.js](https://github.com/odoo/owl/releases/download/v1.0.0-beta2/owl.min.js)
|
||||||
|
|
||||||
Some npm scripts are available:
|
Some npm scripts are available:
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
| --------------------- | ---------------------------------------------------------------------------- |
|
| ---------------- | -------------------------------------------------- |
|
||||||
| `npm install` | install every dependency required for this project |
|
| `npm install` | install every dependency required for this project |
|
||||||
| `npm run build` | build a bundle of _owl_ in the _/dist/_ folder |
|
| `npm run build` | build a bundle of _owl_ in the _/dist/_ folder |
|
||||||
| `npm run minify` | minify the prebuilt owl.js file |
|
| `npm run minify` | minify the prebuilt owl.js file |
|
||||||
| `npm run test` | run all tests |
|
| `npm run test` | run all (owl) tests |
|
||||||
| `npm run test:watch` | run all tests, and keep a watcher |
|
|
||||||
| `npm run tools` | build tools applications, start a static server (see [here](doc/tooling.md)) |
|
|
||||||
| `npm run tools:watch` | same as `tools`, but with a watcher to rebuild owl |
|
|
||||||
|
|
||||||
## Documentation
|
## Quick Overview
|
||||||
|
|
||||||
The complete documentation can be found [here](doc/readme.md). The most important sections are:
|
Owl components in an application are used to define a (dynamic) tree of components.
|
||||||
|
|
||||||
- [Quick Start](doc/quick_start.md)
|
```
|
||||||
- [Component](doc/component.md)
|
Root
|
||||||
- [QWeb](doc/qweb.md)
|
/ \
|
||||||
|
A B
|
||||||
|
/ \
|
||||||
|
C D
|
||||||
|
```
|
||||||
|
|
||||||
Found an issue in the documentation? A broken link? Some outdated information?
|
**State:** each component can manage its own local state. It is a simple ES6
|
||||||
Submit a PR!
|
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
|
||||||
|
|
||||||
OWL is [GPL licensed](./LICENSE).
|
OWL is [LGPL licensed](./LICENSE).
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# 🦉 Rendering Pipeline 🦉
|
||||||
|
|
||||||
|
We explain here how Owl is designed, from the perspective of its rendering
|
||||||
|
pipeline.
|
||||||
|
|
||||||
|
Warning: these notes are technical by nature, and intended for people working
|
||||||
|
on Owl (or interested in understanding its design).
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
A rendering occurs in two phases:
|
||||||
|
|
||||||
|
- virtual rendering: this generates the virtual dom in memory, asynchronously
|
||||||
|
- patch: applies a virtual tree to the screen (synchronously)
|
||||||
|
|
||||||
|
There are several classes involved in a rendering:
|
||||||
|
|
||||||
|
- components
|
||||||
|
- a scheduler
|
||||||
|
- fibers: small objects containing some metadata, associated with a rendering of
|
||||||
|
a specific component
|
||||||
|
|
||||||
|
Components are organized in a dynamic component tree, visible in the user
|
||||||
|
interface. Whenever a rendering is initiated in a component `C`:
|
||||||
|
|
||||||
|
- a fiber is created on `C` with the rendering props information
|
||||||
|
- the virtual rendering phase starts on C (will asynchronously render all the
|
||||||
|
child components)
|
||||||
|
- the fiber is added to the scheduler, which will poll continuously, every
|
||||||
|
animation frame, if the fiber is done
|
||||||
|
- once it is done, the scheduler will call the task callback, which will apply
|
||||||
|
the patch (if it was not cancelled in the meantime).
|
||||||
+169
-20
@@ -3,30 +3,59 @@
|
|||||||
OWL, React and Vue have the same main feature: they allow developers to build
|
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, some
|
In this page, we try to highlight some of these differences. Obviously, a lot of
|
||||||
effort was done 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
|
||||||
|
|
||||||
- [Size](#size)
|
- [Size](#size)
|
||||||
|
- [Class Based](#class-based)
|
||||||
- [Tooling/Build Step](#toolingbuild-step)
|
- [Tooling/Build Step](#toolingbuild-step)
|
||||||
- [Templating](#templating)
|
- [Templating](#templating)
|
||||||
- [Asynchronous rendering](#asynchronous-rendering)
|
- [Asynchronous rendering](#asynchronous-rendering)
|
||||||
- [Reactiveness](#reactiveness)
|
- [Reactiveness](#reactiveness)
|
||||||
- [State Management](#state-management)
|
- [State Management](#state-management)
|
||||||
|
- [Hooks](#hooks)
|
||||||
|
|
||||||
## Size
|
## Size
|
||||||
|
|
||||||
OWL is intended to be small and to work at a slightly lower level of abstraction
|
OWL is intended to be small and to work at a slightly lower level of abstraction
|
||||||
than React and Vue. Also, jQuery is not the same kind of framework, but it is interesting to compare.
|
than React and Vue. Also, jQuery is not the same kind of framework, but it is interesting to compare.
|
||||||
|
|
||||||
| Framework | Size (minified) | Size (minified, gzipped) |
|
| Framework | Size (minified, gzipped) |
|
||||||
| ------------------------ | --------------- | ------------------------ |
|
| ------------------------ | ------------------------ |
|
||||||
| OWL | 32kb | 11kb |
|
| OWL | 18kb |
|
||||||
| Vue + VueX | | 30kb |
|
| Vue + VueX | 30kb |
|
||||||
| React + ReactDOM + Redux | | 40kb |
|
| Vue + VueX + Vue Router | 39kb |
|
||||||
| jQuery | 86kb | 30kb |
|
| React + ReactDOM + Redux | 40kb |
|
||||||
|
| jQuery | 30kb |
|
||||||
|
|
||||||
|
Note that those comparisons are not entirely fair, because we do not compare
|
||||||
|
the same exact set of features. For example, VueX and Vue Router support more
|
||||||
|
advanced use cases.
|
||||||
|
|
||||||
|
## Class Based
|
||||||
|
|
||||||
|
Both React and Vue moved away from defining components with classes. They prefer
|
||||||
|
a more functional approach, in particular, with the new `hooks` mechanisms.
|
||||||
|
|
||||||
|
This has some advantages and disadvantages. But the end result is that React
|
||||||
|
and Vue both offers multiple different ways of defining new components. In
|
||||||
|
contrast, Owl has only one mechanism: class-based components. We believe that Owl
|
||||||
|
components are fast enough for all our usecases, and making it as simple as
|
||||||
|
possible for developers is more valuable (for us).
|
||||||
|
|
||||||
|
Also, functions or class based components are more than just syntax. Functions
|
||||||
|
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
|
||||||
|
exclude the other.
|
||||||
|
|
||||||
|
It certainly looks like the world of UI frameworks is moving toward composition,
|
||||||
|
for many very good reasons. Owl is still good at composition (for example,
|
||||||
|
Owl supports slots, which is the primary mechanism to make generic reusable
|
||||||
|
components). But it can also use inheritance (and this is very important since
|
||||||
|
templates can also be inherited with `xpaths` transformations).
|
||||||
|
|
||||||
## Tooling/Build step
|
## Tooling/Build step
|
||||||
|
|
||||||
@@ -38,14 +67,24 @@ be used by simply adding a script tag to a page.
|
|||||||
<script src="owl.min.js" />
|
<script src="owl.min.js" />
|
||||||
```
|
```
|
||||||
|
|
||||||
In comparison, React encourages using JSX,
|
In comparison, React encourages using JSX, which necessitate a build step, and
|
||||||
which necessitate a build step, and most Vue applications uses single file
|
most Vue applications uses single file components, which also necessitate a build step.
|
||||||
components, which also necessitate a build step.
|
|
||||||
|
|
||||||
On the flipside, external tooling may make it harder to use in some case, but it
|
On the flipside, external tooling may make it harder to use in some case, but it
|
||||||
also brings a lot of benefits. And React/Vue have both a large ecosystem.
|
also brings a lot of benefits. And React/Vue have both a large ecosystem.
|
||||||
|
|
||||||
### Templating
|
Note that since Owl is not dependant on any external tool nor libraries, it is
|
||||||
|
very easy to integrate into any build toolchain. Also, since we cannot rely on
|
||||||
|
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.
|
||||||
|
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
|
||||||
|
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
|
||||||
|
plugins to have autocompletion inside the template.
|
||||||
|
|
||||||
|
## Templating
|
||||||
|
|
||||||
OWL uses its own QWeb engine, which compiles templates on the
|
OWL uses its own QWeb engine, which compiles templates on the
|
||||||
frontend, as they are needed. This is extremely convenient for our use case, in
|
frontend, as they are needed. This is extremely convenient for our use case, in
|
||||||
@@ -67,7 +106,8 @@ into javascript functions. Note that Vue has a separate build which includes the
|
|||||||
template compiler.
|
template compiler.
|
||||||
|
|
||||||
In contrast, most React applications do not use a templating language, but write
|
In contrast, most React applications do not use a templating language, but write
|
||||||
some JSX code, which is precompiled into plain JavaScript by a build step.
|
some JSX code, which is precompiled into plain JavaScript by a build step. This
|
||||||
|
example is done with the (kind of outdated) React class system:
|
||||||
|
|
||||||
```jsx
|
```jsx
|
||||||
class Clock extends React.Component {
|
class Clock extends React.Component {
|
||||||
@@ -86,6 +126,20 @@ This has the advantage of having the full power of Javascript, but is less
|
|||||||
structured than a template language. Note that the tooling is quite impressive:
|
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
|
||||||
|
[`xml`](reference/tags.md#xml-tag) tag helper:
|
||||||
|
|
||||||
|
```js
|
||||||
|
class Clock extends Component {
|
||||||
|
static template = xml`
|
||||||
|
<div>
|
||||||
|
<h1>Hello, world!</h1>
|
||||||
|
<h2>It is {props.date.toLocaleTimeString()}.</h2>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## Asynchronous Rendering
|
## Asynchronous Rendering
|
||||||
|
|
||||||
This is actually a big difference between OWL and React/Vue: components in OWL
|
This is actually a big difference between OWL and React/Vue: components in OWL
|
||||||
@@ -115,12 +169,14 @@ This may be dangerous (to stop the rendering waiting for the network), but it is
|
|||||||
extremely powerful as well, as demonstrated by the Odoo Web Client.
|
extremely powerful as well, as demonstrated by the Odoo Web Client.
|
||||||
|
|
||||||
Lazy loading static libraries can obviously be done with React/Vue, but it is
|
Lazy loading static libraries can obviously be done with React/Vue, but it is
|
||||||
more convoluted.
|
more convoluted. For example, in Vue, you need to use a dynamic import keyword
|
||||||
|
that needs to be transpiled at build time in order for the component to be loaded
|
||||||
|
asynchronously (see [the documentation](https://vuejs.org/v2/guide/components-dynamic-async.html#Async-Components)).
|
||||||
|
|
||||||
## Reactiveness
|
## Reactiveness
|
||||||
|
|
||||||
React has a simple model: whenever the state changes, it is
|
React has a simple model: whenever the state changes, it is
|
||||||
replaced with a new state (via the setState method). Then, the DOM is patched.
|
replaced with a new state (via the `setState` method). Then, the DOM is patched.
|
||||||
This is simple, efficient, and a little bit awkward to write.
|
This is simple, efficient, and a little bit awkward to write.
|
||||||
|
|
||||||
Vue is a little bit different: it replace magically the properties in the state
|
Vue is a little bit different: it replace magically the properties in the state
|
||||||
@@ -128,8 +184,10 @@ by getters/setters. With that, it can notify components whenever the state that
|
|||||||
they read was changed.
|
they read was changed.
|
||||||
|
|
||||||
Owl is closer to vue: it also tracks magically the state properties, but it does
|
Owl is closer to vue: it also tracks magically the state properties, but it does
|
||||||
only increment a counter whenever it changes (and a _deep_ counter for each of
|
only increment an internal counter whenever it changes. Note that it is done
|
||||||
its parents). This assumes that the state is actually a tree.
|
with a `Proxy`, which means that it is totally transparent to the developers.
|
||||||
|
Adding new keys is supported. Once any part of the state has been changed, a
|
||||||
|
rendering is scheduled in the next microtask tick (promise queue).
|
||||||
|
|
||||||
## State Management
|
## State Management
|
||||||
|
|
||||||
@@ -190,7 +248,98 @@ keeps track of who get data, and retrigger a render when it was changed.
|
|||||||
|
|
||||||
**Owl**
|
**Owl**
|
||||||
|
|
||||||
Owl store is a little bit like a mix of redux and vuex: it has mutations and
|
Owl store is a little bit like a mix of redux and vuex: it has actions (but not
|
||||||
actions, like VueX, it keeps track of the state changes, but it does not notify
|
mutations), and like VueX, it keeps track of the state changes. However, it does
|
||||||
a component when the state changes. Instead, components need to connect to the
|
not notify a component when the state changes. Instead, components need to connect
|
||||||
store like in redux, with a function that will listen to the relevant state.
|
to the store like in redux, with the `useStore` hook (see the [store documentation](reference/store.md#connecting-a-component)).
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const actions = {
|
||||||
|
increment({ state }, val) {
|
||||||
|
state.counter.value += val;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
counter: { value: 0 }
|
||||||
|
};
|
||||||
|
const store = new owl.Store({ state, actions });
|
||||||
|
|
||||||
|
class Counter extends Component {
|
||||||
|
static template = xml`
|
||||||
|
<button t-name="Counter" t-on-click="dispatch('increment')">
|
||||||
|
Click Me! [<t t-esc="counter.value"/>]
|
||||||
|
</button>`;
|
||||||
|
counter = useStore(state => state.counter);
|
||||||
|
dispatch = useDispatch();
|
||||||
|
}
|
||||||
|
|
||||||
|
Counter.env.store = store;
|
||||||
|
const counter = new Counter();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Hooks
|
||||||
|
|
||||||
|
[Hooks](https://reactjs.org/docs/hooks-intro.html#motivation) recently took over
|
||||||
|
the React world. They solve a lot of seemingly unconnected problems: attach
|
||||||
|
reusable behavior to a component, in a composable way, extract stateful logic
|
||||||
|
from a component or reuse stateful logic between component, without changing your
|
||||||
|
component hierarchy.
|
||||||
|
|
||||||
|
Here is an example of the React `useState` hook:
|
||||||
|
|
||||||
|
```js
|
||||||
|
import React, { useState } from "react";
|
||||||
|
|
||||||
|
function Example() {
|
||||||
|
// Declare a new state variable, which we'll call "count"
|
||||||
|
const [count, setCount] = useState(0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p>You clicked {count} times</p>
|
||||||
|
<button onClick={() => setCount(count + 1)}>Click me</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Because of the way React designed the hooks API, they only work for functional
|
||||||
|
components. But in that case, they really are powerful. Every major React library
|
||||||
|
is in the process of redesigning their API with hooks (for example,
|
||||||
|
[Redux](https://react-redux.js.org/next/api/hooks)).
|
||||||
|
|
||||||
|
Vue 2 does not have hooks, but the Vue project is working on its next version,
|
||||||
|
which will feature its new [composition API](https://vue-composition-api-rfc.netlify.com/).
|
||||||
|
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
|
||||||
|
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,
|
||||||
|
the `useState` hook is named after React, but its API is closer to the `reactive`
|
||||||
|
Vue hook.
|
||||||
|
|
||||||
|
Here is what the `Counter` example above look like in Owl:
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { Component, Owl } from "owl";
|
||||||
|
import { xml } from "owl/tags";
|
||||||
|
|
||||||
|
class Example extends Component {
|
||||||
|
static template = xml`
|
||||||
|
<div>
|
||||||
|
<p>You clicked {count.value} times</p>
|
||||||
|
<button t-on-click="increment">Click me</button>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
count = useState({ value: 0 });
|
||||||
|
|
||||||
|
increment() {
|
||||||
|
this.state.value++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Since the Owl framework had hooks from early in its life, its main APIs
|
||||||
|
are designed to be interacted with hooks from the start. For example, the
|
||||||
|
`Context` and `Store` abstractions.
|
||||||
|
|||||||
@@ -2,8 +2,17 @@
|
|||||||
|
|
||||||
## Static Server
|
## Static Server
|
||||||
|
|
||||||
Let us assume that we have a static server running somewhere. We could then
|
Let us assume that we have a static server running somewhere. Let us start by
|
||||||
simply add an html page with a few extra files.
|
adding an html page with a few extra files:
|
||||||
|
|
||||||
|
```
|
||||||
|
my-app/
|
||||||
|
index.html
|
||||||
|
app.css
|
||||||
|
app.js
|
||||||
|
owl-X.Y.Z.js
|
||||||
|
templates.xml
|
||||||
|
```
|
||||||
|
|
||||||
### HTML and CSS
|
### HTML and CSS
|
||||||
|
|
||||||
@@ -55,7 +64,7 @@ To build an application (or a sub-part of an application), we need two things:
|
|||||||
|
|
||||||
- an environment: it is the global context in which we are working. It needs to
|
- an environment: it is the global context in which we are working. It needs to
|
||||||
contain a QWeb instance (preloaded with templates), and anything else that we
|
contain a QWeb instance (preloaded with templates), and anything else that we
|
||||||
need. In practice, it could context some user session information, some
|
need. In practice, it could be used to contain some user session information, some
|
||||||
configuration keys (for example, isMobile = true/false if we are in mobile mode).
|
configuration keys (for example, isMobile = true/false if we are in mobile mode).
|
||||||
|
|
||||||
- a description of the user interface: there should be a root component, which can
|
- a description of the user interface: there should be a root component, which can
|
||||||
@@ -72,12 +81,11 @@ Here are a few steps that we may take to get started:
|
|||||||
Let us now add the javascript to make it work, in `app.js`:
|
Let us now add the javascript to make it work, in `app.js`:
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
|
const useState = owl.hooks.useState;
|
||||||
|
|
||||||
class ClickCounter extends owl.Component {
|
class ClickCounter extends owl.Component {
|
||||||
constructor() {
|
static template = "clickcounter";
|
||||||
super(...arguments);
|
state = useState({ value: 0 });
|
||||||
this.template = "clickcounter";
|
|
||||||
this.state = { value: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
increment() {
|
increment() {
|
||||||
this.state.value++;
|
this.state.value++;
|
||||||
@@ -88,11 +96,9 @@ class ClickCounter extends owl.Component {
|
|||||||
// Application initialization
|
// Application initialization
|
||||||
//------------------------------------------------------------------------------
|
//------------------------------------------------------------------------------
|
||||||
async function start() {
|
async function start() {
|
||||||
const templates = await owl.utils.loadTemplates("templates.xml");
|
const templates = await owl.utils.loadFile("templates.xml");
|
||||||
const env = {
|
ClickCounter.env = { qweb: new owl.QWeb({ templates }) };
|
||||||
qweb: new owl.QWeb(templates)
|
const counter = new ClickCounter();
|
||||||
};
|
|
||||||
const counter = new ClickCounter(env);
|
|
||||||
const target = document.getElementById("main");
|
const target = document.getElementById("main");
|
||||||
await counter.mount(target);
|
await counter.mount(target);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
# 🦉 Testing Owl components 🦉
|
||||||
|
|
||||||
|
## Content
|
||||||
|
|
||||||
|
- [Overview](#overview)
|
||||||
|
- [Unit Tests](#unit-tests)
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
It is a good practice to test applications and components to ensure that they
|
||||||
|
behave as expected. There are many ways to test a user interface: manual
|
||||||
|
testing, integration testing, unit testing, ...
|
||||||
|
|
||||||
|
In this section, we will discuss how to write unit tests for components, and
|
||||||
|
how to debug them if necessary.
|
||||||
|
|
||||||
|
## Unit Tests
|
||||||
|
|
||||||
|
Writing unit tests for Owl components really depends on the testing framework
|
||||||
|
used in a project. But usually, it involves the following steps:
|
||||||
|
|
||||||
|
- create a test file: for example `SomeComponent.test.js`,
|
||||||
|
- in that file, import the code for `SomeComponent`,
|
||||||
|
- add a test case:
|
||||||
|
- create a real DOM element to use as test fixture,
|
||||||
|
- create a test environment
|
||||||
|
- create an instance of `SomeComponent`, mount it to the fixture
|
||||||
|
- interact with the component and assert some properties.
|
||||||
|
|
||||||
|
To help with this, it is useful to have a `helper.js` file that contains some
|
||||||
|
common utility functions:
|
||||||
|
|
||||||
|
```js
|
||||||
|
export function makeTestFixture() {
|
||||||
|
let fixture = document.createElement("div");
|
||||||
|
document.body.appendChild(fixture);
|
||||||
|
return fixture;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nextTick() {
|
||||||
|
let requestAnimationFrame = owl.Component.scheduler.requestAnimationFrame;
|
||||||
|
return new Promise(function(resolve) {
|
||||||
|
setTimeout(() => requestAnimationFrame(() => resolve()));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeTestEnv() {
|
||||||
|
// application specific. It needs a way to load actual templates
|
||||||
|
const templates = ...;
|
||||||
|
|
||||||
|
return {
|
||||||
|
qweb: new QWeb(templates),
|
||||||
|
..., // each service can be mocked here
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
With such a file, a typical test suite for Jest will look like this:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// in SomeComponent.test.js
|
||||||
|
import { SomeComponent } from "../../src/ui/SomeComponent";
|
||||||
|
import { nextTick, makeTestFixture, makeTestEnv} from '../helpers';
|
||||||
|
|
||||||
|
|
||||||
|
//------------------------------------------------------------------------------
|
||||||
|
// Setup
|
||||||
|
//------------------------------------------------------------------------------
|
||||||
|
let fixture: HTMLElement;
|
||||||
|
let env: Env;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fixture = makeTestFixture();
|
||||||
|
env = makeTestEnv();
|
||||||
|
// we set here the default environment for each component created in the test
|
||||||
|
Component.env = env;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
fixture.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
//------------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
//------------------------------------------------------------------------------
|
||||||
|
describe("SomeComponent", () => {
|
||||||
|
test("component behaves as expected", async () => {
|
||||||
|
const props = {...}; // depends on the component
|
||||||
|
const comp = new SomeComponent(null, props);
|
||||||
|
await comp.mount(fixture);
|
||||||
|
|
||||||
|
// do some assertions
|
||||||
|
expect(...).toBe(...);
|
||||||
|
|
||||||
|
fixture.querySelector('button').click();
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
// some other assertions
|
||||||
|
expect(...).toBe(...);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Note that Owl does wait for the next animation frame to actually update the DOM.
|
||||||
|
This is why it is necessary to wait with the `nextTick` (or other methods) to
|
||||||
|
make sure that the DOM is up-to-date.
|
||||||
|
|
||||||
|
It is sometimes useful to wait until Owl is completely done updating components
|
||||||
|
(in particular, if we have a highly concurrent user interface). This next
|
||||||
|
helper simply polls every 20ms the internal Owl task queue and returns a promise
|
||||||
|
which resolves when it is empty:
|
||||||
|
|
||||||
|
```js
|
||||||
|
function afterUpdates() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let timer = setTimeout(poll, 20);
|
||||||
|
let counter = 0;
|
||||||
|
function poll() {
|
||||||
|
counter++;
|
||||||
|
if (owl.Component.scheduler.tasks.length) {
|
||||||
|
if (counter > 10) {
|
||||||
|
reject(new Error("timeout"));
|
||||||
|
} else {
|
||||||
|
timer = setTimeout(poll);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,50 +0,0 @@
|
|||||||
# 🦉 Observer 🦉
|
|
||||||
|
|
||||||
Owl need to be able to react to state changes. For example, whenever the state
|
|
||||||
of a component is changed, we need to rerender it. To help with that, we have
|
|
||||||
an Observer class. Its job is to observe some object state, and react to any
|
|
||||||
change. To do that, it recursively replace all keys of the observed state by
|
|
||||||
getters and setters.
|
|
||||||
|
|
||||||
For example, this code will display `update` in the console:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const observer = new owl.Observer();
|
|
||||||
observer.notifyCB = () => console.log("update");
|
|
||||||
observer.observe(obj);
|
|
||||||
|
|
||||||
const obj = { a: { b: 1 } };
|
|
||||||
obj.a.b = 2;
|
|
||||||
```
|
|
||||||
|
|
||||||
## Technical Limitations
|
|
||||||
|
|
||||||
Since the observer uses getters and setters, it is actually unable to react to
|
|
||||||
changes in three situations:
|
|
||||||
|
|
||||||
- adding a key to an object
|
|
||||||
- deleting a key from an object
|
|
||||||
- modifying an array by setting a new value at a given index
|
|
||||||
|
|
||||||
In those situations, we need a way to tell the observer that something happened.
|
|
||||||
This can be done by using the `set` and `delete` (only for objects) static
|
|
||||||
methods of the `Observer`.
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const observer = new owl.Observer();
|
|
||||||
const obj = { a: 1 };
|
|
||||||
observer.observe(obj);
|
|
||||||
obj.b = 2; // won't notify the change
|
|
||||||
owl.Observer.set(obj, "b", 2); // will notify the change
|
|
||||||
|
|
||||||
delete obj.b; // won't notify the change
|
|
||||||
owl.Observer.delete(obj, "b"); // will notify the change
|
|
||||||
```
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const observer = new owl.Observer();
|
|
||||||
const arr = ["a"];
|
|
||||||
observer.observe(arr);
|
|
||||||
arr[0] = "b"; // won't notify the change
|
|
||||||
owl.Observer.set(arr, 0, "b"); // will notify the change
|
|
||||||
```
|
|
||||||
+65
-32
@@ -1,44 +1,77 @@
|
|||||||
# 🦉 OWL Documentation 🦉
|
# 🦉 OWL Documentation 🦉
|
||||||
|
|
||||||
## Owl Content
|
|
||||||
|
|
||||||
Owl is a javascript library that contains some core classes and function to help
|
|
||||||
build applications. Here is a complete representation of its content:
|
|
||||||
|
|
||||||
```
|
|
||||||
owl
|
|
||||||
Component
|
|
||||||
QWeb
|
|
||||||
core
|
|
||||||
EventBus
|
|
||||||
Observer
|
|
||||||
store
|
|
||||||
Store
|
|
||||||
ConnectedComponent
|
|
||||||
utils
|
|
||||||
whenReady
|
|
||||||
loadJS
|
|
||||||
loadTemplates
|
|
||||||
escape
|
|
||||||
debounce
|
|
||||||
```
|
|
||||||
|
|
||||||
## Reference
|
## Reference
|
||||||
|
|
||||||
- [Animations](animations.md)
|
- [Animations](reference/animations.md)
|
||||||
- [Component](component.md)
|
- [Component](reference/component.md)
|
||||||
- [Event Bus](event_bus.md)
|
- [Concurrency Model](reference/concurrency_model.md)
|
||||||
- [Observer](observer.md)
|
- [Configuration](reference/config.md)
|
||||||
- [QWeb](qweb.md)
|
- [Context](reference/context.md)
|
||||||
- [Store](store.md)
|
- [Environment](reference/environment.md)
|
||||||
- [Utils](utils.md)
|
- [Event Bus](reference/event_bus.md)
|
||||||
- [Virtual DOM](vdom.md)
|
- [Hooks](reference/hooks.md)
|
||||||
|
- [Miscellaneous Components](reference/misc.md)
|
||||||
|
- [Observer](reference/observer.md)
|
||||||
|
- [Props](reference/props.md)
|
||||||
|
- [Props Validation](reference/props_validation.md)
|
||||||
|
- [QWeb Templating Language](reference/qweb_templating_language.md)
|
||||||
|
- [QWeb Engine](reference/qweb_engine.md)
|
||||||
|
- [Router](reference/router.md)
|
||||||
|
- [Store](reference/store.md)
|
||||||
|
- [Tags](reference/tags.md)
|
||||||
|
- [Utils](reference/utils.md)
|
||||||
|
|
||||||
## Learning Resources
|
## Learning Resources
|
||||||
|
|
||||||
- [Quick Start](quick_start.md)
|
- [Quick Start: create an (almost) empty Owl application](learning/quick_start.md)
|
||||||
|
- [Tutorial: create a TodoList application](learning/tutorial_todoapp.md)
|
||||||
|
- [Testing Owl components](learning/testing_components.md)
|
||||||
|
|
||||||
## Miscellaneous
|
## Miscellaneous
|
||||||
|
|
||||||
- [Comparison with React/Vue](comparison.md)
|
- [Comparison with React/Vue](comparison.md)
|
||||||
- [Tooling](tooling.md)
|
- [Tooling](tooling.md)
|
||||||
|
- [Templates to start Owl applications (external link)](https://github.com/ged-odoo/owl-templates)
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
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 Portal
|
||||||
|
Store router
|
||||||
|
useState Link
|
||||||
|
config RouteComponent
|
||||||
|
mode Router
|
||||||
|
core tags
|
||||||
|
EventBus xml
|
||||||
|
Observer utils
|
||||||
|
hooks debounce
|
||||||
|
onWillStart escape
|
||||||
|
onMounted loadJS
|
||||||
|
onWillUpdateProps loadFile
|
||||||
|
onWillPatch shallowEqual
|
||||||
|
onPatched whenReady
|
||||||
|
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.
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ btn {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
will produce a nice flash effect whenever the user click (or activate with the
|
will produce a nice flash effect whenever the user clicks (or activates with the
|
||||||
keyboard) the button.
|
keyboard) the button.
|
||||||
|
|
||||||
## CSS Transitions
|
## CSS Transitions
|
||||||
@@ -47,7 +47,7 @@ the lifetime of a node. Since this is not easy to do by hand, Owl `t-transition`
|
|||||||
directive is there to help.
|
directive is there to help.
|
||||||
|
|
||||||
Whenever a node has a `t-transition` directive, with a `name` value, the following
|
Whenever a node has a `t-transition` directive, with a `name` value, the following
|
||||||
will happen:
|
sequence of events will happen:
|
||||||
|
|
||||||
At node insertion:
|
At node insertion:
|
||||||
|
|
||||||
@@ -91,6 +91,5 @@ The `t-transition` directive can be applied on a node element or on a component.
|
|||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
|
|
||||||
- more information on animations are available [here](animations.md).
|
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)
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,183 @@
|
|||||||
|
# 🦉 Concurrency Model 🦉
|
||||||
|
|
||||||
|
## Content
|
||||||
|
|
||||||
|
- [Overview](#overview)
|
||||||
|
- [Rendering Components](#rendering-components)
|
||||||
|
- [Semantics](#semantics)
|
||||||
|
- [Asynchronous Rendering](#asynchronous-rendering)
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Owl was designed from the very beginning with asynchronous components. This comes
|
||||||
|
from the `willStart` and the `willUpdateProps` lifecycle hooks. With these
|
||||||
|
methods, it is possible to build complex highly concurrent applications.
|
||||||
|
|
||||||
|
Owl concurrent mode has several benefits: it makes it possible to delay the
|
||||||
|
rendering until some asynchronous operation is complete, it makes it possible
|
||||||
|
to lazy load libraries, while keeping the previous screen completely functional.
|
||||||
|
It is also good for performance reasons: Owl uses it to only apply the result of
|
||||||
|
many different renderings only once in an animation frame. Owl can cancel
|
||||||
|
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),
|
||||||
|
asynchrony is difficult, because it introduces an additional dimension that
|
||||||
|
vastly increase the complexity of an application. This section will explain
|
||||||
|
how Owl manages this complexity, how concuurent rendering works in a general way.
|
||||||
|
|
||||||
|
## Rendering Components
|
||||||
|
|
||||||
|
The word _rendering_ is a little vague, so, let us explain more precisely the
|
||||||
|
process by which Owl components are displayed on a screen.
|
||||||
|
|
||||||
|
When a component is mounted or updated, a new rendering is started. It has
|
||||||
|
two phases: _virtual rendering_ and _patching_.
|
||||||
|
|
||||||
|
### Virtual rendering
|
||||||
|
|
||||||
|
This phase represent the process of rendering a template, in memory, which create a virtual representation of the desired component html). The output of this phase is a
|
||||||
|
virtual DOM.
|
||||||
|
|
||||||
|
It is asynchronous: each subcomponents needs to either be created (so, `willStart`
|
||||||
|
will need to be called), or updated (which is done with the `willUpdateProps`
|
||||||
|
method). This is completely a recursive process: a component is the root of a
|
||||||
|
component tree, and each sub component needs to be (virtually) rendered.
|
||||||
|
|
||||||
|
### Patching
|
||||||
|
|
||||||
|
Once a rendering is complete, it will be applied on the next animation frame.
|
||||||
|
This is done synchronously: the whole component tree is patched to the real
|
||||||
|
DOM.
|
||||||
|
|
||||||
|
## Semantics
|
||||||
|
|
||||||
|
We give here an informal description of the way components are created/updated
|
||||||
|
in an application. Here, ordered lists describe actions that are executed
|
||||||
|
sequentially, bullet lists describe actions that are executed in parallel.
|
||||||
|
|
||||||
|
**Scenario 1: initial rendering** Imagine we want to render the following component tree:
|
||||||
|
|
||||||
|
```
|
||||||
|
A
|
||||||
|
/ \
|
||||||
|
B C
|
||||||
|
/ \
|
||||||
|
D E
|
||||||
|
```
|
||||||
|
|
||||||
|
Here is what happen whenever we mount the root
|
||||||
|
component (with some code like `app.mount(document.body)`).
|
||||||
|
|
||||||
|
1. `willStart` is called on `A`
|
||||||
|
|
||||||
|
2. when it is done, template `A` is rendered.
|
||||||
|
|
||||||
|
- component `B` is created
|
||||||
|
1. `willStart` is called on `B`
|
||||||
|
2. template `B` is rendered
|
||||||
|
- component `C` is created
|
||||||
|
1. `willStart` is called on `C`
|
||||||
|
2. template `C` is rendered
|
||||||
|
- component `D` is created
|
||||||
|
1. `willStart` is called on `D`
|
||||||
|
2. template `D` is rendered
|
||||||
|
- component `E` is created
|
||||||
|
1. `willStart` is called on `E`
|
||||||
|
2. template `E` is rendered
|
||||||
|
|
||||||
|
3. each components are patched into a detached DOM element, in the following order:
|
||||||
|
`E`, `D`, `C`, `B`, `A`. (so the actual full DOM tree is created
|
||||||
|
in one pass)
|
||||||
|
|
||||||
|
4. the component `A` root element is actually appended to `document.body`
|
||||||
|
|
||||||
|
5. The method `mounted` is called recursively on all components in the following
|
||||||
|
order: `E`, `D`, `C`, `B`, `A`.
|
||||||
|
|
||||||
|
**Scenario 2: rerendering a component**. Now, let's assume that the user clicked on some
|
||||||
|
button in `C`, and this results in a state update, which is supposed to:
|
||||||
|
|
||||||
|
- update `D`,
|
||||||
|
- remove `E`,
|
||||||
|
- add new component `F`.
|
||||||
|
|
||||||
|
So, the component tree should look like this:
|
||||||
|
|
||||||
|
```
|
||||||
|
A
|
||||||
|
/ \
|
||||||
|
B C
|
||||||
|
/ \
|
||||||
|
D F
|
||||||
|
```
|
||||||
|
|
||||||
|
Here is what Owl will do:
|
||||||
|
|
||||||
|
1. because of a state change, the method `render` is called on `C`
|
||||||
|
2. template `C` is rendered again
|
||||||
|
|
||||||
|
- component `D` is updated:
|
||||||
|
1. hook `willUpdateProps` is called on `D` (async)
|
||||||
|
2. template `D` is rerendered
|
||||||
|
- component `F` is created:
|
||||||
|
1. hook `willStart` is called on `E` (async)
|
||||||
|
2. template `F` is rendered
|
||||||
|
|
||||||
|
3. `willPatch` hooks are called recursively on components `C`, `D` (not on `F`,
|
||||||
|
because it is not mounted yet)
|
||||||
|
|
||||||
|
4. components `F`, `D` are patched in that order
|
||||||
|
|
||||||
|
5. component `C` is patched, which will cause recursively:
|
||||||
|
|
||||||
|
1. `willUnmount` hook on `E`
|
||||||
|
2. destruction of `E`,
|
||||||
|
|
||||||
|
6. `mounted` hook is called on `F`, `patched` hooks are called on `D`, `C`
|
||||||
|
|
||||||
|
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,
|
||||||
|
such as a `css` tag, which will be used to write [single file components](../tooling.md#single-file-component).
|
||||||
|
|
||||||
|
### Asynchronous Rendering
|
||||||
|
|
||||||
|
Working with asynchronous code always adds a lot of complexity to a system. Whenever
|
||||||
|
different parts of a system are active at the same time, one needs to think
|
||||||
|
carefully about all possible interactions. Clearly, this is also true for Owl
|
||||||
|
components.
|
||||||
|
|
||||||
|
There are two different common problems with Owl asynchronous rendering model:
|
||||||
|
|
||||||
|
- any component can delay the rendering (initial and subsequent) of the whole
|
||||||
|
application
|
||||||
|
- for a given component, there are two independant situations that will trigger an
|
||||||
|
asynchronous rerendering: a change in the state, or a change in the props.
|
||||||
|
These changes may be done at different times, and Owl has no way of knowing
|
||||||
|
how to reconcile the resulting renderings.
|
||||||
|
|
||||||
|
Here are a few tips on how to work with asynchronous components:
|
||||||
|
|
||||||
|
1. Minimize the use of asynchronous components!
|
||||||
|
2. Maybe move the asynchronous logic in a store, which then triggers (mostly)
|
||||||
|
synchronous renderings
|
||||||
|
3. Lazy loading external libraries is a good use case for async rendering. This
|
||||||
|
is mostly fine, because we can assume that it will only takes a fraction of a
|
||||||
|
second, and only once (see [`owl.utils.loadJS`](utils.md#loadjs))
|
||||||
|
4. For all the other cases, the [`AsyncRoot`](misc.md#asyncroot) component is there to help you. When
|
||||||
|
this component is met, a new rendering
|
||||||
|
sub tree is created, such that the rendering of that component (and its
|
||||||
|
children) is not tied to the rendering of the rest of the interface. It can
|
||||||
|
be used on an asynchronous component, to prevent it from delaying the
|
||||||
|
rendering of the whole interface, or on a synchronous one, such that its
|
||||||
|
rendering isn't delayed by other (asynchronous) components. Note that this
|
||||||
|
directive has no effect on the first rendering, but only on subsequent ones
|
||||||
|
(triggered by state or props changes).
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<div t-name="ParentComponent">
|
||||||
|
<SyncChild />
|
||||||
|
<AsyncRoot>
|
||||||
|
<AsyncChild/>
|
||||||
|
</AsyncRoot>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# 🦉 Config 🦉
|
||||||
|
|
||||||
|
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
|
||||||
|
global `config` object. It currently has one key:
|
||||||
|
|
||||||
|
- [`mode`](#mode).
|
||||||
|
|
||||||
|
## Mode
|
||||||
|
|
||||||
|
By default, Owl is in _production_ mode, this means that it will try to do its
|
||||||
|
job fast, and skip some expensive operations. However, it is sometimes necessary
|
||||||
|
to have better information on what is going on, this is the purpose
|
||||||
|
of the `dev` mode.
|
||||||
|
|
||||||
|
Owl has a mode flag, in `owl.config.mode`. Its default value is `prod`, but
|
||||||
|
it can be set to `dev`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
owl.config.mode = "dev";
|
||||||
|
```
|
||||||
|
|
||||||
|
Note that templates compiled with the `prod` settings will not be recompiled.
|
||||||
|
So, changing this setting is best done at startup.
|
||||||
|
|
||||||
|
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.
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
# 🦉 Context 🦉
|
||||||
|
|
||||||
|
## Content
|
||||||
|
|
||||||
|
- [Overview](#overview)
|
||||||
|
- [Example](#example)
|
||||||
|
- [Reference](#reference)
|
||||||
|
- [`Context`](#context)
|
||||||
|
- [`useContext`](#usecontext)
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The `Context` object provides a way to share data between an arbitrary number
|
||||||
|
of components. Usually, data is passed from a parent to its children component,
|
||||||
|
but when we have to deal with some mostly global information, this can be
|
||||||
|
annoying, since each component will need to pass the information to each children,
|
||||||
|
even though some or most of them will not use the information.
|
||||||
|
|
||||||
|
With a `Context` object, each component can subscribe (with the `useContext` hook)
|
||||||
|
to its state, and will be updated whenever the context state is updated.
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
Assume that we have an application with various components which needs to render
|
||||||
|
differently depending on the size of the device. Here is how we could proceed
|
||||||
|
to make sure that the information is properly shared. First, let us create a
|
||||||
|
context, and add it to the environment:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const deviceContext = new Context({ isMobile: true });
|
||||||
|
App.env.deviceContext = deviceContext;
|
||||||
|
```
|
||||||
|
|
||||||
|
If we want to make it completely responsive, we need to update its value whenever
|
||||||
|
the size of the screen is updated:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const isMobile = () => window.innerWidth <= 768;
|
||||||
|
window.addEventListener(
|
||||||
|
"resize",
|
||||||
|
owl.utils.debounce(() => {
|
||||||
|
const state = deviceContext.state;
|
||||||
|
if (state.isMobile !== isMobile()) {
|
||||||
|
state.isMobile = !state.isMobile;
|
||||||
|
}
|
||||||
|
}, 15)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
Then, each component that want can subscribe and render differently depending on the
|
||||||
|
fact that we are in a mobile or desktop mode.
|
||||||
|
|
||||||
|
```js
|
||||||
|
class SomeComponent extends Component {
|
||||||
|
static template = xml`
|
||||||
|
<div>
|
||||||
|
<t t-if=device.isMobile>
|
||||||
|
some simplified user interface
|
||||||
|
</t>
|
||||||
|
<t t-else="1">
|
||||||
|
a more advanced user interface
|
||||||
|
</t>
|
||||||
|
</div>`;
|
||||||
|
device = useContext(this.env.deviceContext);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Reference
|
||||||
|
|
||||||
|
### `Context`
|
||||||
|
|
||||||
|
A `Context` object should be created with a state object:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const someContext = new Context({ some: "key" });
|
||||||
|
```
|
||||||
|
|
||||||
|
Its state is now available in the `state` key:
|
||||||
|
|
||||||
|
```js
|
||||||
|
someContext.state.some = "other key";
|
||||||
|
```
|
||||||
|
|
||||||
|
This is the way some global code (such as the responsive code above) should
|
||||||
|
read and update the context state. However, components should not ever read the
|
||||||
|
context state directly from the context, they should instead use the `useContext`
|
||||||
|
hook to properly register themselves to state changes.
|
||||||
|
|
||||||
|
Note that the `Context` hook is different from the React version. For example,
|
||||||
|
there is no concept of provider/consumer. So, the `Context` feature does not
|
||||||
|
by itself allow the use of a different context state depending on the component
|
||||||
|
place in the component tree. However, this functionality can be obtained, if
|
||||||
|
necessary, with the use of sub environment.
|
||||||
|
|
||||||
|
### `useContext`
|
||||||
|
|
||||||
|
The `useContext` hook is the normal way for a component to register themselve
|
||||||
|
to context state changes. The `useContext` method returns the context state:
|
||||||
|
|
||||||
|
```js
|
||||||
|
device = useContext(this.env.deviceContext);
|
||||||
|
```
|
||||||
|
|
||||||
|
It is a simple observed state (with an owl `Observer`), which contains the shared
|
||||||
|
information.
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
# 🦉 Environment 🦉
|
||||||
|
|
||||||
|
## Content
|
||||||
|
|
||||||
|
- [Overview](#overview)
|
||||||
|
- [Setting an Environment](#setting-an-environment)
|
||||||
|
- [Using a sub environment](#using-a-sub-environment)
|
||||||
|
- [Content of an Environment](#content-of-an-environment)
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
An environment is an object which contains a [`QWeb` instance](qweb_engine.md). Whenever
|
||||||
|
a root component is created, it is assigned an environment (see
|
||||||
|
[below](#setting-an-environment) for more info on this). This environment is
|
||||||
|
then automatically given to each sub component (and accessible in the `this.env`
|
||||||
|
property).
|
||||||
|
|
||||||
|
```
|
||||||
|
Root
|
||||||
|
/ \
|
||||||
|
A B
|
||||||
|
```
|
||||||
|
|
||||||
|
This way, all components share the same `QWeb` instance. Owl internally requires
|
||||||
|
that the environment has a `qweb` key which maps to a
|
||||||
|
[`QWeb`](qweb_engine.md) instance. This is the QWeb instance that will be used to
|
||||||
|
render each templates in this specific component tree. Note that if no `QWeb`
|
||||||
|
instance is provided, Owl will simply generate it on the fly.
|
||||||
|
|
||||||
|
The environment is mostly static. Each application is free to add anything to
|
||||||
|
the environment, which is very useful, since this can be accessed by each sub
|
||||||
|
component.
|
||||||
|
|
||||||
|
## Setting an environment
|
||||||
|
|
||||||
|
An Owl application needs an [environment](environment.md) to be executed. The
|
||||||
|
environment has an important key: the [QWeb](qweb_engine.md) instance, which will render
|
||||||
|
all templates.
|
||||||
|
|
||||||
|
Whenever a root component `App` is mounted, Owl will setup a valid environment by
|
||||||
|
following the next steps:
|
||||||
|
|
||||||
|
- take the `env` object defined on `App.env` (if no `env` was explicitely setup,
|
||||||
|
this will be return the empty `env` object defined on `Component`)
|
||||||
|
- 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
|
||||||
|
component class, before the first component is created:
|
||||||
|
|
||||||
|
```js
|
||||||
|
App.env = {
|
||||||
|
_t: myTranslateFunction,
|
||||||
|
user: {...},
|
||||||
|
services: {
|
||||||
|
...
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const app = new App();
|
||||||
|
app.mount(document.body);
|
||||||
|
```
|
||||||
|
|
||||||
|
It is also possible to simply share an environment between all root components,
|
||||||
|
by simply doing this:
|
||||||
|
|
||||||
|
```js
|
||||||
|
Component.env = myEnv; // will be the default env for all components
|
||||||
|
```
|
||||||
|
|
||||||
|
Note that this environment is the global owl environment for an application. The
|
||||||
|
next section explains how to extend an environment for a specific sub component
|
||||||
|
and its children.
|
||||||
|
|
||||||
|
## Using a sub environment
|
||||||
|
|
||||||
|
It is sometimes useful to add one (or more) specific keys to the environment,
|
||||||
|
from the perspective of a specific component and its children. In that case, the
|
||||||
|
solution presented above will not work, since it sets the global environment.
|
||||||
|
|
||||||
|
There is a hook for this situation: [`useSubEnv`](hooks.md#usesubenv).
|
||||||
|
|
||||||
|
```js
|
||||||
|
class FormComponent extends Component {
|
||||||
|
constructor(parent, props) {
|
||||||
|
super(parent, props);
|
||||||
|
useSubEnv({ myKey: someValue });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Content of an Environment
|
||||||
|
|
||||||
|
Some good use cases for additional keys in the environment are:
|
||||||
|
|
||||||
|
- some configuration keys,
|
||||||
|
- session information,
|
||||||
|
- generic services (such as doing rpcs, or accessing local storage).
|
||||||
|
|
||||||
|
Doing it this way means that components are easily testable: we can simply
|
||||||
|
create a test environment with mock services.
|
||||||
|
|
||||||
|
For example:
|
||||||
|
|
||||||
|
```js
|
||||||
|
async function myEnv() {
|
||||||
|
const templates = await loadTemplates();
|
||||||
|
const qweb = new QWeb({ templates });
|
||||||
|
const session = getSession();
|
||||||
|
|
||||||
|
return {
|
||||||
|
_t: myTranslateFunction,
|
||||||
|
session: session,
|
||||||
|
qweb: qweb,
|
||||||
|
services: {
|
||||||
|
localStorage: localStorage,
|
||||||
|
rpc: rpc
|
||||||
|
},
|
||||||
|
debug: false,
|
||||||
|
inMobileMode: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function start() {
|
||||||
|
App.env = await myEnv();
|
||||||
|
const app = new App();
|
||||||
|
await app.mount(document.body);
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -5,7 +5,7 @@ parts of the code. Owl has a very simple bus class, which manages subscriptions,
|
|||||||
triggering events, and callbacks.
|
triggering events, and callbacks.
|
||||||
|
|
||||||
```js
|
```js
|
||||||
const bus = new owl.EventBus();
|
const bus = new owl.core.EventBus();
|
||||||
|
|
||||||
bus.on("some-event", null, function(...args) {
|
bus.on("some-event", null, function(...args) {
|
||||||
console.log(...args);
|
console.log(...args);
|
||||||
@@ -0,0 +1,435 @@
|
|||||||
|
# 🦉 Hooks 🦉
|
||||||
|
|
||||||
|
## Content
|
||||||
|
|
||||||
|
- [Overview](#overview)
|
||||||
|
- [Example: Mouse Position](#example-mouse-position)
|
||||||
|
- [Example: Autofocus](#example-autofocus)
|
||||||
|
- [Reference](#reference)
|
||||||
|
- [One Rule](#one-rule)
|
||||||
|
- [`useState`](#usestate)
|
||||||
|
- [`onMounted`](#onmounted)
|
||||||
|
- [`onWillUnmount`](#onwillunmount)
|
||||||
|
- [`onWillPatch`](#onwillpatch)
|
||||||
|
- [`onPatched`](#onpatched)
|
||||||
|
- [`onWillStart`](#onwillstart)
|
||||||
|
- [`onWillUpdateProps`](#onwillupdateprops)
|
||||||
|
- [`useContext`](#usecontext)
|
||||||
|
- [`useRef`](#useref)
|
||||||
|
- [`useSubEnv`](#usesubenv)
|
||||||
|
- [`useStore`](#usestore)
|
||||||
|
- [`useDispatch`](#usedispatch)
|
||||||
|
- [`useGetters`](#usegetters)
|
||||||
|
- [Making customized hooks](#making-customized-hooks)
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Hooks were popularised by React as a way to solve the following issues:
|
||||||
|
|
||||||
|
- help reusing stateful logic between components
|
||||||
|
- help organizing code by feature in complex components
|
||||||
|
- use state in functional components, without writing a class.
|
||||||
|
|
||||||
|
Owl hooks serve the same purpose, except that they work for class components
|
||||||
|
(note: React hooks do not work on class components, and maybe because of that,
|
||||||
|
there seems to be the misconception that hooks are in opposition to class. This
|
||||||
|
is clearly not true, as shown by Owl hooks).
|
||||||
|
|
||||||
|
Hooks work beautifully with Owl components: they solve the problems mentioned
|
||||||
|
above, and in particular, they are the perfect way to make your component
|
||||||
|
reactive.
|
||||||
|
|
||||||
|
## Example: mouse position
|
||||||
|
|
||||||
|
Here is the classical example of a non trivial hook to track the mouse position.
|
||||||
|
|
||||||
|
```js
|
||||||
|
const { useState, onMounted, onWillUnmount } = owl.hooks;
|
||||||
|
|
||||||
|
// We define here a custom behaviour: this hook tracks the state of the mouse
|
||||||
|
// position
|
||||||
|
function useMouse() {
|
||||||
|
const position = useState({ x: 0, y: 0 });
|
||||||
|
|
||||||
|
function update(e) {
|
||||||
|
position.x = e.clientX;
|
||||||
|
position.y = e.clientY;
|
||||||
|
}
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener("mousemove", update);
|
||||||
|
});
|
||||||
|
onWillUnmount(() => {
|
||||||
|
window.removeEventListener("mousemove", update);
|
||||||
|
});
|
||||||
|
|
||||||
|
return position;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main root component
|
||||||
|
class App extends owl.Component {
|
||||||
|
static template = xml`
|
||||||
|
<div t-name="App">
|
||||||
|
<div>Mouse: <t t-esc="mouse.x"/>, <t t-esc="mouse.y"/></div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
// this hooks is bound to the 'mouse' property.
|
||||||
|
mouse = useMouse();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Note that we use the prefix `use` for hooks, just like in React. This is just
|
||||||
|
a convention.
|
||||||
|
|
||||||
|
## Example: autofocus
|
||||||
|
|
||||||
|
Hooks can be combined to create the desired effect. For example, the following
|
||||||
|
hook combines the `useRef` hook with the `onPatched` and `onMounted` functions
|
||||||
|
to create an easy way to focus an input whenever it appears in the DOM:
|
||||||
|
|
||||||
|
```js
|
||||||
|
function useAutofocus(name) {
|
||||||
|
let ref = useRef(name);
|
||||||
|
let isInDom = false;
|
||||||
|
function updateFocus() {
|
||||||
|
if (!isInDom && ref.el) {
|
||||||
|
isInDom = true;
|
||||||
|
ref.el.focus();
|
||||||
|
} else if (isInDom && !ref.el) {
|
||||||
|
isInDom = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onPatched(updateFocus);
|
||||||
|
onMounted(updateFocus);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This hook takes the name of a valid `t-ref` directive, which should be present
|
||||||
|
in the template. It then checks whenever the component is mounted or patched if
|
||||||
|
the reference is not valid, and in this case, it will focus the node element.
|
||||||
|
This hook can be used like this:
|
||||||
|
|
||||||
|
```js
|
||||||
|
class SomeComponent extends Component {
|
||||||
|
static template = xml`
|
||||||
|
<div>
|
||||||
|
<input />
|
||||||
|
<input t-ref="myinput"/>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
constructor(...args) {
|
||||||
|
super(...args);
|
||||||
|
useAutofocus("myinput");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Reference
|
||||||
|
|
||||||
|
### One rule
|
||||||
|
|
||||||
|
There is only one rule: every hook for a component has to be called in the
|
||||||
|
constructor (or in class fields):
|
||||||
|
|
||||||
|
```js
|
||||||
|
// ok
|
||||||
|
class SomeComponent extends Component {
|
||||||
|
state = useState({ value: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// also ok
|
||||||
|
class SomeComponent extends Component {
|
||||||
|
constructor(...args) {
|
||||||
|
super(...args);
|
||||||
|
this.state = useState({ value: 0 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// not ok: this is executed after the constructor is called
|
||||||
|
class SomeComponent extends Component {
|
||||||
|
async willStart() {
|
||||||
|
this.state = useState({ value: 0 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
As you can see, the `useState` hook does not need to be given a reference to
|
||||||
|
the component. This is possible because there is a way to get a reference to the
|
||||||
|
current component: the `Component.current` static property is the reference to the
|
||||||
|
component instance that is currently being created.
|
||||||
|
|
||||||
|
Hooks need to be called in the constructor to ensure that this reference is
|
||||||
|
properly set. This is also a good thing for performance reasons (Owl can use
|
||||||
|
this to optimize its implementation), and for a clean architecture (this makes
|
||||||
|
it easier for developers to understand what is really happening in a component).
|
||||||
|
|
||||||
|
### `useState`
|
||||||
|
|
||||||
|
The `useState` hook is certainly the most important hook for Owl components:
|
||||||
|
this is what allows a component to be reactive, to react to state change.
|
||||||
|
|
||||||
|
The `useState` hook has to be given an object or an array, and will return
|
||||||
|
an observed version of it (using a `Proxy`).
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const { useState } = owl.hooks;
|
||||||
|
|
||||||
|
class Counter extends owl.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++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
It is important to remember that `useState` only works with objects or arrays. It
|
||||||
|
is necessary, since Owl needs to react to a change in state.
|
||||||
|
|
||||||
|
### `onMounted`
|
||||||
|
|
||||||
|
`onMounted` is not a user hook, but is a building block designed to help make useful
|
||||||
|
abstractions. `onMounted` registers a callback, which will be called when the component
|
||||||
|
is mounted (see example on top of this page).
|
||||||
|
|
||||||
|
### `onWillUnmount`
|
||||||
|
|
||||||
|
`onWillUnmount` is not a user hook, but is a building block designed to help make useful
|
||||||
|
abstractions. `onWillUnmount` registers a callback, which will be called when the component
|
||||||
|
is unmounted (see example on top of this page).
|
||||||
|
|
||||||
|
### `onWillPatch`
|
||||||
|
|
||||||
|
`onWillPatch` is not a user hook, but is a building block designed to help make useful
|
||||||
|
abstractions. `onWillPatch` registers a callback, which will be called just
|
||||||
|
before the component patched.
|
||||||
|
|
||||||
|
### `onPatched`
|
||||||
|
|
||||||
|
`onPatched` is not a user hook, but is a building block designed to help make useful
|
||||||
|
abstractions. `onPatched` registers a callback, which will be called just
|
||||||
|
after the component patched.
|
||||||
|
|
||||||
|
### `onWillStart`
|
||||||
|
|
||||||
|
`onWillStart` is an asynchronous hook. This means that the function registered
|
||||||
|
in the hook will be run just before the component is first rendered and can return a
|
||||||
|
promise, to express the fact that it is an asynchronous operation.
|
||||||
|
|
||||||
|
Note that if there are more than one `onWillStart` registered callback, then they
|
||||||
|
will all be run in parallel.
|
||||||
|
|
||||||
|
It can be used to load some initial data. For example, the following hook will
|
||||||
|
automatically load some data from the server, and return an object that will
|
||||||
|
be ready whenever the component is rendered:
|
||||||
|
|
||||||
|
```js
|
||||||
|
function useLoader() {
|
||||||
|
const component = Component.current;
|
||||||
|
const record = useState({});
|
||||||
|
onWillStart(async () => {
|
||||||
|
const recordId = component.props.id;
|
||||||
|
Object.assign(record, await fetchSomeRecord(recordId));
|
||||||
|
});
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Note that this example does not update the record value whenever props are
|
||||||
|
updated. For that situation, we need to use the `onWillUpdateProps` hook.
|
||||||
|
|
||||||
|
### `onWillUpdateProps`
|
||||||
|
|
||||||
|
Just like `onWillStart`, `onWillUpdateProps` is an asynchronous hook. It is
|
||||||
|
designed to be run whenever the component props are updated. This could be
|
||||||
|
useful to perform some asynchronous task such as fetching updated data.
|
||||||
|
|
||||||
|
```js
|
||||||
|
function useLoader() {
|
||||||
|
const component = Component.current;
|
||||||
|
const record = useState({});
|
||||||
|
|
||||||
|
async function updateRecord(id) {
|
||||||
|
Object.assign(record, await fetchSomeRecord(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
onWillStart(() => updateRecord(component.props.id));
|
||||||
|
onWillUpdateProps(nextProps => updateRecord(nextProps.id));
|
||||||
|
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Note that if there are more than one `onWillUpdateProps` registered callback,
|
||||||
|
then they will all be run in parallel.
|
||||||
|
|
||||||
|
### `useContext`
|
||||||
|
|
||||||
|
See [`useContext`](context.md#usecontext) for reference documentation.
|
||||||
|
|
||||||
|
### `useRef`
|
||||||
|
|
||||||
|
The `useRef` hook is useful when we need a way to interact with some inside part
|
||||||
|
of a component, rendered by Owl. It can work either on a DOM node, or on a component,
|
||||||
|
tagged by the `t-ref` directive:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<div>
|
||||||
|
<div t-ref="someDiv"/>
|
||||||
|
<SubComponent t-ref="someComponent"/>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
In this example, the component will be able to access the `div` and the component
|
||||||
|
`SubComponent` using the `useRef` hook:
|
||||||
|
|
||||||
|
```js
|
||||||
|
class Parent extends Component {
|
||||||
|
subRef = useRef("someComponent");
|
||||||
|
divRef = useRef("someDiv");
|
||||||
|
|
||||||
|
someMethod() {
|
||||||
|
// here, if component is mounted, refs are active:
|
||||||
|
// - this.divRef.el is the div HTMLElement
|
||||||
|
// - this.subRef.comp is the instance of the sub component
|
||||||
|
// - this.subRef.el is the root HTML node of the sub component (i.e. this.subRef.comp.el)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
As shown by the example above, html elements are accessed by using the `el`
|
||||||
|
key, and components references are accessed with `comp`.
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- if used on a component, the reference will be set in the `refs`
|
||||||
|
variable between `willPatch` and `patched`,
|
||||||
|
- on a component, accessing `ref.el` will get the root node of the component.
|
||||||
|
|
||||||
|
The `t-ref` directive also accepts dynamic values with string interpolation
|
||||||
|
(like the [`t-attf-`](qweb_templating_language.md#dynamic-attributes) and
|
||||||
|
`t-component` directives). For example,
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<div t-ref="component_{{someCondition ? '1' : '2'}}"/>
|
||||||
|
```
|
||||||
|
|
||||||
|
Here, the references need to be set like this:
|
||||||
|
|
||||||
|
```js
|
||||||
|
this.ref1 = useRef("component_1");
|
||||||
|
this.ref2 = useRef("component_2");
|
||||||
|
```
|
||||||
|
|
||||||
|
References are only guaranteed to be active while the parent component is mounted.
|
||||||
|
If this is not the case, accessing `el` or `comp` on it will return `null`.
|
||||||
|
|
||||||
|
### `useSubEnv`
|
||||||
|
|
||||||
|
The environment is sometimes useful to share some common information between
|
||||||
|
all components. But sometimes, we want to _scope_ that knowledge to a subtree.
|
||||||
|
|
||||||
|
For example, if we have a form view component, maybe we would like to make some
|
||||||
|
`model` object available to all sub components, but not to the whole application.
|
||||||
|
This is where the `useSubEnv` hook may be useful: it lets a component add some
|
||||||
|
information to the environment in a way that only the component and its children
|
||||||
|
can access it:
|
||||||
|
|
||||||
|
```js
|
||||||
|
class FormComponent extends Component {
|
||||||
|
constructor(...args) {
|
||||||
|
super(...args);
|
||||||
|
const model = makeModel();
|
||||||
|
useSubEnv({ model });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `useSubEnv` takes one argument: an object which contains some key/value that
|
||||||
|
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
|
||||||
|
affected.
|
||||||
|
|
||||||
|
### `useStore`
|
||||||
|
|
||||||
|
The `useStore` hook is the entry point for a component to connect to the store.
|
||||||
|
See the [store documentation](store.md) for more information.
|
||||||
|
|
||||||
|
### `useDispatch`
|
||||||
|
|
||||||
|
The `useDispatch` hook is the way for components to get a reference to the store
|
||||||
|
`dispatch` function. See the [store documentation](store.md) for more information.
|
||||||
|
|
||||||
|
### `useGetters`
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
### Making customized hooks
|
||||||
|
|
||||||
|
Hooks are a wonderful way to organize the code of a complex component by feature
|
||||||
|
instead of by lifecycle methods. They are like mixins, except that they can be
|
||||||
|
easily composed together.
|
||||||
|
|
||||||
|
But, like every good things in life, hooks should be used with moderation. They are
|
||||||
|
not the solution to every problem.
|
||||||
|
|
||||||
|
- they may be overkill: if your component needs to perform some action specific
|
||||||
|
to itself (so, the specific code does not need to be shared), there is nothing
|
||||||
|
wrong with a simple class method:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// maybe overkill
|
||||||
|
class A extends Component {
|
||||||
|
constructor(...args) {
|
||||||
|
super(...args);
|
||||||
|
useMySpecificHook();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ok
|
||||||
|
class B extends Component {
|
||||||
|
constructor(...args) {
|
||||||
|
super(...args);
|
||||||
|
this.performSpecificTask();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Note that the second solution is easier to extend in sub components.
|
||||||
|
|
||||||
|
- they may be harder to test: if a customized hook injects some external side
|
||||||
|
effect dependency, then it is harder to test without doing some non obvious
|
||||||
|
manipulation. For example, assume that we want to give a reference to a
|
||||||
|
router in a `useRouter` hook. We could do this:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const router = new Router(...);
|
||||||
|
|
||||||
|
function useRouter() {
|
||||||
|
return router;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
As you can see, this does not _hook_ into the internal of the component. It
|
||||||
|
simply returns a global object, which is difficult to mock.
|
||||||
|
|
||||||
|
A better way would be to do something like this: get the reference from the
|
||||||
|
environment.
|
||||||
|
|
||||||
|
```js
|
||||||
|
function useRouter() {
|
||||||
|
return Component.current.env.router;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
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
|
||||||
|
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.
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
# 🦉 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](component.md#slots).
|
||||||
|
|
||||||
|
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 } = owl;
|
||||||
|
const { Portal } = owl.misc;
|
||||||
|
|
||||||
|
class TeleportedComponent extends Component {}
|
||||||
|
class App extends Component {
|
||||||
|
static components = { Portal, TeleportedComponent };
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = new App();
|
||||||
|
app.mount(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](component.md#event-handling) 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`
|
||||||
|
|
||||||
|
When this component is used, a new rendering sub tree is created, such that the
|
||||||
|
rendering of that component (and its children) is not tied to the rendering of
|
||||||
|
the rest of the interface. It can be used on an asynchronous component, to
|
||||||
|
prevent it from delaying the rendering of the whole interface, or on a
|
||||||
|
synchronous one, such that its rendering isn't delayed by other (asynchronous)
|
||||||
|
components. Note that this directive has no effect on the first rendering, but
|
||||||
|
only on subsequent ones (triggered by state or props changes).
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<div t-name="ParentComponent">
|
||||||
|
<SyncChild />
|
||||||
|
<AsyncRoot>
|
||||||
|
<AsyncChild/>
|
||||||
|
</AsyncRoot>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
The `AsyncRoot` assumes that there is exactly one root node inside it. It can
|
||||||
|
be a dom node or a component.
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# 🦉 Observer 🦉
|
||||||
|
|
||||||
|
Owl needs to be able to react to state changes. For example, whenever the state
|
||||||
|
of a component is changed, Owl needs to rerender it. To help with that, there is
|
||||||
|
an Observer class. Its job is to observe the state of an object (or array), and
|
||||||
|
to react to any change. The observer is implemented with the native `Proxy`
|
||||||
|
object. Note that this means that it will not work on older browsers.
|
||||||
|
|
||||||
|
Note that the `Observer` is used by the `useState` and `useContext` hooks. This
|
||||||
|
is the way most Owl applications will create observers. For the majority of
|
||||||
|
use cases, there is no need to directly instantiate an observer.
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
For example, this code will display `update` in the console:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const observer = new owl.Observer();
|
||||||
|
observer.notifyCB = () => console.log("update");
|
||||||
|
const obj = observer.observe({ a: { b: 1 } });
|
||||||
|
|
||||||
|
obj.a.b = 2;
|
||||||
|
```
|
||||||
|
|
||||||
|
This example shows that an observer can observe nested properties.
|
||||||
|
|
||||||
|
## Reference
|
||||||
|
|
||||||
|
**observe** An observer can observe multiple values with the `observe` method.
|
||||||
|
This method takes an object or an array as its argument and will return a proxy
|
||||||
|
(which is mapped to the initial object/array). With this proxy, the observer
|
||||||
|
can detect whenever any internal value is changed.
|
||||||
|
|
||||||
|
**Registering a callback** Whenever an observer sees a state change, it will
|
||||||
|
call its `notifyCB` method. No additional information is given to the callback.
|
||||||
|
|
||||||
|
**deepRevNumber** Each observed value has an internal revision number, which
|
||||||
|
is incremented every time the value is observed. Sometimes, it can be useful
|
||||||
|
to obtain that number:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const observer = new owl.Observer();
|
||||||
|
const obj = observer.observe({ a: { b: 1 } });
|
||||||
|
|
||||||
|
observer.deepRevNumber(obj.a); // 1
|
||||||
|
obj.a.b = 2;
|
||||||
|
|
||||||
|
observer.deepRevNumber(obj.a); // 2
|
||||||
|
```
|
||||||
|
|
||||||
|
The `deepRevNumber` can also return 0, which indicates that the value is not
|
||||||
|
observed.
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
# 🦉 Props 🦉
|
||||||
|
|
||||||
|
## Content
|
||||||
|
|
||||||
|
- [Overview](#overview)
|
||||||
|
- [Definition](#definition)
|
||||||
|
- [Good Practices](#good-practices)
|
||||||
|
- [Dynamic Props](#dynamic-props)
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
In Owl, `props` (short for _properties_) is an object which contains every piece
|
||||||
|
of data given to a component by its parent.
|
||||||
|
|
||||||
|
```js
|
||||||
|
class Child extends Component {
|
||||||
|
static template = xml`<div><t t-esc="props.a"/><t t-esc="props.b"/></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
class Parent extends Component {
|
||||||
|
static template = xml`<div><ComponentA a="state.a" b="'string'"/></div>`;
|
||||||
|
static components = { Child };
|
||||||
|
state = useState({ a: "fromparent" });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
In this example, the `Child` component receives two props from its parent: `a`
|
||||||
|
and `b`. They are collected into a `props` object by Owl, with each value being
|
||||||
|
evaluated in the context of the parent. So, `props.a` is equal to `'fromparent'` and
|
||||||
|
`props.b` is equal to `'string'`.
|
||||||
|
|
||||||
|
Note that `props` is an object that only makes sense from the perspective of the
|
||||||
|
child component.
|
||||||
|
|
||||||
|
## Definition
|
||||||
|
|
||||||
|
The `props` object is made of every attributes defined on the template, with the
|
||||||
|
following exceptions:
|
||||||
|
|
||||||
|
- every attribute starting with `t-` are not props (they are QWeb directives),
|
||||||
|
- `style` and `class` attributes are excluded as well (they are applied by Owl on
|
||||||
|
the root element of the component).
|
||||||
|
|
||||||
|
In the following example:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<div>
|
||||||
|
<ComponentA a="state.a" b="'string'"/>
|
||||||
|
<ComponentB t-if="state.flag" model="model"/>
|
||||||
|
<ComponentC style="color:red;" class="left-pane" />
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
the `props` object contains the following keys:
|
||||||
|
|
||||||
|
- for `ComponentA`: `a` and `b`,
|
||||||
|
- for `ComponentB`: `model`,
|
||||||
|
- for `ComponentC`: empty object
|
||||||
|
|
||||||
|
## Good Practices
|
||||||
|
|
||||||
|
A `props` object is a collection of values that come from the parent. As such,
|
||||||
|
they are owned by the parent, and should never be modified by the child:
|
||||||
|
|
||||||
|
```js
|
||||||
|
class MyComponent extends Component {
|
||||||
|
constructor(parent, props) {
|
||||||
|
super(parent, props);
|
||||||
|
props.a.b = 43; // Never do that!!!
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Props should be considered readonly, from the perspective of the child component.
|
||||||
|
If there is a need to modify them, then the request to update them should be
|
||||||
|
sent to the parent (for example, with an event).
|
||||||
|
|
||||||
|
Any value can go in a props. Strings, objects, classes, or even callbacks could
|
||||||
|
be given to a child component (but then, in the case of callbacks, communicating
|
||||||
|
with events seems more appropriate).
|
||||||
|
|
||||||
|
## Dynamic Props
|
||||||
|
|
||||||
|
The `t-props` directive can be used to specify totally dynamic props:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<div t-name="ParentComponent">
|
||||||
|
<Child t-props="some.obj"/>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
class ParentComponent {
|
||||||
|
static components = { Child };
|
||||||
|
some = { obj: { a: 1, b: 2 } };
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
# 🦉 Props Validation 🦉
|
||||||
|
|
||||||
|
As an application becomes complex, it may be quite unsafe to define props in an informal way. This leads to two issues:
|
||||||
|
|
||||||
|
- hard to tell how a component should be used, by looking at its code.
|
||||||
|
- unsafe, it is easy to send wrong props into a component, either by refactoring a component, or one of its parents.
|
||||||
|
|
||||||
|
A props type system solves both issues, by describing the types and shapes
|
||||||
|
of the props. Here is how it works in Owl:
|
||||||
|
|
||||||
|
- `props` key is a static key (so, different from `this.props` in a component instance)
|
||||||
|
- it is optional: it is ok for a component to not define a `props` key.
|
||||||
|
- props are validated whenever a component is created/updated
|
||||||
|
- props are only validated in `dev` mode (see [config page](config.md#mode))
|
||||||
|
- if a key does not match the description, an error is thrown
|
||||||
|
- it validates keys defined in (static) `props`. Additional keys given by the
|
||||||
|
parent will cause an error.
|
||||||
|
|
||||||
|
For example:
|
||||||
|
|
||||||
|
```js
|
||||||
|
class ComponentA extends owl.Component {
|
||||||
|
static props = ['id', 'url'];
|
||||||
|
|
||||||
|
...
|
||||||
|
}
|
||||||
|
|
||||||
|
class ComponentB extends owl.Component {
|
||||||
|
static props = {
|
||||||
|
count: {type: Number},
|
||||||
|
messages: {
|
||||||
|
type: Array,
|
||||||
|
element: {type: Object, shape: {id: Boolean, text: 'string' }
|
||||||
|
},
|
||||||
|
date: Date,
|
||||||
|
combinedVal: [Number, Boolean]
|
||||||
|
};
|
||||||
|
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- it is an object or a list of strings
|
||||||
|
- a list of strings is a simplified props definition, which only lists the name
|
||||||
|
of the props. Also, if the name ends with `?`, it is considered optional.
|
||||||
|
- all props are by default required, unless they are defined with `optional: true`
|
||||||
|
(in that case, validation is only done if there is a value)
|
||||||
|
- valid types are: `Number, String, Boolean, Object, Array, Date, Function`, and all
|
||||||
|
constructor functions (so, if you have a `Person` class, it can be used as a type)
|
||||||
|
- arrays are homogeneous (all elements have the same type/shape)
|
||||||
|
|
||||||
|
For each key, a `prop` definition is either a boolean, a constructor, a list of constructors, or an object:
|
||||||
|
|
||||||
|
- a boolean: indicate that the props exists, and is mandatory.
|
||||||
|
- a constructor: this should describe the type, for example: `id: Number` describe
|
||||||
|
the props `id` as a number
|
||||||
|
- a list of constructors. In that case, this means that we allow more than one
|
||||||
|
type. For example, `id: [Number, String]` means that `id` can be either a string
|
||||||
|
or a number.
|
||||||
|
- an object. This makes it possible to have more expressive definition. The following sub keys are then allowed (but not mandatory):
|
||||||
|
- `type`: the main type of the prop being validated
|
||||||
|
- `element`: if the type was `Array`, then the `element` key describes the type of each element in the array. If it is not set, then we only validate the array, not its elements,
|
||||||
|
- `shape`: if the type was `Object`, then the `shape` key describes the interface of the object. If it is not set, then we only validate the object, not its elements,
|
||||||
|
- `validate`: this is a function which should return a boolean to determine if
|
||||||
|
the value is valid or not. Useful for custom validation logic.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// only the existence of those 3 keys is documented
|
||||||
|
static props = ['message', 'id', 'date'];
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
// size is optional
|
||||||
|
static props = ['message', 'size?'];
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
static props = {
|
||||||
|
messageIds: {type: Array, element: Number}, // list of number
|
||||||
|
otherArr: {type: Array}, // just array. no validation is made on sub elements
|
||||||
|
otherArr2: Array, // same as otherArr
|
||||||
|
someObj: {type: Object}, // just an object, no internal validation
|
||||||
|
someObj2: {
|
||||||
|
type: Object,
|
||||||
|
shape: {
|
||||||
|
id: Number,
|
||||||
|
name: {type: String, optional: true},
|
||||||
|
url: String
|
||||||
|
]}, // object, with keys id (number), name (string, optional) and url (string)
|
||||||
|
someFlag: Boolean, // a boolean, mandatory (even if `false`)
|
||||||
|
someVal: [Boolean, Date], // either a boolean or a date
|
||||||
|
otherValue: true, // indicates that it is a prop
|
||||||
|
kindofsmallnumber: {
|
||||||
|
type: Number,
|
||||||
|
validate: n => (0 <= n && n <= 10)
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
validate: e => ["small", "medium", "large"].includes(e)
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
# 🦉 QWeb Engine 🦉
|
||||||
|
|
||||||
|
## Content
|
||||||
|
|
||||||
|
- [Overview](#overview)
|
||||||
|
- [Reference](#reference)
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
[QWeb](https://www.odoo.com/documentation/13.0/reference/qweb.html) is the primary
|
||||||
|
templating engine used by Odoo. The QWeb class in the OWL project is an
|
||||||
|
implementation of that specification with a few interesting points:
|
||||||
|
|
||||||
|
- it compiles templates into functions that output a virtual DOM instead of a
|
||||||
|
string. This is necessary for the component system.
|
||||||
|
- it has a few extra directives: `t-component`, `t-on`, ...
|
||||||
|
|
||||||
|
We present in this section the engine, not the templating language.
|
||||||
|
|
||||||
|
## Reference
|
||||||
|
|
||||||
|
This section is about the javascript code that implements the `QWeb` specification.
|
||||||
|
Owl exports a `QWeb` class in `owl.QWeb`. To use it, it just needs to be
|
||||||
|
instantiated:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const qweb = new owl.QWeb();
|
||||||
|
```
|
||||||
|
|
||||||
|
Its API is quite simple:
|
||||||
|
|
||||||
|
- **`constructor(config)`**: constructor. Takes an optional configuration object
|
||||||
|
with an optional `templates` string to add initial
|
||||||
|
templates (see `addTemplates` for more information on format of the string)
|
||||||
|
and an optional `translateFn` translate function (see the section on
|
||||||
|
[translations](#translations)).
|
||||||
|
|
||||||
|
```js
|
||||||
|
const qweb = new owl.QWeb({ templates: TEMPLATES, translateFn: _t });
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`addTemplate(name, xmlStr, allowDuplicate)`**: add a specific template.
|
||||||
|
|
||||||
|
```js
|
||||||
|
qweb.addTemplate("mytemplate", "<div>hello</div>");
|
||||||
|
```
|
||||||
|
|
||||||
|
If the optional `allowDuplicate` is set to `true`, then `QWeb` will simply
|
||||||
|
ignore templates added for a second time. Otherwise, `QWeb` will crash.
|
||||||
|
|
||||||
|
- **`addTemplates(xmlStr)`**: add a list of templates (identified by `t-name`
|
||||||
|
attribute).
|
||||||
|
|
||||||
|
```js
|
||||||
|
const TEMPLATES = `
|
||||||
|
<templates>
|
||||||
|
<div t-name="App" class="main">main</div>
|
||||||
|
<div t-name="OtherComponent">other component</div>
|
||||||
|
</templates>`;
|
||||||
|
qweb.addTemplates(TEMPLATES);
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`render(name, context, extra)`**: renders a template. This returns a `vnode`,
|
||||||
|
which is a virtual representation of the DOM (see [vdom doc](../architecture/vdom.md)).
|
||||||
|
|
||||||
|
```js
|
||||||
|
const vnode = qweb.render("App", component);
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`renderToString(name, context)`**: renders a template, but returns an html
|
||||||
|
string.
|
||||||
|
|
||||||
|
```js
|
||||||
|
const str = qweb.renderToString("someTemplate", somecontext);
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`registerTemplate(name, template)`**: static function to register a global
|
||||||
|
QWeb template. This is useful for commonly used components accross the
|
||||||
|
application, and for making a template available to an application without
|
||||||
|
having a reference to the actual QWeb instance.
|
||||||
|
|
||||||
|
```js
|
||||||
|
QWeb.registerTemplate("mytemplate", `<div>some template</div>`);
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`registerComponent(name, Component)`**: static function to register an OWL Component
|
||||||
|
to QWeb's global registry. Globally registered Components can be used in
|
||||||
|
templates (see the `t-component` directive). This is useful for commonly used
|
||||||
|
components accross the application.
|
||||||
|
|
||||||
|
```js
|
||||||
|
class Dialog extends owl.Component { ... }
|
||||||
|
QWeb.registerComponent("Dialog", Dialog);
|
||||||
|
|
||||||
|
...
|
||||||
|
|
||||||
|
class ParentComponent extends owl.Component { ... }
|
||||||
|
qweb.addTemplate("ParentComponent", "<div><Dialog/></div>");
|
||||||
|
```
|
||||||
|
|
||||||
|
In some way, a `QWeb` instance is the core of an Owl application. It is the only
|
||||||
|
mandatory element of an [environment](environment.md). As such, it
|
||||||
|
has an extra responsibility: it can act as an event bus for internal communication
|
||||||
|
between Owl classes. This is the reason why `QWeb` actually extends [EventBus](event_bus.md).
|
||||||
|
|
||||||
|
### Translations
|
||||||
|
|
||||||
|
If properly setup, Owl QWeb engine can translate all rendered templates. To do
|
||||||
|
so, it needs a translate function, which takes a string and returns a string.
|
||||||
|
|
||||||
|
For example:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const translations = {
|
||||||
|
hello: "bonjour",
|
||||||
|
yes: "oui",
|
||||||
|
no: "non"
|
||||||
|
};
|
||||||
|
const translateFn = str => translations[str] || str;
|
||||||
|
|
||||||
|
const qweb = new QWeb({ translateFn });
|
||||||
|
```
|
||||||
|
|
||||||
|
Once setup, all rendered templates will be translated using `translateFn`:
|
||||||
|
|
||||||
|
- each text node will be replaced with its translation,
|
||||||
|
- each of the following attribute values will be translated as well: `title`,
|
||||||
|
`placeholder`, `label` and `alt`,
|
||||||
|
- translating text nodes can be disabled with the special attribute `t-translation`,
|
||||||
|
if its value is `off`.
|
||||||
|
|
||||||
|
So, with the above `translateFn`, the following templates:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<div>hello</div>
|
||||||
|
<div t-translation="off">hello</div>
|
||||||
|
<div>Are you sure?</div>
|
||||||
|
<input placeholder="hello" other="yes"/>
|
||||||
|
```
|
||||||
|
|
||||||
|
will be rendered as:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<div>bonjour</div>
|
||||||
|
<div>hello</div>
|
||||||
|
<div>Are you sure?</div>
|
||||||
|
<input placeholder="bonjour" other="yes"/>
|
||||||
|
```
|
||||||
|
|
||||||
|
Note that the translation is done during the compilation of the template, not
|
||||||
|
when it is rendered.
|
||||||
@@ -1,10 +1,9 @@
|
|||||||
# 🦉 QWeb 🦉
|
# 🦉 QWeb Templating Language🦉
|
||||||
|
|
||||||
## Content
|
## Content
|
||||||
|
|
||||||
- [Overview](#overview)
|
- [Overview](#overview)
|
||||||
- [Directives](#directives)
|
- [Directives](#directives)
|
||||||
- [QWeb Engine](#qweb-engine)
|
|
||||||
- [Reference](#reference)
|
- [Reference](#reference)
|
||||||
- [White Spaces](#white-spaces)
|
- [White Spaces](#white-spaces)
|
||||||
- [Root Nodes](#root-nodes)
|
- [Root Nodes](#root-nodes)
|
||||||
@@ -16,148 +15,72 @@
|
|||||||
- [Dynamic Attributes](#dynamic-attributes)
|
- [Dynamic Attributes](#dynamic-attributes)
|
||||||
- [Loops](#loops)
|
- [Loops](#loops)
|
||||||
- [Rendering Sub Templates](#rendering-sub-templates)
|
- [Rendering Sub Templates](#rendering-sub-templates)
|
||||||
|
- [Translations](#translations)
|
||||||
- [Debugging](#debugging)
|
- [Debugging](#debugging)
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
[QWeb](https://www.odoo.com/documentation/12.0/reference/qweb.html) is the primary templating engine used by Odoo. It is based on the XML format, and used
|
[QWeb](https://www.odoo.com/documentation/13.0/reference/qweb.html) is the primary
|
||||||
|
templating engine used by Odoo. It is based on the XML format, and used
|
||||||
mostly to generate HTML. In OWL, QWeb templates are compiled into functions that
|
mostly to generate HTML. In OWL, QWeb templates are compiled into functions that
|
||||||
generate a virtual dom representation of the HTML.
|
generate a virtual dom representation of the HTML.
|
||||||
|
|
||||||
Template directives are specified as XML attributes prefixed with `t-`, for instance `t-if` for conditionals, with elements and other attributes being rendered directly.
|
|
||||||
|
|
||||||
To avoid element rendering, a placeholder element `<t>` is also available, which executes its directive but doesn’t generate any output in and of itself.
|
|
||||||
|
|
||||||
```xml
|
```xml
|
||||||
<div>
|
<div>
|
||||||
<span t-if="somecondition">Some string</span>
|
<span t-if="somecondition">Some string</span>
|
||||||
<ul t-else="1">
|
<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>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
```
|
```
|
||||||
|
|
||||||
The QWeb class in the OWL project is an implementation of that specification
|
Template directives are specified as XML attributes prefixed with `t-`, for
|
||||||
with a few interesting points:
|
instance `t-if` for conditionals, with elements and other attributes being
|
||||||
|
rendered directly.
|
||||||
|
|
||||||
- it compiles templates into functions that output a virtual DOM instead of a
|
To avoid element rendering, a placeholder element `<t>` is also available, which
|
||||||
string. This is necessary for the component system.
|
executes its directive but doesn’t generate any output in and of itself.
|
||||||
- it has a few extra directives: `t-component`, `t-on`, ...
|
|
||||||
|
We present in this section the templating language, including its Owl specific
|
||||||
|
extensions.
|
||||||
|
|
||||||
## Directives
|
## Directives
|
||||||
|
|
||||||
We present here a list of all standard QWeb directives:
|
For reference, here is a list of all standard QWeb directives:
|
||||||
|
|
||||||
| Name | Description |
|
| Name | Description |
|
||||||
| ------------------------------ | ------------------------------------------------------------ |
|
| ------------------------------ | -------------------------------------------------------------- |
|
||||||
| `t-esc` | [Outputting safely a value](#outputting-data) |
|
| `t-esc` | [Outputting safely a value](#outputting-data) |
|
||||||
| `t-raw` | [Outputting value, without escaping](#outputting-data) |
|
| `t-raw` | [Outputting value, without escaping](#outputting-data) |
|
||||||
| `t-set`, `t-value` | [Setting variables](#setting-variables) |
|
| `t-set`, `t-value` | [Setting variables](#setting-variables) |
|
||||||
| `t-if`, `t-elif`, `t-else`, | [conditionally rendering](#conditionals) |
|
| `t-if`, `t-elif`, `t-else`, | [conditionally rendering](#conditionals) |
|
||||||
| `t-foreach`, `t-as` | [Loops](#loops) |
|
| `t-foreach`, `t-as` | [Loops](#loops) |
|
||||||
| `t-att`, `t-attf-*`, `t-att-*` | [Dynamic attributes](#dynamic-attributes) |
|
| `t-att`, `t-attf-*`, `t-att-*` | [Dynamic attributes](#dynamic-attributes) |
|
||||||
| `t-call` | [Rendering sub templates](#rendering-sub-templates) |
|
| `t-call` | [Rendering sub templates](#rendering-sub-templates) |
|
||||||
| `t-debug`, `t-log` | [Debugging](#debugging) |
|
| `t-debug`, `t-log` | [Debugging](#debugging) |
|
||||||
| `t-name` | [Defining a template (not really a directive)](#qweb-engine) |
|
| `t-translation` | [Disabling the translation of a node](#translations) |
|
||||||
|
| `t-name` | [Defining a template (not really a directive)](qweb_engine.md) |
|
||||||
|
|
||||||
The component system in Owl requires additional directives, to express various
|
The component system in Owl requires additional directives, to express various
|
||||||
needs. Here is a list of all Owl specific directives:
|
needs. Here is a list of all Owl specific directives:
|
||||||
|
|
||||||
| Name | Description |
|
| Name | Description |
|
||||||
| ------------------------------------------- | ----------------------------------------------------------------------------------- |
|
| ------------------------ | ------------------------------------------------------------------------------- |
|
||||||
| `t-component`, `t-keepalive`, `t-asyncroot` | [Defining a sub component](component.md#composition) |
|
| `t-component`, `t-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)](component.md#t-key-directive) |
|
| `t-key` | [Defining a key (to help virtual dom reconciliation)](#loops) |
|
||||||
| `t-on-*` | [Event handling](component.md#event-handling) |
|
| `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-mounted` | [Callback when a node or component is mounted](component.md#t-mounted-directive) |
|
| `t-slot` | [Rendering a slot](component.md#slots) |
|
||||||
| `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) |
|
|
||||||
|
|
||||||
## QWeb Engine
|
|
||||||
|
|
||||||
This section is about the javascript code that implements the `QWeb` specification.
|
|
||||||
Owl exports a `QWeb` class in `owl.QWeb`. To use it, it just needs to be
|
|
||||||
instantiated:
|
|
||||||
|
|
||||||
```js
|
|
||||||
const qweb = new owl.QWeb();
|
|
||||||
```
|
|
||||||
|
|
||||||
It's API is quite simple:
|
|
||||||
|
|
||||||
- **`constructor(data)`**: constructor. Takes an optional string to add initial
|
|
||||||
templates (see `addTemplates` for more information on format of the string).
|
|
||||||
|
|
||||||
```js
|
|
||||||
const qweb = new owl.QWeb(TEMPLATES);
|
|
||||||
```
|
|
||||||
|
|
||||||
- **`addTemplate(name, xmlStr, allowDuplicate)`**: add a specific template.
|
|
||||||
|
|
||||||
```js
|
|
||||||
qweb.addTemplate("mytemplate", "<div>hello</div>");
|
|
||||||
```
|
|
||||||
|
|
||||||
If the optional `allowDuplicate` is set to `true`, then `QWeb` will simply return whenever a template is added for a second time. Otherwise, `QWeb` will crash.
|
|
||||||
|
|
||||||
- **`addTemplates(xmlStr)`**: add a list of templates (identified by `t-name`
|
|
||||||
attribute).
|
|
||||||
|
|
||||||
```js
|
|
||||||
const TEMPLATES = `
|
|
||||||
<templates>
|
|
||||||
<div t-name="App" class="main">main</div>
|
|
||||||
<div t-name="OtherComponent">other component</div>
|
|
||||||
</templates>`;
|
|
||||||
qweb.addTemplates(TEMPLATES);
|
|
||||||
```
|
|
||||||
|
|
||||||
- **`render(name, context, extra)`**: renders a template. This returns a `vnode`,
|
|
||||||
which is a virtual representation of the DOM (see [vdom doc](vdom.md)).
|
|
||||||
|
|
||||||
```js
|
|
||||||
const vnode = qweb.render("App", component);
|
|
||||||
```
|
|
||||||
|
|
||||||
- **`renderToString(name, context)`**: renders a template, but returns an html
|
|
||||||
string.
|
|
||||||
|
|
||||||
```js
|
|
||||||
const str = qweb.renderToString("someTemplate", somecontext);
|
|
||||||
```
|
|
||||||
|
|
||||||
- **`register(name, Component)`**: static function to register an OWL Component
|
|
||||||
to QWeb's global registry. Globally registered Components can be used in
|
|
||||||
templates (see the `t-component` directive). This is useful for commonly used
|
|
||||||
components accross the application.
|
|
||||||
|
|
||||||
```js
|
|
||||||
class Dialog extends owl.Component { ... }
|
|
||||||
QWeb.register("Dialog", Dialog);
|
|
||||||
|
|
||||||
...
|
|
||||||
|
|
||||||
class ParentComponent extends owl.Component { ... }
|
|
||||||
qweb.addTemplate("ParentComponent", "<div><Dialog/></div>");
|
|
||||||
```
|
|
||||||
|
|
||||||
In some way, a `QWeb` instance is the core of an Owl application. It is the only
|
|
||||||
mandatory element of an [environment](component.md#environment). As such, it
|
|
||||||
has an extra responsability: it can act as an event bus for internal communication
|
|
||||||
between Owl classes. This is the reason why `QWeb` actually extends [EventBus](event_bus.md).
|
|
||||||
|
|
||||||
## Reference
|
## Reference
|
||||||
|
|
||||||
We define in this section the specification of how `QWeb` templates should be
|
|
||||||
rendered. Note that we only document here the standard QWeb specification. Owl
|
|
||||||
specific extensions are documented in various other parts of the documentation.
|
|
||||||
|
|
||||||
### White Spaces
|
### White Spaces
|
||||||
|
|
||||||
White spaces in a templates are handled in a special way:
|
White spaces in a template are handled in a special way:
|
||||||
|
|
||||||
- consecutive whitespaces are always condensed to a single whitespace
|
- consecutive whitespaces are always condensed to a single whitespace
|
||||||
- if a whitespace-only text node contains a linebreak, it is ignored
|
- if a whitespace-only text node contains a linebreak, it is ignored
|
||||||
@@ -192,14 +115,14 @@ root nodes.
|
|||||||
### Expression Evaluation
|
### Expression Evaluation
|
||||||
|
|
||||||
QWeb expressions are strings that will be processed at compile time. Each variable in
|
QWeb expressions are strings that will be processed at compile time. Each variable in
|
||||||
the javascript expression will be replaced by a lookup in the context (so, the
|
the javascript expression will be replaced with a lookup in the context (so, the
|
||||||
component). For example, `a + b.c(d)` will be converted into:
|
component). For example, `a + b.c(d)` will be converted into:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
context["a"] + context["b"].c(context["d"]);
|
context["a"] + context["b"].c(context["d"]);
|
||||||
```
|
```
|
||||||
|
|
||||||
It is useful to explain the various rules that applies on these expressions:
|
It is useful to explain the various rules that apply on these expressions:
|
||||||
|
|
||||||
1. it should be a simple expression which returns a value. It cannot be a statement.
|
1. it should be a simple expression which returns a value. It cannot be a statement.
|
||||||
|
|
||||||
@@ -225,14 +148,14 @@ It is useful to explain the various rules that applies on these expressions:
|
|||||||
3. it can use a few special operators to avoid using symbols such as `<`, `>`,
|
3. it can use a few special operators to avoid using symbols such as `<`, `>`,
|
||||||
`&` or `|`. This is useful to make sure that we still write valid XML.
|
`&` or `|`. This is useful to make sure that we still write valid XML.
|
||||||
|
|
||||||
| Word | will be replaced by |
|
| Word | replaced with |
|
||||||
| ----- | ------------------- |
|
| ----- | ------------- |
|
||||||
| `and` | `&&` |
|
| `and` | `&&` |
|
||||||
| `or` | `\|\|` |
|
| `or` | `\|\|` |
|
||||||
| `gt` | `>` |
|
| `gt` | `>` |
|
||||||
| `gte` | `>=` |
|
| `gte` | `>=` |
|
||||||
| `lt` | `<` |
|
| `lt` | `<` |
|
||||||
| `lte` | `<=` |
|
| `lte` | `<=` |
|
||||||
|
|
||||||
So, one can write this:
|
So, one can write this:
|
||||||
|
|
||||||
@@ -277,6 +200,11 @@ rendered with the value `value` set to `<span>foo</span>` in the rendering conte
|
|||||||
<p><span>foo</span></p>
|
<p><span>foo</span></p>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Note that since the content of the expression is not known beforehand, the `t-raw`
|
||||||
|
directive has to parse the html (and convert it to a virtual dom structure) for
|
||||||
|
each rendering. So, it will be much slower than a regular template. It is
|
||||||
|
therefore advised to limit the use of `t-raw` whenever possible.
|
||||||
|
|
||||||
### Setting Variables
|
### Setting Variables
|
||||||
|
|
||||||
QWeb allows creating variables from within the template, to memoize a computation (to use it multiple times), give a piece of data a clearer name, ...
|
QWeb allows creating variables from within the template, to memoize a computation (to use it multiple times), give a piece of data a clearer name, ...
|
||||||
@@ -376,14 +304,25 @@ If an expression evaluates to a falsy value, it will not be set at all:
|
|||||||
<div t-att-foo="false"/> <!-- result: <div></div> -->
|
<div t-att-foo="false"/> <!-- result: <div></div> -->
|
||||||
```
|
```
|
||||||
|
|
||||||
There is another way to format a string attribute: the `t-attf-` directive. With
|
It is sometimes convenient to format an attribute with string interpolation. In
|
||||||
it, you get string interpolation:
|
that case, the `t-attf-` directive can be used. It is useful when we need to mix
|
||||||
|
literal and dynamic elements, such as css classes.
|
||||||
|
|
||||||
```xml
|
```xml
|
||||||
<div t-attf-foo="a {{value1}} is {{value2}} of {{value3}} ]"/>
|
<div t-attf-foo="a {{value1}} is {{value2}} of {{value3}} ]"/>
|
||||||
<!-- result if values are set to 1,2 and 3: <div foo="a 0 is 1 of 2 ]"></div> -->
|
<!-- result if values are set to 1,2 and 3: <div foo="a 0 is 1 of 2 ]"></div> -->
|
||||||
```
|
```
|
||||||
|
|
||||||
|
If we need completely dynamic attribute names, then there is an additional
|
||||||
|
directive: `t-att`, which takes either an object (with keys mapping to their
|
||||||
|
values) or a pair `[key, value]`. For example:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<div t-att="{'a': 1, 'b': 2}"/> <!-- result: <div a="1" b="2"></div> -->
|
||||||
|
|
||||||
|
<div t-att="['a', 'b']"/> <!-- <div a="b"></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
|
||||||
@@ -418,7 +357,7 @@ is equivalent to the previous example.
|
|||||||
or an object (the current item will be the current key).
|
or an object (the current item will be the current key).
|
||||||
|
|
||||||
In addition to the name passed via t-as, `t-foreach` provides a few other
|
In addition to the name passed via t-as, `t-foreach` provides a few other
|
||||||
variables for various data points (note: `$as` will be replaced by the name
|
variables for various data points (note: `$as` will be replaced with the name
|
||||||
passed to `t-as`):
|
passed to `t-as`):
|
||||||
|
|
||||||
- `$as_value`: the current iteration value, identical to `$as` for lists and
|
- `$as_value`: the current iteration value, identical to `$as` for lists and
|
||||||
@@ -436,19 +375,83 @@ the context of the `t-foreach`, the value is copied at the end of the foreach
|
|||||||
into the global context.
|
into the global context.
|
||||||
|
|
||||||
```xml
|
```xml
|
||||||
<t t-set="existing_variable" t-value="False"/>
|
<t t-set="existing_variable" t-value="false"/>
|
||||||
<!-- existing_variable now False -->
|
<!-- existing_variable now False -->
|
||||||
|
|
||||||
<p t-foreach="Array(3)" t-as="i">
|
<p t-foreach="Array(3)" t-as="i">
|
||||||
<t t-set="existing_variable" t-value="True"/>
|
<t t-set="existing_variable" t-value="true"/>
|
||||||
<t t-set="new_variable" t-value="True"/>
|
<t t-set="new_variable" t-value="true"/>
|
||||||
<!-- existing_variable and new_variable now True -->
|
<!-- existing_variable and new_variable now true -->
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!-- existing_variable always True -->
|
<!-- existing_variable always true -->
|
||||||
<!-- new_variable undefined -->
|
<!-- new_variable undefined -->
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Even though Owl tries to be as declarative as possible, the DOM does not fully
|
||||||
|
expose its state declaratively in the DOM tree. For example, the scrolling state,
|
||||||
|
the current user selection, the focused element or the state of an input are not
|
||||||
|
set as attribute in the DOM tree. This is why we use a virtual dom
|
||||||
|
algorithm to keep the actual DOM node as much as possible.
|
||||||
|
|
||||||
|
However, in some situations, this is not enough, and we need to help Owl decide
|
||||||
|
if an element is actually the same, or is a different element with the same
|
||||||
|
properties.
|
||||||
|
|
||||||
|
Consider the following situation: we have a list of two items `[{text: "a"}, {text: "b"}]`
|
||||||
|
and we render them in this template:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<p t-foreach="items" t-as="item"><t t-esc="item.text"/></p>
|
||||||
|
```
|
||||||
|
|
||||||
|
The result will be two `<p>` tags with text `a` and `b`. Now, if we swap them,
|
||||||
|
and rerender the template, Owl needs to know what the intent is:
|
||||||
|
|
||||||
|
- should Owl actually swap the DOM nodes,
|
||||||
|
- or should it keep the DOM nodes, but with an updated text content?
|
||||||
|
|
||||||
|
This might look trivial, but it actually matters. These two possibilities lead
|
||||||
|
to different results in some cases. For example, if the user selected the text
|
||||||
|
of the first `p`, swapping them will keep the selection while updating the
|
||||||
|
text content will not.
|
||||||
|
|
||||||
|
There are many other cases where this is important: `input` tags with their
|
||||||
|
value, css classes and animations, scroll position...
|
||||||
|
|
||||||
|
So, the `t-key` directive is used to give an identity to an element. It allows
|
||||||
|
Owl to understand if different elements of a list are actually different or not.
|
||||||
|
|
||||||
|
The above example could be modified by adding an ID: `[{id: 1, text: "a"}, {id: 2, text: "b"}]`.
|
||||||
|
Then, the template could look like this:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<p t-foreach="items" t-as="item" t-key="item.id"><t t-esc="item.text"/></p>
|
||||||
|
```
|
||||||
|
|
||||||
|
The `t-key` directive is useful for lists (`t-foreach`). A key should be
|
||||||
|
a unique number or string (objects will not work: they will be cast to the
|
||||||
|
`"[object Object]"` string, which is obviously not unique).
|
||||||
|
|
||||||
|
Also, the key can be set on a `t` tag or on its children. The following variations
|
||||||
|
are all equivalent:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<p t-foreach="items" t-as="item" t-key="item.id">
|
||||||
|
<t t-esc="item.text"/>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<t t-foreach="items" t-as="item" t-key="item.id">
|
||||||
|
<p t-esc="item.text"/>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
<t t-foreach="items" t-as="item">
|
||||||
|
<p t-key="item.id" t-esc="item.text"/>
|
||||||
|
</t>
|
||||||
|
```
|
||||||
|
|
||||||
|
If there is no `t-key` directive, Owl will use the index as a default key.
|
||||||
|
|
||||||
### 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
|
||||||
@@ -497,6 +500,30 @@ 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 -->
|
||||||
|
```
|
||||||
|
|
||||||
|
### Translations
|
||||||
|
|
||||||
|
By default, QWeb specify that templates should be translated. If this behaviour
|
||||||
|
is not wanted, there is a `t-translation` directive which can turn off
|
||||||
|
translations (if it is set to the `off` value), with the following rules:
|
||||||
|
|
||||||
|
- each text node will be replaced with its translation,
|
||||||
|
- each of the following attribute values will be translated as well: `title`,
|
||||||
|
`placeholder`, `label` and `alt`,
|
||||||
|
- translating text nodes can be disabled with the special attribute `t-translation`,
|
||||||
|
if its value is `off`.
|
||||||
|
|
||||||
|
See [here](qweb_engine.md#translations) for more information on how to setup a
|
||||||
|
translate function in Owl QWeb.
|
||||||
|
|
||||||
### Debugging
|
### Debugging
|
||||||
|
|
||||||
The javascript QWeb implementation provides two useful debugging directives:
|
The javascript QWeb implementation provides two useful debugging directives:
|
||||||
@@ -518,4 +545,4 @@ will stop execution if the browser dev tools are open.
|
|||||||
<t t-log="foo"/>
|
<t t-log="foo"/>
|
||||||
```
|
```
|
||||||
|
|
||||||
will print 42 to the console
|
will print 42 to the console.
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
# 🦉 Router 🦉
|
||||||
|
|
||||||
|
## Content
|
||||||
|
|
||||||
|
- [Overview](#overview)
|
||||||
|
- [Example](#example)
|
||||||
|
- [Reference](#reference)
|
||||||
|
- [Route Definition](#route-definition)
|
||||||
|
- [Router](#router)
|
||||||
|
- [Navigation Guards](#navigation-guards)
|
||||||
|
- [RouteComponent](#routecomponent)
|
||||||
|
- [Link](#link)
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
It is often useful to organize an application around urls. If the application is
|
||||||
|
a single page application, then we need a way to manage those urls in the browser.
|
||||||
|
This is why there are many different routers for different frameworks. A generic
|
||||||
|
router can do the job just fine, but a specialized router for Owl can give a
|
||||||
|
better developer experience.
|
||||||
|
|
||||||
|
The Owl router support the following features:
|
||||||
|
|
||||||
|
- `history` or `hash` mode
|
||||||
|
- declarative routes
|
||||||
|
- route redirection
|
||||||
|
- navigation guards
|
||||||
|
- parameterized routes
|
||||||
|
- a `<Link/>` component
|
||||||
|
- a `<RouteComponent/>` component
|
||||||
|
|
||||||
|
Note that it is still in early stage of developments, and there are probably
|
||||||
|
still some issues.
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
To use the Owl router, there are some steps that needs to be done:
|
||||||
|
|
||||||
|
- declare some routes
|
||||||
|
- create a router
|
||||||
|
- add it to the environment
|
||||||
|
|
||||||
|
```js
|
||||||
|
async function protectRoute({ env, to }) {
|
||||||
|
if (!env.session.authUser) {
|
||||||
|
env.session.setNextRoute(to.name);
|
||||||
|
return { to: "SIGN_IN" };
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ROUTES = [
|
||||||
|
{ name: "LANDING", path: "/", component: Landing },
|
||||||
|
{ name: "TASK", path: "/tasks/{{id}}", component: Task },
|
||||||
|
{ name: "SIGN_UP", path: "/signup", component: SignUp },
|
||||||
|
{ name: "SIGN_IN", path: "/signin", component: SignIn },
|
||||||
|
{ name: "ADMIN", path: "/admin", component: Admin, beforeRouteEnter: protectRoute },
|
||||||
|
{ name: "ACCOUNT", path: "/account", component: Account, beforeRouteEnter: protectRoute },
|
||||||
|
{ name: "UNKNOWN", path: "*", redirect: { to: "LANDING" } }
|
||||||
|
];
|
||||||
|
|
||||||
|
function makeEnvironment() {
|
||||||
|
...
|
||||||
|
const env = { qweb };
|
||||||
|
env.session = new Session(env);
|
||||||
|
env.router = new owl.router.Router(env, ROUTES);
|
||||||
|
await env.router.start();
|
||||||
|
return env;
|
||||||
|
}
|
||||||
|
|
||||||
|
App.env = makeEnvironment();
|
||||||
|
// create root component here
|
||||||
|
```
|
||||||
|
|
||||||
|
Notice that the router needs to be started. This is an asynchronous operation
|
||||||
|
because it needs to apply the potential navigation guards on the current route
|
||||||
|
(which may or may not mean that the application is redirected to another route).
|
||||||
|
|
||||||
|
## Reference
|
||||||
|
|
||||||
|
### Route definition
|
||||||
|
|
||||||
|
A route need to be defined as an object with the following keys:
|
||||||
|
|
||||||
|
- `name` (optional): a (unique) string useful to identify the current route. If not
|
||||||
|
given, it will be assigned an automatic name,
|
||||||
|
- `path`: a string describing the url. It can be static: `/admin` or dynamic: `/users/{{id}}`.
|
||||||
|
It also can be `*`, to catch all remaining routes.
|
||||||
|
- `component` (optional): an Owl component that will be used by the `t-routecomponent`
|
||||||
|
directive if the route is active
|
||||||
|
- `redirect` (optional): should be destination object (with optional keys `path`, `to` and `params`) if given, the application will be redirected to the destination whenever we match this route
|
||||||
|
- `beforeRouteEnter`: defines a [navigation guard](#navigation-guards).
|
||||||
|
|
||||||
|
### `Router`
|
||||||
|
|
||||||
|
The `Router` constructor takes three arguments:
|
||||||
|
|
||||||
|
- `env`: a valid environment,
|
||||||
|
- a list of routes,
|
||||||
|
- an optional object (with the only key `mode` which can be `history` (default
|
||||||
|
value) or `hash`).
|
||||||
|
|
||||||
|
`history` will use the browser [History API](https://developer.mozilla.org/en-US/docs/Web/API/History_API) as the mechanism to manage URL.\
|
||||||
|
Example: `https://yourdomain.tld/my_custom_route`.\
|
||||||
|
For this mechanism to work, you need a way to configure your web server accordingly.
|
||||||
|
|
||||||
|
`hash` will manipulate the hash of the URL.\
|
||||||
|
Example: `https://yourdomain.tld/index.html#/my_custom_route`.
|
||||||
|
|
||||||
|
```js
|
||||||
|
const ROUTES = [...];
|
||||||
|
const router = new owl.router.Router(env, ROUTES, {mode: 'history'});
|
||||||
|
```
|
||||||
|
|
||||||
|
Note that the route are defined in a list, and the order matters: the router
|
||||||
|
tries to find a match by going down the list.
|
||||||
|
|
||||||
|
The router needs to be added to the environment in the `router` sub key.
|
||||||
|
|
||||||
|
Once a router is created, it needs to be started. This is necessary to initialize
|
||||||
|
its current state to the current URL (and also, to potentially apply any
|
||||||
|
navigation guards and/or redirecting).
|
||||||
|
|
||||||
|
```js
|
||||||
|
await router.start();
|
||||||
|
```
|
||||||
|
|
||||||
|
Once started, the router will keep track of the current url and reflect its
|
||||||
|
value in two keys:
|
||||||
|
|
||||||
|
- `router.currentRoute`
|
||||||
|
- `router.currentParams`
|
||||||
|
|
||||||
|
The router also has a `navigate` method, useful to programmatically change the
|
||||||
|
application to another state (and the url):
|
||||||
|
|
||||||
|
```js
|
||||||
|
router.navigate({ to: "USER", params: { id: 51 } });
|
||||||
|
```
|
||||||
|
|
||||||
|
### Navigation Guards
|
||||||
|
|
||||||
|
Navigation guards are very useful to be able to execute some business logic/
|
||||||
|
perform some actions or redirect to other routes whenever the application is
|
||||||
|
entering a new route. For example, the following guard checks if there is an
|
||||||
|
authenticated user, and if it is not the case, redirect to the sign in route.
|
||||||
|
|
||||||
|
```js
|
||||||
|
async function protectRoute({ env, to }) {
|
||||||
|
if (!env.session.authUser) {
|
||||||
|
env.session.setNextRoute(to.name);
|
||||||
|
return { to: "SIGN_IN" };
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
A navigation guard is a function that returns a promise, which either resolves
|
||||||
|
to `true` (the navigation is accepted), or to another destination object.
|
||||||
|
|
||||||
|
### `RouteComponent`
|
||||||
|
|
||||||
|
The `RouteComponent` component directs Owl to render the component associated
|
||||||
|
to the currently active route (if any):
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<div t-name="App">
|
||||||
|
<NavBar />
|
||||||
|
<RouteComponent />
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### `Link`
|
||||||
|
|
||||||
|
The `Link` component is a Owl component which render as a `<a>` tag with any
|
||||||
|
content. It will compute the proper href from its props, and allow Owl to
|
||||||
|
properly navigate to a given url if clicked on it.
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<Link to="'HOME'">Home</Link>
|
||||||
|
```
|
||||||
@@ -0,0 +1,337 @@
|
|||||||
|
# 🦉 Store 🦉
|
||||||
|
|
||||||
|
## Content
|
||||||
|
|
||||||
|
- [Overview](#overview)
|
||||||
|
- [Example](#example)
|
||||||
|
- [Reference](#reference)
|
||||||
|
- [Store](#store)
|
||||||
|
- [Actions](#actions)
|
||||||
|
- [Getters](#getters)
|
||||||
|
- [Connecting a Component](#connecting-a-component)
|
||||||
|
- [`useStore`](#usestore)
|
||||||
|
- [`useDispatch`](#usedispatch)
|
||||||
|
- [`useGetters`](#usegetters)
|
||||||
|
- [Semantics](#semantics)
|
||||||
|
- [Good Practices](#good-practices)
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Managing the state in an application is not an easy task. In some cases, the
|
||||||
|
state of an application can be part of the component tree, in a natural way.
|
||||||
|
However, there are situations where some parts of the state need to be displayed
|
||||||
|
in various parts of the user interface, and then, it is not obvious which
|
||||||
|
component should own which part of the state.
|
||||||
|
|
||||||
|
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
|
||||||
|
`actions`. Owl components can then connect to the store to read their relevant
|
||||||
|
state, and they will be rerendered if the state is updated.
|
||||||
|
|
||||||
|
Note: Owl store is inspired by React Redux and VueX.
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
Here is what a simple store looks like:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const actions = {
|
||||||
|
addTodo({ state }, message) {
|
||||||
|
state.todos.push({
|
||||||
|
id: state.nextId++,
|
||||||
|
message,
|
||||||
|
isCompleted: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
todos: [],
|
||||||
|
nextId: 1
|
||||||
|
};
|
||||||
|
|
||||||
|
const store = new owl.Store({ state, actions });
|
||||||
|
store.on("update", null, () => console.log(store.state));
|
||||||
|
|
||||||
|
// updating the state
|
||||||
|
store.dispatch("addTodo", "fix all bugs");
|
||||||
|
```
|
||||||
|
|
||||||
|
This example shows how a store can be defined and used. Note that in most cases,
|
||||||
|
actions will be dispatched by connected components.
|
||||||
|
|
||||||
|
## Reference
|
||||||
|
|
||||||
|
### `Store`
|
||||||
|
|
||||||
|
The store is a simple [`owl.EventBus`](event_bus.md) that triggers `update` events
|
||||||
|
whenever its state is changed. Note that these events are triggered only after a
|
||||||
|
microtask tick, so only one event will be triggered for any number of state changes in a
|
||||||
|
call stack.
|
||||||
|
|
||||||
|
Also, it is important to mention that the state is observed (with an `owl.Observer`),
|
||||||
|
which is the reason why it is able to know if it was changed. See the
|
||||||
|
[Observer](observer.md)'s documentation for more details.
|
||||||
|
|
||||||
|
The `Store` class is quite small. It has two public methods:
|
||||||
|
|
||||||
|
- its constructor
|
||||||
|
- `dispatch`
|
||||||
|
|
||||||
|
The constructor takes a configuration object with four (optional) keys:
|
||||||
|
|
||||||
|
- the initial state
|
||||||
|
- the actions
|
||||||
|
- the getters
|
||||||
|
- the environment
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const config = {
|
||||||
|
state,
|
||||||
|
actions,
|
||||||
|
getters,
|
||||||
|
env
|
||||||
|
};
|
||||||
|
const store = new Store(config);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Actions
|
||||||
|
|
||||||
|
Actions are used to coordinate state changes. It can be used for both synchronous
|
||||||
|
and asynchronous logic.
|
||||||
|
|
||||||
|
```js
|
||||||
|
const actions = {
|
||||||
|
async login({ state }, info) {
|
||||||
|
state.loginState = "pending";
|
||||||
|
try {
|
||||||
|
const loginInfo = await doSomeRPC("/login/", info);
|
||||||
|
state.loginState = loginInfo;
|
||||||
|
} catch (e) {
|
||||||
|
state.loginState = "error";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
The first argument to an action method is an object with four keys:
|
||||||
|
|
||||||
|
- `state`: the current state of the store content,
|
||||||
|
- `dispatch`: a function that can be used to dispatch other actions,
|
||||||
|
- `getters`: an object containing all getters defined in the store,
|
||||||
|
- `env`: the current environment. This is useful sometimes, in particular if
|
||||||
|
an action needs to apply some side effects (such as performing an rpc), and
|
||||||
|
the `rpc` method is located in the environment.
|
||||||
|
|
||||||
|
Actions are called with the `dispatch` method on the store, and can receive an
|
||||||
|
arbitrary number of arguments.
|
||||||
|
|
||||||
|
```js
|
||||||
|
store.dispatch("login", someInfo);
|
||||||
|
```
|
||||||
|
|
||||||
|
Note that anything returned by an action will also be returned by the `dispatch`
|
||||||
|
call.
|
||||||
|
|
||||||
|
Also, it is important to be aware that we need to be careful with asynchronous
|
||||||
|
logic. Each state change will potentially trigger a rerendering, so we need to
|
||||||
|
make sure that we do not have a partially corrupted state. Here is an example that
|
||||||
|
is likely not a good idea:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const actions = {
|
||||||
|
async fetchSomeData({ state }, recordId) {
|
||||||
|
state.recordId = recordId;
|
||||||
|
const data = await doSomeRPC("/read/", recordId);
|
||||||
|
state.recordData = data;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
In the previous example, there is a period of time in which the state has a
|
||||||
|
`recordId` which does not correspond to the `recordData`. It is more likely that
|
||||||
|
we want an atomic update: updating the `recordId` at the same time as the `recordData`
|
||||||
|
values:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const actions = {
|
||||||
|
async fetchSomeData({ state }, recordId) {
|
||||||
|
const data = await doSomeRPC("/read/", recordId);
|
||||||
|
state.recordId = recordId;
|
||||||
|
state.recordData = data;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Getters
|
||||||
|
|
||||||
|
Usually, data contained in the store will be stored in a normalized way. For
|
||||||
|
example,
|
||||||
|
|
||||||
|
```js
|
||||||
|
{
|
||||||
|
posts: [{id: 11, authorId: 4, content: 'Greetings'}],
|
||||||
|
authors: [{id: 4, name: 'John'}]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
However, the user interface will probably need some denormalized data like
|
||||||
|
|
||||||
|
```js
|
||||||
|
{id: 11, author: {id: 4, name: 'John'}, content: 'Greetings'}
|
||||||
|
```
|
||||||
|
|
||||||
|
This is what `getters` are for: they give a centralized way to process and
|
||||||
|
transform the data contained in the store.
|
||||||
|
|
||||||
|
```js
|
||||||
|
const getters = {
|
||||||
|
getPost({ state }, id) {
|
||||||
|
const post = state.posts.find(p => p.id === id);
|
||||||
|
const author = state.authors.find(a => a.id === post.id);
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
author,
|
||||||
|
content: post.content
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// somewhere else
|
||||||
|
const post = store.getters.getPost(id);
|
||||||
|
```
|
||||||
|
|
||||||
|
Getters take _at most_ one argument.
|
||||||
|
|
||||||
|
Note that getters are not cached.
|
||||||
|
|
||||||
|
### Connecting a Component
|
||||||
|
|
||||||
|
At some point, we need a way to interact with the store from a component. This
|
||||||
|
can be done with the help of the three store hooks:
|
||||||
|
|
||||||
|
- [`useStore`](#usestore) to subscribe a component to some part of the store state,
|
||||||
|
- [`useDispatch`](#usedispatch) to get a reference to a dispatch function,
|
||||||
|
- [`useGetters`](#usegetters) to get a reference to the getters defined in the store.
|
||||||
|
|
||||||
|
Assume we have this store:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const actions = {
|
||||||
|
increment({ state }, val) {
|
||||||
|
state.counter.value += val;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
counter: { value: 0 }
|
||||||
|
};
|
||||||
|
const store = new owl.Store({ state, actions });
|
||||||
|
```
|
||||||
|
|
||||||
|
A counter component can then select this value and dispatch an action like this:
|
||||||
|
|
||||||
|
```js
|
||||||
|
class Counter extends Component {
|
||||||
|
counter = useStore(state => state.counter);
|
||||||
|
dispatch = useDispatch();
|
||||||
|
}
|
||||||
|
|
||||||
|
const counter = new Counter({ store, qweb });
|
||||||
|
```
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<button t-name="Counter" t-on-click="dispatch('increment')">
|
||||||
|
Click Me! [<t t-esc="counter.value"/>]
|
||||||
|
</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
### `useStore`
|
||||||
|
|
||||||
|
The `useStore` hook is used to select some part of the store state. It accepts
|
||||||
|
two arguments:
|
||||||
|
|
||||||
|
- a selector function, which takes the store state as first argument (and the
|
||||||
|
component props as second argument) and which must return the part of the
|
||||||
|
store state that will be made available and observed for changes,
|
||||||
|
- optionally, an object which can have the following optional keys:
|
||||||
|
- a `store` key containing a store object if we want to use another store than
|
||||||
|
the default store,
|
||||||
|
- an `isEqual` key containing an equality function if we want to specialize
|
||||||
|
the comparison (the function must accept two arguments: the previous result
|
||||||
|
and the new result, and must return whether they are equal),
|
||||||
|
- and an `onUpdate` key containing an update function if we want to execute an
|
||||||
|
arbitrary code every time the selected state changes (the function will
|
||||||
|
receive one argument, the new result, and can execute arbitrary code).
|
||||||
|
|
||||||
|
If the `useStore` selector returns a sub part of the store state, the component
|
||||||
|
will only be rerendered whenever this part of the state changes. Otherwise, it
|
||||||
|
will perform a strict equality check (unless the `isEqual` option is defined,
|
||||||
|
then it will call it) and will update the component every time this check fails.
|
||||||
|
|
||||||
|
Note that if the selector function returns a primitive type, the result of
|
||||||
|
`useStore` will be immutable and it will not react to changes. In this case, it
|
||||||
|
is important to define the `onUpdate` option to properly update the value
|
||||||
|
manually when it changes.
|
||||||
|
|
||||||
|
Also, the return value from `useStore` is not supposed to be modified. The store
|
||||||
|
state should only be updated with actions.
|
||||||
|
|
||||||
|
### `useDispatch`
|
||||||
|
|
||||||
|
The `useDispatch` hook is useful when a component needs to be able to dispatch
|
||||||
|
actions. It takes an optional argument, which is a store. If not given, it will
|
||||||
|
use the store in the environment.
|
||||||
|
|
||||||
|
Note that a component does not need to be connected in any other way to the store.
|
||||||
|
For example:
|
||||||
|
|
||||||
|
```js
|
||||||
|
class DoSomethingButton extends Component {
|
||||||
|
static template = xml`<button t-on-click="dispatch('something')">Click</button>`;
|
||||||
|
dispatch = useDispatch();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `useGetters`
|
||||||
|
|
||||||
|
The `useGetters` hook is useful when a component needs to be able to use the
|
||||||
|
getters defined in a store. It takes an optional argument, which is a store. If
|
||||||
|
not given, it will use the store in the environment.
|
||||||
|
|
||||||
|
Note that a component does not need to be connected in any other way to the store.
|
||||||
|
For example:
|
||||||
|
|
||||||
|
```js
|
||||||
|
class InfoButton extends Component {
|
||||||
|
static template = xml`<span><t t-esc="getters.somevalue()"></span>`;
|
||||||
|
getters = useGetters();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Semantics
|
||||||
|
|
||||||
|
The `Store` class and the `useStore` hook try to be smart and to optimize as much
|
||||||
|
as possible the rendering and update process. What is important to know is:
|
||||||
|
|
||||||
|
- components are always updated in the order of their creation (so, parent
|
||||||
|
before children),
|
||||||
|
- they are updated only if they are in the DOM,
|
||||||
|
- if a parent is asynchronous, the system will wait for it to complete its
|
||||||
|
update before updating other components,
|
||||||
|
- in general, updates are not coordinated. This is not a problem for synchronous
|
||||||
|
components, but if there are many asynchronous components, this could lead to
|
||||||
|
a situation where some part of the UI is updated and some other part of the UI is
|
||||||
|
not updated.
|
||||||
|
|
||||||
|
### Good Practices
|
||||||
|
|
||||||
|
- avoid asynchronous components as much as possible. Asynchronous components
|
||||||
|
lead to situations where parts of the UI is not updated immediately,
|
||||||
|
- do not be afraid to connect many components, parent or children if needed. For
|
||||||
|
example, a `MessageList` component could get a list of ids in its `useStore`
|
||||||
|
call and a `Message` component could get the data of its own
|
||||||
|
message,
|
||||||
|
- since the `useStore` function is called for each connected component,
|
||||||
|
for each state update, it is important to make sure that these functions are
|
||||||
|
as fast as possible.
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# 🦉 Tags 🦉
|
||||||
|
|
||||||
|
## Content
|
||||||
|
|
||||||
|
- [Overview](#overview)
|
||||||
|
- [`xml` tag](#xml-tag)
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
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,
|
||||||
|
such as a `css` tag, which will be used to write [single file components](../tooling.md#single-file-component).
|
||||||
|
|
||||||
|
## XML tag
|
||||||
|
|
||||||
|
Without tags, creating a standalone component would look like this:
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { Component } from 'owl'
|
||||||
|
|
||||||
|
const name = 'some-unique-name';
|
||||||
|
const template = `
|
||||||
|
<div>
|
||||||
|
<span t-if="somecondition">text</span>
|
||||||
|
<button t-on-click="someMethod">Click</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
QWeb.registerTemplate(name, template);
|
||||||
|
|
||||||
|
class MyComponent extends Component {
|
||||||
|
static template = name;
|
||||||
|
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
With tags, this process is slightly simplified. The name is uniquely generated,
|
||||||
|
and the template is automatically registered:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const { Component } = owl;
|
||||||
|
const { xml } = owl.tags;
|
||||||
|
|
||||||
|
class MyComponent extends Component {
|
||||||
|
static template = xml`
|
||||||
|
<div>
|
||||||
|
<span t-if="somecondition">text</span>
|
||||||
|
<button t-on-click="someMethod">Click</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
# 🦉 Utils 🦉
|
||||||
|
|
||||||
|
Owl export a few useful utility functions, to help with common issues. Those
|
||||||
|
functions are all available in the `owl.utils` namespace.
|
||||||
|
|
||||||
|
## Content
|
||||||
|
|
||||||
|
- [`whenReady`](#whenready): executing code when DOM is ready
|
||||||
|
- [`loadJS`](#loadjs): loading script files
|
||||||
|
- [`loadFile`](#loadfile): loading a file (useful for templates)
|
||||||
|
- [`escape`](#escape): sanitizing strings
|
||||||
|
- [`debounce`](#debounce): limiting rate of function calls
|
||||||
|
- [`shallowEqual`](#shallowequal): shallow object comparison
|
||||||
|
|
||||||
|
## `whenReady`
|
||||||
|
|
||||||
|
The function `whenReady` returns a `Promise` resolved when the DOM is ready (if
|
||||||
|
not ready yet, resolved directly otherwise). If called with a callback as
|
||||||
|
argument, it executes it as soon as the DOM ready (or directly).
|
||||||
|
|
||||||
|
```js
|
||||||
|
Promise.all([loadFile("templates.xml"), owl.utils.whenReady()]).then(function([templates]) {
|
||||||
|
const qweb = new owl.QWeb({ templates });
|
||||||
|
const app = new App({ qweb });
|
||||||
|
app.mount(document.body);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
or alternatively:
|
||||||
|
|
||||||
|
```js
|
||||||
|
owl.utils.whenReady(function() {
|
||||||
|
const qweb = new owl.QWeb();
|
||||||
|
const app = new App({ qweb });
|
||||||
|
app.mount(document.body);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## `loadJS`
|
||||||
|
|
||||||
|
`loadJS` takes a url (string) for a javascript resource, and loads it (by adding
|
||||||
|
a script tag in the document head). It returns a promise, so the caller can
|
||||||
|
properly reacts when it is ready. Also, it is smart: it maintains a list of urls
|
||||||
|
previously loaded (or currently being loaded), and prevent doing twice the work.
|
||||||
|
|
||||||
|
For example, it is useful for lazy loading external libraries:
|
||||||
|
|
||||||
|
```js
|
||||||
|
class MyComponent extends owl.Component {
|
||||||
|
willStart() {
|
||||||
|
return owl.utils.loadJS("/static/libs/someLib.js");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## `loadFile`
|
||||||
|
|
||||||
|
`loadFile` is a helper function to fetch a file. It simply
|
||||||
|
performs a `GET` request and returns the resulting string in a promise. The
|
||||||
|
initial usecase for this function is to load a template file. For example:
|
||||||
|
|
||||||
|
```js
|
||||||
|
async function makeEnv() {
|
||||||
|
const templates = await owl.utils.loadFile("templates.xml");
|
||||||
|
const qweb = new owl.QWeb({ templates });
|
||||||
|
return { qweb };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Note that unlike `loadJS`, this function returns the content of the file as a
|
||||||
|
string. It does not add a `script` tag or any other side effect.
|
||||||
|
|
||||||
|
## `escape`
|
||||||
|
|
||||||
|
Sometimes, we need to display dynamic data (for example user-generated data) in
|
||||||
|
the user interface. If this is done by a `QWeb` template, it is not an issue:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<div><t t-esc="user.data"/></div>
|
||||||
|
```
|
||||||
|
|
||||||
|
The `QWeb` engine will create a `div` node and add the content of the `user.data`
|
||||||
|
string as a text node, so the web browser will not parse it as html. However,
|
||||||
|
it may be a problem if this is done with some javascript code like this:
|
||||||
|
|
||||||
|
```js
|
||||||
|
class BadComponent extends Component {
|
||||||
|
// some template with a ref to a div
|
||||||
|
// some code ...
|
||||||
|
|
||||||
|
mounted() {
|
||||||
|
this.divRef.el.innerHTML = this.state.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
In this case, the content of the `div` will be parsed as html, which may inject
|
||||||
|
unwanted behaviour. To fix this, the `escape` function will simply transform a
|
||||||
|
string into an escaped version of the same string, which will be properly displayed
|
||||||
|
by the browser, but which will not be parsed as html (for example, `"<ok>"` is
|
||||||
|
escaped to the string: `"<ok>"`). So, the bad example above can be fixed
|
||||||
|
with the following change:
|
||||||
|
|
||||||
|
```js
|
||||||
|
this.divRef.el.innerHTML = owl.utils.escape(this.state.value);
|
||||||
|
```
|
||||||
|
|
||||||
|
## `debounce`
|
||||||
|
|
||||||
|
The `debounce` function is useful when we want to limit the number of times some
|
||||||
|
function/action is perfomed. For example, this may be useful to prevent issue
|
||||||
|
with people double clicking on a button.
|
||||||
|
|
||||||
|
It takes three arguments:
|
||||||
|
|
||||||
|
- `func` (function): this is the function that will be rate limited
|
||||||
|
- `wait` (number): this is the number of milliseconds that we want to use to
|
||||||
|
rate limit the function `func`
|
||||||
|
- `immediate` (optional, boolean, default=false): if `immediate` is true, the
|
||||||
|
function will be triggered immediately (leading edge of the interval). If false,
|
||||||
|
the function will be triggered at the end (trailing edge).
|
||||||
|
|
||||||
|
It returns a function. For example:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const debounce = owl.utils.debounce;
|
||||||
|
window.addEventListener("mousemove", debounce(doSomething, 100));
|
||||||
|
```
|
||||||
|
|
||||||
|
As this example shows, it is usualy useful for event handlers which are triggered
|
||||||
|
very quickly, such as `scroll` or `mousemove` events.
|
||||||
|
|
||||||
|
## `shallowEqual`
|
||||||
|
|
||||||
|
This function checks if two objects have the same values assigned to each keys:
|
||||||
|
|
||||||
|
```js
|
||||||
|
shallowEqual({ a: 1, b: 2 }, { a: 1, b: 2 }); // true
|
||||||
|
shallowEqual({ a: 1, b: 2 }, { a: 1, b: 3 }); // false
|
||||||
|
```
|
||||||
|
|
||||||
|
However, for performance reasons, it assumes that the two objects have the same
|
||||||
|
keys. If we are in a situation where this is not guaranteed, the following code
|
||||||
|
will work:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const completeShallowEqual = (a, b) => shallowEqual(a, b) && shallowEqual(b, a);
|
||||||
|
```
|
||||||
-254
@@ -1,254 +0,0 @@
|
|||||||
# 🦉 Store 🦉
|
|
||||||
|
|
||||||
## Content
|
|
||||||
|
|
||||||
- [Overview](#overview)
|
|
||||||
- [Example](#example)
|
|
||||||
- [Reference](#reference)
|
|
||||||
- [Public API](#public-api)
|
|
||||||
- [Mutations](#mutations)
|
|
||||||
- [Actions](#actions)
|
|
||||||
- [Getters](#getters)
|
|
||||||
- [Connecting a Component](#connecting-a-component)
|
|
||||||
- [Semantics](#semantics)
|
|
||||||
- [Good Practices](#good-practices)
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
Managing the state in an application is not an easy task. In some cases, the
|
|
||||||
state of an application can be part of the component tree, in a natural way.
|
|
||||||
However, there are situations where some part of the state need to be displayed
|
|
||||||
in various parts of the user interface, and then, it is not obvious which
|
|
||||||
component should own which part of the state.
|
|
||||||
|
|
||||||
Owl's solution to this issue is a centralized store. It is a class that owns
|
|
||||||
some state, and let the developer update it in a structured way (through
|
|
||||||
mutations and actions). Owl components can then connect to the store, and will
|
|
||||||
be updated if necessary.
|
|
||||||
|
|
||||||
Note: Owl's store is inspired by React Redux and VueX.
|
|
||||||
|
|
||||||
## Example
|
|
||||||
|
|
||||||
Here is what a simple store looks like:
|
|
||||||
|
|
||||||
```js
|
|
||||||
const actions = {
|
|
||||||
addTodo({ commit }, message) {
|
|
||||||
commit("addTodo", message);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const mutations = {
|
|
||||||
addTodo({ state }, message) {
|
|
||||||
const todo = {
|
|
||||||
id: state.nextId++,
|
|
||||||
message,
|
|
||||||
isCompleted: false
|
|
||||||
};
|
|
||||||
state.todos.push(todo);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const state = {
|
|
||||||
todos: [],
|
|
||||||
nextId: 1
|
|
||||||
};
|
|
||||||
|
|
||||||
const store = new owl.Store({ state, actions, mutations });
|
|
||||||
store.on("update", () => console.log(store.state));
|
|
||||||
|
|
||||||
// updating the state
|
|
||||||
store.dispatch("addTodo", "fix all bugs");
|
|
||||||
```
|
|
||||||
|
|
||||||
## Reference
|
|
||||||
|
|
||||||
The store is a simple [`owl.EventBus`](event_bus.md) that triggers `update` events whenever its
|
|
||||||
state is changed. Note that these events are triggered only after a microtask
|
|
||||||
tick, so only one event will be triggered for any number of state changes in a
|
|
||||||
call stack.
|
|
||||||
|
|
||||||
Also, it is important to mention that the state is observed (with an `owl.Observer`),
|
|
||||||
which is the reason why it is able to know if it was changed. This implies that
|
|
||||||
state changes need to be done carefully in some cases (adding a new key to an
|
|
||||||
object, or modifying an array with the `arr[i] = newValue` syntax). See the
|
|
||||||
[Observer](observer.md)'s documentation for more details.
|
|
||||||
|
|
||||||
### Public API
|
|
||||||
|
|
||||||
1. `constructor`
|
|
||||||
2. `commit`
|
|
||||||
3. `dispatch`
|
|
||||||
|
|
||||||
### Mutations
|
|
||||||
|
|
||||||
Mutations are the only way to modify the state. Changing the state outside a
|
|
||||||
mutation is not allowed (and should throw an error). Mutations are synchronous.
|
|
||||||
|
|
||||||
```js
|
|
||||||
const mutations = {
|
|
||||||
setLoginState({ state }, loginState) {
|
|
||||||
state.loginState = loginState;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
Mutations are called with the `commit` method on the store, and can receive an arbitrary number of arguments.
|
|
||||||
|
|
||||||
```js
|
|
||||||
store.commit("setLoginState", "error");
|
|
||||||
```
|
|
||||||
|
|
||||||
### Actions
|
|
||||||
|
|
||||||
Actions are used to coordinate state changes. It is also useful whenever some
|
|
||||||
asynchronous logic is necessary. For example, fetching data should be done
|
|
||||||
in an action.
|
|
||||||
|
|
||||||
```js
|
|
||||||
const actions = {
|
|
||||||
async login({ commit }, info) {
|
|
||||||
commit("setLoginState", "pending");
|
|
||||||
try {
|
|
||||||
const loginInfo = await doSomeRPC("/login/", info);
|
|
||||||
commit("setLoginState", loginInfo);
|
|
||||||
} catch {
|
|
||||||
commit("setLoginState", "error");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
Actions are called with the `dispatch` method on the store, and can receive an
|
|
||||||
arbitrary number of arguments.
|
|
||||||
|
|
||||||
```js
|
|
||||||
store.dispatch("login", someInfo);
|
|
||||||
```
|
|
||||||
|
|
||||||
Note that anything returned by an action will also be returned by the `dispatch`
|
|
||||||
call.
|
|
||||||
|
|
||||||
### Getters
|
|
||||||
|
|
||||||
Usually, data contained in the store will be stored in a normalized way. For
|
|
||||||
example,
|
|
||||||
|
|
||||||
```js
|
|
||||||
{
|
|
||||||
posts: [{id: 11, authorId: 4, content: 'Greetings'}],
|
|
||||||
authors: [{id: 4, name: 'John'}]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
However, the user interface will probably need some denormalized data like
|
|
||||||
|
|
||||||
```js
|
|
||||||
{id: 11, author: {id: 4, name: 'John'}, content: 'Greetings'}
|
|
||||||
```
|
|
||||||
|
|
||||||
This is what `getters` are for: they give a centralized way to process and
|
|
||||||
transform the data contained in the store.
|
|
||||||
|
|
||||||
```js
|
|
||||||
const getters = {
|
|
||||||
getPost({ state }, id) {
|
|
||||||
const post = state.posts.find(p => p.id === id);
|
|
||||||
const author = state.authors.find(a => (a.id = post.id));
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
author,
|
|
||||||
content: post.content
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// somewhere else
|
|
||||||
const post = store.getters.getPost(id);
|
|
||||||
```
|
|
||||||
|
|
||||||
Getters take _at most_ one argument.
|
|
||||||
|
|
||||||
Note that getters are cached if they don't take any argument, or their argument
|
|
||||||
is a string or a number.
|
|
||||||
|
|
||||||
### Connecting a Component
|
|
||||||
|
|
||||||
At some point, we need a way to access the state in the store from a component.
|
|
||||||
By default, an Owl `Component` is not connected to any store. To do that, we
|
|
||||||
need to create a component inheriting from `OwlComponent`:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const actions = {
|
|
||||||
increment({ commit }) {
|
|
||||||
commit("increment", 1);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const mutations = {
|
|
||||||
increment({ state }, val) {
|
|
||||||
state.counter += val;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const state = {
|
|
||||||
counter: 0
|
|
||||||
};
|
|
||||||
const store = new owl.Store({ state, actions, mutations });
|
|
||||||
|
|
||||||
class Counter extends owl.ConnectedComponent {
|
|
||||||
static mapStoreToProps(state) {
|
|
||||||
return {
|
|
||||||
value: state.counter
|
|
||||||
};
|
|
||||||
}
|
|
||||||
increment() {
|
|
||||||
this.env.store.dispatch("increment");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const counter = new Counter({ store, qweb });
|
|
||||||
```
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<button t-name="Counter" t-on-click="increment">
|
|
||||||
Click Me! [<t t-esc="props.value"/>]
|
|
||||||
</button>
|
|
||||||
```
|
|
||||||
|
|
||||||
The `ConnectedComponent` class can be configured with the following fields:
|
|
||||||
|
|
||||||
- `mapStoreToProps`: a function that extracts the `props` of the Component
|
|
||||||
from the `state` of the `Store` and returns them as a dict.
|
|
||||||
- `getStore`: a function that takes the `env` in arguments and returns an
|
|
||||||
instance of `Store` to connect to (if not given, connects to `env.store`)
|
|
||||||
- `hashFunction`: the function to use to detect changes in the state (if not
|
|
||||||
given, generates a function that uses revision numbers, incremented at
|
|
||||||
each state change)
|
|
||||||
- `deep` (boolean): [only useful if no hashFunction is given] if `false`, only watch
|
|
||||||
for top level state changes (`true` by default)
|
|
||||||
|
|
||||||
### Semantics
|
|
||||||
|
|
||||||
The `Store` and the `ConnectedComponent` try to be smart and to optimize as much
|
|
||||||
as possible the rendering and update process. What is important to know is:
|
|
||||||
|
|
||||||
- components are always updated in the order of their creation (so, parent
|
|
||||||
before children)
|
|
||||||
- they are updated only if they are in the DOM
|
|
||||||
- if a parent is asynchronous, the system will wait for it to complete its
|
|
||||||
update before updating other components.
|
|
||||||
- in general, updates are not coordinated. This is not a problem for synchronous
|
|
||||||
components, but if there are many asynchronous components, this could lead to
|
|
||||||
a situation where some part of the UI is updated and other parts of the UI is
|
|
||||||
not updated.
|
|
||||||
|
|
||||||
### Good Practices
|
|
||||||
|
|
||||||
- avoid asynchronous components as much as possible. Asynchronous components
|
|
||||||
lead to situations where parts of the UI is not updated immediately.
|
|
||||||
- do not be afraid to connect many components, parent or children if needed. For
|
|
||||||
example, a `MessageList` component could get a list of ids in its `mapStoreToProps` and a `Message` component could get the data of its own
|
|
||||||
message
|
|
||||||
- since the `mapStoreToProps` function is called for each connected component,
|
|
||||||
for each state update, it is important to make sure that these functions are
|
|
||||||
as fast as possible.
|
|
||||||
+69
-21
@@ -3,9 +3,10 @@
|
|||||||
## Content
|
## Content
|
||||||
|
|
||||||
- [Overview](#overview)
|
- [Overview](#overview)
|
||||||
- [Development Mode](#development-mode)
|
|
||||||
- [Playground](#playground)
|
- [Playground](#playground)
|
||||||
- [Benchmarks](#benchmarks)
|
- [Benchmarks](#benchmarks)
|
||||||
|
- [Single File Component](#single-file-component)
|
||||||
|
- [Debugging Script](#debugging-script)
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
@@ -20,26 +21,6 @@ by using a static http server. A simple python
|
|||||||
server is available in `server.py`. There is also a npm script to start it:
|
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`).
|
`npm run tools` (and its version with a watcher: `npm run tools:watch`).
|
||||||
|
|
||||||
## Development Mode
|
|
||||||
|
|
||||||
By default, Owl is in _production_ mode, this means that it will try to do its
|
|
||||||
job fast, and skip some expensive operations. However, in some cases, it is
|
|
||||||
convenient to have better information on what is going on, this is the purpose
|
|
||||||
of the dev mode.
|
|
||||||
|
|
||||||
Owl has a mode flag, in `owl.__info__.mode`. Its default value is `prod`, but
|
|
||||||
it can be set to `dev`:
|
|
||||||
|
|
||||||
```js
|
|
||||||
owl.__info__.mode = "dev";
|
|
||||||
```
|
|
||||||
|
|
||||||
Note that templates compiled with the `prod` settings will not be recompiled.
|
|
||||||
So, changing this setting is best done at startup.
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
## Playground
|
## Playground
|
||||||
|
|
||||||
The playground is an important application designed to help learning and
|
The playground is an important application designed to help learning and
|
||||||
@@ -56,3 +37,70 @@ Note: This is more an internal tool, useful for people working on Owl.
|
|||||||
The benchmarks application is a very small application, implemented in different
|
The benchmarks application is a very small application, implemented in different
|
||||||
frameworks, and in different versions of Owl. This is a simple internal tool,
|
frameworks, and in different versions of Owl. This is a simple internal tool,
|
||||||
useful to compare various performance metrics on some tasks.
|
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:
|
||||||
|
|
||||||
|
```
|
||||||
|
function debugOwl(t,n){let e,o="[OWL_DEBUG]";function s(t){let n=JSON.stringify(t||{});return n.length>200&&(n=n.slice(0,200)+"..."),n}if(Object.defineProperty(t.Component,"current",{get:()=>e,set(i){e=i;const r=i.constructor.name;if(n.componentBlackList&&n.componentBlackList.test(r))return;if(n.componentWhiteList&&!n.componentWhiteList.test(r))return;let l;Object.defineProperty(e,"__owl__",{get:()=>l,set(e){!function(e,i,r){let l=`${i}<id=${r}>`,c=t=>console.log(`${o} ${l} ${t}`),u=t=>(!n.methodBlackList||!n.methodBlackList.includes(t))&&!(n.methodWhiteList&&!n.methodWhiteList.includes(t));u("constructor")&&c(`constructor, props=${s(e.props)}`);u("willStart")&&t.hooks.onWillStart(()=>{c("willStart")});u("mounted")&&t.hooks.onMounted(()=>{c("mounted")});u("willUpdateProps")&&t.hooks.onWillUpdateProps(t=>{c(`willUpdateProps, nextprops=${s(t)}`)});u("willPatch")&&t.hooks.onWillPatch(()=>{c("willPatch")});u("patched")&&t.hooks.onPatched(()=>{c("patched")});u("willUnmount")&&t.hooks.onWillUnmount(()=>{c("willUnmount")});const d=e.__render.bind(e);e.__render=function(...t){c("rendering template"),d(...t)};const h=e.render.bind(e);e.render=function(...t){const n=e.__owl__;let o="render";return n.isMounted||n.currentFiber||(o+=" (warning: component is not mounted, this render has no effect)"),c(o),h(...t)};const p=e.mount.bind(e);e.mount=function(...t){return c("mount"),p(...t)}}(i,r,(l=e).id)}})}}),n.logScheduler){let n=t.Component.scheduler.start,e=t.Component.scheduler.stop;t.Component.scheduler.start=function(){this.isRunning||console.log(`${o} scheduler: start running tasks queue`),n.call(this)},t.Component.scheduler.stop=function(){this.isRunning&&console.log(`${o} scheduler: stop running tasks queue`),e.call(this)}}if(n.logStore){let n=t.Store.prototype.dispatch;t.Store.prototype.dispatch=function(t,...e){return console.log(`${o} store: action '${t}' dispatched. Payload: '${s(e)}'`),n.call(this,t,...e)}}}
|
||||||
|
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
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
|
|||||||
@@ -1,65 +0,0 @@
|
|||||||
# 🦉 Utils 🦉
|
|
||||||
|
|
||||||
Owl export a few useful utility functions, to help with common issues. Those
|
|
||||||
functions are all available in the `owl.utils` namespace.
|
|
||||||
|
|
||||||
## Content
|
|
||||||
|
|
||||||
- [`whenReady`](#whenready): executing code when DOM is ready
|
|
||||||
- [`loadJS`](#loadjs): loading script files
|
|
||||||
- [`loadTemplates`](#loadtemplates): loading xml files
|
|
||||||
- [`escape`](#escape): sanitizing strings
|
|
||||||
- [`debounce`](#debounce): limiting rate of function calls
|
|
||||||
|
|
||||||
## `whenReady`
|
|
||||||
|
|
||||||
The function `whenReady` returns a `Promise` resolved when the DOM is ready (if
|
|
||||||
not ready yet, resolved directly otherwise). If called with a callback as
|
|
||||||
argument, it executes it as soon as the DOM ready (or directly).
|
|
||||||
|
|
||||||
```js
|
|
||||||
Promise.all([loadTemplates(), owl.utils.whenReady()]).then(function([
|
|
||||||
templates
|
|
||||||
]) {
|
|
||||||
const qweb = new owl.QWeb(templates);
|
|
||||||
const app = new App({ qweb });
|
|
||||||
app.mount(document.body);
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
```js
|
|
||||||
owl.utils.whenReady(function() {
|
|
||||||
const qweb = new owl.QWeb();
|
|
||||||
const app = new App({ qweb });
|
|
||||||
app.mount(document.body);
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
## `loadJS`
|
|
||||||
|
|
||||||
`loadJS` takes a url (string) for a javascript resource, and loads it. It returns
|
|
||||||
a promise, so the caller can properly react when it is ready. Also, it is smart:
|
|
||||||
it maintains a list of urls previously loaded (or currently being loaded), and
|
|
||||||
prevent doing twice the work.
|
|
||||||
|
|
||||||
```js
|
|
||||||
class MyComponent extends owl.Component {
|
|
||||||
willStart() {
|
|
||||||
return owl.utils.loadJS("/static/libs/someLib.js");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## `loadTemplates`
|
|
||||||
|
|
||||||
```js
|
|
||||||
async function makeEnv() {
|
|
||||||
const templates = await owl.utils.loadTemplates("templates.xml");
|
|
||||||
const qweb = new owl.QWeb(templates);
|
|
||||||
return { qweb };
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## `escape`
|
|
||||||
|
|
||||||
## `debounce`
|
|
||||||
+11
-6
@@ -1,8 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "owl",
|
"name": "owl-framework",
|
||||||
"version": "0.18.0",
|
"version": "1.0.0-beta2",
|
||||||
"description": "Odoo Web Library (OWL)",
|
"description": "Odoo Web Library (OWL)",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.15.3"
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build:js": "tsc --target esnext --module es6 --outDir dist/owl",
|
"build:js": "tsc --target esnext --module es6 --outDir dist/owl",
|
||||||
"build:bundle": "rollup -c",
|
"build:bundle": "rollup -c",
|
||||||
@@ -14,18 +17,20 @@
|
|||||||
"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"
|
||||||
},
|
},
|
||||||
"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": "GPL-3.0-or-later",
|
"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": "^23.3.12",
|
"@types/jest": "^23.3.12",
|
||||||
"cpx": "^1.5.0",
|
"cpx": "^1.5.0",
|
||||||
@@ -34,15 +39,15 @@
|
|||||||
"jest-environment-jsdom": "^24.7.1",
|
"jest-environment-jsdom": "^24.7.1",
|
||||||
"live-server": "^1.2.1",
|
"live-server": "^1.2.1",
|
||||||
"npm-run-all": "^4.1.5",
|
"npm-run-all": "^4.1.5",
|
||||||
|
"prettier": "^1.19.1",
|
||||||
"rollup": "^1.6.0",
|
"rollup": "^1.6.0",
|
||||||
"rollup-plugin-typescript2": "^0.20.1",
|
"rollup-plugin-typescript2": "^0.20.1",
|
||||||
"sass": "^1.16.1",
|
"sass": "^1.16.1",
|
||||||
"source-map-support": "^0.5.10",
|
"source-map-support": "^0.5.10",
|
||||||
"ts-jest": "^23.10.5",
|
"ts-jest": "^23.10.5",
|
||||||
"typescript": "^3.2.2",
|
"typescript": "^3.7.2",
|
||||||
"uglify-es": "^3.3.9"
|
"uglify-es": "^3.3.9"
|
||||||
},
|
},
|
||||||
"dependencies": {},
|
|
||||||
"jest": {
|
"jest": {
|
||||||
"roots": [
|
"roots": [
|
||||||
"<rootDir>/src",
|
"<rootDir>/src",
|
||||||
|
|||||||
+41
@@ -0,0 +1,41 @@
|
|||||||
|
# 🦉 OWL Roadmap 🦉
|
||||||
|
|
||||||
|
- Current version: 1.0.0-beta2
|
||||||
|
- Status: mostly stable
|
||||||
|
|
||||||
|
This roadmap is only an attempt at predicting Owl's future. Everything may
|
||||||
|
change!
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### December 2019
|
||||||
|
|
||||||
|
If all goes well, Owl is upgraded to beta status. From now on, no API change,
|
||||||
|
even small, is expected (but it still could happen).
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
|
- add chrome and firefox devtools,
|
||||||
|
- add support for single file components,
|
||||||
|
- fix every bugs,
|
||||||
|
- improve documentation,
|
||||||
|
- small backward compatible improvements.
|
||||||
|
|
||||||
|
### 2.x (2021? 2022?)
|
||||||
|
|
||||||
|
Maybe:
|
||||||
|
|
||||||
|
- reimplement vdom to use *block* system, like Vue 3, which should make Owl
|
||||||
|
much faster
|
||||||
|
- refactor `QWeb` to use an intermediate representation (some kind of AST) to
|
||||||
|
allow additional optimisations.
|
||||||
|
|
||||||
|
|
||||||
+342
-323
@@ -1,8 +1,11 @@
|
|||||||
import { Observer } from "../core/observer";
|
import { Observer } from "../core/observer";
|
||||||
|
import { OwlEvent } from "../core/owl_event";
|
||||||
import { CompiledTemplate, QWeb } from "../qweb/index";
|
import { CompiledTemplate, QWeb } from "../qweb/index";
|
||||||
import { h, patch, VNode } from "../vdom/index";
|
import { patch, VNode } from "../vdom/index";
|
||||||
import "./directive";
|
import "./directive";
|
||||||
|
import { Fiber } from "./fiber";
|
||||||
import "./props_validation";
|
import "./props_validation";
|
||||||
|
import { Scheduler, scheduler } from "./scheduler";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Owl Component System
|
* Owl Component System
|
||||||
@@ -11,7 +14,7 @@ import "./props_validation";
|
|||||||
* contains:
|
* contains:
|
||||||
*
|
*
|
||||||
* - the Env interface (generic type for the environment)
|
* - the Env interface (generic type for the environment)
|
||||||
* - the Meta interface (the owl specific metadata attached to a component)
|
* - the Internal interface (the owl specific metadata attached to a component)
|
||||||
* - the Component class
|
* - the Component class
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -33,51 +36,76 @@ export interface Env {
|
|||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface MountOptions {
|
||||||
|
position?: "first-child" | "last-child" | "self";
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This is mostly an internal detail of implementation. The Meta interface is
|
* This is mostly an internal detail of implementation. The Meta interface is
|
||||||
* 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.
|
||||||
*/
|
*/
|
||||||
export interface Meta<T extends Env, Props> {
|
interface Internal<T extends Env, Props> {
|
||||||
|
// each component has a unique id, useful mostly to handle parent/child
|
||||||
|
// relationships
|
||||||
readonly id: number;
|
readonly id: number;
|
||||||
|
depth: number;
|
||||||
vnode: VNode | null;
|
vnode: VNode | null;
|
||||||
|
pvnode: VNode | null;
|
||||||
isMounted: boolean;
|
isMounted: boolean;
|
||||||
isDestroyed: boolean;
|
isDestroyed: boolean;
|
||||||
parent: Component<T, any, any> | null;
|
|
||||||
children: { [key: number]: Component<T, any, any> };
|
// parent and children keys are obviously useful to setup the parent-children
|
||||||
// children mapping: from templateID to componentID
|
// relationship.
|
||||||
// should it be a map number => Component?
|
parent: Component<T, any> | null;
|
||||||
|
children: { [key: number]: Component<T, any> };
|
||||||
|
// children mapping: from templateID to componentID. templateID identifies a
|
||||||
|
// place in a template. The t-component directive needs it to be able to get
|
||||||
|
// the component instance back whenever the template is rerendered.
|
||||||
cmap: { [key: number]: number };
|
cmap: { [key: number]: number };
|
||||||
|
|
||||||
renderId: number;
|
currentFiber: Fiber | null;
|
||||||
|
// parentLastFiberId is there to help the parent component to detect, among
|
||||||
|
// its children, those that are not used anymore and thus can be destroyed
|
||||||
|
parentLastFiberId: number;
|
||||||
|
|
||||||
// the renderProps and renderPromise keys are only useful for the "prepare"
|
// when a rendering is initiated by a parent, it may set variables in 'scope'
|
||||||
// step of the lifecycle of a component. Once a component has been rendered
|
// (typically when the component is rendered in a slot). We need to
|
||||||
// and patched, it is no longer useful.
|
// store that information in case the component would be re-rendered later on.
|
||||||
renderProps: Props | null;
|
scope: any;
|
||||||
renderPromise: Promise<VNode> | null;
|
|
||||||
|
|
||||||
boundHandlers: { [key: number]: any };
|
boundHandlers: { [key: number]: any };
|
||||||
observer?: Observer;
|
observer: Observer | null;
|
||||||
render?: CompiledTemplate;
|
renderFn: CompiledTemplate;
|
||||||
mountedHandlers: { [key: number]: Function };
|
mountedCB: Function | null;
|
||||||
classObj?: { [key: string]: boolean };
|
willUnmountCB: Function | null;
|
||||||
|
willPatchCB: Function | null;
|
||||||
|
patchedCB: Function | null;
|
||||||
|
willStartCB: Function | null;
|
||||||
|
willUpdatePropsCB: Function | null;
|
||||||
|
classObj: { [key: string]: boolean } | null;
|
||||||
|
refs: { [key: string]: Component<T, any> | HTMLElement | undefined } | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If a component does not define explicitely a template
|
export const portalSymbol = Symbol("portal"); // FIXME
|
||||||
// key, it needs to find a template with its name (or a parent's). This is
|
|
||||||
// qweb dependant, so we need a place to store this information indexed by
|
|
||||||
// qweb instances.
|
|
||||||
const TEMPLATE_MAP: { [key: number]: { [name: string]: string } } = {};
|
|
||||||
|
|
||||||
//------------------------------------------------------------------------------
|
//------------------------------------------------------------------------------
|
||||||
// Component
|
// Component
|
||||||
//------------------------------------------------------------------------------
|
//------------------------------------------------------------------------------
|
||||||
let nextId = 1;
|
let nextId = 1;
|
||||||
|
|
||||||
export class Component<T extends Env, Props extends {}, State extends {}> {
|
export class Component<T extends Env, Props extends {}> {
|
||||||
readonly __owl__: Meta<Env, Props>;
|
readonly __owl__: Internal<Env, Props>;
|
||||||
template?: string;
|
static template?: string | null = null;
|
||||||
|
static _template?: string | null = null;
|
||||||
|
static current: Component<any, any> | null = null;
|
||||||
|
static components = {};
|
||||||
|
static props?: any;
|
||||||
|
static defaultProps?: any;
|
||||||
|
static env: any = {};
|
||||||
|
// expose scheduler s.t. it can be mocked for testing purposes
|
||||||
|
static scheduler: Scheduler = scheduler;
|
||||||
|
__target: HTMLElement | undefined;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The `el` is the root element of the component. Note that it could be null:
|
* The `el` is the root element of the component. Note that it could be null:
|
||||||
@@ -88,17 +116,8 @@ export class Component<T extends Env, Props extends {}, State extends {}> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
env: T;
|
env: T;
|
||||||
state?: State;
|
|
||||||
props: Props;
|
props: Props;
|
||||||
|
|
||||||
// type of props is not easily representable in typescript...
|
|
||||||
static props?: any;
|
|
||||||
static defaultProps?: any;
|
|
||||||
|
|
||||||
refs: {
|
|
||||||
[key: string]: Component<T, any, any> | HTMLElement | undefined;
|
|
||||||
} = {};
|
|
||||||
|
|
||||||
//--------------------------------------------------------------------------
|
//--------------------------------------------------------------------------
|
||||||
// Lifecycle
|
// Lifecycle
|
||||||
//--------------------------------------------------------------------------
|
//--------------------------------------------------------------------------
|
||||||
@@ -106,44 +125,36 @@ export class Component<T extends Env, Props extends {}, State extends {}> {
|
|||||||
/**
|
/**
|
||||||
* Creates an instance of Component.
|
* Creates an instance of Component.
|
||||||
*
|
*
|
||||||
* The root component of a component tree needs an environment:
|
|
||||||
*
|
|
||||||
* ```javascript
|
|
||||||
* const root = new RootComponent(env, props);
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* Every other component simply needs a reference to its parent:
|
|
||||||
*
|
|
||||||
* ```javascript
|
|
||||||
* const child = new SomeComponent(parent, props);
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* Note that most of the time, only the root component needs to be created by
|
* Note that most of the time, only the root component needs to be created by
|
||||||
* 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<T, any, any> | T, props?: Props) {
|
constructor(parent?: Component<T, any> | null, props?: Props) {
|
||||||
const defaultProps = (<any>this.constructor).defaultProps;
|
Component.current = this;
|
||||||
|
|
||||||
|
let constr = this.constructor as any;
|
||||||
|
const defaultProps = constr.defaultProps;
|
||||||
if (defaultProps) {
|
if (defaultProps) {
|
||||||
props = this.__applyDefaultProps(props, defaultProps);
|
props = props || ({} as Props);
|
||||||
|
this.__applyDefaultProps(props, defaultProps);
|
||||||
}
|
}
|
||||||
// is this a good idea?
|
this.props = <Props>props;
|
||||||
// Pro: if props is empty, we can create easily a component
|
if (QWeb.dev) {
|
||||||
// Con: this is not really safe
|
QWeb.utils.validateProps(constr, this.props);
|
||||||
// Pro: but creating component (by a template) is always unsafe anyway
|
}
|
||||||
this.props = <Props>props || <Props>{};
|
|
||||||
let id: number = nextId++;
|
const id: number = nextId++;
|
||||||
let p: Component<T, any, any> | null = null;
|
let depth;
|
||||||
if (parent instanceof Component) {
|
if (parent) {
|
||||||
p = parent;
|
|
||||||
this.env = parent.env;
|
this.env = parent.env;
|
||||||
parent.__owl__.children[id] = this;
|
const __powl__ = parent.__owl__;
|
||||||
|
__powl__.children[id] = this;
|
||||||
|
depth = __powl__.depth + 1;
|
||||||
} else {
|
} else {
|
||||||
this.env = parent;
|
// we are the root component
|
||||||
if (QWeb.dev) {
|
this.env = (this.constructor as any).env;
|
||||||
// we only validate props for root widgets here. "Regular" widget
|
if (!this.env.qweb) {
|
||||||
// props are validated by the t-component directive
|
this.env.qweb = new QWeb();
|
||||||
QWeb.utils.validateProps(this.constructor, this.props);
|
|
||||||
}
|
}
|
||||||
this.env.qweb.on("update", this, () => {
|
this.env.qweb.on("update", this, () => {
|
||||||
if (this.__owl__.isMounted) {
|
if (this.__owl__.isMounted) {
|
||||||
@@ -158,20 +169,35 @@ export class Component<T extends Env, Props extends {}, State extends {}> {
|
|||||||
this.env.qweb.off("update", this);
|
this.env.qweb.off("update", this);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
depth = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const qweb = this.env.qweb;
|
||||||
|
const template = constr.template || this.__getTemplate(qweb);
|
||||||
this.__owl__ = {
|
this.__owl__ = {
|
||||||
id: id,
|
id: id,
|
||||||
|
depth: depth,
|
||||||
vnode: null,
|
vnode: null,
|
||||||
|
pvnode: null,
|
||||||
isMounted: false,
|
isMounted: false,
|
||||||
isDestroyed: false,
|
isDestroyed: false,
|
||||||
parent: p,
|
parent: parent || null,
|
||||||
children: {},
|
children: {},
|
||||||
cmap: {},
|
cmap: {},
|
||||||
renderId: 1,
|
currentFiber: null,
|
||||||
renderPromise: null,
|
parentLastFiberId: 0,
|
||||||
renderProps: props || null,
|
|
||||||
boundHandlers: {},
|
boundHandlers: {},
|
||||||
mountedHandlers: {}
|
mountedCB: null,
|
||||||
|
willUnmountCB: null,
|
||||||
|
willPatchCB: null,
|
||||||
|
patchedCB: null,
|
||||||
|
willStartCB: null,
|
||||||
|
willUpdatePropsCB: null,
|
||||||
|
observer: null,
|
||||||
|
renderFn: qweb.render.bind(qweb, template),
|
||||||
|
classObj: null,
|
||||||
|
refs: null,
|
||||||
|
scope: null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,8 +244,6 @@ export class Component<T extends Env, Props extends {}, State extends {}> {
|
|||||||
* It is not called on the initial render. This is useful to get some
|
* It is not called on the initial render. This is useful to get some
|
||||||
* information which are in the DOM. For example, the current position of the
|
* information which are in the DOM. For example, the current position of the
|
||||||
* scrollbar
|
* scrollbar
|
||||||
*
|
|
||||||
* The return value of willPatch will be given to the patched function.
|
|
||||||
*/
|
*/
|
||||||
willPatch(): any {}
|
willPatch(): any {}
|
||||||
|
|
||||||
@@ -235,10 +259,8 @@ export class Component<T extends Env, Props extends {}, State extends {}> {
|
|||||||
* One need to be careful, because updates here will cause rerender, which in
|
* One need to be careful, because updates here will cause rerender, which in
|
||||||
* turn will cause other calls to updated. So, we need to be particularly
|
* turn will cause other calls to updated. So, we need to be particularly
|
||||||
* careful at avoiding endless cycles.
|
* careful at avoiding endless cycles.
|
||||||
*
|
|
||||||
* The snapshot parameter is the result of the call to willPatch.
|
|
||||||
*/
|
*/
|
||||||
patched(snapshot: any) {}
|
patched() {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* willUnmount is a hook that is called each time just before a component is
|
* willUnmount is a hook that is called each time just before a component is
|
||||||
@@ -254,8 +276,11 @@ export class Component<T extends Env, Props extends {}, State extends {}> {
|
|||||||
/**
|
/**
|
||||||
* catchError is a method called whenever some error happens in the rendering or
|
* catchError is a method called whenever some error happens in the rendering or
|
||||||
* lifecycle hooks of a child.
|
* lifecycle hooks of a child.
|
||||||
|
*
|
||||||
|
* It needs to be implemented by a component that is designed to handle the
|
||||||
|
* error properly.
|
||||||
*/
|
*/
|
||||||
catchError(error: Error): void {}
|
catchError?(error?: Error): void;
|
||||||
|
|
||||||
//--------------------------------------------------------------------------
|
//--------------------------------------------------------------------------
|
||||||
// Public
|
// Public
|
||||||
@@ -269,27 +294,40 @@ export class Component<T extends Env, Props extends {}, State extends {}> {
|
|||||||
*
|
*
|
||||||
* 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): Promise<void> {
|
async mount(target: HTMLElement | DocumentFragment, options: MountOptions = {}): Promise<void> {
|
||||||
if (this.__owl__.isMounted) {
|
const position = options.position || "last-child";
|
||||||
return;
|
const __owl__ = this.__owl__;
|
||||||
|
if (__owl__.isMounted) {
|
||||||
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
if (!this.__owl__.vnode ) {
|
if (!(target instanceof HTMLElement || target instanceof DocumentFragment)) {
|
||||||
// we use the fact that renderId === 1 as a way to determine that the
|
let message = `Component '${this.constructor.name}' cannot be mounted: the target is not a valid DOM node.`;
|
||||||
// component is mounted for the first time
|
message += `\nMaybe the DOM is not ready yet? (in that case, you can use owl.utils.whenReady)`;
|
||||||
const vnode = await this.__prepare();
|
throw new Error(message);
|
||||||
if (this.__owl__.isDestroyed) {
|
|
||||||
// component was destroyed before we get here...
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.__patch(vnode);
|
|
||||||
}
|
}
|
||||||
target.appendChild(this.el!);
|
let inserter =
|
||||||
|
position === "last-child"
|
||||||
if (document.body.contains(target)) {
|
? el => target.appendChild(el)
|
||||||
this.__callMounted();
|
: position === "first-child"
|
||||||
|
? el => target.prepend(el)
|
||||||
|
: el => {};
|
||||||
|
if (position === "self") {
|
||||||
|
this.__target = target as HTMLElement;
|
||||||
}
|
}
|
||||||
|
const fiber = new Fiber(null, this, false, inserter);
|
||||||
|
fiber.shouldPatch = false;
|
||||||
|
if (!__owl__.vnode) {
|
||||||
|
this.__prepareAndRender(fiber, () => {});
|
||||||
|
} else {
|
||||||
|
this.__render(fiber);
|
||||||
|
}
|
||||||
|
return scheduler.addFiber(fiber);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The unmount method is the opposite of the mount method. It is useful
|
||||||
|
* to call willUnmount calls and remove the component from the DOM.
|
||||||
|
*/
|
||||||
unmount() {
|
unmount() {
|
||||||
if (this.__owl__.isMounted) {
|
if (this.__owl__.isMounted) {
|
||||||
this.__callWillUnmount();
|
this.__callWillUnmount();
|
||||||
@@ -297,24 +335,45 @@ export class Component<T extends Env, Props extends {}, State extends {}> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async render(force: boolean = false, patchQueue?: any[], scope?: any, vars?: any): Promise<void> {
|
/**
|
||||||
|
* The render method is the main entry point to render a component (once it
|
||||||
|
* is ready. This method is not initially called when the component is
|
||||||
|
* rendered the first time).
|
||||||
|
*
|
||||||
|
* This method will cause all its sub components to potentially rerender
|
||||||
|
* themselves. Note that `render` is not called if a component is updated via
|
||||||
|
* its props.
|
||||||
|
*/
|
||||||
|
async render(force: boolean = false): Promise<void> {
|
||||||
const __owl__ = this.__owl__;
|
const __owl__ = this.__owl__;
|
||||||
if (!__owl__.isMounted) {
|
if (!__owl__.isMounted && !__owl__.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;
|
||||||
}
|
}
|
||||||
const shouldPatch: boolean = !patchQueue;
|
if (__owl__.currentFiber && !__owl__.currentFiber.isRendered) {
|
||||||
if (shouldPatch) {
|
return scheduler.addFiber(__owl__.currentFiber.root);
|
||||||
patchQueue = [];
|
|
||||||
}
|
|
||||||
__owl__.renderId++
|
|
||||||
const renderId = __owl__.renderId;
|
|
||||||
await this.__render(force, patchQueue, scope, vars);
|
|
||||||
|
|
||||||
if (shouldPatch && __owl__.isMounted && renderId === __owl__.renderId) {
|
|
||||||
// we only update the vnode and the actual DOM if no other rendering
|
|
||||||
// occurred between now and when the render method was initially called.
|
|
||||||
this.__applyPatchQueue(<any[]>patchQueue);
|
|
||||||
}
|
}
|
||||||
|
// 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
|
||||||
|
// about to be mounted
|
||||||
|
const isMounted = __owl__.isMounted;
|
||||||
|
const fiber = new Fiber(null, this, force, null);
|
||||||
|
Promise.resolve().then(() => {
|
||||||
|
if (__owl__.isMounted || !isMounted) {
|
||||||
|
// we are mounted (__owl__.isMounted), or if we are currently being
|
||||||
|
// mounted (!isMounted), so we call __render
|
||||||
|
this.__render(fiber);
|
||||||
|
} else {
|
||||||
|
// we were mounted when render was called, but we aren't anymore, so we
|
||||||
|
// were actually about to be unmounted ; we can thus forget about this
|
||||||
|
// fiber
|
||||||
|
fiber.isCompleted = true;
|
||||||
|
__owl__.currentFiber = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return scheduler.addFiber(fiber);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -346,26 +405,6 @@ export class Component<T extends Env, Props extends {}, State extends {}> {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* This method is the correct way to update the environment of a component. Doing
|
|
||||||
* this will cause a full rerender of the component and its children, so this is
|
|
||||||
* an operation that should not be done frequently.
|
|
||||||
*
|
|
||||||
* A good usecase for updating the environment would be to update some mostly
|
|
||||||
* static config keys, such as a boolean to determine if we are in mobile
|
|
||||||
* mode or not.
|
|
||||||
*/
|
|
||||||
async updateEnv(nextEnv: Partial<T>): Promise<void> {
|
|
||||||
const __owl__ = this.__owl__;
|
|
||||||
if (__owl__.parent && __owl__.parent.env === this.env) {
|
|
||||||
this.env = Object.create(this.env);
|
|
||||||
}
|
|
||||||
Object.assign(this.env, nextEnv);
|
|
||||||
if (__owl__.isMounted) {
|
|
||||||
await this.render(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Emit a custom event of type 'eventType' with the given 'payload' on the
|
* Emit a custom event of type 'eventType' with the given 'payload' on the
|
||||||
* component's el, if it exists. However, note that the event will only bubble
|
* component's el, if it exists. However, note that the event will only bubble
|
||||||
@@ -373,24 +412,31 @@ export class Component<T extends Env, Props extends {}, State extends {}> {
|
|||||||
* willUnmount().
|
* willUnmount().
|
||||||
*/
|
*/
|
||||||
trigger(eventType: string, payload?: any) {
|
trigger(eventType: string, payload?: any) {
|
||||||
if (this.el) {
|
this.__trigger(this, eventType, payload);
|
||||||
const ev = new CustomEvent(eventType, {
|
|
||||||
bubbles: true,
|
|
||||||
cancelable: true,
|
|
||||||
detail: payload
|
|
||||||
});
|
|
||||||
this.el.dispatchEvent(ev);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
//--------------------------------------------------------------------------
|
//--------------------------------------------------------------------------
|
||||||
// Private
|
// Private
|
||||||
//--------------------------------------------------------------------------
|
//--------------------------------------------------------------------------
|
||||||
|
|
||||||
__destroy(parent: Component<any, any, any> | null) {
|
/**
|
||||||
|
* Private helper to perform a full destroy, from the point of view of an Owl
|
||||||
|
* component. It does not remove the el (this is done only once on the top
|
||||||
|
* level destroyed component, for performance reasons).
|
||||||
|
*
|
||||||
|
* The job of this method is mostly to call willUnmount hooks, and to perform
|
||||||
|
* all necessary internal cleanup.
|
||||||
|
*
|
||||||
|
* Note that it does not call the __callWillUnmount method to avoid visiting
|
||||||
|
* all children many times.
|
||||||
|
*/
|
||||||
|
__destroy(parent: Component<any, any> | null) {
|
||||||
const __owl__ = this.__owl__;
|
const __owl__ = this.__owl__;
|
||||||
const isMounted = __owl__.isMounted;
|
const isMounted = __owl__.isMounted;
|
||||||
if (isMounted) {
|
if (isMounted) {
|
||||||
|
if (__owl__.willUnmountCB) {
|
||||||
|
__owl__.willUnmountCB();
|
||||||
|
}
|
||||||
this.willUnmount();
|
this.willUnmount();
|
||||||
__owl__.isMounted = false;
|
__owl__.isMounted = false;
|
||||||
}
|
}
|
||||||
@@ -405,33 +451,33 @@ export class Component<T extends Env, Props extends {}, State extends {}> {
|
|||||||
}
|
}
|
||||||
__owl__.isDestroyed = true;
|
__owl__.isDestroyed = true;
|
||||||
delete __owl__.vnode;
|
delete __owl__.vnode;
|
||||||
|
if (__owl__.currentFiber) {
|
||||||
|
__owl__.currentFiber.isCompleted = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
__callMounted() {
|
__callMounted() {
|
||||||
const __owl__ = this.__owl__;
|
const __owl__ = this.__owl__;
|
||||||
const children = __owl__.children;
|
|
||||||
for (let id in children) {
|
|
||||||
const comp = children[id];
|
|
||||||
if (!comp.__owl__.isMounted && this.el!.contains(comp.el)) {
|
|
||||||
comp.__callMounted();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
__owl__.isMounted = true;
|
__owl__.isMounted = true;
|
||||||
const handlers = __owl__.mountedHandlers;
|
__owl__.currentFiber = null;
|
||||||
for (let key in handlers) {
|
this.mounted();
|
||||||
handlers[key]();
|
if (__owl__.mountedCB) {
|
||||||
}
|
__owl__.mountedCB();
|
||||||
try {
|
|
||||||
this.mounted();
|
|
||||||
} catch (e) {
|
|
||||||
errorHandler(e, this);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
__callWillUnmount() {
|
__callWillUnmount() {
|
||||||
this.willUnmount();
|
|
||||||
const __owl__ = this.__owl__;
|
const __owl__ = this.__owl__;
|
||||||
|
if (__owl__.willUnmountCB) {
|
||||||
|
__owl__.willUnmountCB();
|
||||||
|
}
|
||||||
|
this.willUnmount();
|
||||||
__owl__.isMounted = false;
|
__owl__.isMounted = false;
|
||||||
|
if (this.__owl__.currentFiber) {
|
||||||
|
this.__owl__.currentFiber.isCompleted = true;
|
||||||
|
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];
|
||||||
@@ -440,139 +486,179 @@ export class Component<T extends Env, Props extends {}, State extends {}> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
async __updateProps(
|
* Private trigger method, allows to choose the component which triggered
|
||||||
nextProps: Props,
|
* the event in the first place
|
||||||
forceUpdate: boolean = false,
|
*/
|
||||||
patchQueue?: any[],
|
__trigger(component: Component<any, any>, eventType: string, payload?: any) {
|
||||||
scope?: any,
|
if (this.el) {
|
||||||
vars?: any
|
const ev = new OwlEvent(component, eventType, {
|
||||||
): Promise<void> {
|
bubbles: true,
|
||||||
const shouldUpdate = forceUpdate || this.shouldUpdate(nextProps);
|
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
|
||||||
|
* it updates a component (so, when the parent template is rerendered).
|
||||||
|
*/
|
||||||
|
async __updateProps(nextProps: Props, parentFiber: Fiber, scope: any): Promise<void> {
|
||||||
|
this.__owl__.scope = scope;
|
||||||
|
const shouldUpdate = parentFiber.force || this.shouldUpdate(nextProps);
|
||||||
if (shouldUpdate) {
|
if (shouldUpdate) {
|
||||||
|
const __owl__ = this.__owl__;
|
||||||
|
const fiber = new Fiber(parentFiber, this, parentFiber.force, null);
|
||||||
|
if (!parentFiber.child) {
|
||||||
|
parentFiber.child = fiber;
|
||||||
|
} else {
|
||||||
|
parentFiber.lastChild!.sibling = fiber;
|
||||||
|
}
|
||||||
|
parentFiber.lastChild = fiber;
|
||||||
|
|
||||||
const defaultProps = (<any>this.constructor).defaultProps;
|
const defaultProps = (<any>this.constructor).defaultProps;
|
||||||
if (defaultProps) {
|
if (defaultProps) {
|
||||||
nextProps = this.__applyDefaultProps(nextProps, defaultProps);
|
this.__applyDefaultProps(nextProps, defaultProps);
|
||||||
|
}
|
||||||
|
if (QWeb.dev) {
|
||||||
|
QWeb.utils.validateProps(this.constructor, nextProps);
|
||||||
|
}
|
||||||
|
await Promise.all([
|
||||||
|
this.willUpdateProps(nextProps),
|
||||||
|
__owl__.willUpdatePropsCB && __owl__.willUpdatePropsCB(nextProps)
|
||||||
|
]);
|
||||||
|
if (fiber.isCompleted) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
await this.willUpdateProps(nextProps);
|
|
||||||
this.props = nextProps;
|
this.props = nextProps;
|
||||||
await this.render(forceUpdate, patchQueue, scope, vars);
|
|
||||||
|
this.__render(fiber);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
__patch(vnode) {
|
/**
|
||||||
|
* Main patching method. We call the virtual dom patch method here to convert
|
||||||
|
* a virtual dom vnode into some actual dom.
|
||||||
|
*/
|
||||||
|
__patch(vnode: VNode) {
|
||||||
const __owl__ = this.__owl__;
|
const __owl__ = this.__owl__;
|
||||||
const target = __owl__.vnode || document.createElement(vnode.sel!);
|
if (this.__target) {
|
||||||
if (this.__owl__.classObj) {
|
if (this.__target.tagName.toLowerCase() !== vnode.sel) {
|
||||||
(<any>vnode).data.class = Object.assign((<any>vnode).data.class || {}, this.__owl__.classObj);
|
throw new Error(
|
||||||
}
|
`Cannot attach '${this.constructor.name}' to target node (not same tag name)`
|
||||||
__owl__.vnode = patch(target, vnode);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
__prepare(scope?: Object, vars?: any): Promise<VNode> {
|
|
||||||
const __owl__ = this.__owl__;
|
|
||||||
__owl__.renderProps = this.props;
|
|
||||||
__owl__.renderPromise = this.__prepareAndRender(scope, vars);
|
|
||||||
return __owl__.renderPromise;
|
|
||||||
}
|
|
||||||
|
|
||||||
async __prepareAndRender(scope?: Object, vars?: any): Promise<VNode> {
|
|
||||||
try {
|
|
||||||
await this.willStart();
|
|
||||||
} catch (e) {
|
|
||||||
errorHandler(e, this);
|
|
||||||
return Promise.resolve(h("div"));
|
|
||||||
}
|
|
||||||
const __owl__ = this.__owl__;
|
|
||||||
if (__owl__.isDestroyed) {
|
|
||||||
return Promise.resolve(h("div"));
|
|
||||||
}
|
|
||||||
const qweb = this.env.qweb;
|
|
||||||
if (!this.template) {
|
|
||||||
let tmap = TEMPLATE_MAP[qweb.id];
|
|
||||||
if (!tmap) {
|
|
||||||
tmap = {};
|
|
||||||
TEMPLATE_MAP[qweb.id] = tmap;
|
|
||||||
}
|
}
|
||||||
let p = (<any>this).constructor;
|
__owl__.vnode = patch(this.__target, vnode);
|
||||||
let name: string = p.name;
|
delete this.__target;
|
||||||
let template = tmap[name];
|
} else {
|
||||||
if (template) {
|
const target = __owl__.vnode || document.createElement(vnode.sel!);
|
||||||
this.template = template;
|
__owl__.vnode = patch(target, vnode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The __prepare method is only called by the t-component directive, when a
|
||||||
|
* subcomponent is created. It gets its scope, if any, from the
|
||||||
|
* parent template.
|
||||||
|
*/
|
||||||
|
__prepare(parentFiber: Fiber, scope: any, cb: CallableFunction): Fiber {
|
||||||
|
this.__owl__.scope = scope;
|
||||||
|
const fiber = new Fiber(parentFiber, this, parentFiber.force, null);
|
||||||
|
fiber.shouldPatch = false;
|
||||||
|
if (!parentFiber.child) {
|
||||||
|
parentFiber.child = fiber;
|
||||||
|
} else {
|
||||||
|
parentFiber.lastChild!.sibling = fiber;
|
||||||
|
}
|
||||||
|
parentFiber.lastChild = fiber;
|
||||||
|
this.__prepareAndRender(fiber, cb);
|
||||||
|
return fiber;
|
||||||
|
}
|
||||||
|
|
||||||
|
__getTemplate(qweb: QWeb): string {
|
||||||
|
let p = (<any>this).constructor;
|
||||||
|
if (!p.hasOwnProperty("_template")) {
|
||||||
|
// here, the component and none of its superclasses defines a static `template`
|
||||||
|
// key. So we fall back on looking for a template matching its name (or
|
||||||
|
// one of its subclass).
|
||||||
|
|
||||||
|
let template: string;
|
||||||
|
while ((template = p.name) && !(template in qweb.templates) && p !== Component) {
|
||||||
|
p = p.__proto__;
|
||||||
|
}
|
||||||
|
if (p === Component) {
|
||||||
|
throw new Error(`Could not find template for component "${this.constructor.name}"`);
|
||||||
} else {
|
} else {
|
||||||
while ((template = p.name) && !(template in qweb.templates) && p !== Component) {
|
p._template = template;
|
||||||
p = p.__proto__;
|
|
||||||
}
|
|
||||||
if (p === Component) {
|
|
||||||
throw new Error(`Could not find template for component "${this.constructor.name}"`);
|
|
||||||
} else {
|
|
||||||
tmap[name] = template;
|
|
||||||
this.template = template;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
__owl__.render = qweb.render.bind(qweb, this.template);
|
return p._template;
|
||||||
this.__observeState();
|
}
|
||||||
return this.__render(false, [], scope, vars);
|
async __prepareAndRender(fiber: Fiber, cb: CallableFunction) {
|
||||||
|
try {
|
||||||
|
await Promise.all([this.willStart(), this.__owl__.willStartCB && this.__owl__.willStartCB()]);
|
||||||
|
} catch (e) {
|
||||||
|
fiber.handleError(e);
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
if (this.__owl__.isDestroyed) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
if (!fiber.isCompleted) {
|
||||||
|
this.__render(fiber);
|
||||||
|
cb();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
__render(
|
__render(fiber: Fiber) {
|
||||||
force: boolean = false,
|
|
||||||
patchQueue: any[] = [],
|
|
||||||
scope?: Object,
|
|
||||||
vars?: any
|
|
||||||
): Promise<VNode> {
|
|
||||||
const __owl__ = this.__owl__;
|
const __owl__ = this.__owl__;
|
||||||
const promises: Promise<void>[] = [];
|
|
||||||
const patch: any[] = [this];
|
|
||||||
if (__owl__.isMounted) {
|
|
||||||
patchQueue.push(patch);
|
|
||||||
}
|
|
||||||
if (__owl__.observer) {
|
if (__owl__.observer) {
|
||||||
__owl__.observer.allowMutations = false;
|
__owl__.observer.allowMutations = false;
|
||||||
}
|
}
|
||||||
let vnode;
|
let error;
|
||||||
try {
|
try {
|
||||||
vnode = __owl__.render!(this, {
|
let vnode = __owl__.renderFn!(this, {
|
||||||
promises,
|
|
||||||
handlers: __owl__.boundHandlers,
|
handlers: __owl__.boundHandlers,
|
||||||
mountedHandlers: __owl__.mountedHandlers,
|
fiber: fiber
|
||||||
forceUpdate: force,
|
|
||||||
patchQueue,
|
|
||||||
scope,
|
|
||||||
vars
|
|
||||||
});
|
});
|
||||||
|
// 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
|
||||||
|
// 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
|
||||||
|
for (let childKey in __owl__.children) {
|
||||||
|
let child = __owl__.children[childKey];
|
||||||
|
if (!child.__owl__.isMounted && child.__owl__.parentLastFiberId < fiber.id) {
|
||||||
|
child.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!vnode) {
|
||||||
|
throw new Error(`Rendering '${this.constructor.name}' did not return anything`);
|
||||||
|
}
|
||||||
|
fiber.vnode = vnode;
|
||||||
|
// we apply here the class information described on the component by the
|
||||||
|
// template (so, something like <MyComponent class="..."/>) to the actual
|
||||||
|
// root vnode
|
||||||
|
if (__owl__.classObj) {
|
||||||
|
const data = vnode.data!;
|
||||||
|
data.class = Object.assign(data.class || {}, __owl__.classObj);
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
vnode = __owl__.vnode || h("div");
|
error = e;
|
||||||
errorHandler(e, this);
|
|
||||||
}
|
}
|
||||||
patch.push(vnode);
|
|
||||||
if (__owl__.observer) {
|
if (__owl__.observer) {
|
||||||
__owl__.observer.allowMutations = true;
|
__owl__.observer.allowMutations = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// this part is critical for the patching process to be done correctly. The
|
fiber.root.counter--;
|
||||||
// tricky part is that a child component can be rerendered on its own, which
|
fiber.isRendered = true;
|
||||||
// will update its own vnode representation without the knowledge of the
|
if (error) {
|
||||||
// parent component. With this, we make sure that the parent component will be
|
fiber.handleError(error);
|
||||||
// able to patch itself properly after
|
|
||||||
vnode.key = __owl__.id;
|
|
||||||
return Promise.all(promises).then(() => vnode);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Only called by qweb t-component directive
|
|
||||||
*/
|
|
||||||
__mount(vnode: VNode, elm: HTMLElement): VNode {
|
|
||||||
const __owl__ = this.__owl__;
|
|
||||||
if (__owl__.classObj) {
|
|
||||||
(<any>vnode).data.class = Object.assign((<any>vnode).data.class || {}, __owl__.classObj);
|
|
||||||
}
|
}
|
||||||
__owl__.vnode = patch(elm, vnode);
|
|
||||||
if (__owl__.parent!.__owl__.isMounted && !__owl__.isMounted) {
|
|
||||||
this.__callMounted();
|
|
||||||
}
|
|
||||||
return __owl__.vnode;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -586,83 +672,16 @@ export class Component<T extends Env, Props extends {}, State extends {}> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
__observeState() {
|
|
||||||
if (this.state) {
|
|
||||||
const __owl__ = this.__owl__;
|
|
||||||
__owl__.observer = new Observer();
|
|
||||||
this.state = __owl__.observer.observe(this.state);
|
|
||||||
__owl__.observer.notifyCB = this.render.bind(this);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Apply default props (only top level).
|
* Apply default props (only top level).
|
||||||
*
|
*
|
||||||
* Note that this method does not modify in place the props, it returns a new
|
* Note that this method does modify in place the props
|
||||||
* prop object
|
|
||||||
*/
|
*/
|
||||||
__applyDefaultProps(props: Object | undefined, defaultProps: Object): Props {
|
__applyDefaultProps(props: Object, defaultProps: Object) {
|
||||||
props = props ? Object.assign({}, props) : {};
|
|
||||||
for (let propName in defaultProps) {
|
for (let propName in defaultProps) {
|
||||||
if (props![propName] === undefined) {
|
if (props![propName] === undefined) {
|
||||||
props![propName] = defaultProps[propName];
|
props![propName] = defaultProps[propName];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return <Props>props;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Apply the given patch queue. A patch is a pair [c, vn], where c is a
|
|
||||||
* Component instance and vn a VNode.
|
|
||||||
* 1) Call 'willPatch' on the component of each patch
|
|
||||||
* 2) Call '__patch' on the component of each patch
|
|
||||||
* 3) Call 'patched' on the component of each patch, in inverse order
|
|
||||||
*/
|
|
||||||
__applyPatchQueue(patchQueue: any[]) {
|
|
||||||
let component = this;
|
|
||||||
try {
|
|
||||||
const patchLen = patchQueue.length;
|
|
||||||
for (let i = 0; i < patchLen; i++) {
|
|
||||||
const patch = patchQueue[i];
|
|
||||||
component = patch[0];
|
|
||||||
patch.push(patch[0].willPatch());
|
|
||||||
}
|
|
||||||
for (let i = 0; i < patchLen; i++) {
|
|
||||||
const patch = patchQueue[i];
|
|
||||||
patch[0].__patch(patch[1]);
|
|
||||||
}
|
|
||||||
for (let i = patchLen - 1; i >= 0; i--) {
|
|
||||||
const patch = patchQueue[i];
|
|
||||||
component = patch[0];
|
|
||||||
patch[0].patched(patch[2]);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
errorHandler(e, component);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
//------------------------------------------------------------------------------
|
|
||||||
// Error handling
|
|
||||||
//------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function errorHandler(error, component) {
|
|
||||||
let canCatch = false;
|
|
||||||
let qweb = component.env.qweb;
|
|
||||||
let root = component;
|
|
||||||
while (component && !(canCatch = component.catchError !== Component.prototype.catchError)) {
|
|
||||||
root = component;
|
|
||||||
component = component.__owl__.parent;
|
|
||||||
}
|
|
||||||
console.error(error);
|
|
||||||
// we trigger error on QWeb so it can be logged/handled
|
|
||||||
qweb.trigger("error", error);
|
|
||||||
|
|
||||||
if (canCatch) {
|
|
||||||
setTimeout(() => {
|
|
||||||
component.catchError(error);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
root.destroy();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+104
-148
@@ -1,4 +1,5 @@
|
|||||||
import { QWeb } from "../qweb/index";
|
import { QWeb } from "../qweb/index";
|
||||||
|
import { INTERP_REGEXP } from "../qweb/compilation_context";
|
||||||
import { MODS_CODE } from "../qweb/extensions";
|
import { MODS_CODE } from "../qweb/extensions";
|
||||||
|
|
||||||
//------------------------------------------------------------------------------
|
//------------------------------------------------------------------------------
|
||||||
@@ -185,16 +186,15 @@ QWeb.utils.defineProxy = function defineProxy(target, source) {
|
|||||||
|
|
||||||
QWeb.addDirective({
|
QWeb.addDirective({
|
||||||
name: "component",
|
name: "component",
|
||||||
extraNames: ["props", "keepalive", "asyncroot"],
|
extraNames: ["props"],
|
||||||
priority: 100,
|
priority: 100,
|
||||||
atNodeEncounter({ ctx, value, node, qweb }): boolean {
|
atNodeEncounter({ ctx, value, node, qweb }): boolean {
|
||||||
ctx.addLine("//COMPONENT");
|
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;
|
||||||
let keepAlive = node.getAttribute("t-keepalive") ? true : false;
|
ctx.rootContext.shouldDefineScope = true;
|
||||||
let async = node.getAttribute("t-asyncroot") ? 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[], string, string][] = [];
|
const events: [string, string[], string, string][] = [];
|
||||||
@@ -207,11 +207,11 @@ QWeb.addDirective({
|
|||||||
if (name.startsWith("t-on-")) {
|
if (name.startsWith("t-on-")) {
|
||||||
const [eventName, ...mods] = name.slice(5).split(".");
|
const [eventName, ...mods] = name.slice(5).split(".");
|
||||||
let extraArgs;
|
let extraArgs;
|
||||||
let handlerName = value.replace(/\(.*\)/, function(args) {
|
let handlerValue = value.replace(/\(.*\)/, function(args) {
|
||||||
extraArgs = args.slice(1, -1);
|
extraArgs = args.slice(1, -1);
|
||||||
return "";
|
return "";
|
||||||
});
|
});
|
||||||
events.push([eventName, mods, handlerName, extraArgs]);
|
events.push([eventName, mods, handlerValue, extraArgs]);
|
||||||
} else if (name === "t-transition") {
|
} else if (name === "t-transition") {
|
||||||
transition = value;
|
transition = value;
|
||||||
} else if (!name.startsWith("t-")) {
|
} else if (!name.startsWith("t-")) {
|
||||||
@@ -222,61 +222,31 @@ QWeb.addDirective({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let key = node.getAttribute("t-key");
|
|
||||||
if (key) {
|
|
||||||
key = ctx.formatExpression(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 dummyID = ctx.generateID();
|
|
||||||
let defID = ctx.generateID();
|
|
||||||
let componentID = ctx.generateID();
|
let componentID = ctx.generateID();
|
||||||
let keyID = key && ctx.generateID();
|
|
||||||
if (key) {
|
|
||||||
// we bind a variable to the key (could be a complex expression, so we
|
|
||||||
// want to evaluate it only once)
|
|
||||||
ctx.addLine(`let key${keyID} = ${key};`);
|
|
||||||
}
|
|
||||||
ctx.addLine(`let def${defID};`);
|
|
||||||
let templateID = key
|
|
||||||
? `key${keyID}`
|
|
||||||
: ctx.inLoop
|
|
||||||
? `String(-${componentID} - i)`
|
|
||||||
: String(componentID);
|
|
||||||
if (ctx.allowMultipleRoots) {
|
|
||||||
// necessary to prevent collisions
|
|
||||||
if (!key && ctx.inLoop) {
|
|
||||||
let id = ctx.generateID();
|
|
||||||
ctx.addLine(`let template${id} = "_slot_" + String(-${componentID} - i)`);
|
|
||||||
templateID = `template${id}`;
|
|
||||||
} else {
|
|
||||||
templateID = `"_slot_${templateID}"`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
const 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 = "";
|
||||||
if (ref) {
|
if (ref) {
|
||||||
|
ctx.rootContext.shouldDefineRefs = true;
|
||||||
refKey = `ref${ctx.generateID()}`;
|
refKey = `ref${ctx.generateID()}`;
|
||||||
ctx.addLine(`const ${refKey} = ${ctx.interpolate(ref)};`);
|
ctx.addLine(`const ${refKey} = ${ctx.interpolate(ref)};`);
|
||||||
refExpr = `context.refs[${refKey}] = w${componentID};`;
|
refExpr = `context.__owl__.refs[${refKey}] = w${componentID};`;
|
||||||
}
|
}
|
||||||
let transitionsInsertCode = "";
|
let finalizeComponentCode = `w${componentID}.destroy();`;
|
||||||
if (transition) {
|
if (ref) {
|
||||||
transitionsInsertCode = `utils.transitionInsert(vn, '${transition}');`;
|
finalizeComponentCode += `delete context.__owl__.refs[${refKey}];`;
|
||||||
}
|
|
||||||
let finalizeComponentCode = `w${componentID}.${keepAlive ? "unmount" : "destroy"}();`;
|
|
||||||
if (ref && !keepAlive) {
|
|
||||||
finalizeComponentCode += `delete context.refs[${refKey}];`;
|
|
||||||
}
|
}
|
||||||
if (transition) {
|
if (transition) {
|
||||||
finalizeComponentCode = `let finalize = () => {
|
finalizeComponentCode = `let finalize = () => {
|
||||||
${finalizeComponentCode}
|
${finalizeComponentCode}
|
||||||
};
|
};
|
||||||
|
delete w${componentID}.__owl__.transitionInserted;
|
||||||
utils.transitionRemove(vn, '${transition}', finalize);`;
|
utils.transitionRemove(vn, '${transition}', finalize);`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -314,32 +284,31 @@ QWeb.addDirective({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
let eventsCode = events
|
let eventsCode = events
|
||||||
.map(function([eventName, mods, handlerName, extraArgs]) {
|
.map(function([eventName, mods, handlerValue, extraArgs]) {
|
||||||
let params = "owner";
|
let params = "context";
|
||||||
if (extraArgs) {
|
if (extraArgs) {
|
||||||
if (ctx.inLoop) {
|
if (ctx.loopNumber) {
|
||||||
let argId = ctx.generateID();
|
let argId = ctx.generateID();
|
||||||
// we need to evaluate the arguments now, because the handler will
|
// we need to evaluate the arguments now, because the handler will
|
||||||
// be set asynchronously later when the widget is ready, and the
|
// be set asynchronously later when the widget is ready, and the
|
||||||
// context might be different.
|
// context might be different.
|
||||||
ctx.addLine(`let arg${argId} = ${ctx.formatExpression(extraArgs)};`);
|
ctx.addLine(`let arg${argId} = ${ctx.formatExpression(extraArgs)};`);
|
||||||
params = `owner, arg${argId}`;
|
params = `context, arg${argId}`;
|
||||||
} else {
|
} else {
|
||||||
params = `owner, ${ctx.formatExpression(extraArgs)}`;
|
params = `context, ${ctx.formatExpression(extraArgs)}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let handler;
|
let handler = `function (e) {if(!context.__owl__.isMounted){return}`;
|
||||||
if (mods.length > 0) {
|
handler += mods
|
||||||
handler = `function (e) {`;
|
.map(function(mod) {
|
||||||
handler += mods
|
return T_COMPONENT_MODS_CODE[mod];
|
||||||
.map(function(mod) {
|
})
|
||||||
return T_COMPONENT_MODS_CODE[mod];
|
.join("");
|
||||||
})
|
if (handlerValue) {
|
||||||
.join("");
|
handler += `const fn = context['${handlerValue}'];`;
|
||||||
handler += `owner['${handlerName}'].call(${params}, e);}`;
|
handler += `if (fn) { fn.call(${params}, e); } else { context.${handlerValue}; }`;
|
||||||
} else {
|
|
||||||
handler = `owner['${handlerName}'].bind(${params})`;
|
|
||||||
}
|
}
|
||||||
|
handler += `}`;
|
||||||
return `vn.elm.addEventListener('${eventName}', ${handler});`;
|
return `vn.elm.addEventListener('${eventName}', ${handler});`;
|
||||||
})
|
})
|
||||||
.join("");
|
.join("");
|
||||||
@@ -349,71 +318,88 @@ QWeb.addDirective({
|
|||||||
}
|
}
|
||||||
|
|
||||||
ctx.addLine(
|
ctx.addLine(
|
||||||
`let w${componentID} = ${templateID} in parent.__owl__.cmap ? parent.__owl__.children[parent.__owl__.cmap[${templateID}]] : false;`
|
`let w${componentID} = ${templateKey} in parent.__owl__.cmap ? parent.__owl__.children[parent.__owl__.cmap[${templateKey}]] : false;`
|
||||||
);
|
);
|
||||||
if (ctx.parentNode) {
|
let shouldProxy = !ctx.parentNode;
|
||||||
ctx.addLine(`let _${dummyID}_index = c${ctx.parentNode}.length;`);
|
if (shouldProxy) {
|
||||||
|
let id = ctx.generateID();
|
||||||
|
ctx.rootContext.rootNode = id;
|
||||||
|
shouldProxy = true;
|
||||||
|
ctx.rootContext.shouldDefineResult = true;
|
||||||
|
ctx.addLine(`let vn${id} = {};`);
|
||||||
|
ctx.addLine(`result = vn${id};`);
|
||||||
}
|
}
|
||||||
let shouldProxy = false;
|
if (hasDynamicProps) {
|
||||||
if (async) {
|
const dynamicProp = ctx.formatExpression(node.getAttribute("t-props")!);
|
||||||
ctx.addLine(`const patchQueue${componentID} = [];`);
|
ctx.addLine(`let props${componentID} = Object.assign({${propStr}}, ${dynamicProp});`);
|
||||||
ctx.addLine(
|
|
||||||
`c${ctx.parentNode}.push(w${componentID} && w${componentID}.__owl__.pvnode || null);`
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
if (ctx.parentNode) {
|
ctx.addLine(`let props${componentID} = {${propStr}};`);
|
||||||
ctx.addLine(`c${ctx.parentNode}.push(null);`);
|
|
||||||
} else {
|
|
||||||
let id = ctx.generateID();
|
|
||||||
ctx.rootContext.rootNode = id;
|
|
||||||
shouldProxy = true;
|
|
||||||
ctx.rootContext.shouldDefineResult = true;
|
|
||||||
ctx.addLine(`let vn${id} = {};`);
|
|
||||||
ctx.addLine(`result = vn${id};`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
ctx.addLine(`let props${componentID} = {${propStr}};`);
|
|
||||||
ctx.addIf(
|
ctx.addIf(
|
||||||
`w${componentID} && w${componentID}.__owl__.renderPromise && !w${componentID}.__owl__.vnode`
|
`w${componentID} && w${componentID}.__owl__.currentFiber && !w${componentID}.__owl__.vnode`
|
||||||
);
|
);
|
||||||
ctx.addIf(`utils.shallowEqual(props${componentID}, w${componentID}.__owl__.renderProps)`);
|
|
||||||
ctx.addLine(`def${defID} = w${componentID}.__owl__.renderPromise;`);
|
|
||||||
ctx.addElse();
|
|
||||||
ctx.addLine(`w${componentID}.destroy();`);
|
ctx.addLine(`w${componentID}.destroy();`);
|
||||||
ctx.addLine(`w${componentID} = false;`);
|
ctx.addLine(`w${componentID} = false;`);
|
||||||
ctx.closeIf();
|
ctx.closeIf();
|
||||||
ctx.closeIf();
|
|
||||||
|
|
||||||
ctx.addIf(`!w${componentID}`);
|
let registerCode = "";
|
||||||
// new component
|
if (shouldProxy) {
|
||||||
ctx.addLine(`let componentKey${componentID} = ${ctx.interpolate(value)};`);
|
registerCode = `utils.defineProxy(vn${ctx.rootNode}, pvnode);`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// SLOTS
|
||||||
|
const hasSlots = node.childNodes.length;
|
||||||
|
|
||||||
|
let scope = hasSlots ? `Object.assign(Object.create(context), scope)` : "undefined";
|
||||||
|
|
||||||
|
ctx.addIf(`w${componentID}`);
|
||||||
|
|
||||||
|
// need to update component
|
||||||
|
let styleCode = "";
|
||||||
|
if (tattStyle) {
|
||||||
|
styleCode = `.then(()=>{if (w${componentID}.__owl__.isDestroyed) {return};w${componentID}.el.style=${tattStyle};});`;
|
||||||
|
}
|
||||||
ctx.addLine(
|
ctx.addLine(
|
||||||
`let W${componentID} = context.components && context.components[componentKey${componentID}] || QWeb.components[componentKey${componentID}];`
|
`w${componentID}.__updateProps(props${componentID}, extra.fiber, ${scope})${styleCode};`
|
||||||
);
|
);
|
||||||
|
ctx.addLine(`let pvnode = w${componentID}.__owl__.pvnode;`);
|
||||||
|
if (registerCode) {
|
||||||
|
ctx.addLine(registerCode);
|
||||||
|
}
|
||||||
|
if (ctx.parentNode) {
|
||||||
|
ctx.addLine(`c${ctx.parentNode}.push(pvnode);`);
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.addElse();
|
||||||
|
|
||||||
|
// new component
|
||||||
|
let dynamicFallback = "";
|
||||||
|
if (!value.match(INTERP_REGEXP)) {
|
||||||
|
dynamicFallback = `|| ${ctx.formatExpression(value)}`;
|
||||||
|
}
|
||||||
|
const interpValue = ctx.interpolate(value);
|
||||||
|
ctx.addLine(`let componentKey${componentID} = ${interpValue};`);
|
||||||
|
ctx.addLine(
|
||||||
|
`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...
|
||||||
ctx.addLine(
|
ctx.addLine(
|
||||||
`if (!W${componentID}) {throw new Error('Cannot find the definition of component "' + componentKey${componentID} + '"')}`
|
`if (!W${componentID}) {throw new Error('Cannot find the definition of component "' + componentKey${componentID} + '"')}`
|
||||||
);
|
);
|
||||||
if (QWeb.dev) {
|
|
||||||
ctx.addLine(`utils.validateProps(W${componentID}, props${componentID})`);
|
|
||||||
}
|
|
||||||
ctx.addLine(`w${componentID} = new W${componentID}(parent, props${componentID});`);
|
ctx.addLine(`w${componentID} = new W${componentID}(parent, props${componentID});`);
|
||||||
ctx.addLine(`parent.__owl__.cmap[${templateID}] = w${componentID}.__owl__.id;`);
|
if (transition) {
|
||||||
|
ctx.addLine(`const __patch${componentID} = w${componentID}.__patch;`);
|
||||||
|
ctx.addLine(
|
||||||
|
`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;`);
|
||||||
|
|
||||||
// SLOTS
|
|
||||||
const varDefs: string[] = [];
|
|
||||||
const hasSlots = node.childNodes.length;
|
|
||||||
if (hasSlots) {
|
if (hasSlots) {
|
||||||
ctx.rootContext.shouldTrackScope = true;
|
|
||||||
for (let v of Object.values(ctx.variables)) {
|
|
||||||
if (v["id"]) {
|
|
||||||
varDefs.push(v["id"]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const clone = <Element>node.cloneNode(true);
|
const clone = <Element>node.cloneNode(true);
|
||||||
const slotNodes = clone.querySelectorAll("[t-set]");
|
const slotNodes = clone.querySelectorAll("[t-set]");
|
||||||
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++) {
|
||||||
@@ -422,7 +408,7 @@ QWeb.addDirective({
|
|||||||
const key = slotNode.getAttribute("t-set")!;
|
const key = slotNode.getAttribute("t-set")!;
|
||||||
slotNode.removeAttribute("t-set");
|
slotNode.removeAttribute("t-set");
|
||||||
const slotFn = qweb._compile(`slot_${key}_template`, slotNode, ctx);
|
const slotFn = qweb._compile(`slot_${key}_template`, slotNode, ctx);
|
||||||
qweb.slots[`${slotId}_${key}`] = slotFn.bind(qweb);
|
QWeb.slots[`${slotId}_${key}`] = slotFn;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (clone.childNodes.length) {
|
if (clone.childNodes.length) {
|
||||||
@@ -431,63 +417,33 @@ QWeb.addDirective({
|
|||||||
t.appendChild(child);
|
t.appendChild(child);
|
||||||
}
|
}
|
||||||
const slotFn = qweb._compile(`slot_default_template`, t, ctx);
|
const slotFn = qweb._compile(`slot_default_template`, t, ctx);
|
||||||
qweb.slots[`${slotId}_default`] = slotFn.bind(qweb);
|
QWeb.slots[`${slotId}_default`] = slotFn;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let scopeVars = "";
|
ctx.addLine(
|
||||||
if (hasSlots) {
|
`let fiber = w${componentID}.__prepare(extra.fiber, ${scope}, () => { const vnode = fiber.vnode; pvnode.sel = vnode.sel; ${createHook}});`
|
||||||
scopeVars += ctx.scopeVars.length ? `Object.assign({}, scope)` : varDefs.length ? `{}` : "";
|
);
|
||||||
if (varDefs.length) {
|
|
||||||
scopeVars += `, {${varDefs.join(",")}}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ctx.addLine(`def${defID} = w${componentID}.__prepare(${scopeVars});`);
|
|
||||||
// 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
|
||||||
let registerCode = `c${ctx.parentNode}[_${dummyID}_index]=pvnode;`;
|
const insertHook = refExpr ? `insert(vn) {${refExpr}},` : "";
|
||||||
if (shouldProxy) {
|
|
||||||
registerCode = `utils.defineProxy(vn${ctx.rootNode}, pvnode);`;
|
|
||||||
}
|
|
||||||
ctx.addLine(
|
ctx.addLine(
|
||||||
`def${defID} = def${defID}.then(vnode=>{${createHook}let pvnode=h(vnode.sel, {key: ${templateID}, hook: {insert(vn) {let nvn=w${componentID}.__mount(vnode, pvnode.elm);pvnode.elm=nvn.elm;${refExpr}${transitionsInsertCode}},remove() {},destroy(vn) {${finalizeComponentCode}}}});${registerCode}w${componentID}.__owl__.pvnode = pvnode;});`
|
`let pvnode = h('dummy', {key: ${templateKey}, hook: {${insertHook}remove() {},destroy(vn) {${finalizeComponentCode}}}});`
|
||||||
);
|
);
|
||||||
|
if (registerCode) {
|
||||||
|
ctx.addLine(registerCode);
|
||||||
|
}
|
||||||
|
if (ctx.parentNode) {
|
||||||
|
ctx.addLine(`c${ctx.parentNode}.push(pvnode);`);
|
||||||
|
}
|
||||||
|
ctx.addLine(`w${componentID}.__owl__.pvnode = pvnode;`);
|
||||||
|
|
||||||
ctx.addElse();
|
|
||||||
// need to update component
|
|
||||||
const patchQueueCode = async ? `patchQueue${componentID}` : "extra.patchQueue";
|
|
||||||
if (QWeb.dev) {
|
|
||||||
ctx.addLine(`utils.validateProps(w${componentID}.constructor, props${componentID})`);
|
|
||||||
}
|
|
||||||
ctx.addLine(
|
|
||||||
`def${defID} = def${defID} || w${componentID}.__updateProps(props${componentID}, extra.forceUpdate, ${patchQueueCode}${scopeVars &&
|
|
||||||
", " + scopeVars});`
|
|
||||||
);
|
|
||||||
let keepAliveCode = "";
|
|
||||||
if (keepAlive) {
|
|
||||||
keepAliveCode = `pvnode.data.hook.insert = vn => {vn.elm.parentNode.replaceChild(w${componentID}.el,vn.elm);vn.elm=w${componentID}.el;w${componentID}.__remount();};`;
|
|
||||||
}
|
|
||||||
ctx.addLine(
|
|
||||||
`def${defID} = def${defID}.then(()=>{if (w${componentID}.__owl__.isDestroyed) {return};${
|
|
||||||
tattStyle ? `w${componentID}.el.style=${tattStyle};` : ""
|
|
||||||
}let pvnode=w${componentID}.__owl__.pvnode;${keepAliveCode}${registerCode}});`
|
|
||||||
);
|
|
||||||
ctx.closeIf();
|
ctx.closeIf();
|
||||||
|
|
||||||
if (classObj) {
|
if (classObj) {
|
||||||
ctx.addLine(`w${componentID}.__owl__.classObj=${classObj};`);
|
ctx.addLine(`w${componentID}.__owl__.classObj=${classObj};`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (async) {
|
ctx.addLine(`w${componentID}.__owl__.parentLastFiberId = extra.fiber.id;`);
|
||||||
ctx.addLine(
|
|
||||||
`def${defID}.then(w${componentID}.__applyPatchQueue.bind(w${componentID}, patchQueue${componentID}));`
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
ctx.addLine(`extra.promises.push(def${defID});`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (node.hasAttribute("t-if") || node.hasAttribute("t-else") || node.hasAttribute("t-elif")) {
|
|
||||||
ctx.closeIf();
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,286 @@
|
|||||||
|
import { h, VNode } from "../vdom/index";
|
||||||
|
import { Component } from "./component";
|
||||||
|
import { scheduler } from "./scheduler";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owl Fiber Class
|
||||||
|
*
|
||||||
|
* Fibers are small abstractions designed to contain all the internal state
|
||||||
|
* associated with a "rendering work unit", relative to a specific component.
|
||||||
|
*
|
||||||
|
* A rendering will cause the creation of a fiber for each impacted components.
|
||||||
|
*
|
||||||
|
* Fibers capture all that necessary information, which is critical to owl
|
||||||
|
* asynchronous rendering pipeline. Fibers can be cancelled, can be in different
|
||||||
|
* states and in general determine the state of the rendering.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class Fiber {
|
||||||
|
static nextId: number = 1;
|
||||||
|
id: number = Fiber.nextId++;
|
||||||
|
|
||||||
|
// The force attribute determines if a rendering should bypass the `shouldUpdate`
|
||||||
|
// method potentially implemented by a component. It is usually set to false.
|
||||||
|
force: boolean;
|
||||||
|
|
||||||
|
// isCompleted means that the rendering corresponding to this fiber's work is
|
||||||
|
// done, either because the component has been mounted or patched, or because
|
||||||
|
// fiber has been cancelled.
|
||||||
|
isCompleted: boolean = false;
|
||||||
|
|
||||||
|
// the fibers corresponding to component updates (updateProps) need to call
|
||||||
|
// the willPatch and patched hooks from the corresponding component. However,
|
||||||
|
// fibers corresponding to a new component do not need to do that. So, the
|
||||||
|
// shouldPatch hook is the boolean that we check whenever we need to apply
|
||||||
|
// a patch.
|
||||||
|
shouldPatch: boolean = true;
|
||||||
|
|
||||||
|
// isRendered is the last state of a fiber. If true, this means that it has
|
||||||
|
// been rendered and is inert (so, it should not be taken into account when
|
||||||
|
// counting the number of active fibers).
|
||||||
|
isRendered: boolean = false;
|
||||||
|
|
||||||
|
// the counter number is a critical information. It is only necessary for a
|
||||||
|
// root fiber. For that fiber, this number counts the number of active sub
|
||||||
|
// fibers. When that number reaches 0, the fiber can be applied by the
|
||||||
|
// scheduler.
|
||||||
|
counter: number = 0;
|
||||||
|
|
||||||
|
inserter: (el: HTMLElement) => void | null;
|
||||||
|
|
||||||
|
scope: any;
|
||||||
|
|
||||||
|
component: Component<any, any>;
|
||||||
|
vnode: VNode | null = null;
|
||||||
|
|
||||||
|
root: Fiber;
|
||||||
|
child: Fiber | null = null;
|
||||||
|
sibling: Fiber | null = null;
|
||||||
|
lastChild: Fiber | null = null;
|
||||||
|
parent: Fiber | null = null;
|
||||||
|
|
||||||
|
error?: Error;
|
||||||
|
|
||||||
|
constructor(parent: Fiber | null, component: Component<any, any>, force, inserter) {
|
||||||
|
this.component = component;
|
||||||
|
this.force = force;
|
||||||
|
this.inserter = inserter;
|
||||||
|
|
||||||
|
const __owl__ = component.__owl__;
|
||||||
|
this.scope = __owl__.scope;
|
||||||
|
|
||||||
|
this.root = parent ? parent.root : this;
|
||||||
|
this.parent = parent;
|
||||||
|
|
||||||
|
let oldFiber = __owl__.currentFiber;
|
||||||
|
if (oldFiber && !oldFiber.isCompleted) {
|
||||||
|
if (oldFiber.root === oldFiber && !parent) {
|
||||||
|
// both oldFiber and this fiber are root fibers
|
||||||
|
this._reuseFiber(oldFiber);
|
||||||
|
return oldFiber;
|
||||||
|
} else {
|
||||||
|
this._remapFiber(oldFiber);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.root.counter++;
|
||||||
|
|
||||||
|
__owl__.currentFiber = this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When the oldFiber is not completed yet, and both oldFiber and this fiber
|
||||||
|
* are root fibers, we want to reuse the oldFiber instead of creating a new
|
||||||
|
* one. Doing so will guarantee that the initiator(s) of those renderings will
|
||||||
|
* be notified (the promise will resolve) when the last rendering will be done.
|
||||||
|
*
|
||||||
|
* This function thus assumes that oldFiber is a root fiber.
|
||||||
|
*/
|
||||||
|
_reuseFiber(oldFiber: Fiber) {
|
||||||
|
oldFiber.cancel(); // cancel children fibers
|
||||||
|
oldFiber.isCompleted = false; // keep the root fiber alive
|
||||||
|
oldFiber.isRendered = false; // the fiber has to be re-rendered
|
||||||
|
if (oldFiber.child) {
|
||||||
|
// remove relation to children
|
||||||
|
oldFiber.child.parent = null;
|
||||||
|
oldFiber.child = null;
|
||||||
|
oldFiber.lastChild = null;
|
||||||
|
}
|
||||||
|
oldFiber.counter = 1; // re-initialize counter
|
||||||
|
oldFiber.id = Fiber.nextId++;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In some cases, a rendering initiated at some component can detect that it
|
||||||
|
* should be part of a larger rendering initiated somewhere up the component
|
||||||
|
* tree. In that case, it needs to cancel the previous rendering and
|
||||||
|
* remap itself as a part of the current parent rendering.
|
||||||
|
*/
|
||||||
|
_remapFiber(oldFiber: Fiber) {
|
||||||
|
oldFiber.cancel();
|
||||||
|
this.shouldPatch = oldFiber.shouldPatch;
|
||||||
|
if (oldFiber === oldFiber.root) {
|
||||||
|
oldFiber.counter++;
|
||||||
|
}
|
||||||
|
if (oldFiber.parent && !this.parent) {
|
||||||
|
// re-map links
|
||||||
|
this.parent = oldFiber.parent;
|
||||||
|
this.root = this.parent.root;
|
||||||
|
this.sibling = oldFiber.sibling;
|
||||||
|
if (this.parent.lastChild === oldFiber) {
|
||||||
|
this.parent.lastChild = this;
|
||||||
|
}
|
||||||
|
if (this.parent.child === oldFiber) {
|
||||||
|
this.parent.child = this;
|
||||||
|
} else {
|
||||||
|
let current = this.parent.child!;
|
||||||
|
while (true) {
|
||||||
|
if (current.sibling === oldFiber) {
|
||||||
|
current.sibling = this;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
current = current.sibling!;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This function has been taken from
|
||||||
|
* https://medium.com/react-in-depth/the-how-and-why-on-reacts-usage-of-linked-list-in-fiber-67f1014d0eb7
|
||||||
|
*/
|
||||||
|
_walk(doWork: (f: Fiber) => Fiber | null) {
|
||||||
|
let root = this;
|
||||||
|
let current: Fiber = this;
|
||||||
|
while (true) {
|
||||||
|
const child = doWork(current);
|
||||||
|
if (child) {
|
||||||
|
current = child;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (current === root) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
while (!current.sibling) {
|
||||||
|
if (!current.parent || current.parent === root) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
current = current.parent;
|
||||||
|
}
|
||||||
|
current = current.sibling;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Successfully complete the work of the fiber: call the mount or patch hooks
|
||||||
|
* and patch the DOM. This function is called once the fiber and its children
|
||||||
|
* are ready, and the scheduler decides to process it.
|
||||||
|
*/
|
||||||
|
complete() {
|
||||||
|
let component = this.component;
|
||||||
|
this.isCompleted = true;
|
||||||
|
if (!this.inserter && !component.__owl__.isMounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// build patchQueue
|
||||||
|
const patchQueue: Fiber[] = [];
|
||||||
|
const doWork: (Fiber) => Fiber | null = function(f) {
|
||||||
|
patchQueue.push(f);
|
||||||
|
return f.child;
|
||||||
|
};
|
||||||
|
this._walk(doWork);
|
||||||
|
const patchLen = patchQueue.length;
|
||||||
|
|
||||||
|
// call willPatch hook on each fiber of patchQueue
|
||||||
|
for (let i = 0; i < patchLen; i++) {
|
||||||
|
const fiber = patchQueue[i];
|
||||||
|
if (fiber.shouldPatch) {
|
||||||
|
component = fiber.component;
|
||||||
|
if (component.__owl__.willPatchCB) {
|
||||||
|
component.__owl__.willPatchCB();
|
||||||
|
}
|
||||||
|
component.willPatch();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// call __patch on each fiber of (reversed) patchQueue
|
||||||
|
for (let i = patchLen - 1; i >= 0; i--) {
|
||||||
|
const fiber = patchQueue[i];
|
||||||
|
component = fiber.component;
|
||||||
|
component.__patch(fiber.vnode!);
|
||||||
|
if (!fiber.shouldPatch && (!fiber.inserter || i !== 0)) {
|
||||||
|
component.__owl__.pvnode!.elm = component.__owl__.vnode!.elm;
|
||||||
|
}
|
||||||
|
component.__owl__.currentFiber = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// insert into the DOM (mount case)
|
||||||
|
let inDOM = false;
|
||||||
|
if (this.inserter) {
|
||||||
|
this.inserter(this.component.el!);
|
||||||
|
inDOM = document.body.contains(this.component.el);
|
||||||
|
this.component.env.qweb.trigger("dom-appended");
|
||||||
|
}
|
||||||
|
|
||||||
|
// call patched/mounted hook on each fiber of (reversed) patchQueue
|
||||||
|
for (let i = patchLen - 1; i >= 0; i--) {
|
||||||
|
const fiber = patchQueue[i];
|
||||||
|
component = fiber.component;
|
||||||
|
if (fiber.shouldPatch && !this.inserter) {
|
||||||
|
component.patched();
|
||||||
|
if (component.__owl__.patchedCB) {
|
||||||
|
component.__owl__.patchedCB();
|
||||||
|
}
|
||||||
|
} else if (this.inserter ? inDOM : true) {
|
||||||
|
component.__callMounted();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel a fiber and all its children.
|
||||||
|
*/
|
||||||
|
cancel() {
|
||||||
|
this._walk(f => {
|
||||||
|
if (!f.isRendered) {
|
||||||
|
f.root.counter--;
|
||||||
|
}
|
||||||
|
f.isCompleted = true;
|
||||||
|
return f.child;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This is the global error handler for errors occurring in Owl main lifecycle
|
||||||
|
* methods. Caught errors are triggered on the QWeb instance, and are
|
||||||
|
* potentially given to some parent component which implements `catchError`.
|
||||||
|
*
|
||||||
|
* If there are no such component, we destroy everything. This is better than
|
||||||
|
* being in a corrupted state.
|
||||||
|
*/
|
||||||
|
handleError(error: Error) {
|
||||||
|
let component = this.component;
|
||||||
|
this.vnode = component.__owl__.vnode || h("div");
|
||||||
|
|
||||||
|
const qweb = component.env.qweb;
|
||||||
|
let root = component;
|
||||||
|
let canCatch = false;
|
||||||
|
while (component && !(canCatch = !!component.catchError)) {
|
||||||
|
root = component;
|
||||||
|
component = component.__owl__.parent!;
|
||||||
|
}
|
||||||
|
qweb.trigger("error", error);
|
||||||
|
|
||||||
|
if (canCatch) {
|
||||||
|
component.catchError!(error);
|
||||||
|
} else {
|
||||||
|
// 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
|
||||||
|
this.root.counter = 0;
|
||||||
|
this.root.error = error;
|
||||||
|
scheduler.flush();
|
||||||
|
root.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,7 +21,7 @@ QWeb.utils.validateProps = function(Widget, props: Object) {
|
|||||||
// optional prop
|
// optional prop
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (!props[propName]) {
|
if (!(propName in props)) {
|
||||||
throw new Error(`Missing props '${propsDef[i]}' (component '${Widget.name}')`);
|
throw new Error(`Missing props '${propsDef[i]}' (component '${Widget.name}')`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -40,9 +40,15 @@ QWeb.utils.validateProps = function(Widget, props: Object) {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let isValid = isValidProp(props[propName], propsDef[propName]);
|
let isValid;
|
||||||
|
try {
|
||||||
|
isValid = isValidProp(props[propName], propsDef[propName]);
|
||||||
|
} catch (e) {
|
||||||
|
e.message = `Invalid prop '${propName}' in component ${Widget.name} (${e.message})`;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
if (!isValid) {
|
if (!isValid) {
|
||||||
throw new Error(`Props '${propName}' of invalid type in component '${Widget.name}'`);
|
throw new Error(`Invalid Prop '${propName}' in component '${Widget.name}'`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (let propName in props) {
|
for (let propName in props) {
|
||||||
@@ -80,17 +86,30 @@ function isValidProp(prop, propDef): boolean {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
// propsDef is an object
|
// propsDef is an object
|
||||||
let result = isValidProp(prop, propDef.type);
|
if (propDef.optional && prop === undefined) {
|
||||||
if (propDef.type === Array) {
|
return true;
|
||||||
|
}
|
||||||
|
let result = propDef.type ? isValidProp(prop, propDef.type) : true;
|
||||||
|
if (propDef.validate) {
|
||||||
|
result = result && propDef.validate(prop);
|
||||||
|
}
|
||||||
|
if (propDef.type === Array && propDef.element) {
|
||||||
for (let i = 0, iLen = prop.length; i < iLen; i++) {
|
for (let i = 0, iLen = prop.length; i < iLen; i++) {
|
||||||
result = result && isValidProp(prop[i], propDef.element);
|
result = result && isValidProp(prop[i], propDef.element);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (propDef.type === Object) {
|
if (propDef.type === Object && propDef.shape) {
|
||||||
const shape = propDef.shape;
|
const shape = propDef.shape;
|
||||||
for (let key in shape) {
|
for (let key in shape) {
|
||||||
result = result && isValidProp(prop[key], shape[key]);
|
result = result && isValidProp(prop[key], shape[key]);
|
||||||
}
|
}
|
||||||
|
if (result) {
|
||||||
|
for (let propName in prop) {
|
||||||
|
if (!(propName in shape)) {
|
||||||
|
throw new Error(`unknown prop '${propName}'`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { Fiber } from "./fiber";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owl Scheduler Class
|
||||||
|
*
|
||||||
|
* The scheduler is the part of Owl that will effectively apply a rendering
|
||||||
|
* whenever a fiber is ready.
|
||||||
|
*
|
||||||
|
* Briefly, it can be used to register root fibers. Whenever there is an
|
||||||
|
* active root fiber, it will poll continuously each animation frame (so, about
|
||||||
|
* once every 16ms) and whenever a root fiber is ready, it will apply it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface Task {
|
||||||
|
fiber: Fiber;
|
||||||
|
callback: (err?: Error) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Scheduler {
|
||||||
|
tasks: Task[] = [];
|
||||||
|
isRunning: boolean = false;
|
||||||
|
requestAnimationFrame: typeof window.requestAnimationFrame;
|
||||||
|
|
||||||
|
constructor(requestAnimationFrame) {
|
||||||
|
this.requestAnimationFrame = requestAnimationFrame;
|
||||||
|
}
|
||||||
|
|
||||||
|
start() {
|
||||||
|
this.isRunning = true;
|
||||||
|
this.scheduleTasks();
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
this.isRunning = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
addFiber(fiber): Promise<void> {
|
||||||
|
// 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
|
||||||
|
fiber = fiber.root;
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (fiber.error) {
|
||||||
|
return reject(fiber.error);
|
||||||
|
}
|
||||||
|
this.tasks.push({
|
||||||
|
fiber,
|
||||||
|
callback: () => {
|
||||||
|
if (fiber.error) {
|
||||||
|
return reject(fiber.error);
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!this.isRunning) {
|
||||||
|
this.start();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process all current tasks. This only applies to the fibers that are ready.
|
||||||
|
* Other tasks are left unchanged.
|
||||||
|
*/
|
||||||
|
flush() {
|
||||||
|
let tasks = this.tasks;
|
||||||
|
this.tasks = [];
|
||||||
|
tasks = tasks.filter(task => {
|
||||||
|
if (task.fiber.isCompleted) {
|
||||||
|
task.callback();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (task.fiber.counter === 0) {
|
||||||
|
if (!task.fiber.error) {
|
||||||
|
try {
|
||||||
|
task.fiber.complete();
|
||||||
|
} catch (e) {
|
||||||
|
task.fiber.handleError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
task.callback();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
this.tasks = tasks.concat(this.tasks);
|
||||||
|
if (this.tasks.length === 0) {
|
||||||
|
this.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleTasks() {
|
||||||
|
this.requestAnimationFrame(() => {
|
||||||
|
this.flush();
|
||||||
|
if (this.isRunning) {
|
||||||
|
this.scheduleTasks();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const raf = window.requestAnimationFrame.bind(window);
|
||||||
|
export const scheduler = new Scheduler(raf);
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { QWeb } from "./qweb/index";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This file creates and exports the OWL 'config' object, with keys:
|
||||||
|
* - 'mode': 'prod' or 'dev',
|
||||||
|
* - 'env': the environment to use in root components.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface Config {
|
||||||
|
mode: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {} as Config;
|
||||||
|
|
||||||
|
Object.defineProperty(config, "mode", {
|
||||||
|
get() {
|
||||||
|
return QWeb.dev ? "dev" : "prod";
|
||||||
|
},
|
||||||
|
set(mode: string) {
|
||||||
|
QWeb.dev = mode === "dev";
|
||||||
|
if (QWeb.dev) {
|
||||||
|
const url = `https://github.com/odoo/owl/blob/master/doc/reference/config.md#mode`;
|
||||||
|
console.warn(
|
||||||
|
`Owl is running in 'dev' mode. This is not suitable for production use. See ${url} for more information.`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.log(`Owl is now running in 'prod' mode.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
+148
@@ -0,0 +1,148 @@
|
|||||||
|
import { Component } from "./component/component";
|
||||||
|
import { scheduler } from "./component/scheduler";
|
||||||
|
import { EventBus } from "./core/event_bus";
|
||||||
|
import { Observer } from "./core/observer";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `Context` object provides a way to share data between an arbitrary number
|
||||||
|
* of component. Usually, data is passed from a parent to its children component,
|
||||||
|
* but when we have to deal with some mostly global information, this can be
|
||||||
|
* annoying, since each component will need to pass the information to each
|
||||||
|
* children, even though some or most of them will not use the information.
|
||||||
|
*
|
||||||
|
* With a `Context` object, each component can subscribe (with the `useContext`
|
||||||
|
* hook) to its state, and will be updated whenever the context state is updated.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function partitionBy<T>(arr: T[], fn: (t: T) => boolean) {
|
||||||
|
let lastGroup: T[] | false = false;
|
||||||
|
let lastValue;
|
||||||
|
return arr.reduce((acc: T[][], cur) => {
|
||||||
|
let curVal = fn(cur);
|
||||||
|
if (lastGroup) {
|
||||||
|
if (curVal === lastValue) {
|
||||||
|
lastGroup.push(cur);
|
||||||
|
} else {
|
||||||
|
lastGroup = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!lastGroup) {
|
||||||
|
lastGroup = [cur];
|
||||||
|
acc.push(lastGroup);
|
||||||
|
}
|
||||||
|
lastValue = curVal;
|
||||||
|
return acc;
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Context extends EventBus {
|
||||||
|
state: any;
|
||||||
|
observer: Observer;
|
||||||
|
rev: number = 1;
|
||||||
|
// mapping from component id to last observed context id
|
||||||
|
mapping: { [componentId: number]: number } = {};
|
||||||
|
|
||||||
|
constructor(state: Object = {}) {
|
||||||
|
super();
|
||||||
|
this.observer = new Observer();
|
||||||
|
this.observer.notifyCB = () => {
|
||||||
|
// notify components in the next microtask tick to ensure that subscribers
|
||||||
|
// are notified only once for all changes that occur in the same micro tick
|
||||||
|
let rev = this.rev;
|
||||||
|
return Promise.resolve().then(() => {
|
||||||
|
if (rev === this.rev) {
|
||||||
|
this.__notifyComponents();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
this.state = this.observer.observe(state);
|
||||||
|
this.subscriptions.update = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Instead of using trigger to emit an update event, we actually implement
|
||||||
|
* our own function to do that. The reason is that we need to be smarter than
|
||||||
|
* a simple trigger function: we need to wait for parent components to be
|
||||||
|
* done before doing children components. More precisely, if an update
|
||||||
|
* as an effect of destroying a children, we do not want to call any code
|
||||||
|
* from the child, and certainly not render it.
|
||||||
|
*
|
||||||
|
* This method implements a simple grouping algorithm by depth. If we have
|
||||||
|
* connected components of depths [2, 4,4,4,4, 3,8,8], the Context will notify
|
||||||
|
* them in the following groups: [2], [4,4,4,4], [3], [8,8]. Each group will
|
||||||
|
* be updated sequentially, but each components in a given group will be done in
|
||||||
|
* parallel.
|
||||||
|
*
|
||||||
|
* This is a very simple algorithm, but it avoids checking if a given
|
||||||
|
* component is a child of another.
|
||||||
|
*/
|
||||||
|
async __notifyComponents() {
|
||||||
|
const rev = ++this.rev;
|
||||||
|
const subscriptions = this.subscriptions.update;
|
||||||
|
const groups = partitionBy(subscriptions, s => (s.owner ? s.owner.__owl__.depth : -1));
|
||||||
|
for (let group of groups) {
|
||||||
|
const proms = group.map(sub => sub.callback.call(sub.owner, rev));
|
||||||
|
// at this point, each component in the current group has registered a
|
||||||
|
// 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
|
||||||
|
// flush the scheduler. This will force the scheduler to check
|
||||||
|
// immediately if they are done, which will cause their rendering
|
||||||
|
// promise to resolve earlier, which means that there is a chance of
|
||||||
|
// processing the next group in the same frame.
|
||||||
|
scheduler.flush();
|
||||||
|
await Promise.all(proms);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The`useContext` hook is the normal way for a component to register themselve
|
||||||
|
* to context state changes. The `useContext` method returns the context state
|
||||||
|
*/
|
||||||
|
export function useContext(ctx: Context): any {
|
||||||
|
const component: Component<any, any> = Component.current!;
|
||||||
|
return useContextWithCB(ctx, component, component.render.bind(component));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useContextWithCB(ctx: Context, component: Component<any, any>, method): any {
|
||||||
|
const __owl__ = component.__owl__;
|
||||||
|
const id = __owl__.id;
|
||||||
|
const mapping = ctx.mapping;
|
||||||
|
if (id in mapping) {
|
||||||
|
return ctx.state;
|
||||||
|
}
|
||||||
|
if (!__owl__.observer) {
|
||||||
|
__owl__.observer = new Observer();
|
||||||
|
__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;
|
||||||
|
const renderFn = __owl__.renderFn;
|
||||||
|
__owl__.renderFn = function(comp, params) {
|
||||||
|
mapping[id] = ctx.rev;
|
||||||
|
return renderFn(comp, params);
|
||||||
|
};
|
||||||
|
ctx.on("update", component, async contextRev => {
|
||||||
|
if (mapping[id] < contextRev) {
|
||||||
|
mapping[id] = contextRev;
|
||||||
|
await method();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const __destroy = component.__destroy;
|
||||||
|
component.__destroy = parent => {
|
||||||
|
ctx.off("update", component);
|
||||||
|
delete mapping[id];
|
||||||
|
__destroy.call(component, parent);
|
||||||
|
};
|
||||||
|
return ctx.state;
|
||||||
|
}
|
||||||
+4
-20
@@ -20,21 +20,11 @@
|
|||||||
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() {}
|
||||||
async notifyChange() {
|
|
||||||
|
|
||||||
this.dirty = true;
|
observe<T>(value: T, parent?: any): T {
|
||||||
await Promise.resolve();
|
|
||||||
if (this.dirty) {
|
|
||||||
this.dirty = false;
|
|
||||||
this.notifyCB();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
observe(value: any, parent?: any): any {
|
|
||||||
if (value === null || typeof value !== "object" || value instanceof Date) {
|
if (value === null || typeof value !== "object" || value instanceof Date) {
|
||||||
// fun fact: typeof null === 'object'
|
// fun fact: typeof null === 'object'
|
||||||
return value;
|
return value;
|
||||||
@@ -47,10 +37,6 @@ export class Observer {
|
|||||||
const metadata = this.weakMap.get(value);
|
const metadata = this.weakMap.get(value);
|
||||||
return metadata ? metadata.rev : 0;
|
return metadata ? metadata.rev : 0;
|
||||||
}
|
}
|
||||||
deepRevNumber(value): number {
|
|
||||||
const metadata = this.weakMap.get(value);
|
|
||||||
return metadata ? metadata.deepRev : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
_observe(value, parent) {
|
_observe(value, parent) {
|
||||||
var self = this;
|
var self = this;
|
||||||
@@ -70,7 +56,7 @@ export class Observer {
|
|||||||
}
|
}
|
||||||
self._updateRevNumber(target);
|
self._updateRevNumber(target);
|
||||||
target[key] = newVal;
|
target[key] = newVal;
|
||||||
self.notifyChange();
|
self.notifyCB();
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
@@ -78,7 +64,7 @@ export class Observer {
|
|||||||
if (key in target) {
|
if (key in target) {
|
||||||
delete target[key];
|
delete target[key];
|
||||||
self._updateRevNumber(target);
|
self._updateRevNumber(target);
|
||||||
self.notifyChange();
|
self.notifyCB();
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -88,7 +74,6 @@ export class Observer {
|
|||||||
value,
|
value,
|
||||||
proxy,
|
proxy,
|
||||||
rev: this.rev,
|
rev: this.rev,
|
||||||
deepRev: this.rev,
|
|
||||||
parent
|
parent
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -100,11 +85,10 @@ export class Observer {
|
|||||||
_updateRevNumber(target: any) {
|
_updateRevNumber(target: any) {
|
||||||
this.rev++;
|
this.rev++;
|
||||||
let metadata = this.weakMap.get(target);
|
let metadata = this.weakMap.get(target);
|
||||||
metadata.rev!++;
|
|
||||||
let parent = target;
|
let parent = target;
|
||||||
do {
|
do {
|
||||||
metadata = this.weakMap.get(parent);
|
metadata = this.weakMap.get(parent);
|
||||||
metadata.deepRev++;
|
metadata.rev++;
|
||||||
} while ((parent = metadata.parent) && parent !== target);
|
} while ((parent = metadata.parent) && parent !== target);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { Component } from "../component/component";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* We define here OwlEvent, a subclass of CustomEvent, with an additional
|
||||||
|
* attribute:
|
||||||
|
* - originalComponent: the component that triggered the event
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class OwlEvent<T> extends CustomEvent<T> {
|
||||||
|
originalComponent: Component<any, any>;
|
||||||
|
constructor(component, eventType, options) {
|
||||||
|
super(eventType, options);
|
||||||
|
this.originalComponent = component;
|
||||||
|
}
|
||||||
|
}
|
||||||
+133
@@ -0,0 +1,133 @@
|
|||||||
|
import { Component } from "./component/component";
|
||||||
|
import { Observer } from "./core/observer";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owl Hook System
|
||||||
|
*
|
||||||
|
* This file introduces the concept of hooks, similar to React or Vue hooks.
|
||||||
|
* We have currently an implementation of:
|
||||||
|
* - useState (reactive state)
|
||||||
|
* - onMounted
|
||||||
|
* - onWillUnmount
|
||||||
|
* - useRef
|
||||||
|
*/
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// useState
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This is the main way a component can be made reactive. The useState hook
|
||||||
|
* will return an observed object (or array). Changes to that value will then
|
||||||
|
* trigger a rerendering of the current component.
|
||||||
|
*/
|
||||||
|
export function useState<T>(state: T): T {
|
||||||
|
const component: Component<any, any> = Component.current!;
|
||||||
|
const __owl__ = component.__owl__;
|
||||||
|
if (!__owl__.observer) {
|
||||||
|
__owl__.observer = new Observer();
|
||||||
|
__owl__.observer.notifyCB = component.render.bind(component);
|
||||||
|
}
|
||||||
|
return __owl__.observer.observe(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Life cycle hooks
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
function makeLifecycleHook(method: string, reverse: boolean = false) {
|
||||||
|
if (reverse) {
|
||||||
|
return function(cb) {
|
||||||
|
const component: Component<any, any> = Component.current!;
|
||||||
|
if (component.__owl__[method]) {
|
||||||
|
const current = component.__owl__[method];
|
||||||
|
component.__owl__[method] = function() {
|
||||||
|
current.call(component);
|
||||||
|
cb.call(component);
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
component.__owl__[method] = cb;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return function(cb) {
|
||||||
|
const component: Component<any, any> = Component.current!;
|
||||||
|
if (component.__owl__[method]) {
|
||||||
|
const current = component.__owl__[method];
|
||||||
|
component.__owl__[method] = function() {
|
||||||
|
cb.call(component);
|
||||||
|
current.call(component);
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
component.__owl__[method] = cb;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeAsyncHook(method: string) {
|
||||||
|
return function(cb) {
|
||||||
|
const component: Component<any, any> = Component.current!;
|
||||||
|
if (component.__owl__[method]) {
|
||||||
|
const current = component.__owl__[method];
|
||||||
|
component.__owl__[method] = function(...args) {
|
||||||
|
return Promise.all[(current.call(component, ...args), cb.call(component, ...args))];
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
component.__owl__[method] = cb;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const onMounted = makeLifecycleHook("mountedCB", true);
|
||||||
|
export const onWillUnmount = makeLifecycleHook("willUnmountCB");
|
||||||
|
export const onWillPatch = makeLifecycleHook("willPatchCB");
|
||||||
|
export const onPatched = makeLifecycleHook("patchedCB", true);
|
||||||
|
|
||||||
|
export const onWillStart = makeAsyncHook("willStartCB");
|
||||||
|
export const onWillUpdateProps = makeAsyncHook("willUpdatePropsCB");
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// useRef
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The purpose of this hook is to allow components to get a reference to a sub
|
||||||
|
* html node or component.
|
||||||
|
*/
|
||||||
|
interface Ref {
|
||||||
|
el: HTMLElement | null;
|
||||||
|
comp: Component<any, any> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRef(name: string): Ref {
|
||||||
|
const __owl__ = Component.current!.__owl__;
|
||||||
|
return {
|
||||||
|
get el(): HTMLElement | null {
|
||||||
|
const val = __owl__.refs && __owl__.refs[name];
|
||||||
|
if (val instanceof HTMLElement) {
|
||||||
|
return val;
|
||||||
|
} else if (val instanceof Component) {
|
||||||
|
return val.el;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
get comp(): Component<any, any> | null {
|
||||||
|
const val = __owl__.refs && __owl__.refs[name];
|
||||||
|
return val instanceof Component ? val : null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// useSubEnv
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This hook is a simple way to let components use a sub environment. Note that
|
||||||
|
* like for all hooks, it is important that this is only called in the
|
||||||
|
* constructor method.
|
||||||
|
*/
|
||||||
|
export function useSubEnv(nextEnv) {
|
||||||
|
const component = Component.current!;
|
||||||
|
component.env = Object.assign(Object.create(component.env), nextEnv);
|
||||||
|
}
|
||||||
+24
-24
@@ -7,34 +7,34 @@
|
|||||||
import { EventBus } from "./core/event_bus";
|
import { EventBus } from "./core/event_bus";
|
||||||
import { Observer } from "./core/observer";
|
import { Observer } from "./core/observer";
|
||||||
import { QWeb } from "./qweb/index";
|
import { QWeb } from "./qweb/index";
|
||||||
import { ConnectedComponent } from "./store/connected_component";
|
import { config } from "./config";
|
||||||
import { Store } from "./store/store";
|
import * as _store from "./store";
|
||||||
import * as _utils from "./utils";
|
import * as _utils from "./utils";
|
||||||
import { Link } from "./router/Link";
|
import * as _tags from "./tags";
|
||||||
import { Router } from "./router/Router";
|
import { AsyncRoot } from "./misc/async_root";
|
||||||
|
import { Portal } from "./misc/portal";
|
||||||
|
import * as _hooks from "./hooks";
|
||||||
|
import * as _context from "./context";
|
||||||
|
import { Link } from "./router/link";
|
||||||
|
import { RouteComponent } from "./router/route_component";
|
||||||
|
import { Router } from "./router/router";
|
||||||
|
|
||||||
export { Component } from "./component/component";
|
export { Component } from "./component/component";
|
||||||
export { QWeb };
|
export { QWeb };
|
||||||
|
export { config };
|
||||||
|
|
||||||
|
export const Context = _context.Context;
|
||||||
|
export const useState = _hooks.useState;
|
||||||
export const core = { EventBus, Observer };
|
export const core = { EventBus, Observer };
|
||||||
export const router = { Router, Link };
|
export const router = { Router, RouteComponent, Link };
|
||||||
export const store = { Store, ConnectedComponent };
|
export const Store = _store.Store;
|
||||||
export const utils = _utils;
|
export const utils = _utils;
|
||||||
|
export const tags = _tags;
|
||||||
export const __info__ = {};
|
export const misc = { AsyncRoot, Portal };
|
||||||
|
export const hooks = Object.assign({}, _hooks, {
|
||||||
Object.defineProperty(__info__, "mode", {
|
useContext: _context.useContext,
|
||||||
get() {
|
useDispatch: _store.useDispatch,
|
||||||
return QWeb.dev ? "dev" : "prod";
|
useGetters: _store.useGetters,
|
||||||
},
|
useStore: _store.useStore
|
||||||
set(mode: string) {
|
|
||||||
QWeb.dev = mode === "dev";
|
|
||||||
if (QWeb.dev) {
|
|
||||||
const url = `https://github.com/odoo/owl/blob/master/doc/tooling.md#development-mode`;
|
|
||||||
console.warn(
|
|
||||||
`Owl is running in 'dev' mode. This is not suitable for production use. See ${url} for more information.`
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
console.log(`Owl is now running in 'prod' mode.`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
export const __info__ = {};
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { Component } from "../component/component";
|
||||||
|
import { xml } from "../tags";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AsyncRoot
|
||||||
|
*
|
||||||
|
* Owl is by default asynchronous, and the user interface will wait for all its
|
||||||
|
* subcomponents to be rendered before updating the DOM. This is most of the
|
||||||
|
* time what we want, but in some cases, it makes sense to "detach" a component
|
||||||
|
* from this coordination. This is the goal of the AsyncRoot component.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class AsyncRoot extends Component<any, any> {
|
||||||
|
static template = xml`<t t-slot="default"/>`;
|
||||||
|
|
||||||
|
async __updateProps(nextProps, parentFiber) {
|
||||||
|
this.render(parentFiber.force);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class Portal extends Component<any, any> {
|
||||||
|
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: HTMLElement | 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(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(vnode);
|
||||||
|
|
||||||
|
if (shouldDeploy) {
|
||||||
|
this.__deployPortal();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Override to set the env
|
||||||
|
*/
|
||||||
|
__trigger(component: Component<any, any>, eventType: string, payload?: any) {
|
||||||
|
const env = this.env;
|
||||||
|
this.env = this.parentEnv;
|
||||||
|
super.__trigger(component, eventType, payload);
|
||||||
|
this.env = env;
|
||||||
|
}
|
||||||
|
}
|
||||||
+150
-106
@@ -1,6 +1,6 @@
|
|||||||
import { Context } from "./context";
|
import { CompilationContext } from "./compilation_context";
|
||||||
import { QWebExprVar } from "./expression_parser";
|
|
||||||
import { QWeb } from "./qweb";
|
import { QWeb } from "./qweb";
|
||||||
|
import { htmlToVDOM } from "../vdom/html_to_vdom";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Owl QWeb Directives
|
* Owl QWeb Directives
|
||||||
@@ -19,33 +19,40 @@ import { QWeb } from "./qweb";
|
|||||||
//------------------------------------------------------------------------------
|
//------------------------------------------------------------------------------
|
||||||
// t-esc and t-raw
|
// t-esc and t-raw
|
||||||
//------------------------------------------------------------------------------
|
//------------------------------------------------------------------------------
|
||||||
QWeb.utils.getFragment = function(str: string): DocumentFragment {
|
QWeb.utils.htmlToVDOM = htmlToVDOM;
|
||||||
const temp = document.createElement("template");
|
|
||||||
temp.innerHTML = str;
|
|
||||||
return temp.content;
|
|
||||||
};
|
|
||||||
|
|
||||||
function compileValueNode(value: any, node: Element, qweb: QWeb, ctx: Context) {
|
function compileValueNode(value: any, node: Element, qweb: QWeb, ctx: CompilationContext) {
|
||||||
if (value === "0" && ctx.caller) {
|
ctx.rootContext.shouldDefineScope = true;
|
||||||
qweb._compileNode(ctx.caller, ctx);
|
if (value === "0") {
|
||||||
return;
|
if (ctx.parentNode) {
|
||||||
}
|
// the 'zero' magical symbol is where we can find the result of the rendering
|
||||||
|
// of the body of the t-call.
|
||||||
if (value.xml instanceof NodeList) {
|
ctx.rootContext.shouldDefineUtils = true;
|
||||||
for (let node of Array.from(value.xml)) {
|
const zeroArgs = ctx.escaping
|
||||||
qweb._compileNode(<ChildNode>node, ctx);
|
? `{text: utils.vDomToString(scope[utils.zero])}`
|
||||||
|
: `...scope[utils.zero]`;
|
||||||
|
ctx.addLine(`c${ctx.parentNode}.push(${zeroArgs});`);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let exprID: string;
|
let exprID: string;
|
||||||
if (typeof value === "string") {
|
if (typeof value === "string") {
|
||||||
exprID = `_${ctx.generateID()}`;
|
exprID = `_${ctx.generateID()}`;
|
||||||
ctx.addLine(`var ${exprID} = ${ctx.formatExpression(value)};`);
|
ctx.addLine(`var ${exprID} = ${ctx.formatExpression(value)};`);
|
||||||
} else {
|
} else {
|
||||||
exprID = value.id;
|
exprID = `scope.${value.id}`;
|
||||||
}
|
}
|
||||||
ctx.addIf(`${exprID} || ${exprID} === 0`);
|
ctx.addIf(`${exprID} || ${exprID} === 0`);
|
||||||
|
|
||||||
if (ctx.escaping) {
|
if (ctx.escaping) {
|
||||||
|
let protectID;
|
||||||
|
if (value.hasBody) {
|
||||||
|
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) {
|
||||||
@@ -55,17 +62,23 @@ function compileValueNode(value: any, node: Element, qweb: QWeb, ctx: Context) {
|
|||||||
ctx.rootContext.rootNode = nodeID;
|
ctx.rootContext.rootNode = nodeID;
|
||||||
ctx.rootContext.parentTextNode = nodeID;
|
ctx.rootContext.parentTextNode = nodeID;
|
||||||
ctx.addLine(`var vn${nodeID} = {text: ${exprID}};`);
|
ctx.addLine(`var vn${nodeID} = {text: ${exprID}};`);
|
||||||
|
if (ctx.rootContext.shouldDefineResult) {
|
||||||
|
ctx.addLine(`result = vn${nodeID}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (value.hasBody) {
|
||||||
|
ctx.stopProtectScope(protectID);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let fragID = ctx.generateID();
|
|
||||||
ctx.rootContext.shouldDefineUtils = true;
|
ctx.rootContext.shouldDefineUtils = true;
|
||||||
ctx.addLine(`var frag${fragID} = utils.getFragment(${exprID})`);
|
if (value.hasBody) {
|
||||||
let tempNodeID = ctx.generateID();
|
ctx.addLine(
|
||||||
ctx.addLine(`var p${tempNodeID} = {hook: {`);
|
`const vnodeArray = ${exprID} instanceof utils.VDomArray ? ${exprID} : utils.htmlToVDOM(${exprID});`
|
||||||
ctx.addLine(` insert: n => n.elm.parentNode.replaceChild(frag${fragID}, n.elm),`);
|
);
|
||||||
ctx.addLine(`}};`);
|
ctx.addLine(`c${ctx.parentNode}.push(...vnodeArray);`);
|
||||||
ctx.addLine(`var vn${tempNodeID} = h('div', p${tempNodeID})`);
|
} else {
|
||||||
ctx.addLine(`c${ctx.parentNode}.push(vn${tempNodeID});`);
|
ctx.addLine(`c${ctx.parentNode}.push(...utils.htmlToVDOM(${exprID}));`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (node.childNodes.length) {
|
if (node.childNodes.length) {
|
||||||
ctx.addElse();
|
ctx.addElse();
|
||||||
@@ -79,10 +92,6 @@ QWeb.addDirective({
|
|||||||
name: "esc",
|
name: "esc",
|
||||||
priority: 70,
|
priority: 70,
|
||||||
atNodeEncounter({ node, qweb, ctx }): boolean {
|
atNodeEncounter({ node, qweb, ctx }): boolean {
|
||||||
if (node.nodeName !== "t") {
|
|
||||||
let nodeID = qweb._compileGenericNode(node, ctx);
|
|
||||||
ctx = ctx.withParent(nodeID);
|
|
||||||
}
|
|
||||||
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;
|
||||||
@@ -93,10 +102,6 @@ QWeb.addDirective({
|
|||||||
name: "raw",
|
name: "raw",
|
||||||
priority: 80,
|
priority: 80,
|
||||||
atNodeEncounter({ node, qweb, ctx }): boolean {
|
atNodeEncounter({ node, qweb, ctx }): boolean {
|
||||||
if (node.nodeName !== "t") {
|
|
||||||
let nodeID = qweb._compileGenericNode(node, ctx);
|
|
||||||
ctx = ctx.withParent(nodeID);
|
|
||||||
}
|
|
||||||
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;
|
||||||
@@ -110,25 +115,46 @@ QWeb.addDirective({
|
|||||||
name: "set",
|
name: "set",
|
||||||
extraNames: ["value"],
|
extraNames: ["value"],
|
||||||
priority: 60,
|
priority: 60,
|
||||||
atNodeEncounter({ node, ctx }): boolean {
|
atNodeEncounter({ node, qweb, 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] || {};
|
||||||
|
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);
|
||||||
if (ctx.variables.hasOwnProperty(variable)) {
|
ctx.addLine(`${qwebvar.expr} = ${formattedValue};`);
|
||||||
ctx.addLine(`${(<QWebExprVar>ctx.variables[variable]).id} = ${formattedValue}`);
|
qwebvar.value = formattedValue;
|
||||||
} else {
|
}
|
||||||
const varName = `_${ctx.generateID()}`;
|
|
||||||
ctx.addLine(`var ${varName} = ${formattedValue};`);
|
if (hasBody) {
|
||||||
ctx.variables[variable] = {
|
ctx.rootContext.shouldDefineUtils = true;
|
||||||
id: varName,
|
if (value) {
|
||||||
expr: formattedValue
|
ctx.addIf(`!(${qwebvar.expr})`);
|
||||||
};
|
}
|
||||||
|
const tempParentNodeID = ctx.generateID();
|
||||||
|
const _parentNode = ctx.parentNode;
|
||||||
|
ctx.parentNode = tempParentNodeID;
|
||||||
|
|
||||||
|
ctx.addLine(`const 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 {
|
|
||||||
ctx.variables[variable] = {
|
|
||||||
xml: node.childNodes
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -142,7 +168,7 @@ 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(`${ctx.formatExpression(cond)}`);
|
ctx.addIf(typeof cond === "string" ? ctx.formatExpression(cond) : `scope.${cond.id!}`);
|
||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
finalize({ ctx }) {
|
finalize({ ctx }) {
|
||||||
@@ -155,7 +181,9 @@ 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(`else if (${ctx.formatExpression(cond)}) {`);
|
ctx.addLine(
|
||||||
|
`else if (${typeof cond === "string" ? ctx.formatExpression(cond) : `scope.${cond.id}`}) {`
|
||||||
|
);
|
||||||
ctx.indent();
|
ctx.indent();
|
||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
@@ -184,6 +212,9 @@ QWeb.addDirective({
|
|||||||
name: "call",
|
name: "call",
|
||||||
priority: 50,
|
priority: 50,
|
||||||
atNodeEncounter({ node, qweb, ctx }): boolean {
|
atNodeEncounter({ node, qweb, ctx }): boolean {
|
||||||
|
// Step 1: sanity checks
|
||||||
|
// ------------------------------------------------
|
||||||
|
ctx.rootContext.shouldDefineScope = true;
|
||||||
if (node.nodeName !== "t") {
|
if (node.nodeName !== "t") {
|
||||||
throw new Error("Invalid tag for t-call directive (should be 't')");
|
throw new Error("Invalid tag for t-call directive (should be 't')");
|
||||||
}
|
}
|
||||||
@@ -192,44 +223,63 @@ QWeb.addDirective({
|
|||||||
if (!nodeTemplate) {
|
if (!nodeTemplate) {
|
||||||
throw new Error(`Cannot find template "${subTemplate}" (t-call)`);
|
throw new Error(`Cannot find template "${subTemplate}" (t-call)`);
|
||||||
}
|
}
|
||||||
const nodeCopy = node.cloneNode(true) as Element;
|
|
||||||
nodeCopy.removeAttribute("t-call");
|
|
||||||
|
|
||||||
// extract variables from nodecopy
|
// Step 2: compile target template in sub templates
|
||||||
const tempCtx = new Context();
|
// ------------------------------------------------
|
||||||
tempCtx.nextID = ctx.rootContext.nextID;
|
if (!qweb.subTemplates[subTemplate]) {
|
||||||
qweb._compileNode(nodeCopy, tempCtx);
|
qweb.subTemplates[subTemplate] = true;
|
||||||
const vars = Object.assign({}, ctx.variables, tempCtx.variables);
|
const subTemplateFn = qweb._compile(subTemplate, nodeTemplate.elem, ctx);
|
||||||
ctx.rootContext.nextID = tempCtx.nextID;
|
qweb.subTemplates[subTemplate] = subTemplateFn;
|
||||||
|
}
|
||||||
|
|
||||||
// open new scope, if necessary
|
// Step 3: compile t-call body if necessary
|
||||||
const hasNewVariables = Object.keys(tempCtx.variables).length > 0;
|
// ------------------------------------------------
|
||||||
if (hasNewVariables) {
|
let hasBody = node.hasChildNodes();
|
||||||
ctx.addLine("{");
|
let protectID;
|
||||||
|
if (hasBody) {
|
||||||
|
// we add a sub scope to protect the ambient scope
|
||||||
|
ctx.addLine(`{`);
|
||||||
ctx.indent();
|
ctx.indent();
|
||||||
// add new variables, if any
|
protectID = ctx.startProtectScope();
|
||||||
for (let key in tempCtx.variables) {
|
const nodeCopy = node.cloneNode(true) as Element;
|
||||||
const v = tempCtx.variables[key];
|
nodeCopy.removeAttribute("t-call");
|
||||||
if ((<QWebExprVar>v).expr) {
|
const parentNode = ctx.parentNode;
|
||||||
ctx.addLine(`let ${(<QWebExprVar>v).id} = ${(<QWebExprVar>v).expr};`);
|
ctx.parentNode = "__0";
|
||||||
}
|
// this local scope is intended to trap c__0
|
||||||
// todo: handle XML variables...
|
ctx.addLine(`{`);
|
||||||
}
|
ctx.indent();
|
||||||
}
|
ctx.addLine("let c__0 = [];");
|
||||||
|
qweb._compileNode(nodeCopy, ctx);
|
||||||
// compile sub template
|
ctx.rootContext.shouldDefineUtils = true;
|
||||||
const subCtx = ctx.subContext("caller", nodeCopy).subContext("variables", Object.create(vars));
|
ctx.addLine("scope[utils.zero] = c__0;");
|
||||||
|
ctx.parentNode = parentNode;
|
||||||
qweb._compileNode(nodeTemplate.elem, subCtx);
|
|
||||||
|
|
||||||
// close new scope
|
|
||||||
if (hasNewVariables) {
|
|
||||||
ctx.dedent();
|
ctx.dedent();
|
||||||
ctx.addLine("}");
|
ctx.addLine(`}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (node.hasAttribute("t-if") || node.hasAttribute("t-else") || node.hasAttribute("t-elif")) {
|
// Step 4: add the appropriate function call to current component
|
||||||
ctx.closeIf();
|
// ------------------------------------------------
|
||||||
|
const callingScope = hasBody ? "scope" : "Object.assign(Object.create(context), scope)";
|
||||||
|
if (ctx.parentNode) {
|
||||||
|
ctx.addLine(
|
||||||
|
`this.subTemplates['${subTemplate}'].call(this, ${callingScope}, Object.assign({}, extra, {parentNode: c${ctx.parentNode}}));`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// this is a t-call with no parentnode, we need to extract the result
|
||||||
|
ctx.rootContext.shouldDefineResult = true;
|
||||||
|
ctx.addLine(`result = []`);
|
||||||
|
ctx.addLine(
|
||||||
|
`this.subTemplates['${subTemplate}'].call(this, ${callingScope}, Object.assign({}, extra, {parentNode: result}));`
|
||||||
|
);
|
||||||
|
ctx.addLine(`result = result[0]`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 5: restore previous scope
|
||||||
|
// ------------------------------------------------
|
||||||
|
if (hasBody) {
|
||||||
|
ctx.stopProtectScope(protectID);
|
||||||
|
ctx.dedent();
|
||||||
|
ctx.addLine(`}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
@@ -244,8 +294,8 @@ QWeb.addDirective({
|
|||||||
extraNames: ["as"],
|
extraNames: ["as"],
|
||||||
priority: 10,
|
priority: 10,
|
||||||
atNodeEncounter({ node, qweb, ctx }): boolean {
|
atNodeEncounter({ node, qweb, ctx }): boolean {
|
||||||
ctx.rootContext.shouldProtectContext = true;
|
ctx.rootContext.shouldDefineScope = true;
|
||||||
ctx = ctx.subContext("inLoop", true);
|
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();
|
||||||
@@ -259,39 +309,33 @@ QWeb.addDirective({
|
|||||||
ctx.addLine(`_${valuesID} = Object.values(_${arrayID});`);
|
ctx.addLine(`_${valuesID} = Object.values(_${arrayID});`);
|
||||||
ctx.closeIf();
|
ctx.closeIf();
|
||||||
ctx.addLine(`var _length${keysID} = _${keysID}.length;`);
|
ctx.addLine(`var _length${keysID} = _${keysID}.length;`);
|
||||||
ctx.addLine(`for (let i = 0; i < _length${keysID}; i++) {`);
|
let varsID = ctx.startProtectScope();
|
||||||
|
const loopVar = `i${ctx.loopNumber}`;
|
||||||
|
ctx.addLine(`for (let ${loopVar} = 0; ${loopVar} < _length${keysID}; ${loopVar}++) {`);
|
||||||
ctx.indent();
|
ctx.indent();
|
||||||
ctx.addToScope(name + "_first", "i === 0");
|
|
||||||
ctx.addToScope(name + "_last", `i === _length${keysID} - 1`);
|
ctx.addLine(`scope.${name}_first = ${loopVar} === 0`);
|
||||||
ctx.addToScope(name + "_index", "i");
|
ctx.addLine(`scope.${name}_last = ${loopVar} === _length${keysID} - 1`);
|
||||||
ctx.addToScope(name, `_${keysID}[i]`);
|
ctx.addLine(`scope.${name}_index = ${loopVar}`);
|
||||||
ctx.addToScope(name + "_value", `_${valuesID}[i]`);
|
ctx.addLine(`scope.${name} = _${keysID}[${loopVar}]`);
|
||||||
|
ctx.addLine(`scope.${name}_value = _${valuesID}[${loopVar}]`);
|
||||||
const nodeCopy = <Element>node.cloneNode(true);
|
const nodeCopy = <Element>node.cloneNode(true);
|
||||||
let shouldWarn = nodeCopy.tagName !== "t" && !nodeCopy.hasAttribute("t-key");
|
let shouldWarn =
|
||||||
if (!shouldWarn && node.tagName === "t") {
|
!nodeCopy.hasAttribute("t-key") &&
|
||||||
if (node.hasAttribute("t-component") && !node.hasAttribute("t-key")) {
|
node.children.length === 1 &&
|
||||||
shouldWarn = true;
|
node.children[0].tagName !== "t" &&
|
||||||
}
|
!node.children[0].hasAttribute("t-key");
|
||||||
if (
|
|
||||||
!shouldWarn &&
|
|
||||||
node.children.length === 1 &&
|
|
||||||
node.children[0].tagName !== "t" &&
|
|
||||||
!node.children[0].hasAttribute("t-key")
|
|
||||||
) {
|
|
||||||
shouldWarn = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (shouldWarn) {
|
if (shouldWarn) {
|
||||||
console.warn(
|
console.warn(
|
||||||
`Directive t-foreach should always be used with a t-key! (in template: '${
|
`Directive t-foreach should always be used with a t-key! (in template: '${ctx.templateName}')`
|
||||||
ctx.templateName
|
|
||||||
}')`
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,33 +1,32 @@
|
|||||||
import { compileExpr, QWebVar } from "./expression_parser";
|
import { compileExpr, QWebVar } from "./expression_parser";
|
||||||
|
|
||||||
|
export const INTERP_REGEXP = /\{\{.*?\}\}/g;
|
||||||
//------------------------------------------------------------------------------
|
//------------------------------------------------------------------------------
|
||||||
// Compilation Context
|
// Compilation Context
|
||||||
//------------------------------------------------------------------------------
|
//------------------------------------------------------------------------------
|
||||||
|
|
||||||
export class Context {
|
export class CompilationContext {
|
||||||
nextID: number = 1;
|
static nextID: number = 1;
|
||||||
code: string[] = [];
|
code: string[] = [];
|
||||||
variables: { [key: string]: QWebVar } = {};
|
variables: { [key: string]: QWebVar } = {};
|
||||||
escaping: boolean = false;
|
escaping: boolean = false;
|
||||||
parentNode: number | null = null;
|
parentNode: number | null | string = null;
|
||||||
parentTextNode: number | null = null;
|
parentTextNode: number | null = null;
|
||||||
rootNode: number | null = null;
|
rootNode: number | null = null;
|
||||||
indentLevel: number = 0;
|
indentLevel: number = 0;
|
||||||
rootContext: Context;
|
rootContext: CompilationContext;
|
||||||
caller: Element | undefined;
|
|
||||||
shouldDefineOwner: boolean = false;
|
|
||||||
shouldDefineParent: boolean = false;
|
shouldDefineParent: boolean = false;
|
||||||
|
shouldDefineScope: boolean = false;
|
||||||
shouldDefineQWeb: boolean = false;
|
shouldDefineQWeb: boolean = false;
|
||||||
shouldDefineUtils: boolean = false;
|
shouldDefineUtils: boolean = false;
|
||||||
shouldDefineResult: boolean = false;
|
shouldDefineRefs: boolean = false;
|
||||||
shouldProtectContext: boolean = false;
|
shouldDefineResult: boolean = true;
|
||||||
shouldTrackScope: boolean = false;
|
loopNumber: number = 0;
|
||||||
inLoop: boolean = false;
|
|
||||||
inPreTag: boolean = false;
|
inPreTag: boolean = false;
|
||||||
templateName: string;
|
templateName: string;
|
||||||
allowMultipleRoots: boolean = false;
|
allowMultipleRoots: boolean = false;
|
||||||
hasParentWidget: boolean = false;
|
hasParentWidget: boolean = false;
|
||||||
scopeVars: any[] = [];
|
currentKey: string = "";
|
||||||
|
|
||||||
constructor(name?: string) {
|
constructor(name?: string) {
|
||||||
this.rootContext = this;
|
this.rootContext = this;
|
||||||
@@ -36,31 +35,43 @@ export class Context {
|
|||||||
}
|
}
|
||||||
|
|
||||||
generateID(): number {
|
generateID(): number {
|
||||||
const id = this.rootContext.nextID++;
|
return CompilationContext.nextID++;
|
||||||
return id;
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This method generates a "template key", which is basically a unique key
|
||||||
|
* which depends on the currently set keys, and on the iteration numbers (if
|
||||||
|
* we are in a loop).
|
||||||
|
*
|
||||||
|
* Such a key is necessary when we need to associate an id to some element
|
||||||
|
* generated by a template (for example, a component)
|
||||||
|
*/
|
||||||
|
generateTemplateKey(): string {
|
||||||
|
const id = this.generateID();
|
||||||
|
let locationExpr = `\`__${this.generateID()}__`;
|
||||||
|
for (let i = 0; i < this.loopNumber - 1; i++) {
|
||||||
|
locationExpr += `\${i${i + 1}}__`;
|
||||||
|
}
|
||||||
|
if (this.currentKey) {
|
||||||
|
const k = this.currentKey;
|
||||||
|
this.addLine(`let k${id} = ${locationExpr}\` + ${k};`);
|
||||||
|
} else {
|
||||||
|
locationExpr += this.loopNumber ? `\${i${this.loopNumber}}__\`` : "`";
|
||||||
|
this.addLine(`let k${id} = ${locationExpr};`);
|
||||||
|
}
|
||||||
|
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.shouldDefineOwner) {
|
|
||||||
// this is necessary to prevent some directives (t-forach for ex) to
|
if (this.shouldDefineScope) {
|
||||||
// pollute the rendering context by adding some keys in it.
|
this.code.unshift(" let scope = Object.create(context);");
|
||||||
this.code.unshift(" let owner = context;");
|
}
|
||||||
|
if (this.shouldDefineRefs) {
|
||||||
|
this.code.unshift(" context.__owl__.refs = context.__owl__.refs || {};");
|
||||||
}
|
}
|
||||||
if (this.shouldDefineParent) {
|
if (this.shouldDefineParent) {
|
||||||
if (this.hasParentWidget) {
|
if (this.hasParentWidget) {
|
||||||
@@ -78,7 +89,7 @@ export class Context {
|
|||||||
return this.code;
|
return this.code;
|
||||||
}
|
}
|
||||||
|
|
||||||
withParent(node: number): Context {
|
withParent(node: number): CompilationContext {
|
||||||
if (
|
if (
|
||||||
!this.allowMultipleRoots &&
|
!this.allowMultipleRoots &&
|
||||||
this === this.rootContext &&
|
this === this.rootContext &&
|
||||||
@@ -89,10 +100,13 @@ export class Context {
|
|||||||
if (!this.rootContext.rootNode) {
|
if (!this.rootContext.rootNode) {
|
||||||
this.rootContext.rootNode = node;
|
this.rootContext.rootNode = node;
|
||||||
}
|
}
|
||||||
|
if (!this.parentNode && this.rootContext.shouldDefineResult) {
|
||||||
|
this.addLine(`result = vn${node};`);
|
||||||
|
}
|
||||||
return this.subContext("parentNode", node);
|
return this.subContext("parentNode", node);
|
||||||
}
|
}
|
||||||
|
|
||||||
subContext(key: keyof Context, value: any): Context {
|
subContext(key: keyof CompilationContext, value: any): CompilationContext {
|
||||||
const newContext = Object.create(this);
|
const newContext = Object.create(this);
|
||||||
newContext[key] = value;
|
newContext[key] = value;
|
||||||
return newContext;
|
return newContext;
|
||||||
@@ -112,11 +126,6 @@ export class Context {
|
|||||||
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();
|
||||||
@@ -133,7 +142,7 @@ export class Context {
|
|||||||
this.addLine("}");
|
this.addLine("}");
|
||||||
}
|
}
|
||||||
|
|
||||||
getValue(val: any): any {
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,6 +153,7 @@ export class Context {
|
|||||||
* - 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,7 +166,7 @@ export class Context {
|
|||||||
* '{{x ? 'a': 'b'}}' -> (x ? 'a' : 'b')
|
* '{{x ? 'a': 'b'}}' -> (x ? 'a' : 'b')
|
||||||
*/
|
*/
|
||||||
interpolate(s: string): string {
|
interpolate(s: string): string {
|
||||||
let matches = s.match(/\{\{.*?\}\}/g);
|
let matches = s.match(INTERP_REGEXP);
|
||||||
if (matches && matches[0].length === s.length) {
|
if (matches && matches[0].length === s.length) {
|
||||||
return `(${this.formatExpression(s.slice(2, -2))})`;
|
return `(${this.formatExpression(s.slice(2, -2))})`;
|
||||||
}
|
}
|
||||||
@@ -164,4 +174,14 @@ export class Context {
|
|||||||
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(): number {
|
||||||
|
const protectID = this.generateID();
|
||||||
|
this.rootContext.shouldDefineScope = true;
|
||||||
|
this.addLine(`const _origScope${protectID} = scope;`);
|
||||||
|
this.addLine(`scope = Object.assign(Object.create(context), scope);`);
|
||||||
|
return protectID;
|
||||||
|
}
|
||||||
|
stopProtectScope(protectID: number) {
|
||||||
|
this.addLine(`scope = _origScope${protectID};`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
// Misc types, constants and helpers
|
// Misc types, constants and helpers
|
||||||
//------------------------------------------------------------------------------
|
//------------------------------------------------------------------------------
|
||||||
|
|
||||||
const RESERVED_WORDS = "true,false,NaN,null,undefined,debugger,console,window,in,instanceof,new,function,return,this,typeof,eval,void,Math,RegExp,Array,Object,Date".split(
|
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(
|
||||||
","
|
","
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -38,17 +38,13 @@ const WORD_REPLACEMENT = {
|
|||||||
lte: "<="
|
lte: "<="
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface QWebExprVar {
|
export interface QWebVar {
|
||||||
id: string;
|
id: string; // foo
|
||||||
expr: string;
|
expr: string; // scope.foo (local variables => only foo)
|
||||||
|
value?: string; // 1 + 3
|
||||||
|
hasBody?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface QWebXMLVar {
|
|
||||||
xml: NodeList;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type QWebVar = QWebExprVar | QWebXMLVar;
|
|
||||||
|
|
||||||
//------------------------------------------------------------------------------
|
//------------------------------------------------------------------------------
|
||||||
// Tokenizer
|
// Tokenizer
|
||||||
//------------------------------------------------------------------------------
|
//------------------------------------------------------------------------------
|
||||||
@@ -68,6 +64,7 @@ type TKind =
|
|||||||
interface Token {
|
interface Token {
|
||||||
type: TKind;
|
type: TKind;
|
||||||
value: string;
|
value: string;
|
||||||
|
originalValue?: string;
|
||||||
size?: number;
|
size?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,7 +79,9 @@ const STATIC_TOKEN_MAP: { [key: string]: TKind } = {
|
|||||||
")": "RIGHT_PAREN"
|
")": "RIGHT_PAREN"
|
||||||
};
|
};
|
||||||
|
|
||||||
const OPERATORS = ".,===,==,+,!==,!=,!,||,&&,>=,>,<=,<,?,-,*,/,%".split(",");
|
// note that the space after typeof is relevant. It makes sure that the formatted
|
||||||
|
// expression has a space after typeof
|
||||||
|
const OPERATORS = ".,===,==,+,!==,!=,!,||,&&,>=,>,<=,<,?,-,*,/,%,typeof ,=>".split(",");
|
||||||
|
|
||||||
type Tokenizer = (expr: string) => Token | false;
|
type Tokenizer = (expr: string) => Token | false;
|
||||||
|
|
||||||
@@ -165,9 +164,9 @@ const tokenizeOperator: Tokenizer = function(expr) {
|
|||||||
const TOKENIZERS = [
|
const TOKENIZERS = [
|
||||||
tokenizeString,
|
tokenizeString,
|
||||||
tokenizeNumber,
|
tokenizeNumber,
|
||||||
|
tokenizeOperator,
|
||||||
tokenizeSymbol,
|
tokenizeSymbol,
|
||||||
tokenizeStatic,
|
tokenizeStatic
|
||||||
tokenizeOperator
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -230,35 +229,53 @@ export function tokenize(expr: string): 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 compileExpr(expr: string, vars: { [key: string]: QWebVar }): string {
|
export function compileExpr(expr: string, scope: { [key: string]: QWebVar }): string {
|
||||||
|
scope = Object.create(scope);
|
||||||
const tokens = tokenize(expr);
|
const tokens = tokenize(expr);
|
||||||
let result = "";
|
|
||||||
for (let i = 0; i < tokens.length; i++) {
|
for (let i = 0; i < tokens.length; i++) {
|
||||||
let token = tokens[i];
|
let token = tokens[i];
|
||||||
|
let prevToken = tokens[i - 1];
|
||||||
|
let nextToken = tokens[i + 1];
|
||||||
|
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) {
|
||||||
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 (isVar) {
|
}
|
||||||
if (token.value in vars && "id" in vars[token.value]) {
|
if (nextToken && nextToken.type === "OPERATOR" && nextToken.value === "=>") {
|
||||||
token.value = (<QWebExprVar>vars[token.value]).id;
|
if (token.type === "RIGHT_PAREN") {
|
||||||
} else {
|
let j = i - 1;
|
||||||
token.value = `context['${token.value}']`;
|
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 };
|
||||||
|
}
|
||||||
|
j--;
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
scope[token.value] = { id: token.value, expr: token.value };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isVar) {
|
||||||
|
if (token.value in scope && "id" in scope[token.value]) {
|
||||||
|
token.value = scope[token.value].expr!;
|
||||||
|
} else {
|
||||||
|
token.originalValue = token.value;
|
||||||
|
token.value = `scope['${token.value}']`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
result += token.value;
|
|
||||||
}
|
}
|
||||||
return result;
|
return tokens.map(t => t.value).join("");
|
||||||
}
|
}
|
||||||
|
|||||||
+93
-84
@@ -30,7 +30,6 @@ QWeb.addDirective({
|
|||||||
name: "on",
|
name: "on",
|
||||||
priority: 90,
|
priority: 90,
|
||||||
atNodeCreation({ ctx, fullName, value, nodeID }) {
|
atNodeCreation({ ctx, fullName, value, nodeID }) {
|
||||||
ctx.rootContext.shouldDefineOwner = true;
|
|
||||||
const [eventName, ...mods] = fullName.slice(5).split(".");
|
const [eventName, ...mods] = fullName.slice(5).split(".");
|
||||||
if (!eventName) {
|
if (!eventName) {
|
||||||
throw new Error("Missing event name with t-on directive");
|
throw new Error("Missing event name with t-on directive");
|
||||||
@@ -40,34 +39,34 @@ QWeb.addDirective({
|
|||||||
extraArgs = args.slice(1, -1);
|
extraArgs = args.slice(1, -1);
|
||||||
return "";
|
return "";
|
||||||
});
|
});
|
||||||
ctx.addIf(`!context['${handlerName}']`);
|
let params = extraArgs ? `context, ${ctx.formatExpression(extraArgs)}` : "context";
|
||||||
ctx.addLine(
|
let handler = `function (e) {if (!context.__owl__.isMounted){return}`;
|
||||||
`throw new Error('Missing handler \\'' + '${handlerName}' + \`\\' when evaluating template '${ctx.templateName.replace(
|
handler += mods
|
||||||
/`/g,
|
.map(function(mod) {
|
||||||
"'"
|
return MODS_CODE[mod];
|
||||||
)}'\`)`
|
})
|
||||||
);
|
.join("");
|
||||||
ctx.closeIf();
|
if (handlerName) {
|
||||||
let params = extraArgs ? `owner, ${ctx.formatExpression(extraArgs)}` : "owner";
|
if (!extraArgs) {
|
||||||
let handler;
|
handler += `const fn = context['${handlerName}'];`;
|
||||||
if (mods.length > 0) {
|
handler += `if (fn) { fn.call(${params}, e); } else { context.${handlerName}; }`;
|
||||||
handler = `function (e) {`;
|
handler += `}`;
|
||||||
handler += mods
|
ctx.addLine(
|
||||||
.map(function(mod) {
|
`extra.handlers['${eventName}' + ${nodeID}] = extra.handlers['${eventName}' + ${nodeID}] || ${handler};`
|
||||||
return MODS_CODE[mod];
|
);
|
||||||
})
|
ctx.addLine(`p${nodeID}.on['${eventName}'] = extra.handlers['${eventName}' + ${nodeID}];`);
|
||||||
.join("");
|
} else {
|
||||||
handler += `context['${handlerName}'].call(${params}, e);}`;
|
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 {
|
} else {
|
||||||
handler = `context['${handlerName}'].bind(${params})`;
|
handler += "}";
|
||||||
}
|
|
||||||
if (extraArgs) {
|
|
||||||
ctx.addLine(`p${nodeID}.on['${eventName}'] = ${handler};`);
|
ctx.addLine(`p${nodeID}.on['${eventName}'] = ${handler};`);
|
||||||
} else {
|
|
||||||
ctx.addLine(
|
|
||||||
`extra.handlers['${eventName}' + ${nodeID}] = extra.handlers['${eventName}' + ${nodeID}] || ${handler};`
|
|
||||||
);
|
|
||||||
ctx.addLine(`p${nodeID}.on['${eventName}'] = extra.handlers['${eventName}' + ${nodeID}];`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -79,9 +78,11 @@ QWeb.addDirective({
|
|||||||
name: "ref",
|
name: "ref",
|
||||||
priority: 95,
|
priority: 95,
|
||||||
atNodeCreation({ ctx, value, addNodeHook }) {
|
atNodeCreation({ ctx, value, addNodeHook }) {
|
||||||
|
ctx.rootContext.shouldDefineRefs = true;
|
||||||
const refKey = `ref${ctx.generateID()}`;
|
const refKey = `ref${ctx.generateID()}`;
|
||||||
ctx.addLine(`const ${refKey} = ${ctx.interpolate(value)};`);
|
ctx.addLine(`const ${refKey} = ${ctx.interpolate(value)};`);
|
||||||
addNodeHook("create", `context.refs[${refKey}] = n.elm;`);
|
addNodeHook("create", `context.__owl__.refs[${refKey}] = n.elm;`);
|
||||||
|
addNodeHook("destroy", `delete context.__owl__.refs[${refKey}];`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -103,6 +104,8 @@ QWeb.utils.transitionInsert = function(vn: VNode, name: string) {
|
|||||||
|
|
||||||
elm.classList.add(name + "-enter");
|
elm.classList.add(name + "-enter");
|
||||||
elm.classList.add(name + "-enter-active");
|
elm.classList.add(name + "-enter-active");
|
||||||
|
elm.classList.remove(name + "-leave-active");
|
||||||
|
elm.classList.remove(name + "-leave-to");
|
||||||
const finalize = () => {
|
const finalize = () => {
|
||||||
elm.classList.remove(name + "-enter-active");
|
elm.classList.remove(name + "-enter-active");
|
||||||
elm.classList.remove(name + "-enter-to");
|
elm.classList.remove(name + "-enter-to");
|
||||||
@@ -121,6 +124,9 @@ QWeb.utils.transitionRemove = function(vn: VNode, name: string, rm: () => void)
|
|||||||
elm.classList.add(name + "-leave");
|
elm.classList.add(name + "-leave");
|
||||||
elm.classList.add(name + "-leave-active");
|
elm.classList.add(name + "-leave-active");
|
||||||
const finalize = () => {
|
const finalize = () => {
|
||||||
|
if (!elm.classList.contains(name + "-leave-active")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
elm.classList.remove(name + "-leave-active");
|
elm.classList.remove(name + "-leave-active");
|
||||||
elm.classList.remove(name + "-leave-to");
|
elm.classList.remove(name + "-leave-to");
|
||||||
rm();
|
rm();
|
||||||
@@ -182,58 +188,38 @@ QWeb.addDirective({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
//------------------------------------------------------------------------------
|
|
||||||
// t-mounted
|
|
||||||
//------------------------------------------------------------------------------
|
|
||||||
QWeb.addDirective({
|
|
||||||
name: "mounted",
|
|
||||||
priority: 97,
|
|
||||||
atNodeCreation({ ctx, fullName, value, nodeID, addNodeHook }) {
|
|
||||||
ctx.rootContext.shouldDefineOwner = true;
|
|
||||||
const eventName = fullName.slice(5);
|
|
||||||
if (!eventName) {
|
|
||||||
throw new Error("Missing event name with t-on directive");
|
|
||||||
}
|
|
||||||
let extraArgs;
|
|
||||||
let handler = value.replace(/\(.*\)/, function(args) {
|
|
||||||
extraArgs = args.slice(1, -1);
|
|
||||||
return "";
|
|
||||||
});
|
|
||||||
let error = `(function () {throw new Error('Missing handler \\'' + '${handler}' + \`\\' when evaluating template '${ctx.templateName.replace(
|
|
||||||
/`/g,
|
|
||||||
"'"
|
|
||||||
)}'\`)})()`;
|
|
||||||
if (extraArgs) {
|
|
||||||
ctx.addLine(
|
|
||||||
`extra.mountedHandlers[${nodeID}] = (context['${handler}'] || ${error}).bind(owner, ${ctx.formatExpression(
|
|
||||||
extraArgs
|
|
||||||
)});`
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
ctx.addLine(
|
|
||||||
`extra.mountedHandlers[${nodeID}] = extra.mountedHandlers[${nodeID}] || (context['${handler}'] || ${error}).bind(owner);`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
addNodeHook("insert", `if (context.__owl__.isMounted) { extra.mountedHandlers[${nodeID}](); }`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
//------------------------------------------------------------------------------
|
//------------------------------------------------------------------------------
|
||||||
// t-slot
|
// t-slot
|
||||||
//------------------------------------------------------------------------------
|
//------------------------------------------------------------------------------
|
||||||
QWeb.addDirective({
|
QWeb.addDirective({
|
||||||
name: "slot",
|
name: "slot",
|
||||||
priority: 80,
|
priority: 80,
|
||||||
atNodeEncounter({ ctx, value }): boolean {
|
atNodeEncounter({ ctx, value, node, qweb }): boolean {
|
||||||
const slotKey = ctx.generateID();
|
const slotKey = ctx.generateID();
|
||||||
ctx.rootContext.shouldDefineOwner = true;
|
|
||||||
ctx.addLine(`const slot${slotKey} = this.slots[context.__owl__.slotId + '_' + '${value}'];`);
|
|
||||||
ctx.addIf(`slot${slotKey}`);
|
|
||||||
ctx.addLine(
|
ctx.addLine(
|
||||||
`slot${slotKey}(context.__owl__.parent, Object.assign({}, extra, {parentNode: c${
|
`const slot${slotKey} = this.constructor.slots[context.__owl__.slotId + '_' + '${value}'];`
|
||||||
ctx.parentNode
|
|
||||||
}, vars: extra.vars, parent: owner}));`
|
|
||||||
);
|
);
|
||||||
|
ctx.addIf(`slot${slotKey}`);
|
||||||
|
let parentNode = `c${ctx.parentNode}`;
|
||||||
|
if (!ctx.parentNode) {
|
||||||
|
ctx.rootContext.shouldDefineResult = true;
|
||||||
|
ctx.rootContext.shouldDefineUtils = true;
|
||||||
|
parentNode = `children${ctx.generateID()}`;
|
||||||
|
ctx.addLine(`let ${parentNode}= []`);
|
||||||
|
ctx.addLine(`result = {}`);
|
||||||
|
}
|
||||||
|
ctx.addLine(
|
||||||
|
`slot${slotKey}.call(this, context.__owl__.scope, Object.assign({}, extra, {parentNode: ${parentNode}, parent: extra.parent || context}));`
|
||||||
|
);
|
||||||
|
if (!ctx.parentNode) {
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
@@ -250,35 +236,58 @@ QWeb.utils.toNumber = function(val: string): number | string {
|
|||||||
QWeb.addDirective({
|
QWeb.addDirective({
|
||||||
name: "model",
|
name: "model",
|
||||||
priority: 42,
|
priority: 42,
|
||||||
atNodeCreation({ ctx, nodeID, value, node, fullName }) {
|
atNodeCreation({ ctx, nodeID, value, node, fullName, addNodeHook }) {
|
||||||
const type = node.getAttribute("type");
|
const type = node.getAttribute("type");
|
||||||
let handler;
|
let handler;
|
||||||
let event = fullName.includes(".lazy") ? "change" : "input";
|
let event = fullName.includes(".lazy") ? "change" : "input";
|
||||||
|
|
||||||
|
// we keep here a reference to the "base expression" (if the expression
|
||||||
|
// is `t-model="some.expr.value", then the base expression is "some.expr").
|
||||||
|
// This is necessary so we can capture it in the handler closure.
|
||||||
|
let expr = ctx.formatExpression(value);
|
||||||
|
const index = expr.lastIndexOf(".");
|
||||||
|
const baseExpr = expr.slice(0, index);
|
||||||
|
ctx.addLine(`let expr${nodeID} = ${baseExpr};`);
|
||||||
|
|
||||||
|
expr = `expr${nodeID}.${expr.slice(index + 1)}`;
|
||||||
|
const key = ctx.generateTemplateKey();
|
||||||
if (node.tagName === "select") {
|
if (node.tagName === "select") {
|
||||||
ctx.addLine(`p${nodeID}.props = {value: context.state['${value}']};`);
|
ctx.addLine(`p${nodeID}.props = {value: ${expr}};`);
|
||||||
|
addNodeHook("create", `n.elm.value=${expr};`);
|
||||||
event = "change";
|
event = "change";
|
||||||
handler = `(ev) => {context.state['${value}'] = ev.target.value}`;
|
handler = `(ev) => {${expr} = ev.target.value}`;
|
||||||
} else if (type === "checkbox") {
|
} else if (type === "checkbox") {
|
||||||
ctx.addLine(`p${nodeID}.props = {checked: context.state['${value}']};`);
|
ctx.addLine(`p${nodeID}.props = {checked: ${expr}};`);
|
||||||
handler = `(ev) => {context.state['${value}'] = ev.target.checked}`;
|
handler = `(ev) => {${expr} = ev.target.checked}`;
|
||||||
} else if (type === "radio") {
|
} else if (type === "radio") {
|
||||||
const nodeValue = node.getAttribute("value")!;
|
const nodeValue = node.getAttribute("value")!;
|
||||||
ctx.addLine(`p${nodeID}.props = {checked:context.state['${value}'] === '${nodeValue}'};`);
|
ctx.addLine(`p${nodeID}.props = {checked:${expr} === '${nodeValue}'};`);
|
||||||
handler = `(ev) => {context.state['${value}'] = ev.target.value}`;
|
handler = `(ev) => {${expr} = ev.target.value}`;
|
||||||
event = "click";
|
event = "click";
|
||||||
} else {
|
} else {
|
||||||
ctx.addLine(`p${nodeID}.props = {value: context.state['${value}']};`);
|
ctx.addLine(`p${nodeID}.props = {value: ${expr}};`);
|
||||||
const trimCode = fullName.includes(".trim") ? ".trim()" : "";
|
const trimCode = fullName.includes(".trim") ? ".trim()" : "";
|
||||||
let valueCode = `ev.target.value${trimCode}`;
|
let valueCode = `ev.target.value${trimCode}`;
|
||||||
if (fullName.includes(".number")) {
|
if (fullName.includes(".number")) {
|
||||||
ctx.rootContext.shouldDefineUtils = true;
|
ctx.rootContext.shouldDefineUtils = true;
|
||||||
valueCode = `utils.toNumber(${valueCode})`;
|
valueCode = `utils.toNumber(${valueCode})`;
|
||||||
}
|
}
|
||||||
handler = `(ev) => {context.state['${value}'] = ${valueCode}}`;
|
handler = `(ev) => {${expr} = ${valueCode}}`;
|
||||||
}
|
}
|
||||||
ctx.addLine(
|
ctx.addLine(`extra.handlers[${key}] = extra.handlers[${key}] || (${handler});`);
|
||||||
`extra.handlers['${event}' + ${nodeID}] = extra.handlers['${event}' + ${nodeID}] || (${handler});`
|
ctx.addLine(`p${nodeID}.on['${event}'] = extra.handlers[${key}];`);
|
||||||
);
|
}
|
||||||
ctx.addLine(`p${nodeID}.on['${event}'] = extra.handlers['${event}' + ${nodeID}];`);
|
});
|
||||||
|
|
||||||
|
//------------------------------------------------------------------------------
|
||||||
|
// t-key
|
||||||
|
//------------------------------------------------------------------------------
|
||||||
|
QWeb.addDirective({
|
||||||
|
name: "key",
|
||||||
|
priority: 45,
|
||||||
|
atNodeEncounter({ ctx, value }) {
|
||||||
|
let id = ctx.generateID();
|
||||||
|
ctx.addLine(`const nodeKey${id} = ${ctx.formatExpression(value)};`);
|
||||||
|
ctx.currentKey = `nodeKey${id}`;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
+161
-76
@@ -1,6 +1,8 @@
|
|||||||
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 { Context } from "./context";
|
import { CompilationContext } from "./compilation_context";
|
||||||
|
import { shallowEqual } from "../utils";
|
||||||
|
import { addNS } from "../vdom/vdom";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Owl QWeb Engine
|
* Owl QWeb Engine
|
||||||
@@ -34,7 +36,7 @@ interface Template {
|
|||||||
interface CompilationInfo {
|
interface CompilationInfo {
|
||||||
node: Element;
|
node: Element;
|
||||||
qweb: QWeb;
|
qweb: QWeb;
|
||||||
ctx: Context;
|
ctx: CompilationContext;
|
||||||
fullName: string;
|
fullName: string;
|
||||||
value: string;
|
value: string;
|
||||||
}
|
}
|
||||||
@@ -55,19 +57,27 @@ export interface Directive {
|
|||||||
finalize?(info: CompilationInfo): void;
|
finalize?(info: CompilationInfo): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface QWebConfig {
|
||||||
|
templates?: string;
|
||||||
|
translateFn?(text: string): string;
|
||||||
|
}
|
||||||
|
|
||||||
//------------------------------------------------------------------------------
|
//------------------------------------------------------------------------------
|
||||||
// Const/global stuff/helpers
|
// Const/global stuff/helpers
|
||||||
//------------------------------------------------------------------------------
|
//------------------------------------------------------------------------------
|
||||||
|
|
||||||
const DISABLED_TAGS = ["input", "textarea", "button", "select", "option", "optgroup"];
|
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 NODE_HOOKS_PARAMS = {
|
const NODE_HOOKS_PARAMS = {
|
||||||
create: "(_, n)",
|
create: "(_, n)",
|
||||||
insert: "vn",
|
insert: "vn",
|
||||||
remove: "(vn, rm)"
|
remove: "(vn, rm)",
|
||||||
|
destroy: "()"
|
||||||
};
|
};
|
||||||
|
|
||||||
interface Utils {
|
interface Utils {
|
||||||
@@ -77,6 +87,7 @@ interface Utils {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const UTILS: Utils = {
|
const UTILS: Utils = {
|
||||||
|
zero: Symbol("zero"),
|
||||||
toObj(expr) {
|
toObj(expr) {
|
||||||
if (typeof expr === "string") {
|
if (typeof expr === "string") {
|
||||||
expr = expr.trim();
|
expr = expr.trim();
|
||||||
@@ -92,18 +103,29 @@ const UTILS: Utils = {
|
|||||||
}
|
}
|
||||||
return expr;
|
return expr;
|
||||||
},
|
},
|
||||||
shallowEqual(p1, p2) {
|
shallowEqual,
|
||||||
for (let k in p1) {
|
addNameSpace(vnode) {
|
||||||
if (p1[k] !== p2[k]) {
|
addNS(vnode.data, vnode.children, vnode.sel);
|
||||||
return false;
|
},
|
||||||
}
|
VDomArray: class VDomArray extends Array {},
|
||||||
}
|
vDomToString: function(vdom: VNode[]): string {
|
||||||
return true;
|
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();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
function parseXML(xml: string): Document {
|
function parseXML(xml: string): Document {
|
||||||
const parser = new DOMParser();
|
const parser = new DOMParser();
|
||||||
|
|
||||||
const doc = parser.parseFromString(xml, "text/xml");
|
const doc = parser.parseFromString(xml, "text/xml");
|
||||||
if (doc.getElementsByTagName("parsererror").length) {
|
if (doc.getElementsByTagName("parsererror").length) {
|
||||||
let msg = "Invalid XML in template.";
|
let msg = "Invalid XML in template.";
|
||||||
@@ -131,13 +153,12 @@ function parseXML(xml: string): Document {
|
|||||||
return doc;
|
return doc;
|
||||||
}
|
}
|
||||||
|
|
||||||
let nextID = 1;
|
|
||||||
|
|
||||||
//------------------------------------------------------------------------------
|
//------------------------------------------------------------------------------
|
||||||
// QWeb rendering engine
|
// QWeb rendering engine
|
||||||
//------------------------------------------------------------------------------
|
//------------------------------------------------------------------------------
|
||||||
|
|
||||||
export class QWeb extends EventBus {
|
export class QWeb extends EventBus {
|
||||||
templates: { [name: string]: Template } = {};
|
templates: { [name: string]: Template };
|
||||||
static utils = UTILS;
|
static utils = UTILS;
|
||||||
static components = Object.create(null);
|
static components = Object.create(null);
|
||||||
|
|
||||||
@@ -145,30 +166,39 @@ export class QWeb extends EventBus {
|
|||||||
name: 1,
|
name: 1,
|
||||||
att: 1,
|
att: 1,
|
||||||
attf: 1,
|
attf: 1,
|
||||||
key: 1
|
translation: 1
|
||||||
};
|
};
|
||||||
static DIRECTIVES: Directive[] = [];
|
static DIRECTIVES: Directive[] = [];
|
||||||
|
|
||||||
|
static TEMPLATES: { [name: string]: Template } = {};
|
||||||
|
|
||||||
|
static nextId: number = 1;
|
||||||
|
|
||||||
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;
|
||||||
|
|
||||||
// the id field is useful to be able to hash qweb instances. The current
|
|
||||||
// use case is that component's templates are qweb dependant, and need to be
|
|
||||||
// able to map a qweb instance to a template name.
|
|
||||||
id = nextID++;
|
|
||||||
|
|
||||||
// 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.
|
||||||
slots = {};
|
static slots = {};
|
||||||
nextSlotId = 1;
|
static nextSlotId = 1;
|
||||||
|
|
||||||
|
// recursiveTemplates contains sub templates called with t-call, but which
|
||||||
|
// ends up in recursive situations. This is very similar to the slot situation,
|
||||||
|
// as in we need to propagate the scope.
|
||||||
|
subTemplates = {};
|
||||||
|
|
||||||
isUpdating: boolean = false;
|
isUpdating: boolean = false;
|
||||||
|
translateFn?: QWebConfig["translateFn"];
|
||||||
|
|
||||||
constructor(data?: string) {
|
constructor(config: QWebConfig = {}) {
|
||||||
super();
|
super();
|
||||||
if (data) {
|
this.templates = Object.create(QWeb.TEMPLATES);
|
||||||
this.addTemplates(data);
|
if (config.templates) {
|
||||||
|
this.addTemplates(config.templates);
|
||||||
|
}
|
||||||
|
if (config.translateFn) {
|
||||||
|
this.translateFn = config.translateFn;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,13 +214,27 @@ export class QWeb extends EventBus {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static register(name: string, Component: any) {
|
static registerComponent(name: string, Component: any) {
|
||||||
if (QWeb.components[name]) {
|
if (QWeb.components[name]) {
|
||||||
throw new Error(`Component '${name}' has already been registered`);
|
throw new Error(`Component '${name}' has already been registered`);
|
||||||
}
|
}
|
||||||
QWeb.components[name] = Component;
|
QWeb.components[name] = Component;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register globally a template. All QWeb instances will obtain their
|
||||||
|
* templates from their own template map, and then, from the global static
|
||||||
|
* TEMPLATES property.
|
||||||
|
*/
|
||||||
|
static registerTemplate(name: string, template: string) {
|
||||||
|
if (QWeb.TEMPLATES[name]) {
|
||||||
|
throw new Error(`Template '${name}' has already been registered`);
|
||||||
|
}
|
||||||
|
const qweb = new QWeb();
|
||||||
|
qweb.addTemplate(name, template);
|
||||||
|
QWeb.TEMPLATES[name] = qweb.templates[name];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add a template to the internal template map. Note that it is not
|
* Add a template to the internal template map. Note that it is not
|
||||||
* immediately compiled.
|
* immediately compiled.
|
||||||
@@ -207,12 +251,12 @@ export class QWeb extends EventBus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load templates from a xml (as a string). This will look up for the first
|
* Load templates from a xml (as a string or xml document). This will look up
|
||||||
* <templates> tag, and will consider each child of this as a template, with
|
* for the first <templates> tag, and will consider each child of this as a
|
||||||
* the name given by the t-name attribute.
|
* template, with the name given by the t-name attribute.
|
||||||
*/
|
*/
|
||||||
addTemplates(xmlstr: string) {
|
addTemplates(xmlstr: string | Document) {
|
||||||
const doc = parseXML(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) {
|
||||||
return;
|
return;
|
||||||
@@ -230,7 +274,7 @@ export class QWeb extends EventBus {
|
|||||||
this._processTemplate(elem);
|
this._processTemplate(elem);
|
||||||
const template = {
|
const template = {
|
||||||
elem,
|
elem,
|
||||||
fn: (context, extra) => {
|
fn: function(this: QWeb, context, extra) {
|
||||||
const compiledFunction = this._compile(name, elem);
|
const compiledFunction = this._compile(name, elem);
|
||||||
template.fn = compiledFunction;
|
template.fn = compiledFunction;
|
||||||
return compiledFunction.call(this, context, extra);
|
return compiledFunction.call(this, context, extra);
|
||||||
@@ -298,8 +342,8 @@ export class QWeb extends EventBus {
|
|||||||
* to render a full component tree, since this is an asynchronous operation.
|
* to render a full component tree, since this is an asynchronous operation.
|
||||||
* This method can only render templates without components.
|
* This method can only render templates without components.
|
||||||
*/
|
*/
|
||||||
renderToString(name: string, context: EvalContext = {}): string {
|
renderToString(name: string, context: EvalContext = {}, extra?: any): string {
|
||||||
const vnode = this.render(name, context);
|
const vnode = this.render(name, context, extra);
|
||||||
if (vnode.sel === undefined) {
|
if (vnode.sel === undefined) {
|
||||||
return vnode.text!;
|
return vnode.text!;
|
||||||
}
|
}
|
||||||
@@ -324,26 +368,19 @@ export class QWeb extends EventBus {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
_compile(name: string, elem: Element, parentContext?: Context): CompiledTemplate {
|
_compile(name: string, elem: Element, parentContext?: CompilationContext): CompiledTemplate {
|
||||||
const isDebug = elem.attributes.hasOwnProperty("t-debug");
|
const isDebug = elem.attributes.hasOwnProperty("t-debug");
|
||||||
const ctx = new Context(name);
|
const ctx = new CompilationContext(name);
|
||||||
if (parentContext) {
|
if (elem.tagName !== "t") {
|
||||||
ctx.variables = Object.create(parentContext.variables);
|
ctx.shouldDefineResult = false;
|
||||||
ctx.nextID = parentContext.parentNode! + 1;
|
|
||||||
ctx.parentNode = parentContext.parentNode!;
|
|
||||||
ctx.allowMultipleRoots = true;
|
|
||||||
ctx.hasParentWidget = true;
|
|
||||||
ctx.addLine(`let c${ctx.parentNode} = extra.parentNode;`);
|
|
||||||
|
|
||||||
for (let v in parentContext.variables) {
|
|
||||||
let variable = <any>parentContext.variables[v];
|
|
||||||
if (variable.id) {
|
|
||||||
ctx.addLine(`let ${variable.id} = extra.vars.${variable.id}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (parentContext) {
|
if (parentContext) {
|
||||||
ctx.addLine(" Object.assign(context, extra.scope);");
|
ctx.variables = Object.create(parentContext.variables);
|
||||||
|
ctx.parentNode = parentContext.parentNode || ctx.generateID();
|
||||||
|
ctx.allowMultipleRoots = true;
|
||||||
|
ctx.hasParentWidget = true;
|
||||||
|
ctx.shouldDefineResult = false;
|
||||||
|
ctx.addLine(`let c${ctx.parentNode} = extra.parentNode;`);
|
||||||
}
|
}
|
||||||
this._compileNode(elem, ctx);
|
this._compileNode(elem, ctx);
|
||||||
|
|
||||||
@@ -359,12 +396,13 @@ 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();
|
||||||
@@ -386,7 +424,7 @@ export class QWeb extends EventBus {
|
|||||||
* Generate code from an xml node
|
* Generate code from an xml node
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
_compileNode(node: ChildNode, ctx: Context) {
|
_compileNode(node: ChildNode, ctx: CompilationContext) {
|
||||||
if (!(node instanceof Element)) {
|
if (!(node instanceof Element)) {
|
||||||
// this is a text node, there are no directive to apply
|
// this is a text node, there are no directive to apply
|
||||||
let text = node.textContent!;
|
let text = node.textContent!;
|
||||||
@@ -396,8 +434,17 @@ export class QWeb extends EventBus {
|
|||||||
}
|
}
|
||||||
text = text.replace(whitespaceRE, " ");
|
text = text.replace(whitespaceRE, " ");
|
||||||
}
|
}
|
||||||
|
if (this.translateFn) {
|
||||||
|
if ((node.parentNode as any).getAttribute("t-translation") !== "off") {
|
||||||
|
text = this.translateFn(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
if (ctx.parentNode) {
|
if (ctx.parentNode) {
|
||||||
ctx.addLine(`c${ctx.parentNode}.push({text: \`${text}\`});`);
|
if (node.nodeType === 3) {
|
||||||
|
ctx.addLine(`c${ctx.parentNode}.push({text: \`${text}\`});`);
|
||||||
|
} else if (node.nodeType === 8) {
|
||||||
|
ctx.addLine(`c${ctx.parentNode}.push(h('!', \`${text}\`));`);
|
||||||
|
}
|
||||||
} else if (ctx.parentTextNode) {
|
} else if (ctx.parentTextNode) {
|
||||||
ctx.addLine(`vn${ctx.parentTextNode}.text += \`${text}\`;`);
|
ctx.addLine(`vn${ctx.parentTextNode}.text += \`${text}\`;`);
|
||||||
} else {
|
} else {
|
||||||
@@ -405,17 +452,25 @@ export class QWeb extends EventBus {
|
|||||||
// template rendering.
|
// template rendering.
|
||||||
let nodeID = ctx.generateID();
|
let nodeID = ctx.generateID();
|
||||||
ctx.addLine(`var vn${nodeID} = {text: \`${text}\`};`);
|
ctx.addLine(`var vn${nodeID} = {text: \`${text}\`};`);
|
||||||
|
ctx.addLine(`result = vn${nodeID};`);
|
||||||
ctx.rootContext.rootNode = nodeID;
|
ctx.rootContext.rootNode = nodeID;
|
||||||
ctx.rootContext.parentTextNode = nodeID;
|
ctx.rootContext.parentTextNode = nodeID;
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (ctx !== ctx.rootContext) {
|
||||||
|
ctx = ctx.subContext("currentKey", ctx.currentKey);
|
||||||
|
}
|
||||||
|
|
||||||
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 <t 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")) {
|
||||||
|
throw new Error(
|
||||||
|
`Directive 't-component' can only be used on <t> nodes (used on a <${node.tagName}>)`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
const attributes = (<Element>node).attributes;
|
const attributes = (<Element>node).attributes;
|
||||||
|
|
||||||
@@ -425,7 +480,7 @@ export class QWeb extends EventBus {
|
|||||||
fullName: string;
|
fullName: string;
|
||||||
}[] = [];
|
}[] = [];
|
||||||
|
|
||||||
let withHandlers = false;
|
const finalizers: typeof validDirectives = [];
|
||||||
|
|
||||||
// maybe this is not optimal: we iterate on all attributes here, and again
|
// maybe this is not optimal: we iterate on all attributes here, and again
|
||||||
// just after for each directive.
|
// just after for each directive.
|
||||||
@@ -436,11 +491,21 @@ export class QWeb extends EventBus {
|
|||||||
if (!(dName in QWeb.DIRECTIVE_NAMES)) {
|
if (!(dName in QWeb.DIRECTIVE_NAMES)) {
|
||||||
throw new Error(`Unknown QWeb directive: '${attrName}'`);
|
throw new Error(`Unknown QWeb directive: '${attrName}'`);
|
||||||
}
|
}
|
||||||
|
if (node.tagName !== "t" && (attrName === "t-esc" || attrName === "t-raw")) {
|
||||||
|
const tNode = document.createElement("t");
|
||||||
|
tNode.setAttribute(attrName, node.getAttribute(attrName)!);
|
||||||
|
for (let child of Array.from(node.childNodes)) {
|
||||||
|
tNode.appendChild(child);
|
||||||
|
}
|
||||||
|
node.appendChild(tNode);
|
||||||
|
node.removeAttribute(attrName);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const DIR_N = QWeb.DIRECTIVES.length;
|
const DIR_N = QWeb.DIRECTIVES.length;
|
||||||
const ATTR_N = attributes.length;
|
const ATTR_N = attributes.length;
|
||||||
|
let withHandlers = false;
|
||||||
for (let i = 0; i < DIR_N; i++) {
|
for (let i = 0; i < DIR_N; i++) {
|
||||||
let directive = QWeb.DIRECTIVES[i];
|
let directive = QWeb.DIRECTIVES[i];
|
||||||
let fullName;
|
let fullName;
|
||||||
@@ -461,7 +526,11 @@ export class QWeb extends EventBus {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let { directive, value, fullName } of validDirectives) {
|
for (let { directive, value, fullName } of validDirectives) {
|
||||||
|
if (directive.finalize) {
|
||||||
|
finalizers.push({ directive, value, fullName });
|
||||||
|
}
|
||||||
if (directive.atNodeEncounter) {
|
if (directive.atNodeEncounter) {
|
||||||
const isDone = directive.atNodeEncounter({
|
const isDone = directive.atNodeEncounter({
|
||||||
node,
|
node,
|
||||||
@@ -471,6 +540,9 @@ export class QWeb extends EventBus {
|
|||||||
value
|
value
|
||||||
});
|
});
|
||||||
if (isDone) {
|
if (isDone) {
|
||||||
|
for (let { directive, value, fullName } of finalizers) {
|
||||||
|
directive.finalize!({ node, qweb: this, ctx, fullName, value });
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -516,15 +588,28 @@ export class QWeb extends EventBus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this._compileChildren(node, ctx);
|
this._compileChildren(node, ctx);
|
||||||
|
// svg support
|
||||||
|
// we hadd svg namespace if it is a svg or if it is a g, but only if it is
|
||||||
|
// the root node. This is the easiest way to support svg sub components:
|
||||||
|
// they need to have a g tag as root. Otherwise, we would need a complete
|
||||||
|
// list of allowed svg tags.
|
||||||
|
const shouldAddNS =
|
||||||
|
node.nodeName === "svg" || (node.nodeName === "g" && ctx.rootNode === ctx.parentNode);
|
||||||
|
if (shouldAddNS) {
|
||||||
|
ctx.rootContext.shouldDefineUtils = true;
|
||||||
|
ctx.addLine(`utils.addNameSpace(vn${ctx.parentNode});`);
|
||||||
|
}
|
||||||
|
|
||||||
for (let { directive, value, fullName } of validDirectives) {
|
for (let { directive, value, fullName } of finalizers) {
|
||||||
if (directive.finalize) {
|
directive.finalize!({ node, qweb: this, ctx, fullName, value });
|
||||||
directive.finalize({ node, qweb: this, ctx, fullName, value });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_compileGenericNode(node: ChildNode, ctx: Context, withHandlers: boolean = true): number {
|
_compileGenericNode(
|
||||||
|
node: ChildNode,
|
||||||
|
ctx: CompilationContext,
|
||||||
|
withHandlers: boolean = true
|
||||||
|
): number {
|
||||||
// nodeType 1 is generic tag
|
// nodeType 1 is generic tag
|
||||||
if (node.nodeType !== 1) {
|
if (node.nodeType !== 1) {
|
||||||
throw new Error("unsupported node type");
|
throw new Error("unsupported node type");
|
||||||
@@ -559,19 +644,24 @@ export class QWeb extends EventBus {
|
|||||||
|
|
||||||
for (let i = 0; i < attributes.length; i++) {
|
for (let i = 0; i < attributes.length; i++) {
|
||||||
let name = attributes[i].name;
|
let name = attributes[i].name;
|
||||||
const value = attributes[i].textContent!;
|
let value = attributes[i].textContent!;
|
||||||
|
|
||||||
|
if (this.translateFn && TRANSLATABLE_ATTRS.includes(name)) {
|
||||||
|
value = this.translateFn(value);
|
||||||
|
}
|
||||||
|
|
||||||
// regular attributes
|
// regular attributes
|
||||||
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") {
|
||||||
let classDef = value
|
if ((value = value.trim())) {
|
||||||
.trim()
|
let classDef = value
|
||||||
.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}};`);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
ctx.addLine(`var _${attID} = '${value}';`);
|
ctx.addLine(`var _${attID} = '${value}';`);
|
||||||
if (!name.match(/^[a-zA-Z]+$/)) {
|
if (!name.match(/^[a-zA-Z]+$/)) {
|
||||||
@@ -587,7 +677,7 @@ 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 = v.id || ctx.formatExpression(v);
|
let formattedValue = typeof v === "string" ? ctx.formatExpression(v) : `scope.${v.id}`;
|
||||||
|
|
||||||
if (attName === "class") {
|
if (attName === "class") {
|
||||||
ctx.rootContext.shouldDefineUtils = true;
|
ctx.rootContext.shouldDefineUtils = true;
|
||||||
@@ -645,12 +735,7 @@ export class QWeb extends EventBus {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
let nodeID = ctx.generateID();
|
let nodeID = ctx.generateID();
|
||||||
let nodeKey: any = (<Element>node).getAttribute("t-key");
|
let nodeKey = ctx.currentKey || nodeID;
|
||||||
if (nodeKey) {
|
|
||||||
nodeKey = ctx.formatExpression(nodeKey);
|
|
||||||
} else {
|
|
||||||
nodeKey = nodeID;
|
|
||||||
}
|
|
||||||
const parts = [`key:${nodeKey}`];
|
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(",")}}`);
|
||||||
@@ -685,7 +770,7 @@ export class QWeb extends EventBus {
|
|||||||
return nodeID;
|
return nodeID;
|
||||||
}
|
}
|
||||||
|
|
||||||
_compileChildren(node: ChildNode, ctx: Context) {
|
_compileChildren(node: ChildNode, ctx: CompilationContext) {
|
||||||
if (node.childNodes.length > 0) {
|
if (node.childNodes.length > 0) {
|
||||||
for (let child of Array.from(node.childNodes)) {
|
for (let child of Array.from(node.childNodes)) {
|
||||||
this._compileNode(child, ctx);
|
this._compileNode(child, ctx);
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
import { RouterEnv } from "./Router";
|
|
||||||
|
|
||||||
export function makeDirective(env: RouterEnv) {
|
|
||||||
return {
|
|
||||||
name: "routecomponent",
|
|
||||||
priority: 13,
|
|
||||||
atNodeEncounter({ node }): boolean {
|
|
||||||
let first = true;
|
|
||||||
const router = env.router;
|
|
||||||
for (let name of router.routeIds) {
|
|
||||||
const route = router.routes[name];
|
|
||||||
if (route.component) {
|
|
||||||
// make new t t-component element
|
|
||||||
const comp = node.ownerDocument.createElement("t");
|
|
||||||
comp.setAttribute("t-component", "__component__" + route.name);
|
|
||||||
comp.setAttribute(first ? "t-if" : "t-elif", `env.router.currentRouteName === '${route.name}'`);
|
|
||||||
first = false;
|
|
||||||
for (let param of route.params) {
|
|
||||||
comp.setAttribute(param, `env.router.currentParams.${param}`);
|
|
||||||
}
|
|
||||||
node.appendChild(comp);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
node.removeAttribute("t-routecomponent");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,18 +1,18 @@
|
|||||||
import { Component } from "../component/component";
|
import { Component } from "../component/component";
|
||||||
import { Destination, RouterEnv } from "./Router";
|
import { xml } from "../tags";
|
||||||
|
import { Destination, RouterEnv } from "./router";
|
||||||
|
|
||||||
export const LINK_TEMPLATE_NAME = "__owl__-router-link";
|
type Props = Destination;
|
||||||
export const LINK_TEMPLATE = `
|
|
||||||
|
export class Link<Env extends RouterEnv> extends Component<Env, Props> {
|
||||||
|
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"
|
||||||
t-on-click="navigate">
|
t-on-click="navigate">
|
||||||
<t t-slot="default"/>
|
<t t-slot="default"/>
|
||||||
</a>`;
|
</a>
|
||||||
|
`;
|
||||||
|
|
||||||
type Props = Destination;
|
|
||||||
|
|
||||||
export class Link<Env extends RouterEnv> extends Component<Env, Props, {}> {
|
|
||||||
template = LINK_TEMPLATE_NAME;
|
|
||||||
href: string = this.env.router.destToPath(this.props);
|
href: string = this.env.router.destToPath(this.props);
|
||||||
|
|
||||||
async willUpdateProps(nextProps) {
|
async willUpdateProps(nextProps) {
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { Component } from "../component/component";
|
||||||
|
import { xml } from "../tags";
|
||||||
|
|
||||||
|
export class RouteComponent extends Component<any, {}> {
|
||||||
|
static template = xml`
|
||||||
|
<t>
|
||||||
|
<t
|
||||||
|
t-if="routeComponent"
|
||||||
|
t-component="routeComponent"
|
||||||
|
t-key="env.router.currentRouteName"
|
||||||
|
t-props="env.router.currentParams" />
|
||||||
|
</t>
|
||||||
|
`;
|
||||||
|
|
||||||
|
get routeComponent(): any {
|
||||||
|
return this.env.router.currentRoute && this.env.router.currentRoute.component;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Env } from "../component/component";
|
import { Env } from "../component/component";
|
||||||
import { QWeb } from "../qweb/index";
|
import { QWeb } from "../qweb/index";
|
||||||
import { makeDirective } from "./directive";
|
import { shallowEqual } from "../utils";
|
||||||
import { LINK_TEMPLATE, LINK_TEMPLATE_NAME } from "./Link";
|
|
||||||
|
|
||||||
type NavigationGuard = (info: {
|
type NavigationGuard = (info: {
|
||||||
env: Env;
|
env: Env;
|
||||||
@@ -74,7 +73,7 @@ export class Router {
|
|||||||
partialRoute.name = "__route__" + nextId++;
|
partialRoute.name = "__route__" + nextId++;
|
||||||
}
|
}
|
||||||
if (partialRoute.component) {
|
if (partialRoute.component) {
|
||||||
QWeb.register("__component__" + partialRoute.name, partialRoute.component);
|
QWeb.registerComponent("__component__" + partialRoute.name, partialRoute.component);
|
||||||
}
|
}
|
||||||
if (partialRoute.redirect) {
|
if (partialRoute.redirect) {
|
||||||
this.validateDestination(partialRoute.redirect);
|
this.validateDestination(partialRoute.redirect);
|
||||||
@@ -83,13 +82,6 @@ export class Router {
|
|||||||
this.routes[partialRoute.name] = partialRoute as Route;
|
this.routes[partialRoute.name] = partialRoute as Route;
|
||||||
this.routeIds.push(partialRoute.name);
|
this.routeIds.push(partialRoute.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
(this as any)._listener = () => this.matchAndApplyRules(this.currentPath());
|
|
||||||
window.addEventListener("popstate", (this as any)._listener);
|
|
||||||
|
|
||||||
// setup link and directive
|
|
||||||
env.qweb.addTemplate(LINK_TEMPLATE_NAME, LINK_TEMPLATE);
|
|
||||||
QWeb.addDirective(makeDirective(<RouterEnv>env));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
//--------------------------------------------------------------------------
|
//--------------------------------------------------------------------------
|
||||||
@@ -97,6 +89,11 @@ export class Router {
|
|||||||
//--------------------------------------------------------------------------
|
//--------------------------------------------------------------------------
|
||||||
|
|
||||||
async start() {
|
async start() {
|
||||||
|
(this as any)._listener = ev => this._navigate(this.currentPath(), ev);
|
||||||
|
window.addEventListener("popstate", (this as any)._listener);
|
||||||
|
if (this.mode === "hash") {
|
||||||
|
window.addEventListener("hashchange", (this as any)._listener);
|
||||||
|
}
|
||||||
const result = await this.matchAndApplyRules(this.currentPath());
|
const result = await this.matchAndApplyRules(this.currentPath());
|
||||||
if (result.type === "match") {
|
if (result.type === "match") {
|
||||||
this.currentRoute = result.route;
|
this.currentRoute = result.route;
|
||||||
@@ -110,18 +107,27 @@ export class Router {
|
|||||||
|
|
||||||
async navigate(to: Destination): Promise<boolean> {
|
async navigate(to: Destination): Promise<boolean> {
|
||||||
const path = this.destToPath(to);
|
const path = this.destToPath(to);
|
||||||
|
return this._navigate(path);
|
||||||
|
}
|
||||||
|
async _navigate(path: string, ev?: any): Promise<boolean> {
|
||||||
const initialName = this.currentRouteName;
|
const initialName = this.currentRouteName;
|
||||||
|
const initialParams = this.currentParams;
|
||||||
const result = await this.matchAndApplyRules(path);
|
const result = await this.matchAndApplyRules(path);
|
||||||
if (result.type === "match") {
|
if (result.type === "match") {
|
||||||
const finalPath = this.routeToPath(result.route, result.params);
|
const finalPath = this.routeToPath(result.route, result.params);
|
||||||
this.setUrlFromPath(finalPath);
|
const isPopStateEvent = ev && ev instanceof PopStateEvent;
|
||||||
|
if (!isPopStateEvent) {
|
||||||
|
this.setUrlFromPath(finalPath);
|
||||||
|
}
|
||||||
this.currentRoute = result.route;
|
this.currentRoute = result.route;
|
||||||
this.currentParams = result.params;
|
this.currentParams = result.params;
|
||||||
} else if (result.type === "nomatch") {
|
} else if (result.type === "nomatch") {
|
||||||
this.currentRoute = null;
|
this.currentRoute = null;
|
||||||
this.currentParams = null;
|
this.currentParams = null;
|
||||||
}
|
}
|
||||||
if (this.currentRouteName !== initialName) {
|
const didChange =
|
||||||
|
this.currentRouteName !== initialName || !shallowEqual(this.currentParams, initialParams);
|
||||||
|
if (didChange) {
|
||||||
this.env.qweb.forceUpdate();
|
this.env.qweb.forceUpdate();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -142,8 +148,11 @@ export class Router {
|
|||||||
//--------------------------------------------------------------------------
|
//--------------------------------------------------------------------------
|
||||||
|
|
||||||
private setUrlFromPath(path: string) {
|
private setUrlFromPath(path: string) {
|
||||||
const url = location.origin + path;
|
const separator = this.mode === "hash" ? location.pathname : "";
|
||||||
window.history.pushState({}, path, url);
|
const url = location.origin + separator + path;
|
||||||
|
if (url !== window.location.href) {
|
||||||
|
window.history.pushState({}, path, url);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private validateDestination(dest: Destination) {
|
private validateDestination(dest: Destination) {
|
||||||
@@ -164,7 +173,8 @@ export class Router {
|
|||||||
parts[i] = <string>params[key];
|
parts[i] = <string>params[key];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return parts.join("/");
|
const prefix = this.mode === "hash" ? "#" : "";
|
||||||
|
return prefix + parts.join("/");
|
||||||
}
|
}
|
||||||
|
|
||||||
private currentPath(): string {
|
private currentPath(): string {
|
||||||
@@ -223,6 +233,9 @@ export class Router {
|
|||||||
if (route.path === "*") {
|
if (route.path === "*") {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
if (path.startsWith("#")) {
|
||||||
|
path = path.slice(1);
|
||||||
|
}
|
||||||
const descrParts = route.path.split("/");
|
const descrParts = route.path.split("/");
|
||||||
const targetParts = path.split("/");
|
const targetParts = path.split("/");
|
||||||
const l = descrParts.length;
|
const l = descrParts.length;
|
||||||
+156
@@ -0,0 +1,156 @@
|
|||||||
|
import { Component } from "./component/component";
|
||||||
|
import { Env } from "./component/component";
|
||||||
|
import { Context, useContextWithCB } from "./context";
|
||||||
|
import { onWillUpdateProps } from "./hooks";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owl Store
|
||||||
|
*
|
||||||
|
* We have here:
|
||||||
|
* - a Store class
|
||||||
|
* - useStore hook
|
||||||
|
* - useDispatch hook
|
||||||
|
* - useGetters hook
|
||||||
|
*
|
||||||
|
* The Owl store is our answer to the problem of managing complex state across
|
||||||
|
* components. The main idea is that the store owns some state, allow external
|
||||||
|
* code to modify it through actions, and for each state changes,
|
||||||
|
* connected component will be notified, and updated if necessary.
|
||||||
|
*
|
||||||
|
* Note that this code is partly inspired by VueX and React/Redux
|
||||||
|
*/
|
||||||
|
|
||||||
|
//------------------------------------------------------------------------------
|
||||||
|
// Store Definition
|
||||||
|
//------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type Action = ({ state, dispatch, env, getters }, ...payload: any) => any;
|
||||||
|
export type Getter = ({ state: any, getters }, payload?) => any;
|
||||||
|
|
||||||
|
interface StoreConfig {
|
||||||
|
env?: Env;
|
||||||
|
state?: any;
|
||||||
|
actions?: { [name: string]: Action };
|
||||||
|
getters?: { [name: string]: Getter };
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Store extends Context {
|
||||||
|
actions: any;
|
||||||
|
env: any;
|
||||||
|
getters: { [name: string]: (payload?) => any };
|
||||||
|
updateFunctions: { [key: number]: (() => boolean)[] };
|
||||||
|
|
||||||
|
constructor(config: StoreConfig) {
|
||||||
|
super(config.state);
|
||||||
|
this.actions = config.actions;
|
||||||
|
this.env = config.env;
|
||||||
|
this.getters = {};
|
||||||
|
this.updateFunctions = [];
|
||||||
|
if (config.getters) {
|
||||||
|
const firstArg = {
|
||||||
|
state: this.state,
|
||||||
|
getters: this.getters
|
||||||
|
};
|
||||||
|
for (let g in config.getters) {
|
||||||
|
this.getters[g] = config.getters[g].bind(this, firstArg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch(action: string, ...payload: any): Promise<void> | void {
|
||||||
|
if (!this.actions[action]) {
|
||||||
|
throw new Error(`[Error] action ${action} is undefined`);
|
||||||
|
}
|
||||||
|
const result = this.actions[action](
|
||||||
|
{
|
||||||
|
dispatch: this.dispatch.bind(this),
|
||||||
|
env: this.env,
|
||||||
|
state: this.state,
|
||||||
|
getters: this.getters
|
||||||
|
},
|
||||||
|
...payload
|
||||||
|
);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SelectorOptions {
|
||||||
|
store?: Store;
|
||||||
|
isEqual?: (a: any, b: any) => boolean;
|
||||||
|
onUpdate?: (result: any) => any;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isStrictEqual = (a, b) => a === b;
|
||||||
|
|
||||||
|
export function useStore(selector, options: SelectorOptions = {}): any {
|
||||||
|
const component: Component<any, any> = Component.current!;
|
||||||
|
const componentId = component.__owl__.id;
|
||||||
|
const store = options.store || (component.env.store as Store);
|
||||||
|
if (!(store instanceof Store)) {
|
||||||
|
throw new Error(`No store found when connecting '${component.constructor.name}'`);
|
||||||
|
}
|
||||||
|
let result = selector(store.state, component.props);
|
||||||
|
const hashFn = store.observer.revNumber.bind(store.observer);
|
||||||
|
let revNumber = hashFn(result);
|
||||||
|
const isEqual = options.isEqual || isStrictEqual;
|
||||||
|
if (!store.updateFunctions[componentId]) {
|
||||||
|
store.updateFunctions[componentId] = [];
|
||||||
|
}
|
||||||
|
function selectCompareUpdate(state, props): boolean {
|
||||||
|
const oldResult = result;
|
||||||
|
result = selector(state, props);
|
||||||
|
const newRevNumber = hashFn(result);
|
||||||
|
if ((newRevNumber > 0 && revNumber !== newRevNumber) || !isEqual(oldResult, result)) {
|
||||||
|
revNumber = newRevNumber;
|
||||||
|
if (options.onUpdate) {
|
||||||
|
options.onUpdate(result);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
store.updateFunctions[componentId].push(function(): boolean {
|
||||||
|
return selectCompareUpdate(store!.state, component.props);
|
||||||
|
});
|
||||||
|
|
||||||
|
useContextWithCB(store, component, function(): Promise<void> | void {
|
||||||
|
let shouldRender = false;
|
||||||
|
for (let fn of store.updateFunctions[componentId]) {
|
||||||
|
shouldRender = fn() || shouldRender;
|
||||||
|
}
|
||||||
|
if (shouldRender) {
|
||||||
|
return component.render();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
onWillUpdateProps(props => {
|
||||||
|
selectCompareUpdate(store.state, props);
|
||||||
|
});
|
||||||
|
|
||||||
|
const __destroy = component.__destroy;
|
||||||
|
component.__destroy = parent => {
|
||||||
|
delete store.updateFunctions[componentId];
|
||||||
|
__destroy.call(component, parent);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof result !== "object") {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
return new Proxy(result, {
|
||||||
|
get(target, k) {
|
||||||
|
return result[k];
|
||||||
|
},
|
||||||
|
set(target, k, v) {
|
||||||
|
throw new Error("Store state should only be modified through actions");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDispatch(store?: Store): Store["dispatch"] {
|
||||||
|
store = store || (Component.current!.env.store as Store);
|
||||||
|
return store.dispatch.bind(store);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGetters(store?: Store): Store["getters"] {
|
||||||
|
store = store || (Component.current!.env.store as Store);
|
||||||
|
return store.getters;
|
||||||
|
}
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
import { Component, Env } from "../component/component";
|
|
||||||
|
|
||||||
//------------------------------------------------------------------------------
|
|
||||||
// Connect function
|
|
||||||
//------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
type HashFunction = (a: any, b: any) => number;
|
|
||||||
|
|
||||||
export class ConnectedComponent<T extends Env, P, S> extends Component<T, P, S> {
|
|
||||||
deep: boolean = true;
|
|
||||||
getStore(env) {
|
|
||||||
return env.store;
|
|
||||||
}
|
|
||||||
|
|
||||||
hashFunction: HashFunction = ({ storeProps }, options) => {
|
|
||||||
const observer = (this.__owl__ as any).store.observer;
|
|
||||||
let refFunction = this.deep ? observer.deepRevNumber : observer.revNumber;
|
|
||||||
if ("__owl__" in storeProps) {
|
|
||||||
return refFunction.call(observer, storeProps);
|
|
||||||
}
|
|
||||||
const { currentStoreProps } = options;
|
|
||||||
let hash = 0;
|
|
||||||
for (let key in storeProps) {
|
|
||||||
const val = storeProps[key];
|
|
||||||
const hashVal = refFunction.call(observer, val);
|
|
||||||
if (hashVal === 0) {
|
|
||||||
if (val !== currentStoreProps[key]) {
|
|
||||||
options.didChange = true;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
hash += hashVal;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return hash;
|
|
||||||
};
|
|
||||||
|
|
||||||
static mapStoreToProps(storeState, ownProps, getters) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
constructor(parent, props?: any) {
|
|
||||||
super(parent, props);
|
|
||||||
|
|
||||||
const store = this.getStore(this.env);
|
|
||||||
const ownProps = this.props || {};
|
|
||||||
const storeProps = (<any>this.constructor).mapStoreToProps(
|
|
||||||
store.state,
|
|
||||||
ownProps,
|
|
||||||
store.getters
|
|
||||||
);
|
|
||||||
const mergedProps = Object.assign({}, ownProps, storeProps);
|
|
||||||
|
|
||||||
this.props = mergedProps;
|
|
||||||
|
|
||||||
(<any>this.__owl__).ownProps = ownProps;
|
|
||||||
(<any>this.__owl__).currentStoreProps = storeProps;
|
|
||||||
(<any>this.__owl__).store = store;
|
|
||||||
(<any>this.__owl__).storeHash = this.hashFunction(
|
|
||||||
{
|
|
||||||
state: store.state,
|
|
||||||
storeProps: storeProps
|
|
||||||
},
|
|
||||||
{
|
|
||||||
currentStoreProps: storeProps
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* We do not use the mounted hook here for a subtle reason: we want the
|
|
||||||
* updates to be called for the parents before the children. However,
|
|
||||||
* if we use the mounted hook, this will be done in the reverse order.
|
|
||||||
*/
|
|
||||||
__callMounted() {
|
|
||||||
(<any>this.__owl__).store.on("update", this, this.__checkUpdate);
|
|
||||||
super.__callMounted();
|
|
||||||
}
|
|
||||||
willUnmount() {
|
|
||||||
(<any>this.__owl__).store.off("update", this);
|
|
||||||
super.willUnmount();
|
|
||||||
}
|
|
||||||
|
|
||||||
async __checkUpdate(updateId) {
|
|
||||||
if (updateId === (<any>this.__owl__).currentUpdateId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const ownProps = (<any>this.__owl__).ownProps;
|
|
||||||
const storeProps = (<any>this.constructor).mapStoreToProps(
|
|
||||||
(<any>this.__owl__).store.state,
|
|
||||||
ownProps,
|
|
||||||
(<any>this.__owl__).store.getters
|
|
||||||
);
|
|
||||||
const options: any = {
|
|
||||||
currentStoreProps: (<any>this.__owl__).currentStoreProps
|
|
||||||
};
|
|
||||||
const storeHash = this.hashFunction(
|
|
||||||
{
|
|
||||||
state: (<any>this.__owl__).store.state,
|
|
||||||
storeProps: storeProps
|
|
||||||
},
|
|
||||||
options
|
|
||||||
);
|
|
||||||
let didChange = options.didChange;
|
|
||||||
if (storeHash !== (<any>this.__owl__).storeHash) {
|
|
||||||
didChange = true;
|
|
||||||
(<any>this.__owl__).storeHash = storeHash;
|
|
||||||
}
|
|
||||||
if (didChange) {
|
|
||||||
(<any>this.__owl__).currentStoreProps = storeProps;
|
|
||||||
await this.__updateProps(ownProps, false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
__updateProps(nextProps, forceUpdate, patchQueue?: any[]) {
|
|
||||||
const __owl__ = <any>this.__owl__;
|
|
||||||
__owl__.currentUpdateId = __owl__.store._updateId;
|
|
||||||
if (__owl__.ownProps !== nextProps) {
|
|
||||||
__owl__.currentStoreProps = (<any>this.constructor).mapStoreToProps(
|
|
||||||
__owl__.store.state,
|
|
||||||
nextProps,
|
|
||||||
__owl__.store.getters
|
|
||||||
);
|
|
||||||
}
|
|
||||||
__owl__.ownProps = nextProps;
|
|
||||||
const mergedProps = Object.assign({}, nextProps, __owl__.currentStoreProps);
|
|
||||||
return super.__updateProps(mergedProps, forceUpdate, patchQueue);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,162 +0,0 @@
|
|||||||
import { Env } from "../component/component";
|
|
||||||
import { EventBus } from "../core/event_bus";
|
|
||||||
import { Observer } from "../core/observer";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Owl Store
|
|
||||||
*
|
|
||||||
* We have here:
|
|
||||||
* - a Store class
|
|
||||||
* - the ConnectedComponent class
|
|
||||||
*
|
|
||||||
* The Owl store is our answer to the problem of managing complex state across
|
|
||||||
* components. The main idea is that the store owns some state, allow external
|
|
||||||
* code to modify it through actions/mutations, and for each state changes,
|
|
||||||
* connected component will be notified, and updated if necessary.
|
|
||||||
*
|
|
||||||
* Note that this code is partly inspired by VueX and React/Redux
|
|
||||||
*/
|
|
||||||
|
|
||||||
//------------------------------------------------------------------------------
|
|
||||||
// Store Definition
|
|
||||||
//------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
type Mutation = ({ state, commit, getters }, ...payload: any) => void;
|
|
||||||
type Action = ({ commit, state, dispatch, env, getters }, ...payload: any) => void;
|
|
||||||
type Getter = ({ state, getters }, payload) => any;
|
|
||||||
|
|
||||||
interface StoreConfig {
|
|
||||||
env?: Env;
|
|
||||||
state?: any;
|
|
||||||
actions?: { [name: string]: Action };
|
|
||||||
getters?: { [name: string]: Getter };
|
|
||||||
mutations?: { [name: string]: Mutation };
|
|
||||||
}
|
|
||||||
|
|
||||||
interface StoreOption {
|
|
||||||
debug?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Store extends EventBus {
|
|
||||||
state: any;
|
|
||||||
actions: any;
|
|
||||||
mutations: any;
|
|
||||||
_commitLevel: number = 0;
|
|
||||||
history: any[] = [];
|
|
||||||
debug: boolean;
|
|
||||||
env: any;
|
|
||||||
observer: Observer;
|
|
||||||
getters: { [name: string]: (payload?) => any };
|
|
||||||
_gettersCache: { [name: string]: {} };
|
|
||||||
_updateId: number = 1;
|
|
||||||
|
|
||||||
constructor(config: StoreConfig, options: StoreOption = {}) {
|
|
||||||
super();
|
|
||||||
this.debug = options.debug || false;
|
|
||||||
this.actions = config.actions;
|
|
||||||
this.mutations = config.mutations;
|
|
||||||
this.env = config.env;
|
|
||||||
this.observer = new Observer();
|
|
||||||
this.observer.notifyCB = this.__notifyComponents.bind(this);
|
|
||||||
this.observer.allowMutations = false;
|
|
||||||
this.state = this.observer.observe(config.state || {});
|
|
||||||
this.getters = {};
|
|
||||||
this._gettersCache = {};
|
|
||||||
|
|
||||||
if (this.debug) {
|
|
||||||
this.history.push({ state: this.state });
|
|
||||||
}
|
|
||||||
|
|
||||||
const cTypes = ["undefined", "number", "string"];
|
|
||||||
for (let entry of Object.entries(config.getters || {})) {
|
|
||||||
const name: string = entry[0];
|
|
||||||
const func: (...any) => any = entry[1];
|
|
||||||
this.getters[name] = payload => {
|
|
||||||
if (this._commitLevel === 0 && cTypes.indexOf(typeof payload) >= 0) {
|
|
||||||
this._gettersCache[name] = this._gettersCache[name] || {};
|
|
||||||
this._gettersCache[name][payload] =
|
|
||||||
this._gettersCache[name][payload] ||
|
|
||||||
func({ state: this.state, getters: this.getters }, payload);
|
|
||||||
return this._gettersCache[name][payload];
|
|
||||||
}
|
|
||||||
return func({ state: this.state, getters: this.getters }, payload);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dispatch(action: string, ...payload: any): Promise<void> | void {
|
|
||||||
if (!this.actions[action]) {
|
|
||||||
throw new Error(`[Error] action ${action} is undefined`);
|
|
||||||
}
|
|
||||||
const result = this.actions[action](
|
|
||||||
{
|
|
||||||
commit: this.commit.bind(this),
|
|
||||||
dispatch: this.dispatch.bind(this),
|
|
||||||
env: this.env,
|
|
||||||
state: this.state,
|
|
||||||
getters: this.getters
|
|
||||||
},
|
|
||||||
...payload
|
|
||||||
);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
commit(type: string, ...payload: any): any {
|
|
||||||
if (!this.mutations[type]) {
|
|
||||||
throw new Error(`[Error] mutation ${type} is undefined`);
|
|
||||||
}
|
|
||||||
this._commitLevel++;
|
|
||||||
this.observer.allowMutations = true;
|
|
||||||
|
|
||||||
const res = this.mutations[type].call(
|
|
||||||
null,
|
|
||||||
{
|
|
||||||
commit: this.commit.bind(this),
|
|
||||||
state: this.state,
|
|
||||||
getters: this.getters
|
|
||||||
},
|
|
||||||
...payload
|
|
||||||
);
|
|
||||||
|
|
||||||
if (this._commitLevel === 1) {
|
|
||||||
this.observer.allowMutations = false;
|
|
||||||
if (this.debug) {
|
|
||||||
this.history.push({
|
|
||||||
state: this.state,
|
|
||||||
mutation: type,
|
|
||||||
payload: [...payload]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this._commitLevel--;
|
|
||||||
return res;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Instead of using trigger to emit an update event, we actually implement
|
|
||||||
* our own function to do that. The reason is that we need to be smarter than
|
|
||||||
* a simple trigger function: we need to wait for parent components to be
|
|
||||||
* done before doing children components. The reason is that if an update
|
|
||||||
* as an effect of destroying a children, we do not want to call the
|
|
||||||
* mapStoreToProps function of the child, nor rendering it.
|
|
||||||
*
|
|
||||||
* This method is not optimal if we have a bunch of asynchronous components:
|
|
||||||
* we wait sequentially for each component to be completed before updating the
|
|
||||||
* next. However, the only things that matters is that children are updated
|
|
||||||
* after their parents. So, this could be optimized by being smarter, and
|
|
||||||
* updating all widgets concurrently, except for parents/children.
|
|
||||||
*/
|
|
||||||
async __notifyComponents() {
|
|
||||||
this._updateId++;
|
|
||||||
const current = this._updateId;
|
|
||||||
this._gettersCache = {};
|
|
||||||
const subs = this.subscriptions.update || [];
|
|
||||||
for (let i = 0, iLen = subs.length; i < iLen; i++) {
|
|
||||||
const sub = subs[i];
|
|
||||||
const shouldCallback = sub.owner ? sub.owner.__owl__.isMounted : true;
|
|
||||||
if (shouldCallback) {
|
|
||||||
await sub.callback.call(sub.owner, current);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
import { QWeb } from "./qweb/index";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owl Tags
|
||||||
|
*
|
||||||
|
* We have here a (very) small collection of tag functions:
|
||||||
|
*
|
||||||
|
* - xml
|
||||||
|
*
|
||||||
|
* The plan is to add a few other tags such as css, globalcss.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* XML tag helper for defining templates. With this, one can simply define
|
||||||
|
* an inline template with just the template xml:
|
||||||
|
* ```js
|
||||||
|
* class A extends Component {
|
||||||
|
* static template = xml`<div>some template</div>`;
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function xml(strings, ...args) {
|
||||||
|
const name = `__template__${QWeb.nextId++}`;
|
||||||
|
const value = String.raw(strings, ...args);
|
||||||
|
QWeb.registerTemplate(name, value);
|
||||||
|
return name;
|
||||||
|
}
|
||||||
+12
-5
@@ -5,7 +5,7 @@
|
|||||||
*
|
*
|
||||||
* - whenReady
|
* - whenReady
|
||||||
* - loadJS
|
* - loadJS
|
||||||
* - loadTemplates
|
* - loadFile
|
||||||
* - escape
|
* - escape
|
||||||
* - debounce
|
* - debounce
|
||||||
*/
|
*/
|
||||||
@@ -43,14 +43,12 @@ export function loadJS(url: string): Promise<void> {
|
|||||||
return promise;
|
return promise;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadTemplates(url: string): Promise<string> {
|
export async function loadFile(url: string): Promise<string> {
|
||||||
const result = await 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");
|
||||||
}
|
}
|
||||||
let templates = await result.text();
|
return await result.text();
|
||||||
templates = templates.replace(/<!--[\s\S]*?-->/g, "");
|
|
||||||
return templates;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function escape(str: string | number | undefined): string {
|
export function escape(str: string | number | undefined): string {
|
||||||
@@ -95,3 +93,12 @@ export function debounce(func: Function, wait: number, immediate?: boolean): Fun
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function shallowEqual(p1, p2): boolean {
|
||||||
|
for (let k in p1) {
|
||||||
|
if (p1[k] !== p2[k]) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { VNode, h } from "./vdom";
|
||||||
|
|
||||||
|
const parser = new DOMParser();
|
||||||
|
|
||||||
|
export function htmlToVDOM(html: string): VNode[] {
|
||||||
|
const doc = parser.parseFromString(html, "text/html");
|
||||||
|
const result: VNode[] = [];
|
||||||
|
for (let child of doc.body.childNodes) {
|
||||||
|
result.push(htmlToVNode(child));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function htmlToVNode(node: ChildNode): VNode {
|
||||||
|
if (!(node instanceof Element)) {
|
||||||
|
return { text: node.textContent! } as VNode;
|
||||||
|
}
|
||||||
|
const attrs = {};
|
||||||
|
for (let attr of node.attributes) {
|
||||||
|
attrs[attr.name] = attr.textContent;
|
||||||
|
}
|
||||||
|
const children: VNode[] = [];
|
||||||
|
for (let c of node.childNodes) {
|
||||||
|
children.push(htmlToVNode(c));
|
||||||
|
}
|
||||||
|
return h((node as Element).tagName, { attrs }, children);
|
||||||
|
}
|
||||||
+1
-1
@@ -219,7 +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 (!klass[name]) {
|
if (name && !klass[name]) {
|
||||||
elm.classList.remove(name);
|
elm.classList.remove(name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-49
@@ -101,7 +101,7 @@ function isVnode(vnode: any): vnode is VNode {
|
|||||||
|
|
||||||
type KeyToIndexMap = { [key: string]: number };
|
type KeyToIndexMap = { [key: string]: number };
|
||||||
|
|
||||||
type ArraysOf<T> = { [K in keyof T]: (T[K])[] };
|
type ArraysOf<T> = { [K in keyof T]: T[K][] };
|
||||||
|
|
||||||
type ModuleHooks = ArraysOf<Module>;
|
type ModuleHooks = ArraysOf<Module>;
|
||||||
|
|
||||||
@@ -176,18 +176,12 @@ export function init(modules: Array<Partial<Module>>, domApi?: DOMAPI) {
|
|||||||
}
|
}
|
||||||
vnode.elm = api.createComment(vnode.text as string);
|
vnode.elm = api.createComment(vnode.text as string);
|
||||||
} else if (sel !== undefined) {
|
} else if (sel !== undefined) {
|
||||||
// Parse selector
|
const elm =
|
||||||
const hashIdx = sel.indexOf("#");
|
vnode.elm ||
|
||||||
const dotIdx = sel.indexOf(".", hashIdx);
|
(vnode.elm =
|
||||||
const hash = hashIdx > 0 ? hashIdx : sel.length;
|
isDef(data) && isDef((i = (data as VNodeData).ns))
|
||||||
const dot = dotIdx > 0 ? dotIdx : sel.length;
|
? api.createElementNS(i, sel)
|
||||||
const tag = hashIdx !== -1 || dotIdx !== -1 ? sel.slice(0, Math.min(hash, dot)) : sel;
|
: api.createElement(sel));
|
||||||
const elm = (vnode.elm =
|
|
||||||
isDef(data) && isDef((i = (data as VNodeData).ns))
|
|
||||||
? api.createElementNS(i, tag)
|
|
||||||
: api.createElement(tag));
|
|
||||||
if (hash < dot) elm.setAttribute("id", sel.slice(hash + 1, dot));
|
|
||||||
if (dotIdx > 0) elm.setAttribute("class", sel.slice(dot + 1).replace(/\./g, " "));
|
|
||||||
for (i = 0, iLen = cbs.create.length; i < iLen; ++i) cbs.create[i](emptyNode, vnode);
|
for (i = 0, iLen = cbs.create.length; i < iLen; ++i) cbs.create[i](emptyNode, vnode);
|
||||||
if (array(children)) {
|
if (array(children)) {
|
||||||
for (i = 0, iLen = children.length; i < iLen; ++i) {
|
for (i = 0, iLen = children.length; i < iLen; ++i) {
|
||||||
@@ -469,10 +463,6 @@ interface DOMAPI {
|
|||||||
nextSibling: (node: Node) => Node;
|
nextSibling: (node: Node) => Node;
|
||||||
tagName: (elm: Element) => string;
|
tagName: (elm: Element) => string;
|
||||||
setTextContent: (node: Node, text: string | null) => void;
|
setTextContent: (node: Node, text: string | null) => void;
|
||||||
getTextContent: (node: Node) => string | null;
|
|
||||||
isElement: (node: Node) => node is Element;
|
|
||||||
isText: (node: Node) => node is Text;
|
|
||||||
isComment: (node: Node) => node is Comment;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function createElement(tagName: any): HTMLElement {
|
function createElement(tagName: any): HTMLElement {
|
||||||
@@ -519,22 +509,6 @@ function setTextContent(node: Node, text: string | null): void {
|
|||||||
node.textContent = text;
|
node.textContent = text;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTextContent(node: Node): string | null {
|
|
||||||
return node.textContent;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isElement(node: Node): node is Element {
|
|
||||||
return node.nodeType === 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isText(node: Node): node is Text {
|
|
||||||
return node.nodeType === 3;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isComment(node: Node): node is Comment {
|
|
||||||
return node.nodeType === 8;
|
|
||||||
}
|
|
||||||
|
|
||||||
const htmlDomApi = {
|
const htmlDomApi = {
|
||||||
createElement,
|
createElement,
|
||||||
createElementNS,
|
createElementNS,
|
||||||
@@ -546,11 +520,7 @@ const htmlDomApi = {
|
|||||||
parentNode,
|
parentNode,
|
||||||
nextSibling,
|
nextSibling,
|
||||||
tagName,
|
tagName,
|
||||||
setTextContent,
|
setTextContent
|
||||||
getTextContent,
|
|
||||||
isElement,
|
|
||||||
isText,
|
|
||||||
isComment
|
|
||||||
} as DOMAPI;
|
} as DOMAPI;
|
||||||
|
|
||||||
//------------------------------------------------------------------------------
|
//------------------------------------------------------------------------------
|
||||||
@@ -588,13 +558,19 @@ type VNodeChildElement = VNode | string | number | undefined | null;
|
|||||||
type ArrayOrElement<T> = T | T[];
|
type ArrayOrElement<T> = T | T[];
|
||||||
type VNodeChildren = ArrayOrElement<VNodeChildElement>;
|
type VNodeChildren = ArrayOrElement<VNodeChildElement>;
|
||||||
|
|
||||||
function addNS(data: any, children: VNodes | undefined, sel: string | undefined): void {
|
export function addNS(data: any, children: VNodes | undefined, sel: string | undefined): void {
|
||||||
|
if (sel === "dummy") {
|
||||||
|
// we do not need to add the namespace on dummy elements, they come from a
|
||||||
|
// subcomponent, which will handle the namespace itself
|
||||||
|
return;
|
||||||
|
}
|
||||||
data.ns = "http://www.w3.org/2000/svg";
|
data.ns = "http://www.w3.org/2000/svg";
|
||||||
if (sel !== "foreignObject" && children !== undefined) {
|
if (sel !== "foreignObject" && children !== undefined) {
|
||||||
for (let i = 0, iLen = children.length; i < iLen; ++i) {
|
for (let i = 0, iLen = children.length; i < iLen; ++i) {
|
||||||
let childData = children[i].data;
|
const child = children[i];
|
||||||
|
let childData = child.data;
|
||||||
if (childData !== undefined) {
|
if (childData !== undefined) {
|
||||||
addNS(childData, (children[i] as VNode).children as VNodes, children[i].sel);
|
addNS(childData, (child as VNode).children as VNodes, child.sel);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -636,13 +612,5 @@ export function h(sel: any, b?: any, c?: any): VNode {
|
|||||||
children[i] = vnode(undefined, undefined, undefined, children[i], undefined);
|
children[i] = vnode(undefined, undefined, undefined, children[i], undefined);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (
|
|
||||||
sel[0] === "s" &&
|
|
||||||
sel[1] === "v" &&
|
|
||||||
sel[2] === "g" &&
|
|
||||||
(sel.length === 3 || sel[3] === "." || sel[3] === "#")
|
|
||||||
) {
|
|
||||||
addNS(data, children, sel);
|
|
||||||
}
|
|
||||||
return vnode(sel, data, children, text, undefined);
|
return vnode(sel, data, children, text, undefined);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,98 +1,148 @@
|
|||||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
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 owner = context;
|
let scope = Object.create(context);
|
||||||
var h = this.h;
|
var h = this.h;
|
||||||
let c1 = [], p1 = {key:1};
|
let c1 = [], p1 = {key:1};
|
||||||
var vn1 = h('div', p1, c1);
|
var vn1 = h('div', p1, c1);
|
||||||
//COMPONENT
|
//COMPONENT
|
||||||
let def3;
|
let k3 = \`__4__\`;
|
||||||
let w4 = 4 in parent.__owl__.cmap ? parent.__owl__.children[parent.__owl__.cmap[4]] : false;
|
let w2 = k3 in parent.__owl__.cmap ? parent.__owl__.children[parent.__owl__.cmap[k3]] : false;
|
||||||
let _2_index = c1.length;
|
let props2 = {};
|
||||||
c1.push(null);
|
if (w2 && w2.__owl__.currentFiber && !w2.__owl__.vnode) {
|
||||||
let props4 = {};
|
w2.destroy();
|
||||||
if (w4 && w4.__owl__.renderPromise && !w4.__owl__.vnode) {
|
w2 = false;
|
||||||
if (utils.shallowEqual(props4, w4.__owl__.renderProps)) {
|
|
||||||
def3 = w4.__owl__.renderPromise;
|
|
||||||
} else {
|
|
||||||
w4.destroy();
|
|
||||||
w4 = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (!w4) {
|
if (w2) {
|
||||||
let componentKey4 = \`Child\`;
|
w2.__updateProps(props2, extra.fiber, undefined);
|
||||||
let W4 = context.components && context.components[componentKey4] || QWeb.components[componentKey4];
|
let pvnode = w2.__owl__.pvnode;
|
||||||
if (!W4) {throw new Error('Cannot find the definition of component \\"' + componentKey4 + '\\"')}
|
c1.push(pvnode);
|
||||||
w4 = new W4(parent, props4);
|
|
||||||
parent.__owl__.cmap[4] = w4.__owl__.id;
|
|
||||||
def3 = w4.__prepare();
|
|
||||||
def3 = def3.then(vnode=>{let pvnode=h(vnode.sel, {key: 4, hook: {insert(vn) {let nvn=w4.__mount(vnode, pvnode.elm);pvnode.elm=nvn.elm;utils.transitionInsert(vn, 'chimay');},remove() {},destroy(vn) {let finalize = () => {
|
|
||||||
w4.destroy();
|
|
||||||
};
|
|
||||||
utils.transitionRemove(vn, 'chimay', finalize);}}});c1[_2_index]=pvnode;w4.__owl__.pvnode = pvnode;});
|
|
||||||
} else {
|
} else {
|
||||||
def3 = def3 || w4.__updateProps(props4, extra.forceUpdate, extra.patchQueue);
|
let componentKey2 = \`Child\`;
|
||||||
def3 = def3.then(()=>{if (w4.__owl__.isDestroyed) {return};let pvnode=w4.__owl__.pvnode;c1[_2_index]=pvnode;});
|
let W2 = context.constructor.components[componentKey2] || QWeb.components[componentKey2]|| scope['Child'];
|
||||||
|
if (!W2) {throw new Error('Cannot find the definition of component \\"' + componentKey2 + '\\"')}
|
||||||
|
w2 = new W2(parent, props2);
|
||||||
|
const __patch2 = w2.__patch;
|
||||||
|
w2.__patch = fiber => {__patch2.call(w2, fiber); if(!w2.__owl__.transitionInserted){w2.__owl__.transitionInserted = true;utils.transitionInsert(w2.__owl__.vnode, 'chimay');}};
|
||||||
|
parent.__owl__.cmap[k3] = w2.__owl__.id;
|
||||||
|
let fiber = w2.__prepare(extra.fiber, undefined, () => { const vnode = fiber.vnode; pvnode.sel = vnode.sel; });
|
||||||
|
let pvnode = h('dummy', {key: k3, hook: {remove() {},destroy(vn) {let finalize = () => {
|
||||||
|
w2.destroy();
|
||||||
|
};
|
||||||
|
delete w2.__owl__.transitionInserted;
|
||||||
|
utils.transitionRemove(vn, 'chimay', finalize);}}});
|
||||||
|
c1.push(pvnode);
|
||||||
|
w2.__owl__.pvnode = pvnode;
|
||||||
}
|
}
|
||||||
extra.promises.push(def3);
|
w2.__owl__.parentLastFiberId = extra.fiber.id;
|
||||||
return vn1;
|
return vn1;
|
||||||
}"
|
}"
|
||||||
`;
|
`;
|
||||||
|
|
||||||
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 owner = context;
|
let scope = Object.create(context);
|
||||||
var h = this.h;
|
var h = this.h;
|
||||||
let c1 = [], p1 = {key:1};
|
let c1 = [], p1 = {key:1};
|
||||||
var vn1 = h('div', p1, c1);
|
var vn1 = h('div', p1, c1);
|
||||||
if (context['state'].display) {
|
if (scope['state'].display) {
|
||||||
//COMPONENT
|
//COMPONENT
|
||||||
let def3;
|
let k3 = \`__4__\`;
|
||||||
let w4 = 4 in parent.__owl__.cmap ? parent.__owl__.children[parent.__owl__.cmap[4]] : false;
|
let w2 = k3 in parent.__owl__.cmap ? parent.__owl__.children[parent.__owl__.cmap[k3]] : false;
|
||||||
let _2_index = c1.length;
|
let props2 = {};
|
||||||
c1.push(null);
|
if (w2 && w2.__owl__.currentFiber && !w2.__owl__.vnode) {
|
||||||
let props4 = {};
|
w2.destroy();
|
||||||
if (w4 && w4.__owl__.renderPromise && !w4.__owl__.vnode) {
|
w2 = false;
|
||||||
if (utils.shallowEqual(props4, w4.__owl__.renderProps)) {
|
|
||||||
def3 = w4.__owl__.renderPromise;
|
|
||||||
} else {
|
|
||||||
w4.destroy();
|
|
||||||
w4 = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (!w4) {
|
if (w2) {
|
||||||
let componentKey4 = \`Child\`;
|
w2.__updateProps(props2, extra.fiber, undefined);
|
||||||
let W4 = context.components && context.components[componentKey4] || QWeb.components[componentKey4];
|
let pvnode = w2.__owl__.pvnode;
|
||||||
if (!W4) {throw new Error('Cannot find the definition of component \\"' + componentKey4 + '\\"')}
|
c1.push(pvnode);
|
||||||
w4 = new W4(parent, props4);
|
|
||||||
parent.__owl__.cmap[4] = w4.__owl__.id;
|
|
||||||
def3 = w4.__prepare();
|
|
||||||
def3 = def3.then(vnode=>{let pvnode=h(vnode.sel, {key: 4, hook: {insert(vn) {let nvn=w4.__mount(vnode, pvnode.elm);pvnode.elm=nvn.elm;utils.transitionInsert(vn, 'chimay');},remove() {},destroy(vn) {let finalize = () => {
|
|
||||||
w4.destroy();
|
|
||||||
};
|
|
||||||
utils.transitionRemove(vn, 'chimay', finalize);}}});c1[_2_index]=pvnode;w4.__owl__.pvnode = pvnode;});
|
|
||||||
} else {
|
} else {
|
||||||
def3 = def3 || w4.__updateProps(props4, extra.forceUpdate, extra.patchQueue);
|
let componentKey2 = \`Child\`;
|
||||||
def3 = def3.then(()=>{if (w4.__owl__.isDestroyed) {return};let pvnode=w4.__owl__.pvnode;c1[_2_index]=pvnode;});
|
let W2 = context.constructor.components[componentKey2] || QWeb.components[componentKey2]|| scope['Child'];
|
||||||
|
if (!W2) {throw new Error('Cannot find the definition of component \\"' + componentKey2 + '\\"')}
|
||||||
|
w2 = new W2(parent, props2);
|
||||||
|
const __patch2 = w2.__patch;
|
||||||
|
w2.__patch = fiber => {__patch2.call(w2, fiber); if(!w2.__owl__.transitionInserted){w2.__owl__.transitionInserted = true;utils.transitionInsert(w2.__owl__.vnode, 'chimay');}};
|
||||||
|
parent.__owl__.cmap[k3] = w2.__owl__.id;
|
||||||
|
let fiber = w2.__prepare(extra.fiber, undefined, () => { const vnode = fiber.vnode; pvnode.sel = vnode.sel; });
|
||||||
|
let pvnode = h('dummy', {key: k3, hook: {remove() {},destroy(vn) {let finalize = () => {
|
||||||
|
w2.destroy();
|
||||||
|
};
|
||||||
|
delete w2.__owl__.transitionInserted;
|
||||||
|
utils.transitionRemove(vn, 'chimay', finalize);}}});
|
||||||
|
c1.push(pvnode);
|
||||||
|
w2.__owl__.pvnode = pvnode;
|
||||||
}
|
}
|
||||||
extra.promises.push(def3);
|
w2.__owl__.parentLastFiberId = extra.fiber.id;
|
||||||
|
}
|
||||||
|
return vn1;
|
||||||
|
}"
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`animations t-transition combined with t-component, remove and re-add before transitionend 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);
|
||||||
|
var h = this.h;
|
||||||
|
let c1 = [], p1 = {key:1};
|
||||||
|
var vn1 = h('div', p1, c1);
|
||||||
|
if (scope['state'].flag) {
|
||||||
|
//COMPONENT
|
||||||
|
let k3 = \`__4__\`;
|
||||||
|
let w2 = k3 in parent.__owl__.cmap ? parent.__owl__.children[parent.__owl__.cmap[k3]] : 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 = context.constructor.components[componentKey2] || QWeb.components[componentKey2]|| scope['Child'];
|
||||||
|
if (!W2) {throw new Error('Cannot find the definition of component \\"' + componentKey2 + '\\"')}
|
||||||
|
w2 = new W2(parent, props2);
|
||||||
|
const __patch2 = w2.__patch;
|
||||||
|
w2.__patch = fiber => {__patch2.call(w2, fiber); if(!w2.__owl__.transitionInserted){w2.__owl__.transitionInserted = true;utils.transitionInsert(w2.__owl__.vnode, 'chimay');}};
|
||||||
|
parent.__owl__.cmap[k3] = w2.__owl__.id;
|
||||||
|
let fiber = w2.__prepare(extra.fiber, undefined, () => { const vnode = fiber.vnode; pvnode.sel = vnode.sel; });
|
||||||
|
let pvnode = h('dummy', {key: k3, hook: {remove() {},destroy(vn) {let finalize = () => {
|
||||||
|
w2.destroy();
|
||||||
|
};
|
||||||
|
delete w2.__owl__.transitionInserted;
|
||||||
|
utils.transitionRemove(vn, 'chimay', finalize);}}});
|
||||||
|
c1.push(pvnode);
|
||||||
|
w2.__owl__.pvnode = pvnode;
|
||||||
|
}
|
||||||
|
w2.__owl__.parentLastFiberId = extra.fiber.id;
|
||||||
}
|
}
|
||||||
return vn1;
|
return vn1;
|
||||||
}"
|
}"
|
||||||
`;
|
`;
|
||||||
|
|
||||||
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;
|
||||||
var h = this.h;
|
var h = this.h;
|
||||||
let c1 = [], p1 = {key:1};
|
let c1 = [], p1 = {key:1};
|
||||||
@@ -111,8 +161,9 @@ 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;
|
||||||
var h = this.h;
|
var h = this.h;
|
||||||
let c1 = [], p1 = {key:1};
|
let c1 = [], p1 = {key:1};
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
|
exports[`various scenarios scenarios with async store updates and some components events 1`] = `"<div><button>Do stuff</button><div><span>Attachment 100</span><span>Name: text.txt</span></div></div>"`;
|
||||||
|
|
||||||
|
exports[`various scenarios scenarios with async store updates and some components events 2`] = `"<div><button>Do stuff</button></div>"`;
|
||||||
+98
-73
@@ -1,13 +1,15 @@
|
|||||||
import { Component, Env } from "../src/component/component";
|
import { Component, Env } from "../src/component/component";
|
||||||
import { QWeb } from "../src/qweb/index";
|
import { QWeb } from "../src/qweb/index";
|
||||||
|
import { useState, useRef } from "../src/hooks";
|
||||||
|
import { xml } from "../src/tags";
|
||||||
import {
|
import {
|
||||||
makeDeferred,
|
makeDeferred,
|
||||||
makeTestFixture,
|
makeTestFixture,
|
||||||
makeTestEnv,
|
makeTestEnv,
|
||||||
nextTick,
|
|
||||||
patchNextFrame,
|
patchNextFrame,
|
||||||
renderToDOM,
|
renderToDOM,
|
||||||
unpatchNextFrame
|
unpatchNextFrame,
|
||||||
|
nextTick
|
||||||
} from "./helpers";
|
} from "./helpers";
|
||||||
|
|
||||||
//------------------------------------------------------------------------------
|
//------------------------------------------------------------------------------
|
||||||
@@ -29,6 +31,7 @@ let cssEl: HTMLElement;
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
fixture = makeTestFixture();
|
fixture = makeTestFixture();
|
||||||
env = makeTestEnv();
|
env = makeTestEnv();
|
||||||
|
Component.env = env;
|
||||||
qweb = new QWeb();
|
qweb = new QWeb();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -36,7 +39,7 @@ afterEach(() => {
|
|||||||
fixture.remove();
|
fixture.remove();
|
||||||
});
|
});
|
||||||
|
|
||||||
class Widget extends Component<any, any, any> {}
|
class Widget extends Component<any, any> {}
|
||||||
|
|
||||||
//------------------------------------------------------------------------------
|
//------------------------------------------------------------------------------
|
||||||
// Tests
|
// Tests
|
||||||
@@ -106,9 +109,9 @@ describe("animations", () => {
|
|||||||
`<div><span t-if="!state.hide" t-transition="chimay">blue</span></div>`
|
`<div><span t-if="!state.hide" t-transition="chimay">blue</span></div>`
|
||||||
);
|
);
|
||||||
class TestWidget extends Widget {
|
class TestWidget extends Widget {
|
||||||
state = { hide: false };
|
state = useState({ hide: false });
|
||||||
}
|
}
|
||||||
const widget = new TestWidget(env);
|
const widget = new TestWidget();
|
||||||
|
|
||||||
// insert widget into the DOM
|
// insert widget into the DOM
|
||||||
let def = makeDeferred();
|
let def = makeDeferred();
|
||||||
@@ -148,9 +151,10 @@ describe("animations", () => {
|
|||||||
`<div><span t-ref="span" t-transition="chimay">blue</span></div>`
|
`<div><span t-ref="span" t-transition="chimay">blue</span></div>`
|
||||||
);
|
);
|
||||||
class TestWidget extends Widget {
|
class TestWidget extends Widget {
|
||||||
state = { hide: false };
|
state = useState({ hide: false });
|
||||||
|
span = useRef("span");
|
||||||
}
|
}
|
||||||
const widget = new TestWidget(env);
|
const widget = new TestWidget();
|
||||||
|
|
||||||
// insert widget into the DOM
|
// insert widget into the DOM
|
||||||
let def = makeDeferred();
|
let def = makeDeferred();
|
||||||
@@ -163,7 +167,7 @@ describe("animations", () => {
|
|||||||
});
|
});
|
||||||
await widget.mount(fixture);
|
await widget.mount(fixture);
|
||||||
spanNode = widget.el!.children[0];
|
spanNode = widget.el!.children[0];
|
||||||
expect(widget.refs.span).toBe(spanNode);
|
expect(widget.span.el).toBe(spanNode);
|
||||||
expect(spanNode.className).toBe("chimay-enter chimay-enter-active");
|
expect(spanNode.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
|
||||||
spanNode.dispatchEvent(new Event("transitionend")); // mock end of css transition
|
spanNode.dispatchEvent(new Event("transitionend")); // mock end of css transition
|
||||||
@@ -175,11 +179,11 @@ describe("animations", () => {
|
|||||||
|
|
||||||
env.qweb.addTemplate("Parent", `<div><Child t-transition="chimay"/></div>`);
|
env.qweb.addTemplate("Parent", `<div><Child t-transition="chimay"/></div>`);
|
||||||
env.qweb.addTemplate("Child", `<span>blue</span>`);
|
env.qweb.addTemplate("Child", `<span>blue</span>`);
|
||||||
class Parent extends Widget {
|
|
||||||
components = { Child: Child };
|
|
||||||
}
|
|
||||||
class Child extends Widget {}
|
class Child extends Widget {}
|
||||||
const widget = new Parent(env);
|
class Parent extends Widget {
|
||||||
|
static components = { Child: Child };
|
||||||
|
}
|
||||||
|
const widget = new Parent();
|
||||||
|
|
||||||
let def = makeDeferred();
|
let def = makeDeferred();
|
||||||
var spanNode;
|
var spanNode;
|
||||||
@@ -214,12 +218,12 @@ describe("animations", () => {
|
|||||||
`<div><t t-if="state.display" t-component="Child" t-transition="chimay"/></div>`
|
`<div><t t-if="state.display" t-component="Child" t-transition="chimay"/></div>`
|
||||||
);
|
);
|
||||||
env.qweb.addTemplate("Child", `<span>blue</span>`);
|
env.qweb.addTemplate("Child", `<span>blue</span>`);
|
||||||
class Parent extends Widget {
|
|
||||||
components = { Child: Child };
|
|
||||||
state = { display: true };
|
|
||||||
}
|
|
||||||
class Child extends Widget {}
|
class Child extends Widget {}
|
||||||
const widget = new Parent(env);
|
class Parent extends Widget {
|
||||||
|
static components = { Child: Child };
|
||||||
|
state = useState({ display: true });
|
||||||
|
}
|
||||||
|
const widget = new Parent();
|
||||||
|
|
||||||
let def = makeDeferred();
|
let def = makeDeferred();
|
||||||
var spanNode;
|
var spanNode;
|
||||||
@@ -250,11 +254,11 @@ describe("animations", () => {
|
|||||||
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="4">blue</span></div>'
|
'<div><span class="chimay-leave chimay-leave-active" data-owl-key="__4__">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="4">blue</span></div>'
|
'<div><span class="chimay-leave-active chimay-leave-to" data-owl-key="__4__">blue</span></div>'
|
||||||
);
|
);
|
||||||
def.resolve();
|
def.resolve();
|
||||||
});
|
});
|
||||||
@@ -263,28 +267,6 @@ describe("animations", () => {
|
|||||||
expect(fixture.innerHTML).toBe("<div></div>");
|
expect(fixture.innerHTML).toBe("<div></div>");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("t-transition combined with t-mounted", async () => {
|
|
||||||
env.qweb.addTemplate(
|
|
||||||
"TestWidget",
|
|
||||||
`<div><span t-if="state.flag" t-mounted="f" t-transition="chimay">blue</span></div>`
|
|
||||||
);
|
|
||||||
class TestWidget extends Widget {
|
|
||||||
state = { flag: false };
|
|
||||||
f() {}
|
|
||||||
}
|
|
||||||
const widget = new TestWidget(env);
|
|
||||||
widget.f = jest.fn();
|
|
||||||
await widget.mount(fixture);
|
|
||||||
|
|
||||||
patchNextFrame(cb => cb());
|
|
||||||
expect(widget.f).toHaveBeenCalledTimes(0);
|
|
||||||
|
|
||||||
widget.state.flag = true;
|
|
||||||
await nextTick();
|
|
||||||
expect(widget.f).toHaveBeenCalledTimes(1);
|
|
||||||
unpatchNextFrame();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("t-transition, remove and re-add before transitionend", async () => {
|
test("t-transition, remove and re-add before transitionend", async () => {
|
||||||
expect.assertions(11);
|
expect.assertions(11);
|
||||||
|
|
||||||
@@ -297,16 +279,14 @@ describe("animations", () => {
|
|||||||
</templates>`
|
</templates>`
|
||||||
);
|
);
|
||||||
class Parent extends Widget {
|
class Parent extends Widget {
|
||||||
constructor(parent) {
|
state = useState({ flag: false });
|
||||||
super(parent);
|
|
||||||
this.state = { flag: false };
|
|
||||||
}
|
|
||||||
toggle() {
|
toggle() {
|
||||||
this.state.flag = !this.state.flag;
|
this.state.flag = !this.state.flag;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const widget = new Parent(env);
|
const widget = new Parent();
|
||||||
await widget.mount(fixture);
|
await widget.mount(fixture);
|
||||||
let button = widget.el!.querySelector("button");
|
let button = widget.el!.querySelector("button");
|
||||||
|
|
||||||
@@ -343,32 +323,23 @@ describe("animations", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("t-transition combined with t-component, remove and re-add before transitionend", async () => {
|
test("t-transition combined with t-component, remove and re-add before transitionend", async () => {
|
||||||
expect.assertions(11);
|
expect.assertions(12);
|
||||||
|
|
||||||
env.qweb.addTemplates(
|
class Child extends Widget {
|
||||||
`<templates>
|
static template = xml`<span>blue</span>`;
|
||||||
<div t-name="Parent">
|
}
|
||||||
<button t-on-click="toggle">Toggle</button>
|
|
||||||
<t t-if="state.flag" t-component="Child" t-transition="chimay"/>
|
|
||||||
</div>
|
|
||||||
<span t-name="Child">blue</span>
|
|
||||||
</templates>`
|
|
||||||
);
|
|
||||||
class Child extends Widget {}
|
|
||||||
class Parent extends Widget {
|
class Parent extends Widget {
|
||||||
components = { Child };
|
static template = xml`
|
||||||
constructor(parent) {
|
<div t-name="Parent">
|
||||||
super(parent);
|
<t t-if="state.flag" t-component="Child" t-transition="chimay"/>
|
||||||
this.state = { flag: false };
|
</div>`;
|
||||||
}
|
static components = { Child };
|
||||||
toggle() {
|
state = useState({ flag: false });
|
||||||
this.state.flag = !this.state.flag;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const widget = new Parent(env);
|
const widget = new Parent();
|
||||||
await widget.mount(fixture);
|
await widget.mount(fixture);
|
||||||
let button = widget.el!.querySelector("button");
|
expect(env.qweb.templates[Parent.template].fn.toString()).toMatchSnapshot();
|
||||||
|
|
||||||
let def = makeDeferred();
|
let def = makeDeferred();
|
||||||
let phase = "enter";
|
let phase = "enter";
|
||||||
@@ -381,24 +352,78 @@ describe("animations", () => {
|
|||||||
def.resolve();
|
def.resolve();
|
||||||
});
|
});
|
||||||
|
|
||||||
// click display the span
|
// display the span
|
||||||
button!.click();
|
widget.state.flag = true;
|
||||||
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><button>Toggle</button><span class="">blue</span></div>');
|
expect(fixture.innerHTML).toBe('<div><span class="">blue</span></div>');
|
||||||
|
|
||||||
// click to remove the span, and click again to re-add it before transitionend
|
// click to remove the span, and click again to re-add it before transitionend
|
||||||
def = makeDeferred();
|
def = makeDeferred();
|
||||||
phase = "leave";
|
phase = "leave";
|
||||||
button!.click();
|
|
||||||
|
widget.state.flag = false;
|
||||||
|
|
||||||
await def; // wait for the mocked repaint to be done
|
await def; // wait for the mocked repaint to be done
|
||||||
def = makeDeferred();
|
def = makeDeferred();
|
||||||
phase = "enter";
|
phase = "enter";
|
||||||
button!.click();
|
widget.state.flag = true;
|
||||||
|
|
||||||
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><button>Toggle</button><span class="">blue</span></div>');
|
expect(fixture.innerHTML).toBe('<div><span class="" data-owl-key="__4__">blue</span></div>');
|
||||||
|
});
|
||||||
|
|
||||||
|
test("transitionInsert is called the correct amount of times", async () => {
|
||||||
|
const oldTransitionInsert = QWeb.utils.transitionInsert;
|
||||||
|
QWeb.utils.transitionInsert = jest.fn(oldTransitionInsert);
|
||||||
|
|
||||||
|
class Child extends Widget {
|
||||||
|
static template = xml`<span>blue</span>`;
|
||||||
|
}
|
||||||
|
class Parent extends Widget {
|
||||||
|
static template = xml`
|
||||||
|
<div t-name="Parent">
|
||||||
|
<Child t-if="state.flag" t-transition="chimay"/>
|
||||||
|
</div>`;
|
||||||
|
static components = { Child };
|
||||||
|
state = useState({ flag: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
patchNextFrame(cb => cb());
|
||||||
|
|
||||||
|
const widget = new Parent();
|
||||||
|
await widget.mount(fixture);
|
||||||
|
|
||||||
|
widget.state.flag = true;
|
||||||
|
|
||||||
|
await nextTick();
|
||||||
|
widget.el!.querySelector("span")!.dispatchEvent(new Event("transitionend"));
|
||||||
|
expect(fixture.innerHTML).toBe('<div><span class="">blue</span></div>');
|
||||||
|
expect(QWeb.utils.transitionInsert).toBeCalledTimes(1);
|
||||||
|
|
||||||
|
widget.state.flag = false;
|
||||||
|
await nextTick();
|
||||||
|
expect(fixture.innerHTML).toBe(
|
||||||
|
'<div><span class="chimay-leave-active chimay-leave-to" data-owl-key="__4__">blue</span></div>'
|
||||||
|
);
|
||||||
|
expect(QWeb.utils.transitionInsert).toBeCalledTimes(1);
|
||||||
|
|
||||||
|
widget.state.flag = true;
|
||||||
|
await nextTick();
|
||||||
|
expect(fixture.innerHTML).toBe(
|
||||||
|
'<div><span class="chimay-enter-active chimay-enter-to" data-owl-key="__4__">blue</span></div>'
|
||||||
|
);
|
||||||
|
expect(QWeb.utils.transitionInsert).toBeCalledTimes(2);
|
||||||
|
|
||||||
|
widget.state.flag = false;
|
||||||
|
await nextTick();
|
||||||
|
widget.state.flag = true;
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(QWeb.utils.transitionInsert).toBeCalledTimes(3);
|
||||||
|
widget.el!.querySelector("span")!.dispatchEvent(new Event("transitionend"));
|
||||||
|
expect(fixture.innerHTML).toBe('<div><span class="" data-owl-key="__4__">blue</span></div>');
|
||||||
|
QWeb.utils.transitionInsert = oldTransitionInsert;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,44 +1,40 @@
|
|||||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
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 owner = context;
|
let scope = Object.create(context);
|
||||||
var h = this.h;
|
var h = this.h;
|
||||||
let c1 = [], p1 = {key:1};
|
let c1 = [], p1 = {key:1};
|
||||||
var vn1 = h('div', p1, c1);
|
var vn1 = h('div', p1, c1);
|
||||||
//COMPONENT
|
//COMPONENT
|
||||||
let def3;
|
let k3 = \`__4__\`;
|
||||||
let w4 = 4 in parent.__owl__.cmap ? parent.__owl__.children[parent.__owl__.cmap[4]] : false;
|
let w2 = k3 in parent.__owl__.cmap ? parent.__owl__.children[parent.__owl__.cmap[k3]] : false;
|
||||||
let _2_index = c1.length;
|
let props2 = {message:1};
|
||||||
c1.push(null);
|
if (w2 && w2.__owl__.currentFiber && !w2.__owl__.vnode) {
|
||||||
let props4 = {message:1};
|
w2.destroy();
|
||||||
if (w4 && w4.__owl__.renderPromise && !w4.__owl__.vnode) {
|
w2 = false;
|
||||||
if (utils.shallowEqual(props4, w4.__owl__.renderProps)) {
|
|
||||||
def3 = w4.__owl__.renderPromise;
|
|
||||||
} else {
|
|
||||||
w4.destroy();
|
|
||||||
w4 = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (!w4) {
|
if (w2) {
|
||||||
let componentKey4 = \`Child\`;
|
w2.__updateProps(props2, extra.fiber, undefined);
|
||||||
let W4 = context.components && context.components[componentKey4] || QWeb.components[componentKey4];
|
let pvnode = w2.__owl__.pvnode;
|
||||||
if (!W4) {throw new Error('Cannot find the definition of component \\"' + componentKey4 + '\\"')}
|
c1.push(pvnode);
|
||||||
utils.validateProps(W4, props4)
|
|
||||||
w4 = new W4(parent, props4);
|
|
||||||
parent.__owl__.cmap[4] = w4.__owl__.id;
|
|
||||||
def3 = w4.__prepare();
|
|
||||||
def3 = def3.then(vnode=>{let pvnode=h(vnode.sel, {key: 4, hook: {insert(vn) {let nvn=w4.__mount(vnode, pvnode.elm);pvnode.elm=nvn.elm;},remove() {},destroy(vn) {w4.destroy();}}});c1[_2_index]=pvnode;w4.__owl__.pvnode = pvnode;});
|
|
||||||
} else {
|
} else {
|
||||||
utils.validateProps(w4.constructor, props4)
|
let componentKey2 = \`Child\`;
|
||||||
def3 = def3 || w4.__updateProps(props4, extra.forceUpdate, extra.patchQueue);
|
let W2 = context.constructor.components[componentKey2] || QWeb.components[componentKey2]|| scope['Child'];
|
||||||
def3 = def3.then(()=>{if (w4.__owl__.isDestroyed) {return};let pvnode=w4.__owl__.pvnode;c1[_2_index]=pvnode;});
|
if (!W2) {throw new Error('Cannot find the definition of component \\"' + componentKey2 + '\\"')}
|
||||||
|
w2 = new W2(parent, props2);
|
||||||
|
parent.__owl__.cmap[k3] = w2.__owl__.id;
|
||||||
|
let fiber = w2.__prepare(extra.fiber, undefined, () => { const vnode = fiber.vnode; pvnode.sel = vnode.sel; });
|
||||||
|
let pvnode = h('dummy', {key: k3, hook: {remove() {},destroy(vn) {w2.destroy();}}});
|
||||||
|
c1.push(pvnode);
|
||||||
|
w2.__owl__.pvnode = pvnode;
|
||||||
}
|
}
|
||||||
extra.promises.push(def3);
|
w2.__owl__.parentLastFiberId = extra.fiber.id;
|
||||||
return vn1;
|
return vn1;
|
||||||
}"
|
}"
|
||||||
`;
|
`;
|
||||||
|
|||||||
+3663
-1384
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,8 @@
|
|||||||
import { Component, Env } from "../../src/component/component";
|
import { Component, Env } from "../../src/component/component";
|
||||||
import { makeTestFixture, makeTestEnv } from "../helpers";
|
import { makeTestFixture, makeTestEnv, nextTick } from "../helpers";
|
||||||
|
import { useState } from "../../src/hooks";
|
||||||
import { QWeb } from "../../src/qweb";
|
import { QWeb } from "../../src/qweb";
|
||||||
|
import { xml } from "../../src/tags";
|
||||||
|
|
||||||
//------------------------------------------------------------------------------
|
//------------------------------------------------------------------------------
|
||||||
// Setup and helpers
|
// Setup and helpers
|
||||||
@@ -13,6 +15,7 @@ let dev: boolean = false;
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
fixture = makeTestFixture();
|
fixture = makeTestFixture();
|
||||||
env = makeTestEnv();
|
env = makeTestEnv();
|
||||||
|
Component.env = env;
|
||||||
dev = QWeb.dev;
|
dev = QWeb.dev;
|
||||||
QWeb.dev = true;
|
QWeb.dev = true;
|
||||||
});
|
});
|
||||||
@@ -22,7 +25,7 @@ afterEach(() => {
|
|||||||
QWeb.dev = dev;
|
QWeb.dev = dev;
|
||||||
});
|
});
|
||||||
|
|
||||||
class Widget extends Component<any, any, any> {}
|
class Widget extends Component<any, any> {}
|
||||||
|
|
||||||
//------------------------------------------------------------------------------
|
//------------------------------------------------------------------------------
|
||||||
// Tests
|
// Tests
|
||||||
@@ -31,28 +34,55 @@ describe("props validation", () => {
|
|||||||
test("validation is only done in dev mode", async () => {
|
test("validation is only done in dev mode", async () => {
|
||||||
class TestWidget extends Widget {
|
class TestWidget extends Widget {
|
||||||
static props = ["message"];
|
static props = ["message"];
|
||||||
|
static template = xml`<div>hey</div>`;
|
||||||
|
}
|
||||||
|
class Parent extends Widget {
|
||||||
|
static components = { TestWidget };
|
||||||
|
static template = xml`<div><TestWidget /></div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let error;
|
||||||
QWeb.dev = true;
|
QWeb.dev = true;
|
||||||
expect(() => {
|
try {
|
||||||
new TestWidget(env);
|
const p = new Parent();
|
||||||
}).toThrow();
|
await p.mount(fixture);
|
||||||
|
} catch (e) {
|
||||||
|
error = e;
|
||||||
|
}
|
||||||
|
expect(error).toBeDefined();
|
||||||
|
expect(error.message).toBe(`Missing props 'message' (component 'TestWidget')`);
|
||||||
|
|
||||||
|
error = undefined;
|
||||||
|
|
||||||
QWeb.dev = false;
|
QWeb.dev = false;
|
||||||
|
try {
|
||||||
expect(() => {
|
const p = new Parent();
|
||||||
new TestWidget(env);
|
await p.mount(fixture);
|
||||||
}).not.toThrow();
|
} catch (e) {
|
||||||
|
error = e;
|
||||||
|
}
|
||||||
|
expect(error).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("props: list of strings", async () => {
|
test("props: list of strings", async () => {
|
||||||
class TestWidget extends Widget {
|
class TestWidget extends Widget {
|
||||||
static props = ["message"];
|
static props = ["message"];
|
||||||
|
static template = xml`<div>hey</div>`;
|
||||||
|
}
|
||||||
|
class Parent extends Widget {
|
||||||
|
static components = { TestWidget };
|
||||||
|
static template = xml`<div><TestWidget /></div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(() => {
|
let error;
|
||||||
new TestWidget(env);
|
try {
|
||||||
}).toThrow("Missing props 'message' (component 'TestWidget')");
|
const p = new Parent();
|
||||||
|
await p.mount(fixture);
|
||||||
|
} catch (e) {
|
||||||
|
error = e;
|
||||||
|
}
|
||||||
|
expect(error).toBeDefined();
|
||||||
|
expect(error.message).toBe(`Missing props 'message' (component 'TestWidget')`);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("validate simple types", async () => {
|
test("validate simple types", async () => {
|
||||||
@@ -65,22 +95,50 @@ describe("props validation", () => {
|
|||||||
{ type: Function, ok: () => {}, ko: "1" }
|
{ type: Function, ok: () => {}, ko: "1" }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
let props;
|
||||||
|
class Parent extends Component<any, any> {
|
||||||
|
static template = xml`<div><TestWidget p="p"/></div>`;
|
||||||
|
get p() {
|
||||||
|
return props.p;
|
||||||
|
}
|
||||||
|
}
|
||||||
for (let test of Tests) {
|
for (let test of Tests) {
|
||||||
let TestWidget = class extends Widget {
|
let TestWidget = class extends Widget {
|
||||||
|
static template = xml`<div>hey</div>`;
|
||||||
static props = { p: test.type };
|
static props = { p: test.type };
|
||||||
};
|
};
|
||||||
|
Parent.components = { TestWidget };
|
||||||
|
|
||||||
expect(() => {
|
let error;
|
||||||
new TestWidget(env);
|
props = {};
|
||||||
}).toThrow("Missing props 'p'");
|
try {
|
||||||
|
const p = new Parent();
|
||||||
|
await p.mount(fixture);
|
||||||
|
} catch (e) {
|
||||||
|
error = e;
|
||||||
|
}
|
||||||
|
expect(error).toBeDefined();
|
||||||
|
expect(error.message).toBe(`Missing props 'p' (component '_a')`);
|
||||||
|
|
||||||
expect(() => {
|
error = undefined;
|
||||||
new TestWidget(env, { p: test.ok });
|
props = { p: test.ok };
|
||||||
}).not.toThrow();
|
try {
|
||||||
|
const p = new Parent();
|
||||||
|
await p.mount(fixture);
|
||||||
|
} catch (e) {
|
||||||
|
error = e;
|
||||||
|
}
|
||||||
|
expect(error).toBeUndefined();
|
||||||
|
|
||||||
expect(() => {
|
props = { p: test.ko };
|
||||||
new TestWidget(env, { p: test.ko });
|
try {
|
||||||
}).toThrow("Props 'p' of invalid type in component");
|
const p = new Parent();
|
||||||
|
await p.mount(fixture);
|
||||||
|
} catch (e) {
|
||||||
|
error = e;
|
||||||
|
}
|
||||||
|
expect(error).toBeDefined();
|
||||||
|
expect(error.message).toBe("Invalid Prop 'p' in component '_a'");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -94,113 +152,307 @@ describe("props validation", () => {
|
|||||||
{ type: Function, ok: () => {}, ko: "1" }
|
{ type: Function, ok: () => {}, ko: "1" }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
let props;
|
||||||
|
class Parent extends Component<any, any> {
|
||||||
|
static template = xml`<div><TestWidget p="p"/></div>`;
|
||||||
|
get p() {
|
||||||
|
return props.p;
|
||||||
|
}
|
||||||
|
}
|
||||||
for (let test of Tests) {
|
for (let test of Tests) {
|
||||||
let TestWidget = class extends Widget {
|
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>`;
|
||||||
};
|
};
|
||||||
|
Parent.components = { TestWidget };
|
||||||
|
|
||||||
expect(() => {
|
let error;
|
||||||
new TestWidget(env);
|
props = {};
|
||||||
}).toThrow("Missing props 'p'");
|
try {
|
||||||
|
const p = new Parent();
|
||||||
|
await p.mount(fixture);
|
||||||
|
} catch (e) {
|
||||||
|
error = e;
|
||||||
|
}
|
||||||
|
expect(error).toBeDefined();
|
||||||
|
expect(error.message).toBe(`Missing props 'p' (component '_a')`);
|
||||||
|
|
||||||
expect(() => {
|
error = undefined;
|
||||||
new TestWidget(env, { p: test.ok });
|
props = { p: test.ok };
|
||||||
}).not.toThrow();
|
try {
|
||||||
|
const p = new Parent();
|
||||||
|
await p.mount(fixture);
|
||||||
|
} catch (e) {
|
||||||
|
error = e;
|
||||||
|
}
|
||||||
|
expect(error).toBeUndefined();
|
||||||
|
|
||||||
expect(() => {
|
props = { p: test.ko };
|
||||||
new TestWidget(env, { p: test.ko });
|
try {
|
||||||
}).toThrow("Props 'p' of invalid type in component");
|
const p = new Parent();
|
||||||
|
await p.mount(fixture);
|
||||||
|
} catch (e) {
|
||||||
|
error = e;
|
||||||
|
}
|
||||||
|
expect(error).toBeDefined();
|
||||||
|
expect(error.message).toBe("Invalid Prop 'p' in component '_a'");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("can validate a prop with multiple types", async () => {
|
test("can validate a prop with multiple types", async () => {
|
||||||
let TestWidget = class extends Widget {
|
class TestWidget extends Component<any, any> {
|
||||||
|
static template = xml`<div>hey</div>`;
|
||||||
static props = { p: [String, Boolean] };
|
static props = { p: [String, Boolean] };
|
||||||
};
|
}
|
||||||
|
class Parent extends Component<any, any> {
|
||||||
|
static template = xml`<div><TestWidget p="p"/></div>`;
|
||||||
|
static components = { TestWidget };
|
||||||
|
get p() {
|
||||||
|
return props.p;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
expect(() => {
|
let error;
|
||||||
new TestWidget(env, { p: "string" });
|
let props;
|
||||||
new TestWidget(env, { p: true });
|
try {
|
||||||
}).not.toThrow();
|
props = { p: "string" };
|
||||||
|
const p = new Parent();
|
||||||
|
await p.mount(fixture);
|
||||||
|
} catch (e) {
|
||||||
|
error = e;
|
||||||
|
}
|
||||||
|
expect(error).toBeUndefined();
|
||||||
|
|
||||||
expect(() => {
|
try {
|
||||||
new TestWidget(env, { p: 1 });
|
props = { p: true };
|
||||||
}).toThrow("Props 'p' of invalid type in component");
|
const p = new Parent();
|
||||||
|
await p.mount(fixture);
|
||||||
|
} catch (e) {
|
||||||
|
error = e;
|
||||||
|
}
|
||||||
|
expect(error).toBeUndefined();
|
||||||
|
|
||||||
|
try {
|
||||||
|
props = { p: 1 };
|
||||||
|
const p = new Parent();
|
||||||
|
await p.mount(fixture);
|
||||||
|
} catch (e) {
|
||||||
|
error = e;
|
||||||
|
}
|
||||||
|
expect(error).toBeDefined();
|
||||||
|
expect(error.message).toBe("Invalid Prop 'p' in component 'TestWidget'");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("can validate an optional props", async () => {
|
test("can validate an optional props", async () => {
|
||||||
let TestWidget = class extends Widget {
|
class TestWidget extends Component<any, any> {
|
||||||
|
static template = xml`<div>hey</div>`;
|
||||||
static props = { p: { type: String, optional: true } };
|
static props = { p: { type: String, optional: true } };
|
||||||
};
|
}
|
||||||
|
class Parent extends Component<any, any> {
|
||||||
|
static template = xml`<div><TestWidget p="p"/></div>`;
|
||||||
|
static components = { TestWidget };
|
||||||
|
get p() {
|
||||||
|
return props.p;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
expect(() => {
|
let error;
|
||||||
new TestWidget(env, { p: "hey" });
|
let props;
|
||||||
new TestWidget(env, {});
|
try {
|
||||||
}).not.toThrow();
|
props = { p: "key" };
|
||||||
|
const p = new Parent();
|
||||||
|
await p.mount(fixture);
|
||||||
|
} catch (e) {
|
||||||
|
error = e;
|
||||||
|
}
|
||||||
|
expect(error).toBeUndefined();
|
||||||
|
|
||||||
expect(() => {
|
try {
|
||||||
new TestWidget(env, { p: 1 });
|
props = {};
|
||||||
}).toThrow();
|
const p = new Parent();
|
||||||
|
await p.mount(fixture);
|
||||||
|
} catch (e) {
|
||||||
|
error = e;
|
||||||
|
}
|
||||||
|
expect(error).toBeUndefined();
|
||||||
|
|
||||||
|
try {
|
||||||
|
props = { p: 1 };
|
||||||
|
const p = new Parent();
|
||||||
|
await p.mount(fixture);
|
||||||
|
} catch (e) {
|
||||||
|
error = e;
|
||||||
|
}
|
||||||
|
expect(error).toBeDefined();
|
||||||
|
expect(error.message).toBe("Invalid Prop 'p' in component 'TestWidget'");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("can validate an array with given primitive type", async () => {
|
test("can validate an array with given primitive type", async () => {
|
||||||
let TestWidget = class extends Widget {
|
class TestWidget extends Component<any, any> {
|
||||||
|
static template = xml`<div>hey</div>`;
|
||||||
static props = { p: { type: Array, element: String } };
|
static props = { p: { type: Array, element: String } };
|
||||||
};
|
}
|
||||||
|
class Parent extends Component<any, any> {
|
||||||
|
static template = xml`<div><TestWidget p="p"/></div>`;
|
||||||
|
static components = { TestWidget };
|
||||||
|
get p() {
|
||||||
|
return props.p;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
expect(() => {
|
let error;
|
||||||
new TestWidget(env, { p: [] });
|
let props;
|
||||||
new TestWidget(env, { p: ["string"] });
|
try {
|
||||||
}).not.toThrow();
|
props = { p: [] };
|
||||||
|
const p = new Parent();
|
||||||
|
await p.mount(fixture);
|
||||||
|
} catch (e) {
|
||||||
|
error = e;
|
||||||
|
}
|
||||||
|
expect(error).toBeUndefined();
|
||||||
|
|
||||||
expect(() => {
|
try {
|
||||||
new TestWidget(env, { p: [1] });
|
props = { p: ["string"] };
|
||||||
}).toThrow();
|
const p = new Parent();
|
||||||
|
await p.mount(fixture);
|
||||||
|
} catch (e) {
|
||||||
|
error = e;
|
||||||
|
}
|
||||||
|
expect(error).toBeUndefined();
|
||||||
|
|
||||||
expect(() => {
|
try {
|
||||||
new TestWidget(env, { p: ["string", 1] });
|
props = { p: [1] };
|
||||||
}).toThrow();
|
const p = new Parent();
|
||||||
|
await p.mount(fixture);
|
||||||
|
} catch (e) {
|
||||||
|
error = e;
|
||||||
|
}
|
||||||
|
expect(error).toBeDefined();
|
||||||
|
|
||||||
|
error = undefined;
|
||||||
|
try {
|
||||||
|
props = { p: ["string", 1] };
|
||||||
|
const p = new Parent();
|
||||||
|
await p.mount(fixture);
|
||||||
|
} catch (e) {
|
||||||
|
error = e;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("can validate an array with multiple sub element types", async () => {
|
test("can validate an array with multiple sub element types", async () => {
|
||||||
let TestWidget = class extends Widget {
|
class TestWidget extends Component<any, any> {
|
||||||
|
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<any, any> {
|
||||||
|
static template = xml`<div><TestWidget p="p"/></div>`;
|
||||||
|
static components = { TestWidget };
|
||||||
|
get p() {
|
||||||
|
return props.p;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
expect(() => {
|
let error;
|
||||||
new TestWidget(env, { p: [] });
|
let props;
|
||||||
new TestWidget(env, { p: ["string"] });
|
try {
|
||||||
new TestWidget(env, { p: [false, true, "string"] });
|
props = { p: [] };
|
||||||
}).not.toThrow();
|
const p = new Parent();
|
||||||
|
await p.mount(fixture);
|
||||||
|
} catch (e) {
|
||||||
|
error = e;
|
||||||
|
}
|
||||||
|
expect(error).toBeUndefined();
|
||||||
|
|
||||||
expect(() => {
|
try {
|
||||||
new TestWidget(env, { p: [true, 1] });
|
props = { p: ["string"] };
|
||||||
}).toThrow();
|
const p = new Parent();
|
||||||
|
await p.mount(fixture);
|
||||||
|
} catch (e) {
|
||||||
|
error = e;
|
||||||
|
}
|
||||||
|
expect(error).toBeUndefined();
|
||||||
|
|
||||||
|
try {
|
||||||
|
props = { p: [false, true, "string"] };
|
||||||
|
const p = new Parent();
|
||||||
|
await p.mount(fixture);
|
||||||
|
} catch (e) {
|
||||||
|
error = e;
|
||||||
|
}
|
||||||
|
expect(error).toBeUndefined();
|
||||||
|
|
||||||
|
try {
|
||||||
|
props = { p: [true, 1] };
|
||||||
|
const p = new Parent();
|
||||||
|
await p.mount(fixture);
|
||||||
|
} catch (e) {
|
||||||
|
error = e;
|
||||||
|
}
|
||||||
|
expect(error).toBeDefined();
|
||||||
|
expect(error.message).toBe("Invalid Prop 'p' in component 'TestWidget'");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("can validate an object with simple shape", async () => {
|
test("can validate an object with simple shape", async () => {
|
||||||
let TestWidget = class extends Widget {
|
class TestWidget extends Component<any, any> {
|
||||||
|
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<any, any> {
|
||||||
|
static template = xml`<div><TestWidget p="p"/></div>`;
|
||||||
|
static components = { TestWidget };
|
||||||
|
get p() {
|
||||||
|
return props.p;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
expect(() => {
|
let error;
|
||||||
new TestWidget(env, { p: { id: 1, url: "url" } });
|
let props;
|
||||||
new TestWidget(env, { p: { id: 1, url: "url", extra: true } });
|
try {
|
||||||
}).not.toThrow();
|
props = { p: { id: 1, url: "url" } };
|
||||||
|
const p = new Parent();
|
||||||
|
await p.mount(fixture);
|
||||||
|
} catch (e) {
|
||||||
|
error = e;
|
||||||
|
}
|
||||||
|
expect(error).toBeUndefined();
|
||||||
|
|
||||||
expect(() => {
|
try {
|
||||||
new TestWidget(env, { p: { id: "1", url: "url" } });
|
props = { p: { id: 1, url: "url", extra: true } };
|
||||||
}).toThrow();
|
const p = new Parent();
|
||||||
|
await p.mount(fixture);
|
||||||
|
} catch (e) {
|
||||||
|
error = e;
|
||||||
|
}
|
||||||
|
expect(error).toBeDefined();
|
||||||
|
expect(error.message).toBe("Invalid prop 'p' in component TestWidget (unknown prop 'extra')");
|
||||||
|
|
||||||
expect(() => {
|
try {
|
||||||
new TestWidget(env, { p: { id: 1 } });
|
props = { p: { id: "1", url: "url" } };
|
||||||
}).toThrow();
|
const p = new Parent();
|
||||||
|
await p.mount(fixture);
|
||||||
|
} catch (e) {
|
||||||
|
error = e;
|
||||||
|
}
|
||||||
|
expect(error).toBeDefined();
|
||||||
|
expect(error.message).toBe("Invalid Prop 'p' in component 'TestWidget'");
|
||||||
|
|
||||||
|
error = undefined;
|
||||||
|
try {
|
||||||
|
props = { p: { id: 1 } };
|
||||||
|
const p = new Parent();
|
||||||
|
await p.mount(fixture);
|
||||||
|
} catch (e) {
|
||||||
|
error = e;
|
||||||
|
}
|
||||||
|
expect(error).toBeDefined();
|
||||||
|
expect(error.message).toBe("Invalid Prop 'p' in component 'TestWidget'");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("can validate recursively complicated prop def", async () => {
|
test("can validate recursively complicated prop def", async () => {
|
||||||
let TestWidget = class extends Widget {
|
class TestWidget extends Component<any, any> {
|
||||||
|
static template = xml`<div>hey</div>`;
|
||||||
static props = {
|
static props = {
|
||||||
p: {
|
p: {
|
||||||
type: Object,
|
type: Object,
|
||||||
@@ -210,16 +462,141 @@ describe("props validation", () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
};
|
}
|
||||||
|
class Parent extends Component<any, any> {
|
||||||
|
static template = xml`<div><TestWidget p="p"/></div>`;
|
||||||
|
static components = { TestWidget };
|
||||||
|
get p() {
|
||||||
|
return props.p;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
expect(() => {
|
let error;
|
||||||
new TestWidget(env, { p: { id: 1, url: true } });
|
let props;
|
||||||
new TestWidget(env, { p: { id: 1, url: [12] } });
|
try {
|
||||||
}).not.toThrow();
|
props = { p: { id: 1, url: true } };
|
||||||
|
const p = new Parent();
|
||||||
|
await p.mount(fixture);
|
||||||
|
} catch (e) {
|
||||||
|
error = e;
|
||||||
|
}
|
||||||
|
expect(error).toBeUndefined();
|
||||||
|
|
||||||
expect(() => {
|
try {
|
||||||
new TestWidget(env, { p: { id: 1, url: [12, true] } });
|
props = { p: { id: 1, url: [12] } };
|
||||||
}).toThrow();
|
const p = new Parent();
|
||||||
|
await p.mount(fixture);
|
||||||
|
} catch (e) {
|
||||||
|
error = e;
|
||||||
|
}
|
||||||
|
expect(error).toBeUndefined();
|
||||||
|
|
||||||
|
try {
|
||||||
|
props = { p: { id: 1, url: [12, true] } };
|
||||||
|
const p = new Parent();
|
||||||
|
await p.mount(fixture);
|
||||||
|
} catch (e) {
|
||||||
|
error = e;
|
||||||
|
}
|
||||||
|
expect(error).toBeDefined();
|
||||||
|
expect(error.message).toBe("Invalid Prop 'p' in component 'TestWidget'");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("can validate optional attributes in nested sub props", () => {
|
||||||
|
class TestComponent extends Component<any, any> {
|
||||||
|
static props = {
|
||||||
|
myprop: {
|
||||||
|
type: Array,
|
||||||
|
element: {
|
||||||
|
type: Object,
|
||||||
|
shape: {
|
||||||
|
num: { type: Number, optional: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let error;
|
||||||
|
try {
|
||||||
|
QWeb.utils.validateProps(TestComponent, { myprop: [{}] });
|
||||||
|
} catch (e) {
|
||||||
|
error = e;
|
||||||
|
}
|
||||||
|
expect(error).toBeUndefined();
|
||||||
|
|
||||||
|
try {
|
||||||
|
QWeb.utils.validateProps(TestComponent, { myprop: [{ a: 1 }] });
|
||||||
|
} catch (e) {
|
||||||
|
error = e;
|
||||||
|
}
|
||||||
|
expect(error).toBeDefined();
|
||||||
|
expect(error.message).toBe(
|
||||||
|
"Invalid prop 'myprop' in component TestComponent (unknown prop 'a')"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("can validate with a custom validator", () => {
|
||||||
|
class TestComponent extends Component<any, any> {
|
||||||
|
static props = {
|
||||||
|
size: {
|
||||||
|
validate: e => ["small", "medium", "large"].includes(e)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let error;
|
||||||
|
try {
|
||||||
|
QWeb.utils.validateProps(TestComponent, { size: "small" });
|
||||||
|
} catch (e) {
|
||||||
|
error = e;
|
||||||
|
}
|
||||||
|
expect(error).toBeUndefined();
|
||||||
|
|
||||||
|
try {
|
||||||
|
QWeb.utils.validateProps(TestComponent, { size: "abcdef" });
|
||||||
|
} catch (e) {
|
||||||
|
error = e;
|
||||||
|
}
|
||||||
|
expect(error).toBeDefined();
|
||||||
|
expect(error.message).toBe("Invalid Prop 'size' in component 'TestComponent'");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("can validate with a custom validator, and a type", () => {
|
||||||
|
const validator = jest.fn(n => 0 <= n && n <= 10);
|
||||||
|
class TestComponent extends Component<any, any> {
|
||||||
|
static props = {
|
||||||
|
n: {
|
||||||
|
type: Number,
|
||||||
|
validate: validator
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let error;
|
||||||
|
try {
|
||||||
|
QWeb.utils.validateProps(TestComponent, { n: 3 });
|
||||||
|
} catch (e) {
|
||||||
|
error = e;
|
||||||
|
}
|
||||||
|
expect(error).toBeUndefined();
|
||||||
|
expect(validator).toBeCalledTimes(1);
|
||||||
|
|
||||||
|
try {
|
||||||
|
QWeb.utils.validateProps(TestComponent, { n: "str" });
|
||||||
|
} catch (e) {
|
||||||
|
error = e;
|
||||||
|
}
|
||||||
|
expect(error).toBeDefined();
|
||||||
|
expect(error.message).toBe("Invalid Prop 'n' in component 'TestComponent'");
|
||||||
|
expect(validator).toBeCalledTimes(1);
|
||||||
|
|
||||||
|
error = null;
|
||||||
|
try {
|
||||||
|
QWeb.utils.validateProps(TestComponent, { n: 100 });
|
||||||
|
} catch (e) {
|
||||||
|
error = e;
|
||||||
|
}
|
||||||
|
expect(error).toBeDefined();
|
||||||
|
expect(error.message).toBe("Invalid Prop 'n' in component 'TestComponent'");
|
||||||
|
expect(validator).toBeCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("props are validated in dev mode (code snapshot)", async () => {
|
test("props are validated in dev mode (code snapshot)", async () => {
|
||||||
@@ -235,9 +612,9 @@ describe("props validation", () => {
|
|||||||
static props = ["message"];
|
static props = ["message"];
|
||||||
}
|
}
|
||||||
class App extends Widget {
|
class App extends Widget {
|
||||||
components = { Child };
|
static components = { Child };
|
||||||
}
|
}
|
||||||
const app = new App(env);
|
const app = new App();
|
||||||
await app.mount(fixture);
|
await app.mount(fixture);
|
||||||
expect(fixture.innerHTML).toBe("<div><div>1</div></div>");
|
expect(fixture.innerHTML).toBe("<div><div>1</div></div>");
|
||||||
// need to make sure there are 2 call to update props. one at component
|
// need to make sure there are 2 call to update props. one at component
|
||||||
@@ -268,6 +645,32 @@ describe("props validation", () => {
|
|||||||
}).toThrow();
|
}).toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("props with type array, and no element", async () => {
|
||||||
|
class TestWidget extends Widget {
|
||||||
|
static props = { myprop: { type: Array } };
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
QWeb.utils.validateProps(TestWidget, { myprop: [1] });
|
||||||
|
}).not.toThrow();
|
||||||
|
expect(() => {
|
||||||
|
QWeb.utils.validateProps(TestWidget, { myprop: 1 });
|
||||||
|
}).toThrow(`Invalid Prop 'myprop' in component 'TestWidget'`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("props with type object, and no shape", async () => {
|
||||||
|
class TestWidget extends Widget {
|
||||||
|
static props = { myprop: { type: Object } };
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
QWeb.utils.validateProps(TestWidget, { myprop: { a: 3 } });
|
||||||
|
}).not.toThrow();
|
||||||
|
expect(() => {
|
||||||
|
QWeb.utils.validateProps(TestWidget, { myprop: false });
|
||||||
|
}).toThrow(`Invalid Prop 'myprop' in component 'TestWidget'`);
|
||||||
|
});
|
||||||
|
|
||||||
test("props: extra props cause an error", async () => {
|
test("props: extra props cause an error", async () => {
|
||||||
class TestWidget extends Widget {
|
class TestWidget extends Widget {
|
||||||
static props = ["message"];
|
static props = ["message"];
|
||||||
@@ -280,7 +683,7 @@ describe("props validation", () => {
|
|||||||
|
|
||||||
test("props: extra props cause an error, part 2", async () => {
|
test("props: extra props cause an error, part 2", async () => {
|
||||||
class TestWidget extends Widget {
|
class TestWidget extends Widget {
|
||||||
static props = {message: true};
|
static props = { message: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(() => {
|
expect(() => {
|
||||||
@@ -294,42 +697,147 @@ describe("props validation", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
expect(() => {
|
expect(() => {
|
||||||
QWeb.utils.validateProps(TestWidget, { message: 1});
|
QWeb.utils.validateProps(TestWidget, { message: 1 });
|
||||||
}).not.toThrow();
|
}).not.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("optional prop do not cause an error if value is undefined", async () => {
|
test("optional prop do not cause an error if value is undefined", async () => {
|
||||||
class TestWidget extends Widget {
|
class TestWidget extends Widget {
|
||||||
static props = {message: {type: String, optional: true}};
|
static props = { message: { type: String, optional: true } };
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(() => {
|
expect(() => {
|
||||||
QWeb.utils.validateProps(TestWidget, { message: undefined});
|
QWeb.utils.validateProps(TestWidget, { message: undefined });
|
||||||
}).not.toThrow();
|
}).not.toThrow();
|
||||||
expect(() => {
|
expect(() => {
|
||||||
QWeb.utils.validateProps(TestWidget, { message: null});
|
QWeb.utils.validateProps(TestWidget, { message: null });
|
||||||
}).toThrow();
|
}).toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("missing required boolean prop causes an error", async () => {
|
||||||
|
class TestWidget extends Widget {
|
||||||
|
static props = ["p"];
|
||||||
|
static template = xml`<span><t t-if="props.p">hey</t></span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
class App extends Widget {
|
||||||
|
static template = xml`<div><TestWidget/></div>`;
|
||||||
|
static components = { TestWidget };
|
||||||
|
}
|
||||||
|
|
||||||
|
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 'p' (component 'TestWidget')");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("props are validated whenever component is updated", async () => {
|
||||||
|
let error;
|
||||||
|
class TestWidget extends Component<any, any> {
|
||||||
|
static props = { p: { type: Number } };
|
||||||
|
static template = xml`<div><t t-esc="props.p"/></div>`;
|
||||||
|
|
||||||
|
async __updateProps() {
|
||||||
|
try {
|
||||||
|
await Component.prototype.__updateProps.apply(this, arguments);
|
||||||
|
} catch (e) {
|
||||||
|
error = e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
class Parent extends Component<any, any> {
|
||||||
|
static template = xml`<div><TestWidget p="state.p"/></div>`;
|
||||||
|
static components = { TestWidget };
|
||||||
|
state: any = useState({ p: 1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const w = new Parent();
|
||||||
|
await w.mount(fixture);
|
||||||
|
expect(fixture.innerHTML).toBe("<div><div>1</div></div>");
|
||||||
|
|
||||||
|
w.state.p = undefined;
|
||||||
|
await nextTick();
|
||||||
|
expect(error).toBeDefined();
|
||||||
|
expect(error.message).toBe("Missing props 'p' (component 'TestWidget')");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("default values are applied before validating props at update", async () => {
|
||||||
|
class TestWidget extends Component<any, any> {
|
||||||
|
static props = { p: { type: Number } };
|
||||||
|
static template = xml`<div><t t-esc="props.p"/></div>`;
|
||||||
|
static defaultProps = { p: 4 };
|
||||||
|
}
|
||||||
|
class Parent extends Component<any, any> {
|
||||||
|
static template = xml`<div><TestWidget p="state.p"/></div>`;
|
||||||
|
static components = { TestWidget };
|
||||||
|
state: any = useState({ p: 1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const w = new Parent();
|
||||||
|
await w.mount(fixture);
|
||||||
|
expect(fixture.innerHTML).toBe("<div><div>1</div></div>");
|
||||||
|
|
||||||
|
w.state.p = undefined;
|
||||||
|
await nextTick();
|
||||||
|
expect(fixture.innerHTML).toBe("<div><div>4</div></div>");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("default props", () => {
|
describe("default props", () => {
|
||||||
test("can set default values", async () => {
|
test("can set default values", async () => {
|
||||||
class TestWidget extends Widget {
|
class TestWidget extends Component<any, any> {
|
||||||
static defaultProps = { p: 4 };
|
static defaultProps = { p: 4 };
|
||||||
|
static template = xml`<div><t t-esc="props.p"/></div>`;
|
||||||
|
}
|
||||||
|
class Parent extends Component<any, any> {
|
||||||
|
static template = xml`<div><TestWidget /></div>`;
|
||||||
|
static components = { TestWidget };
|
||||||
}
|
}
|
||||||
|
|
||||||
const w = new TestWidget(env, {});
|
const w = new Parent();
|
||||||
expect(w.props.p).toBe(4);
|
await w.mount(fixture);
|
||||||
|
expect(fixture.innerHTML).toBe("<div><div>4</div></div>");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("default values are also set whenever component is updated", async () => {
|
test("default values are also set whenever component is updated", async () => {
|
||||||
class TestWidget extends Widget {
|
class TestWidget extends Widget {
|
||||||
|
static template = xml`<div><t t-esc="props.p"/></div>`;
|
||||||
static defaultProps = { p: 4 };
|
static defaultProps = { p: 4 };
|
||||||
}
|
}
|
||||||
|
class Parent extends Widget {
|
||||||
|
static template = xml`<div><TestWidget p="state.p"/></div>`;
|
||||||
|
static components = { TestWidget };
|
||||||
|
state: any = useState({ p: 1 });
|
||||||
|
}
|
||||||
|
|
||||||
const w = new TestWidget(env, { p: 1 });
|
const w = new Parent();
|
||||||
await w.__updateProps({});
|
await w.mount(fixture);
|
||||||
expect(w.props.p).toBe(4);
|
expect(fixture.innerHTML).toBe("<div><div>1</div></div>");
|
||||||
|
|
||||||
|
w.state.p = undefined;
|
||||||
|
await nextTick();
|
||||||
|
expect(fixture.innerHTML).toBe("<div><div>4</div></div>");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("can set default required boolean values", async () => {
|
||||||
|
class TestWidget extends Widget {
|
||||||
|
static props = ["p", "q"];
|
||||||
|
static defaultProps = { p: true, q: false };
|
||||||
|
static template = xml`<span><t t-if="props.p">hey</t><t t-if="!props.q">hey</t></span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
class App extends Widget {
|
||||||
|
static template = xml`<div><TestWidget/></div>`;
|
||||||
|
static components = { TestWidget };
|
||||||
|
}
|
||||||
|
|
||||||
|
const w = new App(undefined, {});
|
||||||
|
await w.mount(fixture);
|
||||||
|
expect(fixture.innerHTML).toBe("<div><span>heyhey</span></div>");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,339 @@
|
|||||||
|
import { makeDeferred, makeTestEnv, makeTestFixture, nextTick } from "./helpers";
|
||||||
|
import { Component } from "../src/component/component";
|
||||||
|
import { Context, useContext } from "../src/context";
|
||||||
|
import { xml } from "../src/tags";
|
||||||
|
import { useState } from "../src/hooks";
|
||||||
|
|
||||||
|
//------------------------------------------------------------------------------
|
||||||
|
// 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.
|
||||||
|
// - a test env, necessary to create components, that is set as env
|
||||||
|
|
||||||
|
let fixture: HTMLElement;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fixture = makeTestFixture();
|
||||||
|
Component.env = makeTestEnv();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
fixture.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
//------------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
//------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("Context", () => {
|
||||||
|
test("very simple use, with initial value", async () => {
|
||||||
|
const testContext = new Context({ value: 123 });
|
||||||
|
|
||||||
|
class Test extends Component<any, any> {
|
||||||
|
static template = xml`<div><t t-esc="contextObj.value"/></div>`;
|
||||||
|
contextObj = useContext(testContext);
|
||||||
|
}
|
||||||
|
const test = new Test();
|
||||||
|
await test.mount(fixture);
|
||||||
|
expect(fixture.innerHTML).toBe("<div>123</div>");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("useContext hook is reactive, for one component", async () => {
|
||||||
|
const testContext = new Context({ value: 123 });
|
||||||
|
|
||||||
|
class Test extends Component<any, any> {
|
||||||
|
static template = xml`<div><t t-esc="contextObj.value"/></div>`;
|
||||||
|
contextObj = useContext(testContext);
|
||||||
|
}
|
||||||
|
const test = new Test();
|
||||||
|
await test.mount(fixture);
|
||||||
|
expect(fixture.innerHTML).toBe("<div>123</div>");
|
||||||
|
test.contextObj.value = 321;
|
||||||
|
await nextTick();
|
||||||
|
expect(fixture.innerHTML).toBe("<div>321</div>");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("two components can subscribe to same context", async () => {
|
||||||
|
const testContext = new Context({ value: 123 });
|
||||||
|
|
||||||
|
class Child extends Component<any, any> {
|
||||||
|
static template = xml`<span><t t-esc="contextObj.value"/></span>`;
|
||||||
|
contextObj = useContext(testContext);
|
||||||
|
}
|
||||||
|
class Parent extends Component<any, any> {
|
||||||
|
static template = xml`<div><Child /><Child /></div>`;
|
||||||
|
static components = { Child };
|
||||||
|
}
|
||||||
|
const parent = new Parent();
|
||||||
|
await parent.mount(fixture);
|
||||||
|
expect(fixture.innerHTML).toBe("<div><span>123</span><span>123</span></div>");
|
||||||
|
testContext.state.value = 321;
|
||||||
|
await nextTick();
|
||||||
|
expect(fixture.innerHTML).toBe("<div><span>321</span><span>321</span></div>");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("two async components are updated in parallel", async () => {
|
||||||
|
const testContext = new Context({ value: 123 });
|
||||||
|
const def = makeDeferred();
|
||||||
|
const steps: string[] = [];
|
||||||
|
|
||||||
|
class Child extends Component<any, any> {
|
||||||
|
static template = xml`<span><t t-esc="contextObj.value"/></span>`;
|
||||||
|
contextObj = useContext(testContext);
|
||||||
|
async render() {
|
||||||
|
steps.push("render");
|
||||||
|
await def;
|
||||||
|
return super.render();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Parent extends Component<any, any> {
|
||||||
|
static template = xml`<div><Child /><Child /></div>`;
|
||||||
|
static components = { Child };
|
||||||
|
}
|
||||||
|
const parent = new Parent();
|
||||||
|
await parent.mount(fixture);
|
||||||
|
expect(fixture.innerHTML).toBe("<div><span>123</span><span>123</span></div>");
|
||||||
|
testContext.state.value = 321;
|
||||||
|
await nextTick();
|
||||||
|
expect(steps).toEqual(["render", "render"]);
|
||||||
|
expect(fixture.innerHTML).toBe("<div><span>123</span><span>123</span></div>");
|
||||||
|
def.resolve();
|
||||||
|
await nextTick();
|
||||||
|
expect(fixture.innerHTML).toBe("<div><span>321</span><span>321</span></div>");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("two async components on two levels are updated (mostly) in parallel", async () => {
|
||||||
|
const testContext = new Context({ value: 123 });
|
||||||
|
const def = makeDeferred();
|
||||||
|
const steps: string[] = [];
|
||||||
|
|
||||||
|
class SlowComp extends Component<any, any> {
|
||||||
|
static template = xml`<p><t t-esc="props.value"/></p>`;
|
||||||
|
willUpdateProps() {
|
||||||
|
return def;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
class Child extends Component<any, any> {
|
||||||
|
static template = xml`<span><SlowComp value="contextObj.value"/></span>`;
|
||||||
|
static components = { SlowComp };
|
||||||
|
contextObj = useContext(testContext);
|
||||||
|
|
||||||
|
render() {
|
||||||
|
steps.push("render");
|
||||||
|
return super.render();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Parent extends Component<any, any> {
|
||||||
|
static template = xml`<div><Child /><Child /></div>`;
|
||||||
|
static components = { Child };
|
||||||
|
}
|
||||||
|
|
||||||
|
class App extends Component<any, any> {
|
||||||
|
static template = xml`<div><Child /><Parent /></div>`;
|
||||||
|
static components = { Child, Parent };
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = new App();
|
||||||
|
await app.mount(fixture);
|
||||||
|
expect(fixture.innerHTML).toBe(
|
||||||
|
"<div><span><p>123</p></span><div><span><p>123</p></span><span><p>123</p></span></div></div>"
|
||||||
|
);
|
||||||
|
testContext.state.value = 321;
|
||||||
|
await nextTick();
|
||||||
|
expect(steps).toEqual(["render"]);
|
||||||
|
expect(fixture.innerHTML).toBe(
|
||||||
|
"<div><span><p>123</p></span><div><span><p>123</p></span><span><p>123</p></span></div></div>"
|
||||||
|
);
|
||||||
|
def.resolve();
|
||||||
|
await nextTick();
|
||||||
|
// we need to wait for an extra tick because it could happen (even though it
|
||||||
|
// is rare) that the second batch of renderings is not done yet, because
|
||||||
|
// the initial promise has been given to the macrotask queue, so a small
|
||||||
|
// delay happens.
|
||||||
|
await nextTick();
|
||||||
|
expect(steps).toEqual(["render", "render", "render"]);
|
||||||
|
expect(fixture.innerHTML).toBe(
|
||||||
|
"<div><span><p>321</p></span><div><span><p>321</p></span><span><p>321</p></span></div></div>"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("one components can subscribe twice to same context", async () => {
|
||||||
|
const testContext = new Context({ a: 1, b: 2 });
|
||||||
|
const steps: string[] = [];
|
||||||
|
|
||||||
|
class Child extends Component<any, any> {
|
||||||
|
static template = xml`<span><t t-esc="contextObj1.a"/><t t-esc="contextObj2.b"/></span>`;
|
||||||
|
contextObj1 = useContext(testContext);
|
||||||
|
contextObj2 = useContext(testContext);
|
||||||
|
__render(fiber) {
|
||||||
|
steps.push("child");
|
||||||
|
return super.__render(fiber);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
class Parent extends Component<any, any> {
|
||||||
|
static template = xml`<div><Child /></div>`;
|
||||||
|
static components = { Child };
|
||||||
|
}
|
||||||
|
const parent = new Parent();
|
||||||
|
await parent.mount(fixture);
|
||||||
|
expect(fixture.innerHTML).toBe("<div><span>12</span></div>");
|
||||||
|
expect(steps).toEqual(["child"]);
|
||||||
|
testContext.state.a = 3;
|
||||||
|
await nextTick();
|
||||||
|
expect(fixture.innerHTML).toBe("<div><span>32</span></div>");
|
||||||
|
expect(steps).toEqual(["child", "child"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parent and children subscribed to same context", async () => {
|
||||||
|
const testContext = new Context({ a: 123, b: 321 });
|
||||||
|
const steps: string[] = [];
|
||||||
|
|
||||||
|
class Child extends Component<any, any> {
|
||||||
|
static template = xml`<span><t t-esc="contextObj.a"/></span>`;
|
||||||
|
contextObj = useContext(testContext);
|
||||||
|
__render(fiber) {
|
||||||
|
steps.push("child");
|
||||||
|
return super.__render(fiber);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
class Parent extends Component<any, any> {
|
||||||
|
static template = xml`<div><Child /><t t-esc="contextObj.b"/></div>`;
|
||||||
|
static components = { Child };
|
||||||
|
contextObj = useContext(testContext);
|
||||||
|
__render(fiber) {
|
||||||
|
steps.push("parent");
|
||||||
|
return super.__render(fiber);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const parent = new Parent();
|
||||||
|
await parent.mount(fixture);
|
||||||
|
expect(fixture.innerHTML).toBe("<div><span>123</span>321</div>");
|
||||||
|
expect(steps).toEqual(["parent", "child"]);
|
||||||
|
|
||||||
|
parent.contextObj.a = 124;
|
||||||
|
await nextTick();
|
||||||
|
expect(fixture.innerHTML).toBe("<div><span>124</span>321</div>");
|
||||||
|
|
||||||
|
// we only want one render from the child here, not two
|
||||||
|
expect(steps).toEqual(["parent", "child", "parent", "child"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("destroyed component is inactive", async () => {
|
||||||
|
const testContext = new Context({ a: 123 });
|
||||||
|
const steps: string[] = [];
|
||||||
|
|
||||||
|
class Child extends Component<any, any> {
|
||||||
|
static template = xml`<span><t t-esc="contextObj.a"/></span>`;
|
||||||
|
contextObj = useContext(testContext);
|
||||||
|
__render(fiber) {
|
||||||
|
steps.push("child");
|
||||||
|
return super.__render(fiber);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
class Parent extends Component<any, any> {
|
||||||
|
static template = xml`<div><Child t-if="state.flag"/></div>`;
|
||||||
|
static components = { Child };
|
||||||
|
state = useState({ flag: true });
|
||||||
|
__render(fiber) {
|
||||||
|
steps.push("parent");
|
||||||
|
return super.__render(fiber);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const parent = new Parent();
|
||||||
|
await parent.mount(fixture);
|
||||||
|
expect(fixture.innerHTML).toBe("<div><span>123</span></div>");
|
||||||
|
expect(steps).toEqual(["parent", "child"]);
|
||||||
|
expect(testContext.subscriptions.update.length).toBe(1);
|
||||||
|
|
||||||
|
parent.state.flag = false;
|
||||||
|
await nextTick();
|
||||||
|
expect(fixture.innerHTML).toBe("<div></div>");
|
||||||
|
expect(steps).toEqual(["parent", "child", "parent"]);
|
||||||
|
// kind of whitebox...
|
||||||
|
// we make sure we do not have any pending subscriptions to the 'update'
|
||||||
|
// event
|
||||||
|
expect(testContext.subscriptions.update.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("destroyed component before being mounted is inactive", async () => {
|
||||||
|
const testContext = new Context({ a: 123 });
|
||||||
|
|
||||||
|
class Child extends Component<any, any> {
|
||||||
|
static template = xml`<span><t t-esc="contextObj.a"/></span>`;
|
||||||
|
contextObj = useContext(testContext);
|
||||||
|
willStart() {
|
||||||
|
return makeDeferred();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
class Parent extends Component<any, any> {
|
||||||
|
static template = xml`<div><Child t-if="state.flag"/></div>`;
|
||||||
|
static components = { Child };
|
||||||
|
state = useState({ flag: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const parent = new Parent();
|
||||||
|
const prom = parent.mount(fixture);
|
||||||
|
await nextTick(); // wait for Child to be instantiated
|
||||||
|
expect(testContext.subscriptions.update.length).toBe(1);
|
||||||
|
parent.state.flag = false;
|
||||||
|
await prom;
|
||||||
|
expect(fixture.innerHTML).toBe("<div></div>");
|
||||||
|
// kind of whitebox...
|
||||||
|
// we make sure we do not have any pending subscriptions to the 'update'
|
||||||
|
// event
|
||||||
|
expect(testContext.subscriptions.update.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("concurrent renderings", async () => {
|
||||||
|
const testContext = new Context({ x: { n: 1 }, key: "x" });
|
||||||
|
const def = makeDeferred();
|
||||||
|
let stateC;
|
||||||
|
class ComponentC extends Component<any, any> {
|
||||||
|
static template = xml`<span><t t-esc="context[props.key].n"/><t t-esc="state.x"/></span>`;
|
||||||
|
context = useContext(testContext);
|
||||||
|
state = useState({ x: "a" });
|
||||||
|
|
||||||
|
constructor(parent, props) {
|
||||||
|
super(parent, props);
|
||||||
|
stateC = this.state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
class ComponentB extends Component<any, any> {
|
||||||
|
static components = { ComponentC };
|
||||||
|
static template = xml`<p><ComponentC key="props.key"/></p>`;
|
||||||
|
|
||||||
|
willUpdateProps() {
|
||||||
|
return def;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
class ComponentA extends Component<any, any> {
|
||||||
|
static components = { ComponentB };
|
||||||
|
static template = xml`<div><ComponentB key="context.key"/></div>`;
|
||||||
|
context = useContext(testContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
const component = new ComponentA();
|
||||||
|
await component.mount(fixture);
|
||||||
|
|
||||||
|
expect(fixture.innerHTML).toBe("<div><p><span>1a</span></p></div>");
|
||||||
|
testContext.state.key = "y";
|
||||||
|
testContext.state.y = { n: 2 };
|
||||||
|
delete testContext.state.x;
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(fixture.innerHTML).toBe("<div><p><span>1a</span></p></div>");
|
||||||
|
stateC.x = "b";
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(fixture.innerHTML).toBe("<div><p><span>1a</span></p></div>");
|
||||||
|
def.resolve();
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(fixture.innerHTML).toBe("<div><p><span>2b</span></p></div>");
|
||||||
|
});
|
||||||
|
});
|
||||||
+11
-45
@@ -8,13 +8,11 @@ describe("observer", () => {
|
|||||||
|
|
||||||
expect(typeof obj).toBe("object");
|
expect(typeof obj).toBe("object");
|
||||||
expect(observer.revNumber(obj)).toBe(1);
|
expect(observer.revNumber(obj)).toBe(1);
|
||||||
expect(observer.deepRevNumber(obj)).toBe(1);
|
|
||||||
expect(observer.rev).toBe(1);
|
expect(observer.rev).toBe(1);
|
||||||
|
|
||||||
const obj2: any = observer.observe({ a: 1 });
|
const obj2: any = observer.observe({ a: 1 });
|
||||||
expect(observer.revNumber(obj2)).toBe(1);
|
expect(observer.revNumber(obj2)).toBe(1);
|
||||||
expect(observer.revNumber(obj)).toBe(1);
|
expect(observer.revNumber(obj)).toBe(1);
|
||||||
expect(observer.deepRevNumber(obj)).toBe(1);
|
|
||||||
expect(observer.rev).toBe(1);
|
expect(observer.rev).toBe(1);
|
||||||
|
|
||||||
obj2.a = 2;
|
obj2.a = 2;
|
||||||
@@ -33,23 +31,19 @@ describe("observer", () => {
|
|||||||
const obj: any = observer.observe({ a: null, b: undefined });
|
const obj: any = observer.observe({ a: null, b: undefined });
|
||||||
|
|
||||||
expect(observer.revNumber(obj)).toBe(1);
|
expect(observer.revNumber(obj)).toBe(1);
|
||||||
expect(observer.deepRevNumber(obj)).toBe(1);
|
|
||||||
expect(observer.rev).toBe(1);
|
expect(observer.rev).toBe(1);
|
||||||
|
|
||||||
obj.a = 3;
|
obj.a = 3;
|
||||||
expect(observer.revNumber(obj)).toBe(2);
|
expect(observer.revNumber(obj)).toBe(2);
|
||||||
expect(observer.deepRevNumber(obj)).toBe(2);
|
|
||||||
expect(observer.rev).toBe(2);
|
expect(observer.rev).toBe(2);
|
||||||
|
|
||||||
obj.b = 5;
|
obj.b = 5;
|
||||||
expect(observer.revNumber(obj)).toBe(3);
|
expect(observer.revNumber(obj)).toBe(3);
|
||||||
expect(observer.deepRevNumber(obj)).toBe(3);
|
|
||||||
expect(observer.rev).toBe(3);
|
expect(observer.rev).toBe(3);
|
||||||
|
|
||||||
obj.a = null;
|
obj.a = null;
|
||||||
obj.b = undefined;
|
obj.b = undefined;
|
||||||
expect(observer.revNumber(obj)).toBe(5);
|
expect(observer.revNumber(obj)).toBe(5);
|
||||||
expect(observer.deepRevNumber(obj)).toBe(5);
|
|
||||||
expect(observer.rev).toBe(5);
|
expect(observer.rev).toBe(5);
|
||||||
expect(obj).toEqual({
|
expect(obj).toEqual({
|
||||||
a: null,
|
a: null,
|
||||||
@@ -63,15 +57,13 @@ describe("observer", () => {
|
|||||||
const obj: any = observer.observe({ date });
|
const obj: any = observer.observe({ date });
|
||||||
|
|
||||||
expect(observer.revNumber(obj)).toBe(1);
|
expect(observer.revNumber(obj)).toBe(1);
|
||||||
expect(observer.deepRevNumber(obj)).toBe(1);
|
|
||||||
expect(observer.rev).toBe(1);
|
expect(observer.rev).toBe(1);
|
||||||
expect(typeof obj.date.getFullYear()).toBe('number');
|
expect(typeof obj.date.getFullYear()).toBe("number");
|
||||||
expect(obj.date).toBe(date);
|
expect(obj.date).toBe(date);
|
||||||
|
|
||||||
obj.date = new Date();
|
obj.date = new Date();
|
||||||
|
|
||||||
expect(observer.revNumber(obj)).toBe(2);
|
expect(observer.revNumber(obj)).toBe(2);
|
||||||
expect(observer.deepRevNumber(obj)).toBe(2);
|
|
||||||
expect(observer.rev).toBe(2);
|
expect(observer.rev).toBe(2);
|
||||||
expect(obj.date).not.toBe(date);
|
expect(obj.date).not.toBe(date);
|
||||||
});
|
});
|
||||||
@@ -82,14 +74,11 @@ describe("observer", () => {
|
|||||||
|
|
||||||
expect(Array.isArray(obj.arr)).toBe(true);
|
expect(Array.isArray(obj.arr)).toBe(true);
|
||||||
expect(observer.revNumber(obj.arr)).toBe(1);
|
expect(observer.revNumber(obj.arr)).toBe(1);
|
||||||
expect(observer.deepRevNumber(obj.arr)).toBe(1);
|
|
||||||
expect(observer.rev).toBe(1);
|
expect(observer.rev).toBe(1);
|
||||||
|
|
||||||
obj.arr[0] = "nope";
|
obj.arr[0] = "nope";
|
||||||
expect(observer.revNumber(obj.arr)).toBe(2);
|
expect(observer.revNumber(obj.arr)).toBe(2);
|
||||||
expect(observer.deepRevNumber(obj.arr)).toBe(2);
|
expect(observer.revNumber(obj)).toBe(2);
|
||||||
expect(observer.revNumber(obj)).toBe(1);
|
|
||||||
expect(observer.deepRevNumber(obj)).toBe(2);
|
|
||||||
expect(observer.rev).toBe(2);
|
expect(observer.rev).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -98,24 +87,20 @@ describe("observer", () => {
|
|||||||
const obj: any = observer.observe({ a: 1 });
|
const obj: any = observer.observe({ a: 1 });
|
||||||
|
|
||||||
expect(observer.revNumber(obj)).toBe(1);
|
expect(observer.revNumber(obj)).toBe(1);
|
||||||
expect(observer.deepRevNumber(obj)).toBe(1);
|
|
||||||
expect(observer.rev).toBe(1);
|
expect(observer.rev).toBe(1);
|
||||||
|
|
||||||
obj.a = 2;
|
obj.a = 2;
|
||||||
|
|
||||||
expect(observer.revNumber(obj)).toBe(2);
|
expect(observer.revNumber(obj)).toBe(2);
|
||||||
expect(observer.deepRevNumber(obj)).toBe(2);
|
|
||||||
expect(observer.rev).toBe(2);
|
expect(observer.rev).toBe(2);
|
||||||
|
|
||||||
// same value again
|
// same value again
|
||||||
obj.a = 2;
|
obj.a = 2;
|
||||||
expect(observer.revNumber(obj)).toBe(2);
|
expect(observer.revNumber(obj)).toBe(2);
|
||||||
expect(observer.deepRevNumber(obj)).toBe(2);
|
|
||||||
expect(observer.rev).toBe(2);
|
expect(observer.rev).toBe(2);
|
||||||
|
|
||||||
obj.a = 3;
|
obj.a = 3;
|
||||||
expect(observer.revNumber(obj)).toBe(3);
|
expect(observer.revNumber(obj)).toBe(3);
|
||||||
expect(observer.deepRevNumber(obj)).toBe(3);
|
|
||||||
expect(observer.rev).toBe(3);
|
expect(observer.rev).toBe(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -126,19 +111,16 @@ describe("observer", () => {
|
|||||||
expect(Array.isArray(arr)).toBe(true);
|
expect(Array.isArray(arr)).toBe(true);
|
||||||
expect(arr.length).toBe(0);
|
expect(arr.length).toBe(0);
|
||||||
expect(observer.revNumber(arr)).toBe(1);
|
expect(observer.revNumber(arr)).toBe(1);
|
||||||
expect(observer.deepRevNumber(arr)).toBe(1);
|
|
||||||
expect(observer.rev).toBe(1);
|
expect(observer.rev).toBe(1);
|
||||||
|
|
||||||
arr.push(1);
|
arr.push(1);
|
||||||
expect(observer.revNumber(arr)).toBe(2);
|
expect(observer.revNumber(arr)).toBe(2);
|
||||||
expect(observer.deepRevNumber(arr)).toBe(2);
|
|
||||||
expect(observer.rev).toBe(2);
|
expect(observer.rev).toBe(2);
|
||||||
expect(arr.length).toBe(1);
|
expect(arr.length).toBe(1);
|
||||||
expect(arr).toEqual([1]);
|
expect(arr).toEqual([1]);
|
||||||
|
|
||||||
arr.splice(1, 0, "hey");
|
arr.splice(1, 0, "hey");
|
||||||
expect(observer.revNumber(arr)).toBe(3);
|
expect(observer.revNumber(arr)).toBe(3);
|
||||||
expect(observer.deepRevNumber(arr)).toBe(3);
|
|
||||||
expect(observer.rev).toBe(3);
|
expect(observer.rev).toBe(3);
|
||||||
expect(arr).toEqual([1, "hey"]);
|
expect(arr).toEqual([1, "hey"]);
|
||||||
expect(arr.length).toBe(2);
|
expect(arr.length).toBe(2);
|
||||||
@@ -146,7 +128,6 @@ describe("observer", () => {
|
|||||||
arr.unshift("lindemans");
|
arr.unshift("lindemans");
|
||||||
//it generates 3 primitive operations
|
//it generates 3 primitive operations
|
||||||
expect(observer.revNumber(arr)).toBe(6);
|
expect(observer.revNumber(arr)).toBe(6);
|
||||||
expect(observer.deepRevNumber(arr)).toBe(6);
|
|
||||||
expect(observer.rev).toBe(6);
|
expect(observer.rev).toBe(6);
|
||||||
expect(arr).toEqual(["lindemans", 1, "hey"]);
|
expect(arr).toEqual(["lindemans", 1, "hey"]);
|
||||||
expect(arr.length).toBe(3);
|
expect(arr.length).toBe(3);
|
||||||
@@ -154,21 +135,18 @@ describe("observer", () => {
|
|||||||
arr.reverse();
|
arr.reverse();
|
||||||
//it generates 2 primitive operations
|
//it generates 2 primitive operations
|
||||||
expect(observer.revNumber(arr)).toBe(8);
|
expect(observer.revNumber(arr)).toBe(8);
|
||||||
expect(observer.deepRevNumber(arr)).toBe(8);
|
|
||||||
expect(observer.rev).toBe(8);
|
expect(observer.rev).toBe(8);
|
||||||
expect(arr).toEqual(["hey", 1, "lindemans"]);
|
expect(arr).toEqual(["hey", 1, "lindemans"]);
|
||||||
expect(arr.length).toBe(3);
|
expect(arr.length).toBe(3);
|
||||||
|
|
||||||
arr.pop(); // one set, one delete
|
arr.pop(); // one set, one delete
|
||||||
expect(observer.revNumber(arr)).toBe(10);
|
expect(observer.revNumber(arr)).toBe(10);
|
||||||
expect(observer.deepRevNumber(arr)).toBe(10);
|
|
||||||
expect(observer.rev).toBe(10);
|
expect(observer.rev).toBe(10);
|
||||||
expect(arr).toEqual(["hey", 1]);
|
expect(arr).toEqual(["hey", 1]);
|
||||||
expect(arr.length).toBe(2);
|
expect(arr.length).toBe(2);
|
||||||
|
|
||||||
arr.shift(); // 2 sets, 1 delete
|
arr.shift(); // 2 sets, 1 delete
|
||||||
expect(observer.revNumber(arr)).toBe(13);
|
expect(observer.revNumber(arr)).toBe(13);
|
||||||
expect(observer.deepRevNumber(arr)).toBe(13);
|
|
||||||
expect(observer.rev).toBe(13);
|
expect(observer.rev).toBe(13);
|
||||||
expect(arr).toEqual([1]);
|
expect(arr).toEqual([1]);
|
||||||
expect(arr.length).toBe(1);
|
expect(arr.length).toBe(1);
|
||||||
@@ -187,8 +165,7 @@ describe("observer", () => {
|
|||||||
arr[0].kriek = 6;
|
arr[0].kriek = 6;
|
||||||
|
|
||||||
expect(observer.rev).toBe(3);
|
expect(observer.rev).toBe(3);
|
||||||
expect(observer.revNumber(arr)).toBe(2);
|
expect(observer.revNumber(arr)).toBe(3);
|
||||||
expect(observer.deepRevNumber(arr)).toBe(3);
|
|
||||||
expect(observer.revNumber(arr[0])).toBe(3);
|
expect(observer.revNumber(arr[0])).toBe(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -238,7 +215,6 @@ describe("observer", () => {
|
|||||||
|
|
||||||
expect(observer.rev).toBe(1);
|
expect(observer.rev).toBe(1);
|
||||||
expect(observer.revNumber(state)).toBe(1);
|
expect(observer.revNumber(state)).toBe(1);
|
||||||
expect(observer.deepRevNumber(state)).toBe(1);
|
|
||||||
expect(observer.notifyCB).toBeCalledTimes(0);
|
expect(observer.notifyCB).toBeCalledTimes(0);
|
||||||
|
|
||||||
state[1] = "b";
|
state[1] = "b";
|
||||||
@@ -247,7 +223,6 @@ describe("observer", () => {
|
|||||||
|
|
||||||
expect(observer.rev).toBe(2);
|
expect(observer.rev).toBe(2);
|
||||||
expect(observer.revNumber(state)).toBe(2);
|
expect(observer.revNumber(state)).toBe(2);
|
||||||
expect(observer.deepRevNumber(state)).toBe(2);
|
|
||||||
expect(observer.notifyCB).toBeCalledTimes(1);
|
expect(observer.notifyCB).toBeCalledTimes(1);
|
||||||
|
|
||||||
expect(state).toEqual(["a", "b"]);
|
expect(state).toEqual(["a", "b"]);
|
||||||
@@ -259,13 +234,11 @@ describe("observer", () => {
|
|||||||
|
|
||||||
expect(observer.rev).toBe(1);
|
expect(observer.rev).toBe(1);
|
||||||
expect(observer.revNumber(state.arr)).toBe(1);
|
expect(observer.revNumber(state.arr)).toBe(1);
|
||||||
expect(observer.deepRevNumber(state.arr)).toBe(1);
|
|
||||||
expect(state.arr.length).toBe(0);
|
expect(state.arr.length).toBe(0);
|
||||||
|
|
||||||
state.arr.push(1);
|
state.arr.push(1);
|
||||||
expect(observer.rev).toBe(2);
|
expect(observer.rev).toBe(2);
|
||||||
expect(observer.revNumber(state.arr)).toBe(2);
|
expect(observer.revNumber(state.arr)).toBe(2);
|
||||||
expect(observer.deepRevNumber(state.arr)).toBe(2);
|
|
||||||
expect(state.arr.length).toBe(1);
|
expect(state.arr.length).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -280,7 +253,7 @@ describe("observer", () => {
|
|||||||
|
|
||||||
state.arr[0].something = 2;
|
state.arr[0].something = 2;
|
||||||
expect(observer.rev).toBe(2);
|
expect(observer.rev).toBe(2);
|
||||||
expect(observer.revNumber(state.arr)).toBe(1);
|
expect(observer.revNumber(state.arr)).toBe(2);
|
||||||
expect(observer.revNumber(state.arr[0])).toBe(2);
|
expect(observer.revNumber(state.arr[0])).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -294,7 +267,7 @@ describe("observer", () => {
|
|||||||
|
|
||||||
state.a.b = 2;
|
state.a.b = 2;
|
||||||
expect(observer.rev).toBe(2);
|
expect(observer.rev).toBe(2);
|
||||||
expect(observer.revNumber(state)).toBe(1);
|
expect(observer.revNumber(state)).toBe(2);
|
||||||
expect(observer.revNumber(state.a)).toBe(2);
|
expect(observer.revNumber(state.a)).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -312,7 +285,7 @@ describe("observer", () => {
|
|||||||
expect(observer.revNumber(obj.a)).toBe(2);
|
expect(observer.revNumber(obj.a)).toBe(2);
|
||||||
obj.a.b = 3;
|
obj.a.b = 3;
|
||||||
expect(observer.rev).toBe(3);
|
expect(observer.rev).toBe(3);
|
||||||
expect(observer.revNumber(obj)).toBe(2);
|
expect(observer.revNumber(obj)).toBe(3);
|
||||||
expect(observer.revNumber(obj.a)).toBe(3);
|
expect(observer.revNumber(obj.a)).toBe(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -320,22 +293,18 @@ describe("observer", () => {
|
|||||||
const observer = new Observer();
|
const observer = new Observer();
|
||||||
const state: any = observer.observe({ o: { a: 1 }, arr: [1], n: 13 });
|
const state: any = observer.observe({ o: { a: 1 }, arr: [1], n: 13 });
|
||||||
expect(observer.revNumber(state)).toBe(1);
|
expect(observer.revNumber(state)).toBe(1);
|
||||||
expect(observer.deepRevNumber(state)).toBe(1);
|
|
||||||
|
|
||||||
state.o.a = 2;
|
state.o.a = 2;
|
||||||
expect(observer.rev).toBe(2);
|
expect(observer.rev).toBe(2);
|
||||||
expect(observer.revNumber(state)).toBe(1);
|
expect(observer.revNumber(state)).toBe(2);
|
||||||
expect(observer.deepRevNumber(state)).toBe(2);
|
|
||||||
|
|
||||||
state.arr.push(2);
|
state.arr.push(2);
|
||||||
expect(observer.rev).toBe(3);
|
expect(observer.rev).toBe(3);
|
||||||
expect(observer.revNumber(state)).toBe(1);
|
expect(observer.revNumber(state)).toBe(3);
|
||||||
expect(observer.deepRevNumber(state)).toBe(3);
|
|
||||||
|
|
||||||
state.n = 155;
|
state.n = 155;
|
||||||
expect(observer.rev).toBe(4);
|
expect(observer.rev).toBe(4);
|
||||||
expect(observer.revNumber(state)).toBe(2);
|
expect(observer.revNumber(state)).toBe(4);
|
||||||
expect(observer.deepRevNumber(state)).toBe(4);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("properly handle already observed state", () => {
|
test("properly handle already observed state", () => {
|
||||||
@@ -361,18 +330,15 @@ describe("observer", () => {
|
|||||||
const obj: any = observer.observe({});
|
const obj: any = observer.observe({});
|
||||||
|
|
||||||
expect(observer.revNumber(obj)).toBe(1);
|
expect(observer.revNumber(obj)).toBe(1);
|
||||||
expect(observer.deepRevNumber(obj)).toBe(1);
|
|
||||||
expect(observer.rev).toBe(1);
|
expect(observer.rev).toBe(1);
|
||||||
|
|
||||||
obj.aku = "always finds annoying problems";
|
obj.aku = "always finds annoying problems";
|
||||||
expect(observer.revNumber(obj)).toBe(2);
|
expect(observer.revNumber(obj)).toBe(2);
|
||||||
expect(observer.deepRevNumber(obj)).toBe(2);
|
|
||||||
expect(observer.rev).toBe(2);
|
expect(observer.rev).toBe(2);
|
||||||
|
|
||||||
obj.aku = "always finds good problems";
|
obj.aku = "always finds good problems";
|
||||||
|
|
||||||
expect(observer.revNumber(obj)).toBe(3);
|
expect(observer.revNumber(obj)).toBe(3);
|
||||||
expect(observer.deepRevNumber(obj)).toBe(3);
|
|
||||||
expect(observer.rev).toBe(3);
|
expect(observer.rev).toBe(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -416,7 +382,7 @@ describe("observer", () => {
|
|||||||
expect(observer.revNumber(obj2)).toBe(1);
|
expect(observer.revNumber(obj2)).toBe(1);
|
||||||
|
|
||||||
obj2.key = 3;
|
obj2.key = 3;
|
||||||
expect(observer.revNumber(obj1)).toBe(1);
|
expect(observer.revNumber(obj1)).toBe(2);
|
||||||
expect(observer.revNumber(obj2)).toBe(2);
|
expect(observer.revNumber(obj2)).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -442,7 +408,7 @@ describe("observer", () => {
|
|||||||
obj.a = 111;
|
obj.a = 111;
|
||||||
obj.f = 222;
|
obj.f = 222;
|
||||||
await nextMicroTick();
|
await nextMicroTick();
|
||||||
expect(observer.notifyCB).toBeCalledTimes(4);
|
expect(observer.notifyCB).toBeCalledTimes(5);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("throw error when state is mutated in object if allowMutation=false", async () => {
|
test("throw error when state is mutated in object if allowMutation=false", async () => {
|
||||||
|
|||||||
@@ -1,126 +0,0 @@
|
|||||||
/**
|
|
||||||
* Doc Link Checker
|
|
||||||
*
|
|
||||||
* We define here a test to make sure that there are no dead link in the Owl
|
|
||||||
* documentation.
|
|
||||||
*/
|
|
||||||
import * as fs from "fs";
|
|
||||||
|
|
||||||
const LINK_REGEXP = /\[([^\[]+)\]\(([^\)]+)\)/g;
|
|
||||||
const HEADING_REGEXP = /\n(#+\s*)(.*)/g;
|
|
||||||
|
|
||||||
// files to be checked
|
|
||||||
function getFiles(): string[] {
|
|
||||||
const DOCFILES = fs.readdirSync("doc").map(f => `doc/${f}`);
|
|
||||||
const MAINREADME = "README.md";
|
|
||||||
return DOCFILES.concat(MAINREADME);
|
|
||||||
}
|
|
||||||
|
|
||||||
test("All markdown links work", () => {
|
|
||||||
let linkNumber = 0;
|
|
||||||
let invalidLinkNumber = 0;
|
|
||||||
const files = getFiles();
|
|
||||||
const data = readDocData(files);
|
|
||||||
for (let file of data) {
|
|
||||||
for (let link of file.links) {
|
|
||||||
// DEBUG: uncomment next line
|
|
||||||
// console.warn(`Checking "${link.name}" in "${file.name}"`);
|
|
||||||
linkNumber++;
|
|
||||||
if (!isLinkValid(link, file, data)) {
|
|
||||||
console.warn(`Invalid Link: "${link.name}" in "${file.name}"`);
|
|
||||||
invalidLinkNumber++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
expect(invalidLinkNumber).toBe(0);
|
|
||||||
expect(linkNumber).toBeGreaterThan(10);
|
|
||||||
});
|
|
||||||
|
|
||||||
interface MarkDownLink {
|
|
||||||
name: string;
|
|
||||||
link: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MarkDownSection {
|
|
||||||
name: string;
|
|
||||||
slug: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FileData {
|
|
||||||
name: string;
|
|
||||||
links: MarkDownLink[];
|
|
||||||
sections: MarkDownSection[];
|
|
||||||
}
|
|
||||||
|
|
||||||
function isLinkValid(link: MarkDownLink, current: FileData, files: FileData[]): boolean {
|
|
||||||
const parts = link.link.split("#");
|
|
||||||
const currentParts = current.name.split("/");
|
|
||||||
const path = currentParts.length > 1 ? currentParts[0] + "/" : "";
|
|
||||||
const fullName = path + parts[0];
|
|
||||||
if (parts.length === 1) {
|
|
||||||
// no # in url
|
|
||||||
if (parts[0].endsWith(".md")) {
|
|
||||||
// it is a local md file
|
|
||||||
if (!files.find(f => f.name === fullName)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const file = parts[0] === "" ? current : files.find(f => f.name === fullName);
|
|
||||||
if (!file) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (!file.sections.find(s => s.slug === parts[1])) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
// adapted from https://medium.com/@mhagemann/the-ultimate-way-to-slugify-a-url-string-in-javascript-b8e4a0d849e1
|
|
||||||
function slugify(str) {
|
|
||||||
const a = "àáäâãåăæçèéëêǵḧìíïîḿńǹñòóöôœøṕŕßśșțùúüûǘẃẍÿź·_,:;";
|
|
||||||
const b = "aaaaaaaaceeeeghiiiimnnnooooooprssstuuuuuwxyz-----";
|
|
||||||
const p = new RegExp(a.split("").join("|"), "g");
|
|
||||||
return str
|
|
||||||
.toString()
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/\//g, "") // remove /
|
|
||||||
.replace(/\s+/g, "-") // Replace spaces with -
|
|
||||||
.replace(p, c => b.charAt(a.indexOf(c))) // Replace special characters
|
|
||||||
.replace(/&/g, "-and-") // Replace & with ‘and’
|
|
||||||
.replace(/[^\w\-]+/g, "") // Remove all non-word characters
|
|
||||||
.replace(/\-\-+/g, "-") // Replace multiple - with single -
|
|
||||||
.replace(/^-+/, "") // Trim - from start of text
|
|
||||||
.replace(/-+$/, ""); // Trim - from end of text
|
|
||||||
}
|
|
||||||
|
|
||||||
function readDocData(files: string[]): FileData[] {
|
|
||||||
const result: FileData[] = [];
|
|
||||||
|
|
||||||
for (let file of files) {
|
|
||||||
const fileData: FileData = {
|
|
||||||
name: file,
|
|
||||||
links: [],
|
|
||||||
sections: []
|
|
||||||
};
|
|
||||||
const content = fs.readFileSync(file, { encoding: "utf8" });
|
|
||||||
let m;
|
|
||||||
// get links info
|
|
||||||
do {
|
|
||||||
m = LINK_REGEXP.exec(content);
|
|
||||||
if (m) {
|
|
||||||
fileData.links.push({ name: m[0], link: m[2] });
|
|
||||||
}
|
|
||||||
} while (m);
|
|
||||||
// get sections info
|
|
||||||
do {
|
|
||||||
m = HEADING_REGEXP.exec(content);
|
|
||||||
if (m) {
|
|
||||||
fileData.sections.push({ name: m[0], slug: slugify(m[2]) });
|
|
||||||
}
|
|
||||||
} while (m);
|
|
||||||
|
|
||||||
result.push(fileData);
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
+48
-6
@@ -1,16 +1,48 @@
|
|||||||
import { Env } from "../src/component/component";
|
import { Env } from "../src/component/component";
|
||||||
|
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 { patch } from "../src/vdom";
|
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";
|
||||||
|
|
||||||
|
// modifies scheduler to make it faster to test components
|
||||||
|
scheduler.requestAnimationFrame = function(callback: FrameRequestCallback) {
|
||||||
|
setTimeout(callback, 1);
|
||||||
|
return 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Some static cleanup
|
||||||
|
let nextSlotId;
|
||||||
|
let slots;
|
||||||
|
let nextId;
|
||||||
|
let TEMPLATES;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
nextSlotId = QWeb.nextSlotId;
|
||||||
|
CompilationContext.nextID = 1;
|
||||||
|
slots = Object.assign({}, QWeb.slots);
|
||||||
|
nextId = QWeb.nextId;
|
||||||
|
TEMPLATES = Object.assign({}, QWeb.TEMPLATES);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
QWeb.nextSlotId = nextSlotId;
|
||||||
|
QWeb.slots = slots;
|
||||||
|
QWeb.nextId = nextId;
|
||||||
|
QWeb.TEMPLATES = TEMPLATES;
|
||||||
|
});
|
||||||
|
|
||||||
|
// helpers
|
||||||
export function nextMicroTick(): Promise<void> {
|
export function nextMicroTick(): Promise<void> {
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function nextTick(): Promise<void> {
|
export async function nextTick(): Promise<void> {
|
||||||
return new Promise(resolve => setTimeout(resolve));
|
return new Promise(function(resolve) {
|
||||||
|
setTimeout(() => scheduler.requestAnimationFrame(() => resolve()));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function makeTestFixture() {
|
export function makeTestFixture() {
|
||||||
@@ -55,6 +87,11 @@ 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__ = { isMounted: true };
|
||||||
|
}
|
||||||
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
|
||||||
@@ -78,11 +115,16 @@ export function renderToDOM(
|
|||||||
* Note that the result of renderToString is guaranteed to be the same as the
|
* Note that the result of renderToString is guaranteed to be the same as the
|
||||||
* one from QWeb.
|
* one from QWeb.
|
||||||
*/
|
*/
|
||||||
export function renderToString(qweb: QWeb, t: string, context: EvalContext = {}): string {
|
export function renderToString(
|
||||||
const node = renderToDOM(qweb, t, context);
|
qweb: QWeb,
|
||||||
|
t: string,
|
||||||
|
context: EvalContext = {},
|
||||||
|
extra?: any
|
||||||
|
): string {
|
||||||
|
const node = renderToDOM(qweb, t, context, extra);
|
||||||
const result = node instanceof Text ? node.textContent! : node.outerHTML;
|
const result = node instanceof Text ? node.textContent! : node.outerHTML;
|
||||||
if (result !== qweb.renderToString(t, context)) {
|
if (result !== qweb.renderToString(t, context, extra)) {
|
||||||
throw new Error("HTML string returned by renderToString helper does not match QWeb render");
|
throw new Error("HTML string returned by renderToString helper does not match QWeb render");
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,595 @@
|
|||||||
|
import { makeTestEnv, makeTestFixture, nextTick } from "./helpers";
|
||||||
|
import { Component, Env } from "../src/component/component";
|
||||||
|
import {
|
||||||
|
useState,
|
||||||
|
onMounted,
|
||||||
|
onWillUnmount,
|
||||||
|
useRef,
|
||||||
|
onPatched,
|
||||||
|
onWillPatch,
|
||||||
|
onWillStart,
|
||||||
|
onWillUpdateProps,
|
||||||
|
useSubEnv
|
||||||
|
} from "../src/hooks";
|
||||||
|
import { xml } from "../src/tags";
|
||||||
|
|
||||||
|
//------------------------------------------------------------------------------
|
||||||
|
// 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();
|
||||||
|
});
|
||||||
|
|
||||||
|
//------------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
//------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("hooks", () => {
|
||||||
|
test("can use a state hook", async () => {
|
||||||
|
class Counter extends Component<any, any> {
|
||||||
|
static template = xml`<div><t t-esc="counter.value"/></div>`;
|
||||||
|
counter = useState({ value: 42 });
|
||||||
|
}
|
||||||
|
const counter = new Counter();
|
||||||
|
await counter.mount(fixture);
|
||||||
|
expect(fixture.innerHTML).toBe("<div>42</div>");
|
||||||
|
counter.counter.value = 3;
|
||||||
|
await nextTick();
|
||||||
|
expect(fixture.innerHTML).toBe("<div>3</div>");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("can use onMounted, onWillUnmount", async () => {
|
||||||
|
const steps: string[] = [];
|
||||||
|
function useMyHook() {
|
||||||
|
onMounted(() => {
|
||||||
|
steps.push("mounted");
|
||||||
|
});
|
||||||
|
onWillUnmount(() => {
|
||||||
|
steps.push("willunmount");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
class MyComponent extends Component<any, any> {
|
||||||
|
static template = xml`<div>hey</div>`;
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
useMyHook();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const component = new MyComponent();
|
||||||
|
await component.mount(fixture);
|
||||||
|
expect(component).not.toHaveProperty("mounted");
|
||||||
|
expect(component).not.toHaveProperty("willUnmount");
|
||||||
|
expect(fixture.innerHTML).toBe("<div>hey</div>");
|
||||||
|
expect(steps).toEqual(["mounted"]);
|
||||||
|
component.unmount();
|
||||||
|
expect(fixture.innerHTML).toBe("");
|
||||||
|
expect(steps).toEqual(["mounted", "willunmount"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("can use onMounted, onWillUnmount, part 2", async () => {
|
||||||
|
const steps: string[] = [];
|
||||||
|
function useMyHook() {
|
||||||
|
onMounted(() => {
|
||||||
|
steps.push("mounted");
|
||||||
|
});
|
||||||
|
onWillUnmount(() => {
|
||||||
|
steps.push("willunmount");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
class MyComponent extends Component<any, any> {
|
||||||
|
static template = xml`<div>hey</div>`;
|
||||||
|
constructor(parent, props) {
|
||||||
|
super(parent, props);
|
||||||
|
useMyHook();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Parent extends Component<any, any> {
|
||||||
|
static template = xml`<div><MyComponent t-if="state.flag"/></div>`;
|
||||||
|
static components = { MyComponent };
|
||||||
|
state = useState({ flag: true });
|
||||||
|
}
|
||||||
|
const parent = new Parent();
|
||||||
|
await parent.mount(fixture);
|
||||||
|
expect(fixture.innerHTML).toBe("<div><div>hey</div></div>");
|
||||||
|
expect(steps).toEqual(["mounted"]);
|
||||||
|
|
||||||
|
parent.state.flag = false;
|
||||||
|
await nextTick();
|
||||||
|
expect(fixture.innerHTML).toBe("<div></div>");
|
||||||
|
expect(steps).toEqual(["mounted", "willunmount"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("mounted, willUnmount, onMounted, onWillUnmount order", async () => {
|
||||||
|
const steps: string[] = [];
|
||||||
|
function useMyHook() {
|
||||||
|
onMounted(() => {
|
||||||
|
steps.push("hook:mounted");
|
||||||
|
});
|
||||||
|
onWillUnmount(() => {
|
||||||
|
steps.push("hook:willunmount");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
class MyComponent extends Component<any, any> {
|
||||||
|
static template = xml`<div>hey</div>`;
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
useMyHook();
|
||||||
|
}
|
||||||
|
mounted() {
|
||||||
|
steps.push("comp:mounted");
|
||||||
|
}
|
||||||
|
willUnmount() {
|
||||||
|
steps.push("comp:willunmount");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const component = new MyComponent();
|
||||||
|
await component.mount(fixture);
|
||||||
|
expect(fixture.innerHTML).toBe("<div>hey</div>");
|
||||||
|
component.unmount();
|
||||||
|
expect(fixture.innerHTML).toBe("");
|
||||||
|
expect(steps).toEqual(["comp:mounted", "hook:mounted", "hook:willunmount", "comp:willunmount"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("mounted, willUnmount, onMounted, onWillUnmount order, part 2", async () => {
|
||||||
|
const steps: string[] = [];
|
||||||
|
function useMyHook() {
|
||||||
|
onMounted(() => {
|
||||||
|
steps.push("hook:mounted");
|
||||||
|
});
|
||||||
|
onWillUnmount(() => {
|
||||||
|
steps.push("hook:willunmount");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
class MyComponent extends Component<any, any> {
|
||||||
|
static template = xml`<div>hey</div>`;
|
||||||
|
constructor(parent, props) {
|
||||||
|
super(parent, props);
|
||||||
|
useMyHook();
|
||||||
|
}
|
||||||
|
mounted() {
|
||||||
|
steps.push("comp:mounted");
|
||||||
|
}
|
||||||
|
willUnmount() {
|
||||||
|
steps.push("comp:willunmount");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Parent extends Component<any, any> {
|
||||||
|
static template = xml`<div><MyComponent t-if="state.flag"/></div>`;
|
||||||
|
static components = { MyComponent };
|
||||||
|
state = useState({ flag: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const parent = new Parent();
|
||||||
|
await parent.mount(fixture);
|
||||||
|
expect(fixture.innerHTML).toBe("<div><div>hey</div></div>");
|
||||||
|
parent.state.flag = false;
|
||||||
|
await nextTick();
|
||||||
|
expect(fixture.innerHTML).toBe("<div></div>");
|
||||||
|
|
||||||
|
expect(steps).toEqual(["comp:mounted", "hook:mounted", "hook:willunmount", "comp:willunmount"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("two different call to mounted/willunmount should work", async () => {
|
||||||
|
const steps: string[] = [];
|
||||||
|
function useMyHook(i) {
|
||||||
|
onMounted(() => {
|
||||||
|
steps.push("hook:mounted" + i);
|
||||||
|
});
|
||||||
|
onWillUnmount(() => {
|
||||||
|
steps.push("hook:willunmount" + i);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
class MyComponent extends Component<any, any> {
|
||||||
|
static template = xml`<div>hey</div>`;
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
useMyHook(1);
|
||||||
|
useMyHook(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const component = new MyComponent();
|
||||||
|
await component.mount(fixture);
|
||||||
|
expect(fixture.innerHTML).toBe("<div>hey</div>");
|
||||||
|
component.unmount();
|
||||||
|
expect(fixture.innerHTML).toBe("");
|
||||||
|
expect(steps).toEqual([
|
||||||
|
"hook:mounted1",
|
||||||
|
"hook:mounted2",
|
||||||
|
"hook:willunmount2",
|
||||||
|
"hook:willunmount1"
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("useRef hook", async () => {
|
||||||
|
class Counter extends Component<any, any> {
|
||||||
|
static template = xml`<div><button t-ref="button"><t t-esc="value"/></button></div>`;
|
||||||
|
button = useRef("button");
|
||||||
|
value = 0;
|
||||||
|
increment() {
|
||||||
|
this.value++;
|
||||||
|
(this.button.el as HTMLButtonElement).innerHTML = String(this.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const counter = new Counter();
|
||||||
|
expect(counter.button.el).toBe(null);
|
||||||
|
await counter.mount(fixture);
|
||||||
|
expect(fixture.innerHTML).toBe("<div><button>0</button></div>");
|
||||||
|
expect(counter.button.el).not.toBe(null);
|
||||||
|
expect(counter.button.el).toBe(fixture.querySelector("button"));
|
||||||
|
counter.increment();
|
||||||
|
await nextTick();
|
||||||
|
expect(fixture.innerHTML).toBe("<div><button>1</button></div>");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("useRef hook is null if ref is removed ", async () => {
|
||||||
|
expect.assertions(4);
|
||||||
|
class TestRef extends Component<any, any> {
|
||||||
|
static template = xml`<div><span t-if="state.flag" t-ref="span">owl</span></div>`;
|
||||||
|
spanRef = useRef("span");
|
||||||
|
state = useState({ flag: true });
|
||||||
|
willPatch() {
|
||||||
|
expect(this.spanRef.el).not.toBeNull();
|
||||||
|
}
|
||||||
|
patched() {
|
||||||
|
expect(this.spanRef.el).toBeNull();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const component = new TestRef();
|
||||||
|
await component.mount(fixture);
|
||||||
|
expect(fixture.innerHTML).toBe("<div><span>owl</span></div>");
|
||||||
|
component.state.flag = false;
|
||||||
|
await nextTick();
|
||||||
|
expect(fixture.innerHTML).toBe("<div></div>");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("t-refs on widget are components", async () => {
|
||||||
|
class WidgetB extends Component<any, any> {
|
||||||
|
static template = xml`<div>b</div>`;
|
||||||
|
}
|
||||||
|
class WidgetC extends Component<any, any> {
|
||||||
|
static template = xml`<div class="outer-div">Hello<WidgetB t-ref="mywidgetb" /></div>`;
|
||||||
|
static components = { WidgetB };
|
||||||
|
ref = useRef("mywidgetb");
|
||||||
|
}
|
||||||
|
|
||||||
|
const widget = new WidgetC();
|
||||||
|
expect(widget.ref.comp).toBe(null);
|
||||||
|
expect(widget.ref.el).toBe(null);
|
||||||
|
await widget.mount(fixture);
|
||||||
|
expect(widget.ref.comp).toBeInstanceOf(WidgetB);
|
||||||
|
expect(widget.ref.el).toEqual(fixture.querySelector(".outer-div > div"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("t-refs are bound at proper timing", async () => {
|
||||||
|
expect.assertions(2);
|
||||||
|
class Widget extends Component<any, any> {
|
||||||
|
static template = xml`<div>widget</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ParentWidget extends Component<any, any> {
|
||||||
|
static template = xml`
|
||||||
|
<div>
|
||||||
|
<t t-foreach="state.list" t-as="elem" t-ref="child" t-key="elem" t-component="Widget"/>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
static components = { Widget };
|
||||||
|
state = useState({ list: <any>[] });
|
||||||
|
child = useRef("child");
|
||||||
|
willPatch() {
|
||||||
|
expect(this.child.comp).toBeNull();
|
||||||
|
}
|
||||||
|
patched() {
|
||||||
|
expect(this.child.comp).not.toBeNull();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const parent = new ParentWidget();
|
||||||
|
await parent.mount(fixture);
|
||||||
|
parent.state.list.push(1);
|
||||||
|
await nextTick();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("t-refs are bound at proper timing (2)", async () => {
|
||||||
|
expect.assertions(10);
|
||||||
|
class Widget extends Component<any, any> {
|
||||||
|
static template = xml`<div>widget</div>`;
|
||||||
|
}
|
||||||
|
class ParentWidget extends Component<any, any> {
|
||||||
|
static template = xml`
|
||||||
|
<div>
|
||||||
|
<t t-if="state.child1" t-ref="child1" t-component="Widget"/>
|
||||||
|
<t t-if="state.child2" t-ref="child2" t-component="Widget"/>
|
||||||
|
</div>`;
|
||||||
|
static components = { Widget };
|
||||||
|
state = useState({ child1: true, child2: false });
|
||||||
|
child1 = useRef("child1");
|
||||||
|
child2 = useRef("child2");
|
||||||
|
count = 0;
|
||||||
|
mounted() {
|
||||||
|
expect(this.child1.comp).toBeDefined();
|
||||||
|
expect(this.child2.comp).toBeNull();
|
||||||
|
}
|
||||||
|
willPatch() {
|
||||||
|
if (this.count === 0) {
|
||||||
|
expect(this.child1.comp).toBeDefined();
|
||||||
|
expect(this.child2.comp).toBeNull();
|
||||||
|
}
|
||||||
|
if (this.count === 1) {
|
||||||
|
expect(this.child1.comp).toBeDefined();
|
||||||
|
expect(this.child2.comp).toBeDefined();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
patched() {
|
||||||
|
if (this.count === 0) {
|
||||||
|
expect(this.child1.comp).toBeDefined();
|
||||||
|
expect(this.child2.comp).toBeDefined();
|
||||||
|
}
|
||||||
|
if (this.count === 1) {
|
||||||
|
expect(this.child1.comp).toBeNull();
|
||||||
|
expect(this.child2.comp).toBeDefined();
|
||||||
|
}
|
||||||
|
this.count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const parent = new ParentWidget();
|
||||||
|
await parent.mount(fixture);
|
||||||
|
parent.state.child2 = true;
|
||||||
|
await nextTick();
|
||||||
|
parent.state.child1 = false;
|
||||||
|
await nextTick();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("can use onPatched, onWillPatch", async () => {
|
||||||
|
const steps: string[] = [];
|
||||||
|
function useMyHook() {
|
||||||
|
onWillPatch(() => {
|
||||||
|
steps.push("willPatch");
|
||||||
|
});
|
||||||
|
onPatched(() => {
|
||||||
|
steps.push("patched");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class MyComponent extends Component<any, any> {
|
||||||
|
static template = xml`<div><t t-if="state.flag">hey</t></div>`;
|
||||||
|
state = useState({ flag: true });
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
useMyHook();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const component = new MyComponent();
|
||||||
|
await component.mount(fixture);
|
||||||
|
expect(component).not.toHaveProperty("patched");
|
||||||
|
expect(component).not.toHaveProperty("willPatch");
|
||||||
|
expect(steps).toEqual([]);
|
||||||
|
|
||||||
|
expect(fixture.innerHTML).toBe("<div>hey</div>");
|
||||||
|
component.state.flag = false;
|
||||||
|
await nextTick();
|
||||||
|
expect(fixture.innerHTML).toBe("<div></div>");
|
||||||
|
|
||||||
|
expect(steps).toEqual(["willPatch", "patched"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("patched, willPatch, onPatched, onWillPatch order", async () => {
|
||||||
|
const steps: string[] = [];
|
||||||
|
function useMyHook() {
|
||||||
|
onPatched(() => {
|
||||||
|
steps.push("hook:patched");
|
||||||
|
});
|
||||||
|
onWillPatch(() => {
|
||||||
|
steps.push("hook:willPatch");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
class MyComponent extends Component<any, any> {
|
||||||
|
static template = xml`<div><t t-if="state.flag">hey</t></div>`;
|
||||||
|
state = useState({ flag: true });
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
useMyHook();
|
||||||
|
}
|
||||||
|
willPatch() {
|
||||||
|
steps.push("comp:willPatch");
|
||||||
|
}
|
||||||
|
patched() {
|
||||||
|
steps.push("comp:patched");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const component = new MyComponent();
|
||||||
|
await component.mount(fixture);
|
||||||
|
expect(fixture.innerHTML).toBe("<div>hey</div>");
|
||||||
|
component.state.flag = false;
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(steps).toEqual(["hook:willPatch", "comp:willPatch", "comp:patched", "hook:patched"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("two different call to willPatch/patched should work", async () => {
|
||||||
|
const steps: string[] = [];
|
||||||
|
function useMyHook(i) {
|
||||||
|
onPatched(() => {
|
||||||
|
steps.push("hook:patched" + i);
|
||||||
|
});
|
||||||
|
onWillPatch(() => {
|
||||||
|
steps.push("hook:willPatch" + i);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
class MyComponent extends Component<any, any> {
|
||||||
|
static template = xml`<div>hey<t t-esc="state.value"/></div>`;
|
||||||
|
state = useState({ value: 1 });
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
useMyHook(1);
|
||||||
|
useMyHook(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const component = new MyComponent();
|
||||||
|
await component.mount(fixture);
|
||||||
|
expect(fixture.innerHTML).toBe("<div>hey1</div>");
|
||||||
|
component.state.value++;
|
||||||
|
await nextTick();
|
||||||
|
expect(fixture.innerHTML).toBe("<div>hey2</div>");
|
||||||
|
|
||||||
|
expect(steps).toEqual(["hook:willPatch2", "hook:willPatch1", "hook:patched1", "hook:patched2"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("autofocus hook", () => {
|
||||||
|
function useAutofocus(name) {
|
||||||
|
let ref = useRef(name);
|
||||||
|
let isInDom = false;
|
||||||
|
function updateFocus() {
|
||||||
|
if (!isInDom && ref.el) {
|
||||||
|
isInDom = true;
|
||||||
|
ref.el.focus();
|
||||||
|
} else if (isInDom && !ref.el) {
|
||||||
|
isInDom = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onPatched(updateFocus);
|
||||||
|
onMounted(updateFocus);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("simple input", async () => {
|
||||||
|
class SomeComponent extends Component<any, any> {
|
||||||
|
static template = xml`
|
||||||
|
<div>
|
||||||
|
<input t-ref="input1"/>
|
||||||
|
<input t-ref="input2"/>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
useAutofocus("input2");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const component = new SomeComponent();
|
||||||
|
await component.mount(fixture);
|
||||||
|
expect(fixture.innerHTML).toBe("<div><input><input></div>");
|
||||||
|
const input2 = fixture.querySelectorAll("input")[1];
|
||||||
|
expect(input2).toBe(document.activeElement);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("input in a t-if", async () => {
|
||||||
|
class SomeComponent extends Component<any, any> {
|
||||||
|
static template = xml`
|
||||||
|
<div>
|
||||||
|
<input t-ref="input1"/>
|
||||||
|
<t t-if="state.flag"><input t-ref="input2"/></t>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
state = useState({ flag: false });
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
useAutofocus("input2");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const component = new SomeComponent();
|
||||||
|
await component.mount(fixture);
|
||||||
|
expect(fixture.innerHTML).toBe("<div><input></div>");
|
||||||
|
expect(document.activeElement).toBe(document.body);
|
||||||
|
|
||||||
|
component.state.flag = true;
|
||||||
|
await nextTick();
|
||||||
|
const input2 = fixture.querySelectorAll("input")[1];
|
||||||
|
expect(input2).toBe(document.activeElement);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("can use sub env", async () => {
|
||||||
|
class TestComponent extends Component<any, any> {
|
||||||
|
static template = xml`<div><t t-esc="env.val"/></div>`;
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
useSubEnv({ val: 3 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const component = new TestComponent();
|
||||||
|
await component.mount(fixture);
|
||||||
|
expect(fixture.innerHTML).toBe("<div>3</div>");
|
||||||
|
expect(env).not.toHaveProperty("val");
|
||||||
|
expect(component.env).toHaveProperty("val");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parent and child env", async () => {
|
||||||
|
class Child extends Component<any, any> {
|
||||||
|
static template = xml`<div><t t-esc="env.val"/></div>`;
|
||||||
|
constructor(parent, props) {
|
||||||
|
super(parent, props);
|
||||||
|
useSubEnv({ val: 5 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Parent extends Component<any, any> {
|
||||||
|
static template = xml`<div><t t-esc="env.val"/><Child/></div>`;
|
||||||
|
static components = { Child };
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
useSubEnv({ val: 3 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const component = new Parent();
|
||||||
|
await component.mount(fixture);
|
||||||
|
expect(fixture.innerHTML).toBe("<div>3<div>5</div></div>");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("can use onWillStart, onWillUpdateProps", async () => {
|
||||||
|
const steps: string[] = [];
|
||||||
|
function useMyHook() {
|
||||||
|
onWillStart(() => {
|
||||||
|
steps.push("onWillStart");
|
||||||
|
});
|
||||||
|
onWillUpdateProps(nextProps => {
|
||||||
|
expect(nextProps).toEqual({ value: 2 });
|
||||||
|
steps.push("onWillUpdateProps");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
class MyComponent extends Component<any, any> {
|
||||||
|
static template = xml`<span><t t-esc="props.value"/></span>`;
|
||||||
|
constructor(parent, props) {
|
||||||
|
super(parent, props);
|
||||||
|
useMyHook();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
class App extends Component<any, any> {
|
||||||
|
static template = xml`<div><MyComponent value="state.value"/></div>`;
|
||||||
|
static components = { MyComponent };
|
||||||
|
state = useState({ value: 1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = new App();
|
||||||
|
await app.mount(fixture);
|
||||||
|
expect(app).not.toHaveProperty("willStart");
|
||||||
|
expect(app).not.toHaveProperty("willUpdateProps");
|
||||||
|
expect(fixture.innerHTML).toBe("<div><span>1</span></div>");
|
||||||
|
expect(steps).toEqual(["onWillStart"]);
|
||||||
|
|
||||||
|
app.state.value = 2;
|
||||||
|
await nextTick();
|
||||||
|
expect(fixture.innerHTML).toBe("<div><span>2</span></div>");
|
||||||
|
expect(steps).toEqual(["onWillStart", "onWillUpdateProps"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import { AsyncRoot } from "../../src/misc/async_root";
|
||||||
|
import { useState } from "../../src/hooks";
|
||||||
|
import { xml } from "../../src/tags";
|
||||||
|
import { makeDeferred, makeTestFixture, makeTestEnv, nextTick } from "../helpers";
|
||||||
|
import { Component } from "../../src/component/component";
|
||||||
|
|
||||||
|
//------------------------------------------------------------------------------
|
||||||
|
// 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.
|
||||||
|
// - a test env, necessary to create components, that is set as env
|
||||||
|
|
||||||
|
let fixture: HTMLElement;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fixture = makeTestFixture();
|
||||||
|
Component.env = makeTestEnv();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
fixture.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Asyncroot", () => {
|
||||||
|
test("delayed component with AsyncRoot component", async () => {
|
||||||
|
let def;
|
||||||
|
class Child extends Component<any, any> {
|
||||||
|
static template = xml`<span><t t-esc="props.val"/></span>`;
|
||||||
|
}
|
||||||
|
class AsyncChild extends Child {
|
||||||
|
willUpdateProps() {
|
||||||
|
return def;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
class Parent extends Component<any, any> {
|
||||||
|
static template = xml`
|
||||||
|
<div>
|
||||||
|
<button t-on-click="updateApp">Update App State</button>
|
||||||
|
<div class="children">
|
||||||
|
<Child val="state.val"/>
|
||||||
|
<AsyncRoot>
|
||||||
|
<AsyncChild val="state.val"/>
|
||||||
|
</AsyncRoot>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
static components = { Child, AsyncChild, AsyncRoot };
|
||||||
|
state = useState({ val: 0 });
|
||||||
|
|
||||||
|
updateApp() {
|
||||||
|
this.state.val++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const parent = new Parent();
|
||||||
|
await parent.mount(fixture);
|
||||||
|
|
||||||
|
expect(fixture.querySelector(".children")!.innerHTML).toBe("<span>0</span><span>0</span>");
|
||||||
|
|
||||||
|
// click on button to increment Parent counter
|
||||||
|
def = makeDeferred();
|
||||||
|
fixture.querySelector("button")!.click();
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(fixture.querySelector(".children")!.innerHTML).toBe("<span>1</span><span>0</span>");
|
||||||
|
|
||||||
|
def.resolve();
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(fixture.querySelector(".children")!.innerHTML).toBe("<span>1</span><span>1</span>");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("fast component with AsyncRoot", async () => {
|
||||||
|
let def;
|
||||||
|
class Child extends Component<any, any> {
|
||||||
|
static template = xml`<span><t t-esc="props.val"/></span>`;
|
||||||
|
}
|
||||||
|
class AsyncChild extends Child {
|
||||||
|
willUpdateProps() {
|
||||||
|
return def;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Parent extends Component<any, any> {
|
||||||
|
static template = xml`
|
||||||
|
<div>
|
||||||
|
<button t-on-click="updateApp">Update App State</button>
|
||||||
|
<div class="children">
|
||||||
|
<AsyncRoot>
|
||||||
|
<Child val="state.val"/>
|
||||||
|
</AsyncRoot>
|
||||||
|
<AsyncChild val="state.val"/>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
static components = { Child, AsyncChild, AsyncRoot };
|
||||||
|
state = useState({ val: 0 });
|
||||||
|
|
||||||
|
updateApp() {
|
||||||
|
this.state.val++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const parent = new Parent();
|
||||||
|
await parent.mount(fixture);
|
||||||
|
|
||||||
|
expect(fixture.querySelector(".children")!.innerHTML).toBe("<span>0</span><span>0</span>");
|
||||||
|
|
||||||
|
// click on button to increment Parent counter
|
||||||
|
def = makeDeferred();
|
||||||
|
fixture.querySelector("button")!.click();
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(fixture.querySelector(".children")!.innerHTML).toBe("<span>1</span><span>0</span>");
|
||||||
|
|
||||||
|
def.resolve();
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(fixture.querySelector(".children")!.innerHTML).toBe("<span>1</span><span>1</span>");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("asyncroot component: mixed re-renderings", async () => {
|
||||||
|
let def;
|
||||||
|
class Child extends Component<any, any> {
|
||||||
|
static template = xml`
|
||||||
|
<span t-on-click="increment">
|
||||||
|
<t t-esc="state.val"/>/<t t-esc="props.val"/>
|
||||||
|
</span>`;
|
||||||
|
state = useState({ val: 0 });
|
||||||
|
|
||||||
|
increment() {
|
||||||
|
this.state.val++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
class AsyncChild extends Child {
|
||||||
|
willUpdateProps() {
|
||||||
|
return def;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
class Parent extends Component<any, any> {
|
||||||
|
static template = xml`
|
||||||
|
<div>
|
||||||
|
<button t-on-click="updateApp">Update App State</button>
|
||||||
|
<div class="children">
|
||||||
|
<Child val="state.val"/>
|
||||||
|
<AsyncRoot>
|
||||||
|
<AsyncChild val="state.val"/>
|
||||||
|
</AsyncRoot>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
static components = { Child, AsyncChild, AsyncRoot };
|
||||||
|
state = useState({ val: 0 });
|
||||||
|
|
||||||
|
updateApp() {
|
||||||
|
this.state.val++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const parent = new Parent();
|
||||||
|
await parent.mount(fixture);
|
||||||
|
|
||||||
|
expect(fixture.querySelector(".children")!.innerHTML).toBe("<span>0/0</span><span>0/0</span>");
|
||||||
|
|
||||||
|
// click on button to increment Parent counter
|
||||||
|
def = makeDeferred();
|
||||||
|
fixture.querySelector("button")!.click();
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(fixture.querySelector(".children")!.innerHTML).toBe("<span>0/1</span><span>0/0</span>");
|
||||||
|
|
||||||
|
// click on each Child to increment their local counter
|
||||||
|
const children = parent.el!.querySelectorAll("span");
|
||||||
|
children[0]!.click();
|
||||||
|
await nextTick();
|
||||||
|
expect(fixture.querySelector(".children")!.innerHTML).toBe("<span>1/1</span><span>0/0</span>");
|
||||||
|
|
||||||
|
children[1]!.click();
|
||||||
|
await nextTick();
|
||||||
|
expect(fixture.querySelector(".children")!.innerHTML).toBe("<span>1/1</span><span>0/0</span>");
|
||||||
|
|
||||||
|
// finalize first re-rendering (coming from the props update)
|
||||||
|
def.resolve();
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(fixture.querySelector(".children")!.innerHTML).toBe("<span>1/1</span><span>1/1</span>");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,890 @@
|
|||||||
|
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<any, any> {
|
||||||
|
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<any, any> {
|
||||||
|
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<any, any> {
|
||||||
|
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<any, any> {
|
||||||
|
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<any, any> {
|
||||||
|
static template = xml`<div><t t-esc="props.val"/></div>`;
|
||||||
|
}
|
||||||
|
class Parent extends Component<any, any> {
|
||||||
|
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<any, any> {
|
||||||
|
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<any, any> {
|
||||||
|
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<any, any> {
|
||||||
|
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<any, any> {
|
||||||
|
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<any, any> {
|
||||||
|
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<any, any> {
|
||||||
|
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<any, any> {
|
||||||
|
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<any, any> {
|
||||||
|
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<any, any> {
|
||||||
|
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<any, any> {
|
||||||
|
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<any, any> {
|
||||||
|
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<any, any> {
|
||||||
|
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<any, any> {
|
||||||
|
static template = xml`<span t-esc="props.error and this.will.crash" />`;
|
||||||
|
state = {};
|
||||||
|
}
|
||||||
|
class Parent extends Component<any, any> {
|
||||||
|
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();
|
||||||
|
expect(error.message).toBe("Cannot read property 'crash' of undefined");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("portal manual unmount", async () => {
|
||||||
|
class Parent extends Component<any, any> {
|
||||||
|
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<any, any> {
|
||||||
|
static template = xml`<span>gloria</span>`;
|
||||||
|
mounted() {
|
||||||
|
expect(outside.contains(this.el)).toBeTruthy();
|
||||||
|
}
|
||||||
|
willUnmount() {
|
||||||
|
expect(outside.contains(this.el)).toBeTruthy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
class Parent extends Component<any, any> {
|
||||||
|
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<any, any> {
|
||||||
|
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<any, any> | null = null;
|
||||||
|
class Child extends Component<any, any> {
|
||||||
|
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<any, any> {
|
||||||
|
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<any, any> | null = null;
|
||||||
|
class Child extends Component<any, any> {
|
||||||
|
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<any, any> {
|
||||||
|
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<any, any> | null = null;
|
||||||
|
const steps: string[] = [];
|
||||||
|
class Child extends Component<any, any> {
|
||||||
|
static template = xml`
|
||||||
|
<button>child</button>`;
|
||||||
|
|
||||||
|
constructor(parent, props) {
|
||||||
|
super(parent, props);
|
||||||
|
childInst = this;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
class Parent extends Component<any, any> {
|
||||||
|
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<any, any> | null = null;
|
||||||
|
class Child2 extends Component<any, any> {
|
||||||
|
static template = xml`<div>child2</div>`;
|
||||||
|
constructor(parent, props) {
|
||||||
|
super(parent, props);
|
||||||
|
childInst = this;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
class Child extends Component<any, any> {
|
||||||
|
static components = { Portal, Child2 };
|
||||||
|
static template = xml`
|
||||||
|
<Portal target="'#outside2'">
|
||||||
|
<Child2 />
|
||||||
|
</Portal>`;
|
||||||
|
}
|
||||||
|
class Parent extends Component<any, any> {
|
||||||
|
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<any, any> {
|
||||||
|
static template = xml`
|
||||||
|
<button>child</button>`;
|
||||||
|
}
|
||||||
|
class Parent extends Component<any, any> {
|
||||||
|
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<any, any> | null = null;
|
||||||
|
class Child2 extends Component<any, any> {
|
||||||
|
static template = xml`<div>child2</div>`;
|
||||||
|
constructor(parent, props) {
|
||||||
|
super(parent, props);
|
||||||
|
childInst = this;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
class Child extends Component<any, any> {
|
||||||
|
static components = { Portal, Child2 };
|
||||||
|
static template = xml`
|
||||||
|
<Portal target="'#outside'">
|
||||||
|
<t t-slot="default"/>
|
||||||
|
</Portal>`;
|
||||||
|
}
|
||||||
|
class Parent extends Component<any, any> {
|
||||||
|
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<any, any> {
|
||||||
|
static template = xml`
|
||||||
|
<input id="target-me" t-att-placeholder="props.val"/>`;
|
||||||
|
}
|
||||||
|
class Parent extends Component<any, any> {
|
||||||
|
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
+649
-17
@@ -1,5 +1,6 @@
|
|||||||
import { QWeb } from "../../src/qweb/index";
|
import { QWeb } from "../../src/qweb/index";
|
||||||
import { normalize, renderToDOM, renderToString, trim, nextTick } from "../helpers";
|
import { nextTick, normalize, renderToDOM, renderToString, trim } from "../helpers";
|
||||||
|
import { patch } from "../../src/vdom";
|
||||||
|
|
||||||
//------------------------------------------------------------------------------
|
//------------------------------------------------------------------------------
|
||||||
// Setup and helpers
|
// Setup and helpers
|
||||||
@@ -11,6 +12,7 @@ import { normalize, renderToDOM, renderToString, trim, nextTick } from "../helpe
|
|||||||
let qweb: QWeb;
|
let qweb: QWeb;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
QWeb.TEMPLATES = {};
|
||||||
qweb = new QWeb();
|
qweb = new QWeb();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -44,10 +46,25 @@ describe("static templates", () => {
|
|||||||
expect(renderToString(qweb, "test")).toBe("<div>word</div>");
|
expect(renderToString(qweb, "test")).toBe("<div>word</div>");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("div with a class attribute", () => {
|
||||||
|
qweb.addTemplate("test", `<div class="abc">word</div>`);
|
||||||
|
expect(renderToString(qweb, "test")).toBe(`<div class="abc">word</div>`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("div with a empty class attribute", () => {
|
||||||
|
qweb.addTemplate("test", `<div class="">word</div>`);
|
||||||
|
expect(renderToString(qweb, "test")).toBe(`<div>word</div>`);
|
||||||
|
});
|
||||||
|
|
||||||
test("div with a span child node", () => {
|
test("div with a span child node", () => {
|
||||||
qweb.addTemplate("test", "<div><span>word</span></div>");
|
qweb.addTemplate("test", "<div><span>word</span></div>");
|
||||||
expect(renderToString(qweb, "test")).toBe("<div><span>word</span></div>");
|
expect(renderToString(qweb, "test")).toBe("<div><span>word</span></div>");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("properly handle comments", () => {
|
||||||
|
qweb.addTemplate("test", "<div>hello <!-- comment-->owl</div>");
|
||||||
|
expect(renderToString(qweb, "test")).toBe("<div>hello <!-- comment-->owl</div>");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("error handling", () => {
|
describe("error handling", () => {
|
||||||
@@ -73,19 +90,12 @@ describe("error handling", () => {
|
|||||||
expect(() => qweb.addTemplate("test", "<div/>")).toThrow("already defined");
|
expect(() => qweb.addTemplate("test", "<div/>")).toThrow("already defined");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("loadTemplates throw if parser error", () => {
|
test("addTemplates throw if parser error", () => {
|
||||||
expect(() => {
|
expect(() => {
|
||||||
qweb.addTemplates("<templates><abc>></templates>");
|
qweb.addTemplates("<templates><abc>></templates>");
|
||||||
}).toThrow("Invalid XML in template");
|
}).toThrow("Invalid XML in template");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("nice error when t-on-directive is evaluated with a missing handler", () => {
|
|
||||||
qweb.addTemplate("templatename", `<div t-on-click="somemethod"></div>`);
|
|
||||||
expect(() => qweb.render("templatename", {}, { handlers: [] })).toThrow(
|
|
||||||
"Missing handler 'somemethod' when evaluating template 'templatename'"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("nice error when t-on is evaluated with a missing event", () => {
|
test("nice error when t-on is evaluated with a missing event", () => {
|
||||||
qweb.addTemplate("templatename", `<div t-on="somemethod"></div>`);
|
qweb.addTemplate("templatename", `<div t-on="somemethod"></div>`);
|
||||||
expect(() => qweb.render("templatename", { someMethod() {} }, { handlers: [] })).toThrow(
|
expect(() => qweb.render("templatename", { someMethod() {} }, { handlers: [] })).toThrow(
|
||||||
@@ -129,6 +139,17 @@ describe("t-esc", () => {
|
|||||||
qweb.addTemplate("test", `<span t-esc="var">nope</span>`);
|
qweb.addTemplate("test", `<span t-esc="var">nope</span>`);
|
||||||
expect(renderToString(qweb, "test")).toBe("<span>nope</span>");
|
expect(renderToString(qweb, "test")).toBe("<span>nope</span>");
|
||||||
});
|
});
|
||||||
|
test("t-esc is escaped", () => {
|
||||||
|
qweb.addTemplate("test", `<div><t t-set="var"><p>escaped</p></t><t t-esc="var"/></div>`);
|
||||||
|
const domRendered = renderToDOM(qweb, "test");
|
||||||
|
expect(domRendered.textContent).toBe("<p>escaped</p>");
|
||||||
|
});
|
||||||
|
test("t-esc=0 is escaped", () => {
|
||||||
|
qweb.addTemplate("test", `<span><t t-esc="0"/></span>`);
|
||||||
|
qweb.addTemplate("testCaller", `<div><t t-call="test"><p>escaped</p></t></div>`);
|
||||||
|
const domRendered = renderToDOM(qweb, "testCaller") as HTMLElement;
|
||||||
|
expect(domRendered.querySelector("span")!.textContent).toBe("<p>escaped</p>");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("t-raw", () => {
|
describe("t-raw", () => {
|
||||||
@@ -242,6 +263,82 @@ describe("t-set", () => {
|
|||||||
);
|
);
|
||||||
expect(renderToString(qweb, "test", { somevariable: 43 })).toBe("<div>45</div>");
|
expect(renderToString(qweb, "test", { somevariable: 43 })).toBe("<div>45</div>");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("t-set, t-if, and mix of expression/body lookup, 1", () => {
|
||||||
|
qweb.addTemplate(
|
||||||
|
"test",
|
||||||
|
`<div>
|
||||||
|
<t t-if="flag" t-set="ourvar">1</t>
|
||||||
|
<t t-else="" t-set="ourvar" t-value="0"></t>
|
||||||
|
<t t-esc="ourvar"/>
|
||||||
|
</div>`
|
||||||
|
);
|
||||||
|
expect(renderToString(qweb, "test", { flag: true })).toBe("<div>1</div>");
|
||||||
|
expect(renderToString(qweb, "test", { flag: false })).toBe("<div>0</div>");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("t-set, t-if, and mix of expression/body lookup, 2", () => {
|
||||||
|
qweb.addTemplate(
|
||||||
|
"test",
|
||||||
|
`<div>
|
||||||
|
<t t-if="flag" t-set="ourvar" t-value="1"></t>
|
||||||
|
<t t-else="" t-set="ourvar">0</t>
|
||||||
|
<t t-esc="ourvar"/>
|
||||||
|
</div>`
|
||||||
|
);
|
||||||
|
expect(renderToString(qweb, "test", { flag: true })).toBe("<div>1</div>");
|
||||||
|
expect(renderToString(qweb, "test", { flag: false })).toBe("<div>0</div>");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("t-set body is evaluated immediately", () => {
|
||||||
|
qweb.addTemplate(
|
||||||
|
"test",
|
||||||
|
`<div>
|
||||||
|
<t t-set="v1" t-value="'before'"/>
|
||||||
|
<t t-set="v2">
|
||||||
|
<span><t t-esc="v1"/></span>
|
||||||
|
</t>
|
||||||
|
<t t-set="v1" t-value="'after'"/>
|
||||||
|
<t t-raw="v2"/>
|
||||||
|
</div>`
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(renderToString(qweb, "test")).toBe("<div><span>before</span></div>");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("t-set with t-value (falsy) and body", () => {
|
||||||
|
qweb.addTemplate(
|
||||||
|
"test",
|
||||||
|
`<div>
|
||||||
|
<t t-set="v3" t-value="false"/>
|
||||||
|
<t t-set="v1" t-value="'before'"/>
|
||||||
|
<t t-set="v2" t-value="v3">
|
||||||
|
<span><t t-esc="v1"/></span>
|
||||||
|
</t>
|
||||||
|
<t t-set="v1" t-value="'after'"/>
|
||||||
|
<t t-set="v3" t-value="true"/>
|
||||||
|
<t t-raw="v2"/>
|
||||||
|
</div>`);
|
||||||
|
|
||||||
|
expect(renderToString(qweb, "test")).toBe("<div><span>before</span></div>");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("t-set with t-value (truthy) and body", () => {
|
||||||
|
qweb.addTemplate(
|
||||||
|
"test",
|
||||||
|
`<div>
|
||||||
|
<t t-set="v3" t-value="'Truthy'"/>
|
||||||
|
<t t-set="v1" t-value="'before'"/>
|
||||||
|
<t t-set="v2" t-value="v3">
|
||||||
|
<span><t t-esc="v1"/></span>
|
||||||
|
</t>
|
||||||
|
<t t-set="v1" t-value="'after'"/>
|
||||||
|
<t t-set="v3" t-value="false"/>
|
||||||
|
<t t-raw="v2"/>
|
||||||
|
</div>`);
|
||||||
|
|
||||||
|
expect(renderToString(qweb, "test")).toBe("<div>Truthy</div>");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("t-if", () => {
|
describe("t-if", () => {
|
||||||
@@ -322,6 +419,61 @@ describe("t-if", () => {
|
|||||||
};
|
};
|
||||||
expect(normalize(renderToString(qweb, "test", context))).toBe("<div>andormgtnlt</div>");
|
expect(normalize(renderToString(qweb, "test", context))).toBe("<div>andormgtnlt</div>");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("t-esc with t-if", () => {
|
||||||
|
qweb.addTemplate("test", `<div><t t-if="true" t-esc="'x'"/></div>`);
|
||||||
|
expect(renderToString(qweb, "test")).toBe("<div>x</div>");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("t-esc with t-elif", () => {
|
||||||
|
qweb.addTemplate("test", `<div><t t-if="false">abc</t><t t-else="1" t-esc="'x'"/></div>`);
|
||||||
|
expect(renderToString(qweb, "test")).toBe("<div>x</div>");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("t-set, then t-if", () => {
|
||||||
|
qweb.addTemplate(
|
||||||
|
"test",
|
||||||
|
`
|
||||||
|
<div>
|
||||||
|
<t t-set="title" t-value="'test'"/>
|
||||||
|
<t t-if="title"><t t-esc="title"/></t>
|
||||||
|
</div>`
|
||||||
|
);
|
||||||
|
const result = renderToString(qweb, "test");
|
||||||
|
const expected = `<div>test</div>`;
|
||||||
|
expect(result).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("t-set, then t-if, part 2", () => {
|
||||||
|
qweb.addTemplate(
|
||||||
|
"test",
|
||||||
|
`
|
||||||
|
<div>
|
||||||
|
<t t-set="y" t-value="true"/>
|
||||||
|
<t t-set="x" t-value="y"/>
|
||||||
|
<span t-if="x">COUCOU</span>
|
||||||
|
</div>`
|
||||||
|
);
|
||||||
|
const result = renderToString(qweb, "test");
|
||||||
|
const expected = `<div><span>COUCOU</span></div>`;
|
||||||
|
expect(result).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("t-set, then t-elif, part 3", () => {
|
||||||
|
qweb.addTemplate(
|
||||||
|
"test",
|
||||||
|
`
|
||||||
|
<div>
|
||||||
|
<t t-set="y" t-value="false"/>
|
||||||
|
<t t-set="x" t-value="y"/>
|
||||||
|
<span t-if="x">AAA</span>
|
||||||
|
<span t-elif="!x">BBB</span>
|
||||||
|
</div>`
|
||||||
|
);
|
||||||
|
const result = renderToString(qweb, "test");
|
||||||
|
const expected = `<div><span>BBB</span></div>`;
|
||||||
|
expect(result).toBe(expected);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("attributes", () => {
|
describe("attributes", () => {
|
||||||
@@ -351,6 +503,18 @@ describe("attributes", () => {
|
|||||||
expect(result).toBe(`<div foo="bar"></div>`);
|
expect(result).toBe(`<div foo="bar"></div>`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("dynamic class attribute", () => {
|
||||||
|
qweb.addTemplate("test", `<div t-att-class="c"/>`);
|
||||||
|
const result = renderToString(qweb, "test", { c: "abc" });
|
||||||
|
expect(result).toBe(`<div class="abc"></div>`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("dynamic empty class attribute", () => {
|
||||||
|
qweb.addTemplate("test", `<div t-att-class="c"/>`);
|
||||||
|
const result = renderToString(qweb, "test", { c: "" });
|
||||||
|
expect(result).toBe(`<div></div>`);
|
||||||
|
});
|
||||||
|
|
||||||
test("dynamic attribute with a dash", () => {
|
test("dynamic attribute with a dash", () => {
|
||||||
qweb.addTemplate("test", `<div t-att-data-action-id="id"/>`);
|
qweb.addTemplate("test", `<div t-att-data-action-id="id"/>`);
|
||||||
const result = renderToString(qweb, "test", { id: 32 });
|
const result = renderToString(qweb, "test", { id: 32 });
|
||||||
@@ -495,10 +659,19 @@ describe("attributes", () => {
|
|||||||
|
|
||||||
describe("t-call (template calling", () => {
|
describe("t-call (template calling", () => {
|
||||||
test("basic caller", () => {
|
test("basic caller", () => {
|
||||||
|
qweb.addTemplate("_basic-callee", "<span>ok</span>");
|
||||||
|
qweb.addTemplate("caller", '<div><t t-call="_basic-callee"/></div>');
|
||||||
|
const expected = "<div><span>ok</span></div>";
|
||||||
|
expect(renderToString(qweb, "caller")).toBe(expected);
|
||||||
|
expect(qweb.subTemplates["_basic-callee"].toString()).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("basic caller, no parent node", () => {
|
||||||
qweb.addTemplate("_basic-callee", "<div>ok</div>");
|
qweb.addTemplate("_basic-callee", "<div>ok</div>");
|
||||||
qweb.addTemplate("caller", '<t t-call="_basic-callee"/>');
|
qweb.addTemplate("caller", '<t t-call="_basic-callee"/>');
|
||||||
const expected = "<div>ok</div>";
|
const expected = "<div>ok</div>";
|
||||||
expect(renderToString(qweb, "caller")).toBe(expected);
|
expect(renderToString(qweb, "caller")).toBe(expected);
|
||||||
|
expect(qweb.subTemplates["_basic-callee"].toString()).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("t-call with t-if", () => {
|
test("t-call with t-if", () => {
|
||||||
@@ -506,6 +679,7 @@ describe("t-call (template calling", () => {
|
|||||||
qweb.addTemplate("caller", '<div><t t-if="flag" t-call="sub"/></div>');
|
qweb.addTemplate("caller", '<div><t t-if="flag" t-call="sub"/></div>');
|
||||||
const expected = "<div><span>ok</span></div>";
|
const expected = "<div><span>ok</span></div>";
|
||||||
expect(renderToString(qweb, "caller", { flag: true })).toBe(expected);
|
expect(renderToString(qweb, "caller", { flag: true })).toBe(expected);
|
||||||
|
expect(qweb.subTemplates["sub"].toString()).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("t-call not allowed on a non t node", () => {
|
test("t-call not allowed on a non t node", () => {
|
||||||
@@ -573,6 +747,183 @@ describe("t-call (template calling", () => {
|
|||||||
const expected = "<div>ok</div>";
|
const expected = "<div>ok</div>";
|
||||||
expect(trim(renderToString(qweb, "caller"))).toBe(expected);
|
expect(trim(renderToString(qweb, "caller"))).toBe(expected);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("call with several sub nodes on same line", () => {
|
||||||
|
qweb.addTemplates(`
|
||||||
|
<templates>
|
||||||
|
<div t-name="SubTemplate">
|
||||||
|
<t t-raw="0"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div t-name="main">
|
||||||
|
<t t-call="SubTemplate">
|
||||||
|
<span>hey</span> <span>yay</span>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
</templates>
|
||||||
|
`);
|
||||||
|
const expected = "<div><div><span>hey</span> <span>yay</span></div></div>";
|
||||||
|
expect(renderToString(qweb, "main")).toBe(expected);
|
||||||
|
expect(qweb.subTemplates["SubTemplate"].toString()).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("cascading t-call t-raw='0'", () => {
|
||||||
|
qweb.addTemplates(`
|
||||||
|
<templates>
|
||||||
|
<div t-name="finalTemplate">
|
||||||
|
<span>cascade 2</span>
|
||||||
|
<t t-raw="0"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div t-name="subSubTemplate">
|
||||||
|
<t t-call="finalTemplate">
|
||||||
|
<span>cascade 1</span>
|
||||||
|
<t t-raw="0"/>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div t-name="SubTemplate">
|
||||||
|
<t t-call="subSubTemplate">
|
||||||
|
<span>cascade 0</span>
|
||||||
|
<t t-raw="0"/>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div t-name="main">
|
||||||
|
<t t-call="SubTemplate">
|
||||||
|
<span>hey</span> <span>yay</span>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
</templates>
|
||||||
|
`);
|
||||||
|
const expected =
|
||||||
|
"<div><div><div><div><span>cascade 2</span><span>cascade 1</span><span>cascade 0</span><span>hey</span> <span>yay</span></div></div></div></div>";
|
||||||
|
expect(renderToString(qweb, "main")).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("recursive template, part 1", () => {
|
||||||
|
qweb.addTemplates(`
|
||||||
|
<templates>
|
||||||
|
<div t-name="recursive">
|
||||||
|
<span>hey</span>
|
||||||
|
<t t-if="false">
|
||||||
|
<t t-call="recursive"/>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
</templates>
|
||||||
|
`);
|
||||||
|
const expected = "<div><span>hey</span></div>";
|
||||||
|
expect(renderToString(qweb, "recursive")).toBe(expected);
|
||||||
|
const recursiveFn = Object.values(qweb.subTemplates)[0] as any;
|
||||||
|
expect(recursiveFn.toString()).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("recursive template, part 2", () => {
|
||||||
|
qweb.addTemplates(`
|
||||||
|
<templates>
|
||||||
|
<div t-name="Parent">
|
||||||
|
<t t-call="nodeTemplate">
|
||||||
|
<t t-set="node" t-value="root"/>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
<div t-name="nodeTemplate">
|
||||||
|
<p><t t-esc="node.val"/></p>
|
||||||
|
<t t-foreach="node.children or []" t-as="subtree">
|
||||||
|
<t t-call="nodeTemplate">
|
||||||
|
<t t-set="node" t-value="subtree"/>
|
||||||
|
</t>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</templates>
|
||||||
|
`);
|
||||||
|
const root = { val: "a", children: [{ val: "b" }, { val: "c" }] };
|
||||||
|
const expected = "<div><div><p>a</p><div><p>b</p></div><div><p>c</p></div></div></div>";
|
||||||
|
expect(renderToString(qweb, "Parent", { root }, { fiber: { vars: {}, scope: {} } })).toBe(
|
||||||
|
expected
|
||||||
|
);
|
||||||
|
const recursiveFn = Object.values(qweb.subTemplates)[0] as any;
|
||||||
|
expect(recursiveFn.toString()).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("recursive template, part 3", () => {
|
||||||
|
qweb.addTemplates(`
|
||||||
|
<templates>
|
||||||
|
<div t-name="Parent">
|
||||||
|
<t t-call="nodeTemplate">
|
||||||
|
<t t-set="node" t-value="root"/>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
<div t-name="nodeTemplate">
|
||||||
|
<p><t t-esc="node.val"/></p>
|
||||||
|
<t t-foreach="node.children or []" t-as="subtree">
|
||||||
|
<t t-call="nodeTemplate">
|
||||||
|
<t t-set="node" t-value="subtree"/>
|
||||||
|
</t>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</templates>
|
||||||
|
`);
|
||||||
|
const root = { val: "a", children: [{ val: "b", children: [{ val: "d" }] }, { val: "c" }] };
|
||||||
|
const expected =
|
||||||
|
"<div><div><p>a</p><div><p>b</p><div><p>d</p></div></div><div><p>c</p></div></div></div>";
|
||||||
|
expect(renderToString(qweb, "Parent", { root }, { fiber: {} })).toBe(expected);
|
||||||
|
const recursiveFn = Object.values(qweb.subTemplates)[0] as any;
|
||||||
|
expect(recursiveFn.toString()).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("t-call, global templates", () => {
|
||||||
|
QWeb.registerTemplate("abcd", '<div><t t-call="john"/></div>');
|
||||||
|
qweb.addTemplate("john", `<span>desk</span>`);
|
||||||
|
const expected = "<div><span>desk</span></div>";
|
||||||
|
expect(trim(renderToString(qweb, "abcd"))).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("t-call with t-set inside and outside", () => {
|
||||||
|
qweb.addTemplates(`
|
||||||
|
<templates>
|
||||||
|
<div t-name="main">
|
||||||
|
<t t-foreach="list" t-as="v">
|
||||||
|
<t t-set="val" t-value="v.val"/>
|
||||||
|
<t t-call="sub">
|
||||||
|
<t t-set="val3" t-value="val*3"/>
|
||||||
|
</t>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
<t t-name="sub">
|
||||||
|
<span t-esc="val3"/>
|
||||||
|
</t>
|
||||||
|
</templates>
|
||||||
|
`);
|
||||||
|
const expected = "<div><span>3</span><span>6</span><span>9</span></div>";
|
||||||
|
const context = { list: [{ val: 1 }, { val: 2 }, { val: 3 }] };
|
||||||
|
expect(trim(renderToString(qweb, "main", context))).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("t-call with t-set inside and outside. 2", () => {
|
||||||
|
qweb.addTemplates(`
|
||||||
|
<templates>
|
||||||
|
<div t-name="main">
|
||||||
|
<t t-foreach="list" t-as="v">
|
||||||
|
<t t-set="val" t-value="v.val"/>
|
||||||
|
<t t-call="sub">
|
||||||
|
<t t-set="val3" t-value="val*3"/>
|
||||||
|
</t>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
<t t-name="sub">
|
||||||
|
<span t-esc="val3"/>
|
||||||
|
<t t-esc="w"/>
|
||||||
|
</t>
|
||||||
|
<p t-name="wrapper"><t t-set="w" t-value="'fromwrapper'"/><t t-call="main"/></p>
|
||||||
|
</templates>
|
||||||
|
`);
|
||||||
|
const expected =
|
||||||
|
"<p><div><span>3</span>fromwrapper<span>6</span>fromwrapper<span>9</span>fromwrapper</div></p>";
|
||||||
|
const context = { list: [{ val: 1 }, { val: 2 }, { val: 3 }] };
|
||||||
|
expect(trim(renderToString(qweb, "wrapper", context))).toBe(expected);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("foreach", () => {
|
describe("foreach", () => {
|
||||||
@@ -641,9 +992,26 @@ describe("foreach", () => {
|
|||||||
<t t-foreach="[1]" t-as="item"><t t-esc="item"/></t>
|
<t t-foreach="[1]" t-as="item"><t t-esc="item"/></t>
|
||||||
</div>`
|
</div>`
|
||||||
);
|
);
|
||||||
const context = {};
|
const context = { __owl__: {} };
|
||||||
renderToString(qweb, "test", context);
|
renderToString(qweb, "test", context);
|
||||||
expect(Object.keys(context).length).toBe(0);
|
expect(Object.keys(context)).toEqual(["__owl__"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("t-foreach in t-forach", () => {
|
||||||
|
qweb.addTemplate(
|
||||||
|
"test",
|
||||||
|
`<div>
|
||||||
|
<t t-foreach="numbers" t-as="number">
|
||||||
|
<t t-foreach="letters" t-as="letter">
|
||||||
|
[<t t-esc="number"/><t t-esc="letter"/>]
|
||||||
|
</t>
|
||||||
|
</t>
|
||||||
|
</div>`
|
||||||
|
);
|
||||||
|
const context = { numbers: [1, 2, 3], letters: ["a", "b"] };
|
||||||
|
expect(renderToString(qweb, "test", context)).toBe(
|
||||||
|
"<div> [1a] [1b] [2a] [2b] [3a] [3b] </div>"
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("throws error if invalid loop expression", () => {
|
test("throws error if invalid loop expression", () => {
|
||||||
@@ -669,13 +1037,16 @@ describe("foreach", () => {
|
|||||||
);
|
);
|
||||||
renderToString(qweb, "test");
|
renderToString(qweb, "test");
|
||||||
expect(console.warn).toHaveBeenCalledTimes(1);
|
expect(console.warn).toHaveBeenCalledTimes(1);
|
||||||
|
expect(console.warn).toHaveBeenCalledWith(
|
||||||
|
"Directive t-foreach should always be used with a t-key! (in template: 'test')"
|
||||||
|
);
|
||||||
console.warn = consoleWarn;
|
console.warn = consoleWarn;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("misc", () => {
|
describe("misc", () => {
|
||||||
test("global", () => {
|
test("global", () => {
|
||||||
qweb.addTemplate("_callee-asc", `<Año t-att-falló="'agüero'" t-raw="0"/>`);
|
qweb.addTemplate("_callee-asc", `<año t-att-falló="'agüero'" t-raw="0"/>`);
|
||||||
qweb.addTemplate("_callee-uses-foo", `<span t-esc="foo">foo default</span>`);
|
qweb.addTemplate("_callee-uses-foo", `<span t-esc="foo">foo default</span>`);
|
||||||
qweb.addTemplate("_callee-asc-toto", `<div t-raw="toto">toto default</div>`);
|
qweb.addTemplate("_callee-asc-toto", `<div t-raw="toto">toto default</div>`);
|
||||||
qweb.addTemplate(
|
qweb.addTemplate(
|
||||||
@@ -872,6 +1243,35 @@ describe("t-on", () => {
|
|||||||
(<HTMLElement>node).click();
|
(<HTMLElement>node).click();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("t-on with inline statement", () => {
|
||||||
|
qweb.addTemplate("test", `<button t-on-click="state.counter++">Click</button>`);
|
||||||
|
let owner = {
|
||||||
|
state: {
|
||||||
|
counter: 0
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const node = renderToDOM(qweb, "test", owner, { handlers: [] });
|
||||||
|
expect(owner.state.counter).toBe(0);
|
||||||
|
(<HTMLElement>node).click();
|
||||||
|
expect(owner.state.counter).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("t-on with inline statement (function call)", () => {
|
||||||
|
qweb.addTemplate("test", `<button t-on-click="state.incrementCounter(2)">Click</button>`);
|
||||||
|
let owner = {
|
||||||
|
state: {
|
||||||
|
counter: 0,
|
||||||
|
incrementCounter: inc => {
|
||||||
|
owner.state.counter += inc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const node = renderToDOM(qweb, "test", owner, { handlers: [] });
|
||||||
|
expect(owner.state.counter).toBe(0);
|
||||||
|
(<HTMLElement>node).click();
|
||||||
|
expect(owner.state.counter).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
test("t-on with prevent and/or stop modifiers", async () => {
|
test("t-on with prevent and/or stop modifiers", async () => {
|
||||||
expect.assertions(7);
|
expect.assertions(7);
|
||||||
qweb.addTemplate(
|
qweb.addTemplate(
|
||||||
@@ -983,20 +1383,113 @@ describe("t-on", () => {
|
|||||||
|
|
||||||
expect(steps).toEqual([true, true]);
|
expect(steps).toEqual([true, true]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("t-on with prevent modifier in t-foreach", async () => {
|
||||||
|
expect.assertions(5);
|
||||||
|
qweb.addTemplate(
|
||||||
|
"test",
|
||||||
|
`<div>
|
||||||
|
<t t-foreach="projects" t-as="project">
|
||||||
|
<a href="#" t-key="project" t-on-click.prevent="onEdit(project.id)">
|
||||||
|
Edit <t t-esc="project.name"/>
|
||||||
|
</a>
|
||||||
|
</t>
|
||||||
|
</div>`
|
||||||
|
);
|
||||||
|
const steps: string[] = [];
|
||||||
|
const owner = {
|
||||||
|
projects: [
|
||||||
|
{ id: 1, name: "Project 1" },
|
||||||
|
{ id: 2, name: "Project 2" }
|
||||||
|
],
|
||||||
|
|
||||||
|
onEdit(projectId, ev) {
|
||||||
|
expect(ev.defaultPrevented).toBe(true);
|
||||||
|
steps.push(projectId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const node = <HTMLElement>renderToDOM(qweb, "test", owner, { handlers: [] });
|
||||||
|
expect(node.outerHTML).toBe(
|
||||||
|
`<div><a href="#"> Edit Project 1</a><a href="#"> Edit Project 2</a></div>`
|
||||||
|
);
|
||||||
|
|
||||||
|
const links = node.querySelectorAll("a")!;
|
||||||
|
links[0].click();
|
||||||
|
links[1].click();
|
||||||
|
|
||||||
|
expect(steps).toEqual([1, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("t-on with empty handler (only modifiers)", () => {
|
||||||
|
expect.assertions(2);
|
||||||
|
qweb.addTemplate(
|
||||||
|
"test",
|
||||||
|
`<div>
|
||||||
|
<button t-on-click.prevent="">Button</button>
|
||||||
|
</div>`
|
||||||
|
);
|
||||||
|
const node = renderToDOM(qweb, "test", {}, { handlers: [] });
|
||||||
|
|
||||||
|
node.addEventListener("click", e => {
|
||||||
|
expect(e.defaultPrevented).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
const button = (<HTMLElement>node).getElementsByTagName("button")[0];
|
||||||
|
button.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("t-on combined with t-esc", async () => {
|
||||||
|
expect.assertions(3);
|
||||||
|
qweb.addTemplate("test", `<div><button t-on-click="onClick" t-esc="text"/></div>`);
|
||||||
|
const steps: string[] = [];
|
||||||
|
const owner = {
|
||||||
|
text: "Click here",
|
||||||
|
onClick() {
|
||||||
|
steps.push("onClick");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const node = <HTMLElement>renderToDOM(qweb, "test", owner, { handlers: [] });
|
||||||
|
expect(node.outerHTML).toBe(`<div><button>Click here</button></div>`);
|
||||||
|
|
||||||
|
node.querySelector("button")!.click();
|
||||||
|
|
||||||
|
expect(steps).toEqual(["onClick"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("t-on combined with t-raw", async () => {
|
||||||
|
expect.assertions(3);
|
||||||
|
qweb.addTemplate("test", `<div><button t-on-click="onClick" t-raw="html"/></div>`);
|
||||||
|
const steps: string[] = [];
|
||||||
|
const owner = {
|
||||||
|
html: "Click <b>here</b>",
|
||||||
|
onClick() {
|
||||||
|
steps.push("onClick");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const node = <HTMLElement>renderToDOM(qweb, "test", owner, { handlers: [] });
|
||||||
|
expect(node.outerHTML).toBe(`<div><button>Click <b>here</b></button></div>`);
|
||||||
|
|
||||||
|
node.querySelector("button")!.click();
|
||||||
|
|
||||||
|
expect(steps).toEqual(["onClick"]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("t-ref", () => {
|
describe("t-ref", () => {
|
||||||
test("can get a ref on a node", () => {
|
test("can get a ref on a node", () => {
|
||||||
qweb.addTemplate("test", `<div><span t-ref="myspan"/></div>`);
|
qweb.addTemplate("test", `<div><span t-ref="myspan"/></div>`);
|
||||||
let refs: any = {};
|
let refs: any = {};
|
||||||
renderToDOM(qweb, "test", { refs });
|
renderToDOM(qweb, "test", { __owl__: { refs } });
|
||||||
expect(refs.myspan.tagName).toBe("SPAN");
|
expect(refs.myspan.tagName).toBe("SPAN");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("can get a dynamic ref on a node", () => {
|
test("can get a dynamic ref on a node", () => {
|
||||||
qweb.addTemplate("test", `<div><span t-ref="myspan{{id}}"/></div>`);
|
qweb.addTemplate("test", `<div><span t-ref="myspan{{id}}"/></div>`);
|
||||||
let refs: any = {};
|
let refs: any = {};
|
||||||
renderToDOM(qweb, "test", { refs, id: 3 });
|
renderToDOM(qweb, "test", { id: 3, __owl__: { refs } });
|
||||||
expect(refs.myspan3.tagName).toBe("SPAN");
|
expect(refs.myspan3.tagName).toBe("SPAN");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1011,19 +1504,19 @@ describe("t-ref", () => {
|
|||||||
</div>`
|
</div>`
|
||||||
);
|
);
|
||||||
let refs: any = {};
|
let refs: any = {};
|
||||||
renderToDOM(qweb, "test", { refs, items: [1, 2, 3] });
|
renderToDOM(qweb, "test", { items: [1, 2, 3], __owl__: { refs } });
|
||||||
expect(Object.keys(refs)).toEqual(["1", "2", "3"]);
|
expect(Object.keys(refs)).toEqual(["1", "2", "3"]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("loading templates", () => {
|
describe("loading templates", () => {
|
||||||
test("can initialize qweb with a string", () => {
|
test("can initialize qweb with a string", () => {
|
||||||
const data = `
|
const templates = `
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<templates id="template" xml:space="preserve">
|
<templates id="template" xml:space="preserve">
|
||||||
<div t-name="hey">jupiler</div>
|
<div t-name="hey">jupiler</div>
|
||||||
</templates>`;
|
</templates>`;
|
||||||
const qweb = new QWeb(data);
|
const qweb = new QWeb({ templates });
|
||||||
expect(renderToString(qweb, "hey")).toBe("<div>jupiler</div>");
|
expect(renderToString(qweb, "hey")).toBe("<div>jupiler</div>");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1160,6 +1653,22 @@ describe("debugging", () => {
|
|||||||
console.log = consoleLog;
|
console.log = consoleLog;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("t-debug on sub template", () => {
|
||||||
|
const consoleLog = console.log;
|
||||||
|
console.log = jest.fn();
|
||||||
|
qweb.addTemplates(`
|
||||||
|
<templates>
|
||||||
|
<p t-name="sub" t-debug="1">coucou</p>
|
||||||
|
<div t-name="test">
|
||||||
|
<t t-call="sub"/>
|
||||||
|
</div>
|
||||||
|
</templates>`);
|
||||||
|
qweb.render("test");
|
||||||
|
|
||||||
|
expect(console.log).toHaveBeenCalledTimes(1);
|
||||||
|
console.log = consoleLog;
|
||||||
|
});
|
||||||
|
|
||||||
test("t-log", () => {
|
test("t-log", () => {
|
||||||
const consoleLog = console.log;
|
const consoleLog = console.log;
|
||||||
console.log = jest.fn();
|
console.log = jest.fn();
|
||||||
@@ -1190,3 +1699,126 @@ describe("update on event bus", () => {
|
|||||||
expect(fn).toBeCalledTimes(1);
|
expect(fn).toBeCalledTimes(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("global template registration", () => {
|
||||||
|
test("can register template globally", () => {
|
||||||
|
expect.assertions(5);
|
||||||
|
let qweb = new QWeb();
|
||||||
|
try {
|
||||||
|
qweb.render("mytemplate");
|
||||||
|
} catch (e) {
|
||||||
|
expect(e.message).toMatch("Template mytemplate does not exist");
|
||||||
|
}
|
||||||
|
expect(qweb.templates.mytemplate).toBeUndefined();
|
||||||
|
|
||||||
|
QWeb.registerTemplate("mytemplate", "<div>global</div>");
|
||||||
|
expect(qweb.templates.mytemplate).toBeDefined();
|
||||||
|
const vnode = qweb.render("mytemplate");
|
||||||
|
expect(vnode.sel).toBe("div");
|
||||||
|
expect((vnode as any).children[0].text).toBe("global");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("properly support svg", () => {
|
||||||
|
test("add proper namespace to svg", () => {
|
||||||
|
qweb.addTemplate(
|
||||||
|
"test",
|
||||||
|
`<svg width="100px" height="90px"><circle cx="50" cy="50" r="4" stroke="green" stroke-width="1" fill="yellow"/> </svg>`
|
||||||
|
);
|
||||||
|
expect(renderToString(qweb, "test")).toBe(
|
||||||
|
`<svg width=\"100px\" height=\"90px\"><circle cx=\"50\" cy=\"50\" r=\"4\" stroke=\"green\" stroke-width=\"1\" fill=\"yellow\"></circle> </svg>`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("add proper namespace to g tags", () => {
|
||||||
|
// this is necessary if one wants to use components in a svg
|
||||||
|
qweb.addTemplate(
|
||||||
|
"test",
|
||||||
|
`<g><circle cx="50" cy="50" r="4" stroke="green" stroke-width="1" fill="yellow"/> </g>`
|
||||||
|
);
|
||||||
|
expect(renderToString(qweb, "test")).toBe(
|
||||||
|
`<g><circle cx=\"50\" cy=\"50\" r=\"4\" stroke=\"green\" stroke-width=\"1\" fill=\"yellow\"></circle> </g>`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("translation support", () => {
|
||||||
|
test("can translate node content", () => {
|
||||||
|
const translations = {
|
||||||
|
word: "mot"
|
||||||
|
};
|
||||||
|
const translateFn = expr => translations[expr] || expr;
|
||||||
|
const qweb = new QWeb({ translateFn });
|
||||||
|
qweb.addTemplate("test", "<div>word</div>");
|
||||||
|
expect(renderToString(qweb, "test")).toBe("<div>mot</div>");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not translate node content if disabled", () => {
|
||||||
|
const translations = {
|
||||||
|
word: "mot"
|
||||||
|
};
|
||||||
|
const translateFn = expr => translations[expr] || expr;
|
||||||
|
const qweb = new QWeb({ translateFn });
|
||||||
|
qweb.addTemplate(
|
||||||
|
"test",
|
||||||
|
`
|
||||||
|
<div>
|
||||||
|
<span>word</span>
|
||||||
|
<span t-translation="off">word</span>
|
||||||
|
</div>`
|
||||||
|
);
|
||||||
|
expect(renderToString(qweb, "test")).toBe("<div><span>mot</span><span>word</span></div>");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("some attributes are translated", () => {
|
||||||
|
const translations = {
|
||||||
|
word: "mot"
|
||||||
|
};
|
||||||
|
const translateFn = expr => translations[expr] || expr;
|
||||||
|
const qweb = new QWeb({ translateFn });
|
||||||
|
qweb.addTemplate(
|
||||||
|
"test",
|
||||||
|
`
|
||||||
|
<div>
|
||||||
|
<p label="word">word</p>
|
||||||
|
<p title="word">word</p>
|
||||||
|
<p placeholder="word">word</p>
|
||||||
|
<p alt="word">word</p>
|
||||||
|
<p something="word">word</p>
|
||||||
|
</div>`
|
||||||
|
);
|
||||||
|
expect(renderToString(qweb, "test")).toBe(
|
||||||
|
'<div><p label="mot">mot</p><p title="mot">mot</p><p placeholder="mot">mot</p><p alt="mot">mot</p><p something="word">mot</p></div>'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("t-key tests", () => {
|
||||||
|
test("t-key on t-foreach", async () => {
|
||||||
|
qweb.addTemplate(
|
||||||
|
"test",
|
||||||
|
`
|
||||||
|
<div>
|
||||||
|
<t t-foreach="things" t-as="thing" t-key="thing">
|
||||||
|
<span/>
|
||||||
|
</t>
|
||||||
|
</div>`
|
||||||
|
);
|
||||||
|
|
||||||
|
let vnode = qweb.render("test", { things: [1, 2] });
|
||||||
|
vnode = patch(document.createElement("div"), vnode);
|
||||||
|
let elm = vnode.elm as HTMLElement;
|
||||||
|
|
||||||
|
expect(elm.outerHTML).toBe("<div><span></span><span></span></div>");
|
||||||
|
|
||||||
|
const first = elm.querySelectorAll("span")[0];
|
||||||
|
const second = elm.querySelectorAll("span")[1];
|
||||||
|
|
||||||
|
patch(vnode, qweb.render("test", { things: [2, 1] }));
|
||||||
|
|
||||||
|
expect(elm.outerHTML).toBe("<div><span></span><span></span></div>");
|
||||||
|
|
||||||
|
expect(first).toBe(elm.querySelectorAll("span")[1]);
|
||||||
|
expect(second).toBe(elm.querySelectorAll("span")[0]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -27,7 +27,10 @@ describe("tokenizer", () => {
|
|||||||
{ type: "VALUE", value: "2" },
|
{ type: "VALUE", value: "2" },
|
||||||
{ type: "RIGHT_BRACE", value: "}" }
|
{ type: "RIGHT_BRACE", value: "}" }
|
||||||
]);
|
]);
|
||||||
expect(tokenize("a,")).toEqual([{ type: "SYMBOL", value: "a" }, { type: "COMMA", value: "," }]);
|
expect(tokenize("a,")).toEqual([
|
||||||
|
{ type: "SYMBOL", value: "a" },
|
||||||
|
{ 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: "[" }
|
||||||
@@ -43,6 +46,10 @@ describe("tokenizer", () => {
|
|||||||
{ type: "OPERATOR", value: "!==" },
|
{ type: "OPERATOR", value: "!==" },
|
||||||
{ type: "OPERATOR", value: "!=" }
|
{ type: "OPERATOR", value: "!=" }
|
||||||
]);
|
]);
|
||||||
|
expect(tokenize("typeof a")).toEqual([
|
||||||
|
{ type: "OPERATOR", value: "typeof " },
|
||||||
|
{ type: "SYMBOL", value: "a" }
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("strings", () => {
|
test("strings", () => {
|
||||||
@@ -85,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("context['a']*(1+3)");
|
expect(compileExpr("a*(1 +3)", {})).toBe("scope['a']*(1+3)");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("objects and sub objects", () => {
|
test("objects and sub objects", () => {
|
||||||
@@ -93,8 +100,8 @@ describe("expression evaluation", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("replacing variables", () => {
|
test("replacing variables", () => {
|
||||||
expect(compileExpr("a", {})).toBe("context['a']");
|
expect(compileExpr("a", {})).toBe("scope['a']");
|
||||||
expect(compileExpr("a", { a: { id: "_3", expr: "" } })).toBe("_3");
|
expect(compileExpr("a", { a: { id: "_3", expr: "scope._3" } })).toBe("scope._3");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("arrays and objects", () => {
|
test("arrays and objects", () => {
|
||||||
@@ -104,55 +111,63 @@ describe("expression evaluation", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("dot operator", () => {
|
test("dot operator", () => {
|
||||||
expect(compileExpr("a.b", {})).toBe("context['a'].b");
|
expect(compileExpr("a.b", {})).toBe("scope['a'].b");
|
||||||
expect(compileExpr("a.b.c", {})).toBe("context['a'].b.c");
|
expect(compileExpr("a.b.c", {})).toBe("scope['a'].b.c");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("various unary operators", () => {
|
test("various unary operators", () => {
|
||||||
expect(compileExpr("!flag", {})).toBe("!context['flag']");
|
expect(compileExpr("!flag", {})).toBe("!scope['flag']");
|
||||||
expect(compileExpr("-3", {})).toBe("-3");
|
expect(compileExpr("-3", {})).toBe("-3");
|
||||||
expect(compileExpr("-a", {})).toBe("-context['a']");
|
expect(compileExpr("-a", {})).toBe("-scope['a']");
|
||||||
|
expect(compileExpr("typeof a", {})).toBe("typeof scope['a']");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("various binary operators", () => {
|
test("various binary operators", () => {
|
||||||
expect(compileExpr("color == 'black'", {})).toBe("context['color']=='black'");
|
expect(compileExpr("color == 'black'", {})).toBe("scope['color']=='black'");
|
||||||
expect(compileExpr("a || b", {})).toBe("context['a']||context['b']");
|
expect(compileExpr("a || b", {})).toBe("scope['a']||scope['b']");
|
||||||
expect(compileExpr("color === 'black'", {})).toBe("context['color']==='black'");
|
expect(compileExpr("color === 'black'", {})).toBe("scope['color']==='black'");
|
||||||
expect(compileExpr("'li_'+item", {})).toBe("'li_'+context['item']");
|
expect(compileExpr("'li_'+item", {})).toBe("'li_'+scope['item']");
|
||||||
expect(compileExpr("state.val > 1", {})).toBe("context['state'].val>1");
|
expect(compileExpr("state.val > 1", {})).toBe("scope['state'].val>1");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("boolean operations", () => {
|
test("boolean operations", () => {
|
||||||
expect(compileExpr("a && b", {})).toBe("context['a']&&context['b']");
|
expect(compileExpr("a && b", {})).toBe("scope['a']&&scope['b']");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("ternary operators", () => {
|
test("ternary operators", () => {
|
||||||
expect(compileExpr("a ? b: '2'", {})).toBe("context['a']?context['b']:'2'");
|
expect(compileExpr("a ? b: '2'", {})).toBe("scope['a']?scope['b']:'2'");
|
||||||
expect(compileExpr("a ? b: (c or '2') ", {})).toBe(
|
expect(compileExpr("a ? b: (c or '2') ", {})).toBe("scope['a']?scope['b']:(scope['c']||'2')");
|
||||||
"context['a']?context['b']:(context['c']||'2')"
|
|
||||||
);
|
|
||||||
expect(compileExpr("a ? {test:c}: [1,u]", {})).toBe(
|
expect(compileExpr("a ? {test:c}: [1,u]", {})).toBe(
|
||||||
"context['a']?{test:context['c']}:[1,context['u']]"
|
"scope['a']?{test:scope['c']}:[1,scope['u']]"
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("word replacement", () => {
|
test("word replacement", () => {
|
||||||
expect(compileExpr("a or b", {})).toBe("context['a']||context['b']");
|
expect(compileExpr("a or b", {})).toBe("scope['a']||scope['b']");
|
||||||
expect(compileExpr("a and b", {})).toBe("context['a']&&context['b']");
|
expect(compileExpr("a and b", {})).toBe("scope['a']&&scope['b']");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("function calls", () => {
|
test("function calls", () => {
|
||||||
expect(compileExpr("a()", {})).toBe("context['a']()");
|
expect(compileExpr("a()", {})).toBe("scope['a']()");
|
||||||
expect(compileExpr("a(1)", {})).toBe("context['a'](1)");
|
expect(compileExpr("a(1)", {})).toBe("scope['a'](1)");
|
||||||
expect(compileExpr("a(1,2)", {})).toBe("context['a'](1,2)");
|
expect(compileExpr("a(1,2)", {})).toBe("scope['a'](1,2)");
|
||||||
expect(compileExpr("a(1,2,{a:[a]})", {})).toBe("context['a'](1,2,{a:[context['a']]})");
|
expect(compileExpr("a(1,2,{a:[a]})", {})).toBe("scope['a'](1,2,{a:[scope['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: "" } })).toBe(
|
expect(compileExpr("'x'.toUpperCase(a)", { a: { id: "_v5", expr: "scope._v5" } })).toBe(
|
||||||
"'x'.toUpperCase(_v5)"
|
"'x'.toUpperCase(scope._v5)"
|
||||||
);
|
);
|
||||||
expect(compileExpr("'x'.toUpperCase({b: a})", { a: { id: "_v5", expr: "" } })).toBe(
|
expect(compileExpr("'x'.toUpperCase({b: a})", { a: { id: "_v5", expr: "scope._v5" } })).toBe(
|
||||||
"'x'.toUpperCase({b:_v5})"
|
"'x'.toUpperCase({b:scope._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)"
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
|
||||||
|
|
||||||
exports[`Link component can render simple cases 1`] = `
|
|
||||||
"function anonymous(context,extra
|
|
||||||
) {
|
|
||||||
let utils = this.constructor.utils;
|
|
||||||
let owner = context;
|
|
||||||
var h = this.h;
|
|
||||||
let _1 = utils.toObj({'router-link-active':context['isActive']});
|
|
||||||
var _2 = context['href'];
|
|
||||||
let c3 = [], p3 = {key:3,attrs:{href: _2},class:_1,on:{}};
|
|
||||||
var vn3 = h('a', p3, c3);
|
|
||||||
if (!context['navigate']) {
|
|
||||||
throw new Error('Missing handler \\\\'' + 'navigate' + \`\\\\' when evaluating template '__owl__-router-link'\`)
|
|
||||||
}
|
|
||||||
extra.handlers['click' + 3] = extra.handlers['click' + 3] || context['navigate'].bind(owner);
|
|
||||||
p3.on['click'] = extra.handlers['click' + 3];
|
|
||||||
const slot4 = this.slots[context.__owl__.slotId + '_' + 'default'];
|
|
||||||
if (slot4) {
|
|
||||||
slot4(context.__owl__.parent, Object.assign({}, extra, {parentNode: c3, vars: extra.vars, parent: owner}));
|
|
||||||
}
|
|
||||||
return vn3;
|
|
||||||
}"
|
|
||||||
`;
|
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
|
||||||
|
|
||||||
exports[`router directive t-routecomponent can render parameterized route 1`] = `
|
|
||||||
"function anonymous(context,extra
|
|
||||||
) {
|
|
||||||
let utils = this.constructor.utils;
|
|
||||||
let QWeb = this.constructor;
|
|
||||||
let parent = context;
|
|
||||||
let owner = context;
|
|
||||||
var h = this.h;
|
|
||||||
let c1 = [], p1 = {key:1};
|
|
||||||
var vn1 = h('div', p1, c1);
|
|
||||||
if (context['env'].router.currentRouteName==='book') {
|
|
||||||
//COMPONENT
|
|
||||||
let def3;
|
|
||||||
let w4 = 4 in parent.__owl__.cmap ? parent.__owl__.children[parent.__owl__.cmap[4]] : false;
|
|
||||||
let _2_index = c1.length;
|
|
||||||
c1.push(null);
|
|
||||||
let props4 = {title:context['env'].router.currentParams.title};
|
|
||||||
if (w4 && w4.__owl__.renderPromise && !w4.__owl__.vnode) {
|
|
||||||
if (utils.shallowEqual(props4, w4.__owl__.renderProps)) {
|
|
||||||
def3 = w4.__owl__.renderPromise;
|
|
||||||
} else {
|
|
||||||
w4.destroy();
|
|
||||||
w4 = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!w4) {
|
|
||||||
let componentKey4 = \`__component__book\`;
|
|
||||||
let W4 = context.components && context.components[componentKey4] || QWeb.components[componentKey4];
|
|
||||||
if (!W4) {throw new Error('Cannot find the definition of component \\"' + componentKey4 + '\\"')}
|
|
||||||
w4 = new W4(parent, props4);
|
|
||||||
parent.__owl__.cmap[4] = w4.__owl__.id;
|
|
||||||
def3 = w4.__prepare();
|
|
||||||
def3 = def3.then(vnode=>{let pvnode=h(vnode.sel, {key: 4, hook: {insert(vn) {let nvn=w4.__mount(vnode, pvnode.elm);pvnode.elm=nvn.elm;},remove() {},destroy(vn) {w4.destroy();}}});c1[_2_index]=pvnode;w4.__owl__.pvnode = pvnode;});
|
|
||||||
} else {
|
|
||||||
def3 = def3 || w4.__updateProps(props4, extra.forceUpdate, extra.patchQueue);
|
|
||||||
def3 = def3.then(()=>{if (w4.__owl__.isDestroyed) {return};let pvnode=w4.__owl__.pvnode;c1[_2_index]=pvnode;});
|
|
||||||
}
|
|
||||||
extra.promises.push(def3);
|
|
||||||
}
|
|
||||||
return vn1;
|
|
||||||
}"
|
|
||||||
`;
|
|
||||||
|
|
||||||
exports[`router directive t-routecomponent can render parameterized route with suffixes 1`] = `
|
|
||||||
"function anonymous(context,extra
|
|
||||||
) {
|
|
||||||
let utils = this.constructor.utils;
|
|
||||||
let QWeb = this.constructor;
|
|
||||||
let parent = context;
|
|
||||||
let owner = context;
|
|
||||||
var h = this.h;
|
|
||||||
let c1 = [], p1 = {key:1};
|
|
||||||
var vn1 = h('div', p1, c1);
|
|
||||||
if (context['env'].router.currentRouteName==='book') {
|
|
||||||
//COMPONENT
|
|
||||||
let def3;
|
|
||||||
let w4 = 4 in parent.__owl__.cmap ? parent.__owl__.children[parent.__owl__.cmap[4]] : false;
|
|
||||||
let _2_index = c1.length;
|
|
||||||
c1.push(null);
|
|
||||||
let props4 = {title:context['env'].router.currentParams.title,val:context['env'].router.currentParams.val};
|
|
||||||
if (w4 && w4.__owl__.renderPromise && !w4.__owl__.vnode) {
|
|
||||||
if (utils.shallowEqual(props4, w4.__owl__.renderProps)) {
|
|
||||||
def3 = w4.__owl__.renderPromise;
|
|
||||||
} else {
|
|
||||||
w4.destroy();
|
|
||||||
w4 = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!w4) {
|
|
||||||
let componentKey4 = \`__component__book\`;
|
|
||||||
let W4 = context.components && context.components[componentKey4] || QWeb.components[componentKey4];
|
|
||||||
if (!W4) {throw new Error('Cannot find the definition of component \\"' + componentKey4 + '\\"')}
|
|
||||||
w4 = new W4(parent, props4);
|
|
||||||
parent.__owl__.cmap[4] = w4.__owl__.id;
|
|
||||||
def3 = w4.__prepare();
|
|
||||||
def3 = def3.then(vnode=>{let pvnode=h(vnode.sel, {key: 4, hook: {insert(vn) {let nvn=w4.__mount(vnode, pvnode.elm);pvnode.elm=nvn.elm;},remove() {},destroy(vn) {w4.destroy();}}});c1[_2_index]=pvnode;w4.__owl__.pvnode = pvnode;});
|
|
||||||
} else {
|
|
||||||
def3 = def3 || w4.__updateProps(props4, extra.forceUpdate, extra.patchQueue);
|
|
||||||
def3 = def3.then(()=>{if (w4.__owl__.isDestroyed) {return};let pvnode=w4.__owl__.pvnode;c1[_2_index]=pvnode;});
|
|
||||||
}
|
|
||||||
extra.promises.push(def3);
|
|
||||||
}
|
|
||||||
return vn1;
|
|
||||||
}"
|
|
||||||
`;
|
|
||||||
|
|
||||||
exports[`router directive t-routecomponent can render simple cases 1`] = `
|
|
||||||
"function anonymous(context,extra
|
|
||||||
) {
|
|
||||||
let utils = this.constructor.utils;
|
|
||||||
let QWeb = this.constructor;
|
|
||||||
let parent = context;
|
|
||||||
let owner = context;
|
|
||||||
var h = this.h;
|
|
||||||
let c1 = [], p1 = {key:1};
|
|
||||||
var vn1 = h('div', p1, c1);
|
|
||||||
if (context['env'].router.currentRouteName==='about') {
|
|
||||||
//COMPONENT
|
|
||||||
let def3;
|
|
||||||
let w4 = 4 in parent.__owl__.cmap ? parent.__owl__.children[parent.__owl__.cmap[4]] : false;
|
|
||||||
let _2_index = c1.length;
|
|
||||||
c1.push(null);
|
|
||||||
let props4 = {};
|
|
||||||
if (w4 && w4.__owl__.renderPromise && !w4.__owl__.vnode) {
|
|
||||||
if (utils.shallowEqual(props4, w4.__owl__.renderProps)) {
|
|
||||||
def3 = w4.__owl__.renderPromise;
|
|
||||||
} else {
|
|
||||||
w4.destroy();
|
|
||||||
w4 = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!w4) {
|
|
||||||
let componentKey4 = \`__component__about\`;
|
|
||||||
let W4 = context.components && context.components[componentKey4] || QWeb.components[componentKey4];
|
|
||||||
if (!W4) {throw new Error('Cannot find the definition of component \\"' + componentKey4 + '\\"')}
|
|
||||||
w4 = new W4(parent, props4);
|
|
||||||
parent.__owl__.cmap[4] = w4.__owl__.id;
|
|
||||||
def3 = w4.__prepare();
|
|
||||||
def3 = def3.then(vnode=>{let pvnode=h(vnode.sel, {key: 4, hook: {insert(vn) {let nvn=w4.__mount(vnode, pvnode.elm);pvnode.elm=nvn.elm;},remove() {},destroy(vn) {w4.destroy();}}});c1[_2_index]=pvnode;w4.__owl__.pvnode = pvnode;});
|
|
||||||
} else {
|
|
||||||
def3 = def3 || w4.__updateProps(props4, extra.forceUpdate, extra.patchQueue);
|
|
||||||
def3 = def3.then(()=>{if (w4.__owl__.isDestroyed) {return};let pvnode=w4.__owl__.pvnode;c1[_2_index]=pvnode;});
|
|
||||||
}
|
|
||||||
extra.promises.push(def3);
|
|
||||||
}
|
|
||||||
else if (context['env'].router.currentRouteName==='users') {
|
|
||||||
//COMPONENT
|
|
||||||
let def6;
|
|
||||||
let w7 = 7 in parent.__owl__.cmap ? parent.__owl__.children[parent.__owl__.cmap[7]] : false;
|
|
||||||
let _5_index = c1.length;
|
|
||||||
c1.push(null);
|
|
||||||
let props7 = {};
|
|
||||||
if (w7 && w7.__owl__.renderPromise && !w7.__owl__.vnode) {
|
|
||||||
if (utils.shallowEqual(props7, w7.__owl__.renderProps)) {
|
|
||||||
def6 = w7.__owl__.renderPromise;
|
|
||||||
} else {
|
|
||||||
w7.destroy();
|
|
||||||
w7 = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!w7) {
|
|
||||||
let componentKey7 = \`__component__users\`;
|
|
||||||
let W7 = context.components && context.components[componentKey7] || QWeb.components[componentKey7];
|
|
||||||
if (!W7) {throw new Error('Cannot find the definition of component \\"' + componentKey7 + '\\"')}
|
|
||||||
w7 = new W7(parent, props7);
|
|
||||||
parent.__owl__.cmap[7] = w7.__owl__.id;
|
|
||||||
def6 = w7.__prepare();
|
|
||||||
def6 = def6.then(vnode=>{let pvnode=h(vnode.sel, {key: 7, hook: {insert(vn) {let nvn=w7.__mount(vnode, pvnode.elm);pvnode.elm=nvn.elm;},remove() {},destroy(vn) {w7.destroy();}}});c1[_5_index]=pvnode;w7.__owl__.pvnode = pvnode;});
|
|
||||||
} else {
|
|
||||||
def6 = def6 || w7.__updateProps(props7, extra.forceUpdate, extra.patchQueue);
|
|
||||||
def6 = def6.then(()=>{if (w7.__owl__.isDestroyed) {return};let pvnode=w7.__owl__.pvnode;c1[_5_index]=pvnode;});
|
|
||||||
}
|
|
||||||
extra.promises.push(def6);
|
|
||||||
}
|
|
||||||
return vn1;
|
|
||||||
}"
|
|
||||||
`;
|
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
|
exports[`Link component can render simple cases 1`] = `
|
||||||
|
"function anonymous(context, extra
|
||||||
|
) {
|
||||||
|
// Template name: \\"__template__1\\"
|
||||||
|
let utils = this.constructor.utils;
|
||||||
|
let scope = Object.create(context);
|
||||||
|
var h = this.h;
|
||||||
|
let _5 = utils.toObj({'router-link-active':scope['isActive']});
|
||||||
|
var _6 = scope['href'];
|
||||||
|
let c7 = [], p7 = {key:7,attrs:{href: _6},class:_5,on:{}};
|
||||||
|
var vn7 = h('a', p7, c7);
|
||||||
|
extra.handlers['click' + 7] = extra.handlers['click' + 7] || function (e) {if (!context.__owl__.isMounted){return}const fn = context['navigate'];if (fn) { fn.call(context, e); } else { context.navigate; }};
|
||||||
|
p7.on['click'] = extra.handlers['click' + 7];
|
||||||
|
const slot8 = this.constructor.slots[context.__owl__.slotId + '_' + 'default'];
|
||||||
|
if (slot8) {
|
||||||
|
slot8.call(this, context.__owl__.scope, Object.assign({}, extra, {parentNode: c7, parent: extra.parent || context}));
|
||||||
|
}
|
||||||
|
return vn7;
|
||||||
|
}"
|
||||||
|
`;
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
|
exports[`RouteComponent can render simple cases 1`] = `
|
||||||
|
"function anonymous(context, extra
|
||||||
|
) {
|
||||||
|
// Template name: \\"__template__1\\"
|
||||||
|
let utils = this.constructor.utils;
|
||||||
|
let QWeb = this.constructor;
|
||||||
|
let parent = context;
|
||||||
|
let scope = Object.create(context);
|
||||||
|
let result;
|
||||||
|
var h = this.h;
|
||||||
|
if (scope['routeComponent']) {
|
||||||
|
const nodeKey5 = scope['env'].router.currentRouteName;
|
||||||
|
//COMPONENT
|
||||||
|
let k7 = \`__8__\` + nodeKey5;
|
||||||
|
let w6 = k7 in parent.__owl__.cmap ? parent.__owl__.children[parent.__owl__.cmap[k7]] : false;
|
||||||
|
let vn9 = {};
|
||||||
|
result = vn9;
|
||||||
|
let props6 = Object.assign({}, scope['env'].router.currentParams);
|
||||||
|
if (w6 && w6.__owl__.currentFiber && !w6.__owl__.vnode) {
|
||||||
|
w6.destroy();
|
||||||
|
w6 = false;
|
||||||
|
}
|
||||||
|
if (w6) {
|
||||||
|
w6.__updateProps(props6, extra.fiber, undefined);
|
||||||
|
let pvnode = w6.__owl__.pvnode;
|
||||||
|
utils.defineProxy(vn9, pvnode);
|
||||||
|
} else {
|
||||||
|
let componentKey6 = \`routeComponent\`;
|
||||||
|
let W6 = context.constructor.components[componentKey6] || QWeb.components[componentKey6]|| scope['routeComponent'];
|
||||||
|
if (!W6) {throw new Error('Cannot find the definition of component \\"' + componentKey6 + '\\"')}
|
||||||
|
w6 = new W6(parent, props6);
|
||||||
|
parent.__owl__.cmap[k7] = w6.__owl__.id;
|
||||||
|
let fiber = w6.__prepare(extra.fiber, undefined, () => { const vnode = fiber.vnode; pvnode.sel = vnode.sel; });
|
||||||
|
let pvnode = h('dummy', {key: k7, hook: {remove() {},destroy(vn) {w6.destroy();}}});
|
||||||
|
utils.defineProxy(vn9, pvnode);
|
||||||
|
w6.__owl__.pvnode = pvnode;
|
||||||
|
}
|
||||||
|
w6.__owl__.parentLastFiberId = extra.fiber.id;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}"
|
||||||
|
`;
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Component } from "../../src/component/component";
|
import { Component } from "../../src/component/component";
|
||||||
import { Link, LINK_TEMPLATE_NAME } from "../../src/router/Link";
|
import { Link } from "../../src/router/link";
|
||||||
import { RouterEnv } from "../../src/router/Router";
|
import { RouterEnv } from "../../src/router/router";
|
||||||
import { makeTestEnv, makeTestFixture, nextTick } from "../helpers";
|
import { makeTestEnv, makeTestFixture, nextTick } from "../helpers";
|
||||||
import { TestRouter } from "./TestRouter";
|
import { TestRouter } from "./test_router";
|
||||||
|
|
||||||
describe("Link component", () => {
|
describe("Link component", () => {
|
||||||
let fixture: HTMLElement;
|
let fixture: HTMLElement;
|
||||||
@@ -12,6 +12,7 @@ describe("Link component", () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
fixture = makeTestFixture();
|
fixture = makeTestFixture();
|
||||||
env = <RouterEnv>makeTestEnv();
|
env = <RouterEnv>makeTestEnv();
|
||||||
|
Component.env = env;
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -30,15 +31,18 @@ describe("Link component", () => {
|
|||||||
</div>
|
</div>
|
||||||
</templates>
|
</templates>
|
||||||
`);
|
`);
|
||||||
class App extends Component<any, any, any> {
|
class App extends Component<any, any> {
|
||||||
components = { Link: Link };
|
static components = { Link: Link };
|
||||||
}
|
}
|
||||||
|
|
||||||
const routes = [{ name: "about", path: "/about" }, { name: "users", path: "/users" }];
|
const routes = [
|
||||||
|
{ name: "about", path: "/about" },
|
||||||
|
{ name: "users", path: "/users" }
|
||||||
|
];
|
||||||
|
|
||||||
router = new TestRouter(env, routes, { mode: "history" });
|
router = new TestRouter(env, routes, { mode: "history" });
|
||||||
router.navigate({ to: "users" });
|
router.navigate({ to: "users" });
|
||||||
const app = new App(env);
|
const app = new App();
|
||||||
await app.mount(fixture);
|
await app.mount(fixture);
|
||||||
expect(fixture.innerHTML).toBe('<div><a href="/about">About</a></div>');
|
expect(fixture.innerHTML).toBe('<div><a href="/about">About</a></div>');
|
||||||
|
|
||||||
@@ -50,7 +54,7 @@ describe("Link component", () => {
|
|||||||
'<div><a href="/about" class="router-link-active">About</a></div>'
|
'<div><a href="/about" class="router-link-active">About</a></div>'
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(env.qweb.templates[LINK_TEMPLATE_NAME].fn.toString()).toMatchSnapshot();
|
expect(env.qweb.templates[Link.template].fn.toString()).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("do not redirect if right clicking", async () => {
|
test("do not redirect if right clicking", async () => {
|
||||||
@@ -61,15 +65,18 @@ describe("Link component", () => {
|
|||||||
</div>
|
</div>
|
||||||
</templates>
|
</templates>
|
||||||
`);
|
`);
|
||||||
class App extends Component<any, any, any> {
|
class App extends Component<any, any> {
|
||||||
components = { Link: Link };
|
static components = { Link: Link };
|
||||||
}
|
}
|
||||||
|
|
||||||
const routes = [{ name: "about", path: "/about" }, { name: "users", path: "/users" }];
|
const routes = [
|
||||||
|
{ name: "about", path: "/about" },
|
||||||
|
{ name: "users", path: "/users" }
|
||||||
|
];
|
||||||
|
|
||||||
router = new TestRouter(env, routes, { mode: "history" });
|
router = new TestRouter(env, routes, { mode: "history" });
|
||||||
router.navigate({ to: "users" });
|
router.navigate({ to: "users" });
|
||||||
const app = new App(env);
|
const app = new App();
|
||||||
await app.mount(fixture);
|
await app.mount(fixture);
|
||||||
|
|
||||||
expect(window.location.pathname).toBe("/users");
|
expect(window.location.pathname).toBe("/users");
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import { Component } from "../../src/component/component";
|
import { Component } from "../../src/component/component";
|
||||||
import { RouterEnv } from "../../src/router/Router";
|
import { RouterEnv } from "../../src/router/router";
|
||||||
|
import { RouteComponent } from "../../src/router/route_component";
|
||||||
import { makeTestEnv, makeTestFixture, nextTick } from "../helpers";
|
import { makeTestEnv, makeTestFixture, nextTick } from "../helpers";
|
||||||
import { TestRouter } from "./TestRouter";
|
import { TestRouter } from "./test_router";
|
||||||
|
|
||||||
describe("router directive t-routecomponent", () => {
|
describe("RouteComponent", () => {
|
||||||
let fixture: HTMLElement;
|
let fixture: HTMLElement;
|
||||||
let env: RouterEnv;
|
let env: RouterEnv;
|
||||||
let router: TestRouter | null = null;
|
let router: TestRouter | null = null;
|
||||||
@@ -11,6 +12,7 @@ describe("router directive t-routecomponent", () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
fixture = makeTestFixture();
|
fixture = makeTestFixture();
|
||||||
env = <RouterEnv>makeTestEnv();
|
env = <RouterEnv>makeTestEnv();
|
||||||
|
Component.env = env;
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -25,16 +27,16 @@ describe("router directive t-routecomponent", () => {
|
|||||||
env.qweb.addTemplates(`
|
env.qweb.addTemplates(`
|
||||||
<templates>
|
<templates>
|
||||||
<div t-name="App">
|
<div t-name="App">
|
||||||
<t t-routecomponent="1" />
|
<RouteComponent />
|
||||||
</div>
|
</div>
|
||||||
<span t-name="About">About</span>
|
<span t-name="About">About</span>
|
||||||
<span t-name="Users">Users</span>
|
<span t-name="Users">Users</span>
|
||||||
</templates>
|
</templates>
|
||||||
`);
|
`);
|
||||||
class About extends Component<any, any, any> {}
|
class About extends Component<any, any> {}
|
||||||
class Users extends Component<any, any, any> {}
|
class Users extends Component<any, any> {}
|
||||||
class App extends Component<any, any, any> {
|
class App extends Component<any, any> {
|
||||||
components = { About, Users };
|
static components = { RouteComponent };
|
||||||
}
|
}
|
||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
@@ -44,63 +46,61 @@ describe("router directive t-routecomponent", () => {
|
|||||||
|
|
||||||
router = new TestRouter(env, routes, { mode: "history" });
|
router = new TestRouter(env, routes, { mode: "history" });
|
||||||
await router.navigate({ to: "about" });
|
await router.navigate({ to: "about" });
|
||||||
const app = new App(env);
|
const app = new App();
|
||||||
await app.mount(fixture);
|
await app.mount(fixture);
|
||||||
expect(fixture.innerHTML).toBe("<div><span>About</span></div>");
|
expect(fixture.innerHTML).toBe("<div><span>About</span></div>");
|
||||||
|
|
||||||
await router.navigate({ to: "users" });
|
await router.navigate({ to: "users" });
|
||||||
await nextTick();
|
await nextTick();
|
||||||
expect(fixture.innerHTML).toBe("<div><span>Users</span></div>");
|
expect(fixture.innerHTML).toBe("<div><span>Users</span></div>");
|
||||||
expect(env.qweb.templates.App.fn.toString()).toMatchSnapshot();
|
expect(env.qweb.templates[RouteComponent.template].fn.toString()).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("can render parameterized route", async () => {
|
test("can render parameterized route", async () => {
|
||||||
env.qweb.addTemplates(`
|
env.qweb.addTemplates(`
|
||||||
<templates>
|
<templates>
|
||||||
<div t-name="App">
|
<div t-name="App">
|
||||||
<t t-routecomponent="1" />
|
<RouteComponent />
|
||||||
</div>
|
</div>
|
||||||
<span t-name="Book">Book <t t-esc="props.title"/></span>
|
<span t-name="Book">Book <t t-esc="props.title"/></span>
|
||||||
</templates>
|
</templates>
|
||||||
`);
|
`);
|
||||||
class Book extends Component<any, any, any> {}
|
class Book extends Component<any, any> {}
|
||||||
class App extends Component<any, any, any> {
|
class App extends Component<any, any> {
|
||||||
components = { Book };
|
static components = { RouteComponent };
|
||||||
}
|
}
|
||||||
|
|
||||||
const routes = [{ name: "book", path: "/book/{{title}}", component: Book }];
|
const routes = [{ name: "book", path: "/book/{{title}}", component: Book }];
|
||||||
router = new TestRouter(env, routes, { mode: "history" });
|
router = new TestRouter(env, routes, { mode: "history" });
|
||||||
await router.navigate({ to: "book", params: { title: "1984" } });
|
await router.navigate({ to: "book", params: { title: "1984" } });
|
||||||
const app = new App(env);
|
const app = new App();
|
||||||
await app.mount(fixture);
|
await app.mount(fixture);
|
||||||
expect(fixture.innerHTML).toBe("<div><span>Book 1984</span></div>");
|
expect(fixture.innerHTML).toBe("<div><span>Book 1984</span></div>");
|
||||||
expect(env.qweb.templates.App.fn.toString()).toMatchSnapshot();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("can render parameterized route with suffixes", async () => {
|
test("can render parameterized route with suffixes", async () => {
|
||||||
env.qweb.addTemplates(`
|
env.qweb.addTemplates(`
|
||||||
<templates>
|
<templates>
|
||||||
<div t-name="App">
|
<div t-name="App">
|
||||||
<t t-routecomponent="1" />
|
<RouteComponent />
|
||||||
</div>
|
</div>
|
||||||
<span t-name="Book">Book <t t-esc="props.title"/>|<t t-esc="incVal"/></span>
|
<span t-name="Book">Book <t t-esc="props.title"/>|<t t-esc="incVal"/></span>
|
||||||
</templates>
|
</templates>
|
||||||
`);
|
`);
|
||||||
class Book extends Component<any, any, any> {
|
class Book extends Component<any, any> {
|
||||||
get incVal() {
|
get incVal() {
|
||||||
return this.props.val + 1;
|
return this.props.val + 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
class App extends Component<any, any, any> {
|
class App extends Component<any, any> {
|
||||||
components = { Book };
|
static components = { RouteComponent };
|
||||||
}
|
}
|
||||||
|
|
||||||
const routes = [{ name: "book", path: "/book/{{title}}/{{val.number}}", component: Book }];
|
const routes = [{ name: "book", path: "/book/{{title}}/{{val.number}}", component: Book }];
|
||||||
router = new TestRouter(env, routes, { mode: "history" });
|
router = new TestRouter(env, routes, { mode: "history" });
|
||||||
await router.navigate({ to: "book", params: { title: "1984", val: "123" } });
|
await router.navigate({ to: "book", params: { title: "1984", val: "123" } });
|
||||||
const app = new App(env);
|
const app = new App();
|
||||||
await app.mount(fixture);
|
await app.mount(fixture);
|
||||||
expect(fixture.innerHTML).toBe("<div><span>Book 1984|124</span></div>");
|
expect(fixture.innerHTML).toBe("<div><span>Book 1984|124</span></div>");
|
||||||
expect(env.qweb.templates.App.fn.toString()).toMatchSnapshot();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+112
-26
@@ -1,6 +1,6 @@
|
|||||||
import { Destination, Router, RouterEnv, Route } from "../../src/router/Router";
|
import { Destination, RouterEnv, Route } from "../../src/router/router";
|
||||||
import { makeTestEnv } from "../helpers";
|
import { makeTestEnv, nextTick } from "../helpers";
|
||||||
import { TestRouter } from "./TestRouter";
|
import { TestRouter } from "./test_router";
|
||||||
|
|
||||||
let env: RouterEnv;
|
let env: RouterEnv;
|
||||||
let router: TestRouter | null = null;
|
let router: TestRouter | null = null;
|
||||||
@@ -25,18 +25,66 @@ describe("router miscellaneous", () => {
|
|||||||
]);
|
]);
|
||||||
}).toThrow(`Invalid destination: {"abc":"hey"}`);
|
}).toThrow(`Invalid destination: {"abc":"hey"}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("navigate to same route but with different params should trigger update", async () => {
|
||||||
|
router = new TestRouter(env, [{ name: "users", path: "/users/{{id}}" }]);
|
||||||
|
env.qweb.forceUpdate = jest.fn();
|
||||||
|
await router.navigate({ to: "users", params: { id: 3 } });
|
||||||
|
expect(window.location.pathname).toBe("/users/3");
|
||||||
|
expect(env.qweb.forceUpdate).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
await router.navigate({ to: "users", params: { id: 5 } });
|
||||||
|
expect(window.location.pathname).toBe("/users/5");
|
||||||
|
expect(env.qweb.forceUpdate).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("changing url to same route but with different params should trigger update (hash mode)", async () => {
|
||||||
|
env.qweb.forceUpdate = jest.fn();
|
||||||
|
router = new TestRouter(env, [{ name: "users", path: "/users/{{id}}" }], { mode: "hash" });
|
||||||
|
await router.start();
|
||||||
|
await router.navigate({ to: "users", params: { id: 3 } });
|
||||||
|
expect(window.location.hash).toBe("#/users/3");
|
||||||
|
expect(env.qweb.forceUpdate).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
window.location.hash = "/users/5";
|
||||||
|
window.dispatchEvent(new Event("hashchange"));
|
||||||
|
await nextTick();
|
||||||
|
expect(window.location.hash).toBe("#/users/5");
|
||||||
|
expect(env.qweb.forceUpdate).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("navigate in hash mode preserve location", async () => {
|
||||||
|
router = new TestRouter(env, [{ name: "users", path: "/users/{{id}}" }], { mode: "hash" });
|
||||||
|
window.history.pushState({}, "title", window.location.origin + "/test.html");
|
||||||
|
expect(window.location.href).toBe("http://localhost/test.html");
|
||||||
|
await router.navigate({ to: "users", params: { id: 3 } });
|
||||||
|
expect(window.location.href).toBe("http://localhost/test.html#/users/3");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("routeToPath", () => {
|
describe("routeToPath", () => {
|
||||||
const routeToPath = Router.prototype["routeToPath"];
|
|
||||||
test("simple non parameterized path", () => {
|
test("simple non parameterized path", () => {
|
||||||
expect(routeToPath({path: "/abc"} as Route, {})).toBe("/abc");
|
router = new TestRouter(env, []);
|
||||||
expect(routeToPath({path: "/abc/def"} as Route, {})).toBe("/abc/def");
|
expect(router["routeToPath"]({ path: "/abc" } as Route, {})).toBe("/abc");
|
||||||
expect(routeToPath({path: "/abc"} as Route, { val: 12 })).toBe("/abc");
|
expect(router["routeToPath"]({ path: "/abc/def" } as Route, {})).toBe("/abc/def");
|
||||||
|
expect(router["routeToPath"]({ path: "/abc" } as Route, { val: 12 })).toBe("/abc");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("simple parameterized path", () => {
|
test("simple parameterized path", () => {
|
||||||
expect(routeToPath({path: "/abc/{{def}}"} as Route, { def: 34 })).toBe("/abc/34");
|
router = new TestRouter(env, []);
|
||||||
|
expect(router["routeToPath"]({ path: "/abc/{{def}}" } as Route, { def: 34 })).toBe("/abc/34");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("simple non parameterized path, mode = hash", () => {
|
||||||
|
router = new TestRouter(env, [], { mode: "hash" });
|
||||||
|
expect(router["routeToPath"]({ path: "/abc" } as Route, {})).toBe("#/abc");
|
||||||
|
expect(router["routeToPath"]({ path: "/abc/def" } as Route, {})).toBe("#/abc/def");
|
||||||
|
expect(router["routeToPath"]({ path: "/abc" } as Route, { val: 12 })).toBe("#/abc");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("simple parameterized path, mode=hash", () => {
|
||||||
|
router = new TestRouter(env, [], { mode: "hash" });
|
||||||
|
expect(router["routeToPath"]({ path: "/abc/{{def}}" } as Route, { def: 34 })).toBe("#/abc/34");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -58,26 +106,60 @@ describe("destToPath", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("getRouteParams", () => {
|
describe("getRouteParams", () => {
|
||||||
const getRouteParams = Router.prototype["getRouteParams"];
|
|
||||||
test("properly match simple routes", () => {
|
test("properly match simple routes", () => {
|
||||||
|
router = new TestRouter(env, []);
|
||||||
// simple route
|
// simple route
|
||||||
expect(getRouteParams({path: "/home"} as Route, "/home")).toEqual({});
|
expect(router["getRouteParams"]({ path: "/home" } as Route, "/home")).toEqual({});
|
||||||
|
|
||||||
// no match
|
// no match
|
||||||
expect(getRouteParams({path: "/home"} as Route, "/otherpath")).toEqual(false);
|
expect(router["getRouteParams"]({ path: "/home" } as Route, "/otherpath")).toEqual(false);
|
||||||
|
|
||||||
// fallback route
|
// fallback route
|
||||||
expect(getRouteParams({path: "*"} as Route, "somepath")).toEqual({});
|
expect(router["getRouteParams"]({ path: "*" } as Route, "somepath")).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("properly match simple routes, mode hash", () => {
|
||||||
|
router = new TestRouter(env, [], { mode: "hash" });
|
||||||
|
// simple route
|
||||||
|
expect(router["getRouteParams"]({ path: "/home" } as Route, "#/home")).toEqual({});
|
||||||
|
|
||||||
|
// no match
|
||||||
|
expect(router["getRouteParams"]({ path: "/home" } as Route, "#/otherpath")).toEqual(false);
|
||||||
|
|
||||||
|
// fallback route
|
||||||
|
expect(router["getRouteParams"]({ path: "*" } as Route, "#/somepath")).toEqual({});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("match some parameterized routes", () => {
|
test("match some parameterized routes", () => {
|
||||||
expect(getRouteParams({path: "/invoices/{{id}}"} as Route, "/invoices/3")).toEqual({
|
router = new TestRouter(env, []);
|
||||||
|
expect(router["getRouteParams"]({ path: "/invoices/{{id}}" } as Route, "/invoices/3")).toEqual({
|
||||||
id: "3"
|
id: "3"
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("match some parameterized routes, mode hash", () => {
|
||||||
|
router = new TestRouter(env, [], { mode: "hash" });
|
||||||
|
expect(router["getRouteParams"]({ path: "/invoices/{{id}}" } as Route, "#/invoices/3")).toEqual(
|
||||||
|
{
|
||||||
|
id: "3"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test("can convert to number if needed", () => {
|
test("can convert to number if needed", () => {
|
||||||
expect(getRouteParams({path: "/invoices/{{id.number}}"} as Route, "/invoices/3")).toEqual({
|
router = new TestRouter(env, []);
|
||||||
|
expect(
|
||||||
|
router["getRouteParams"]({ path: "/invoices/{{id.number}}" } as Route, "/invoices/3")
|
||||||
|
).toEqual({
|
||||||
|
id: 3
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("can convert to number if needed, mode: hash", () => {
|
||||||
|
router = new TestRouter(env, [], { mode: "hash" });
|
||||||
|
expect(
|
||||||
|
router["getRouteParams"]({ path: "/invoices/{{id.number}}" } as Route, "#/invoices/3")
|
||||||
|
).toEqual({
|
||||||
id: 3
|
id: 3
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -131,9 +213,9 @@ describe("beforeRouteEnter", () => {
|
|||||||
expect(window.location.pathname).toBe("/");
|
expect(window.location.pathname).toBe("/");
|
||||||
const guard = jest.fn(() => false);
|
const guard = jest.fn(() => false);
|
||||||
router = new TestRouter(env, [
|
router = new TestRouter(env, [
|
||||||
{ name: "routea", path: "/some/patha"},
|
{ name: "routea", path: "/some/patha" },
|
||||||
{ name: "routeb", path: "/some/pathb", beforeRouteEnter: guard }
|
{ name: "routeb", path: "/some/pathb", beforeRouteEnter: guard }
|
||||||
]);
|
]);
|
||||||
|
|
||||||
await router.start();
|
await router.start();
|
||||||
await router.navigate({ to: "routea" });
|
await router.navigate({ to: "routea" });
|
||||||
@@ -147,12 +229,14 @@ describe("beforeRouteEnter", () => {
|
|||||||
|
|
||||||
test("navigation is redirected if guard decides so", async () => {
|
test("navigation is redirected if guard decides so", async () => {
|
||||||
expect(window.location.pathname).toBe("/");
|
expect(window.location.pathname).toBe("/");
|
||||||
const guard = jest.fn(() => {return {to: "routec"}});
|
const guard = jest.fn(() => {
|
||||||
|
return { to: "routec" };
|
||||||
|
});
|
||||||
router = new TestRouter(env, [
|
router = new TestRouter(env, [
|
||||||
{ name: "routea", path: "/some/patha"},
|
{ name: "routea", path: "/some/patha" },
|
||||||
{ name: "routeb", path: "/some/pathb", beforeRouteEnter: guard },
|
{ name: "routeb", path: "/some/pathb", beforeRouteEnter: guard },
|
||||||
{ name: "routec", path: "/some/pathc"},
|
{ name: "routec", path: "/some/pathc" }
|
||||||
]);
|
]);
|
||||||
|
|
||||||
await router.start();
|
await router.start();
|
||||||
const result = await router.navigate({ to: "routea" });
|
const result = await router.navigate({ to: "routea" });
|
||||||
@@ -166,11 +250,13 @@ describe("beforeRouteEnter", () => {
|
|||||||
|
|
||||||
test("navigation is initially redirected if guard decides so", async () => {
|
test("navigation is initially redirected if guard decides so", async () => {
|
||||||
expect(window.location.pathname).toBe("/");
|
expect(window.location.pathname).toBe("/");
|
||||||
const guard = jest.fn(() => {return {to: "otherroute"}});
|
const guard = jest.fn(() => {
|
||||||
|
return { to: "otherroute" };
|
||||||
|
});
|
||||||
router = new TestRouter(env, [
|
router = new TestRouter(env, [
|
||||||
{ name: "landing", path: "/", beforeRouteEnter: guard},
|
{ name: "landing", path: "/", beforeRouteEnter: guard },
|
||||||
{ name: "otherroute", path: "/some/other/route"}
|
{ name: "otherroute", path: "/some/other/route" }
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(window.location.pathname).toBe("/");
|
expect(window.location.pathname).toBe("/");
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import { Router } from "../../src/router/Router";
|
import { Router } from "../../src/router/router";
|
||||||
import { QWeb } from "../../src/qweb/index";
|
import { QWeb } from "../../src/qweb/index";
|
||||||
|
|
||||||
export class TestRouter extends Router {
|
export class TestRouter extends Router {
|
||||||
destroy() {
|
destroy() {
|
||||||
window.removeEventListener("popstate", (this as any)._listener);
|
window.removeEventListener("popstate", (this as any)._listener);
|
||||||
delete QWeb.DIRECTIVE_NAMES.routecomponent;
|
|
||||||
QWeb.DIRECTIVES = QWeb.DIRECTIVES.filter(d => d.name !== "routecomponent");
|
|
||||||
|
|
||||||
// remove component defined inroutes
|
// remove component defined inroutes
|
||||||
for (let key in QWeb.components) {
|
for (let key in QWeb.components) {
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
import { Env } from "../src/component/component";
|
||||||
|
import { Store, Getter } from "../src/store";
|
||||||
|
import { nextTick, nextMicroTick } from "./helpers";
|
||||||
|
|
||||||
|
describe("basic use", () => {
|
||||||
|
test("dispatch an action", () => {
|
||||||
|
const state = { n: 1 };
|
||||||
|
const actions = {
|
||||||
|
inc({ state }, delta) {
|
||||||
|
state.n += delta;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const store = new Store({ state, actions });
|
||||||
|
|
||||||
|
expect(store.state.n).toBe(1);
|
||||||
|
store.dispatch("inc", 14);
|
||||||
|
expect(store.state.n).toBe(15);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("dispatch an action with positional arguments", () => {
|
||||||
|
const state = { n1: 1, n2: 1, n3: 1 };
|
||||||
|
|
||||||
|
const actions = {
|
||||||
|
batchInc({ state }, delta1, delta2, delta3) {
|
||||||
|
state.n1 += delta1;
|
||||||
|
state.n2 += delta2;
|
||||||
|
state.n3 += delta3;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const store = new Store({ state, actions });
|
||||||
|
|
||||||
|
expect(store.state.n1).toBe(1);
|
||||||
|
expect(store.state.n2).toBe(1);
|
||||||
|
expect(store.state.n3).toBe(1);
|
||||||
|
store.dispatch("batchInc", 14, 30, 88);
|
||||||
|
expect(store.state.n1).toBe(15);
|
||||||
|
expect(store.state.n2).toBe(31);
|
||||||
|
expect(store.state.n3).toBe(89);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("can dispatch an action in an action", () => {
|
||||||
|
const state = { n: 1 };
|
||||||
|
|
||||||
|
const actions = {
|
||||||
|
inc({ state }, delta) {
|
||||||
|
state.n += delta;
|
||||||
|
},
|
||||||
|
inc100({ dispatch }) {
|
||||||
|
dispatch("inc", 100);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const store = new Store({ state, actions });
|
||||||
|
|
||||||
|
expect(store.state.n).toBe(1);
|
||||||
|
store.dispatch("inc100");
|
||||||
|
expect(store.state.n).toBe(101);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("return data from dispatching an action", () => {
|
||||||
|
const state = { n: 1 };
|
||||||
|
const actions = {
|
||||||
|
inc({ state }) {
|
||||||
|
return ++state.n;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const store = new Store({ state, actions });
|
||||||
|
|
||||||
|
expect(store.state.n).toBe(1);
|
||||||
|
const res = store.dispatch("inc");
|
||||||
|
expect(store.state.n).toBe(2);
|
||||||
|
expect(res).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("dispatch allow synchronizing between actions", async () => {
|
||||||
|
const state = { n: 1 };
|
||||||
|
const actions = {
|
||||||
|
inc({ state }, delta) {
|
||||||
|
state.n += delta;
|
||||||
|
},
|
||||||
|
setN({ state }, n) {
|
||||||
|
state.n = n;
|
||||||
|
},
|
||||||
|
async dosomething({ dispatch }) {
|
||||||
|
await dispatch("setTo10");
|
||||||
|
dispatch("inc", 3);
|
||||||
|
},
|
||||||
|
async setTo10({ dispatch }) {
|
||||||
|
await Promise.resolve();
|
||||||
|
dispatch("setN", 10);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const store = new Store({ state, actions });
|
||||||
|
|
||||||
|
expect(store.state.n).toBe(1);
|
||||||
|
store.dispatch("dosomething");
|
||||||
|
expect(store.state.n).toBe(1);
|
||||||
|
await nextTick();
|
||||||
|
expect(store.state.n).toBe(13);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("return data from dispatching an action", async () => {
|
||||||
|
const state = { n: 1 };
|
||||||
|
const actions = {
|
||||||
|
inc({ state }, delta) {
|
||||||
|
state.n += delta;
|
||||||
|
},
|
||||||
|
setN({ state }, n) {
|
||||||
|
state.n = n;
|
||||||
|
},
|
||||||
|
async dosomething({ dispatch }) {
|
||||||
|
const val = await dispatch("setTo10");
|
||||||
|
dispatch("inc", val);
|
||||||
|
},
|
||||||
|
async setTo10({ dispatch }) {
|
||||||
|
await Promise.resolve();
|
||||||
|
dispatch("setN", 10);
|
||||||
|
return 5;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const store = new Store({ state, actions });
|
||||||
|
|
||||||
|
expect(store.state.n).toBe(1);
|
||||||
|
store.dispatch("dosomething");
|
||||||
|
expect(store.state.n).toBe(1);
|
||||||
|
await nextTick();
|
||||||
|
expect(store.state.n).toBe(15);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("env is given to actions", () => {
|
||||||
|
expect.assertions(1);
|
||||||
|
const someEnv = <Env>{};
|
||||||
|
const actions = {
|
||||||
|
someaction({ env }) {
|
||||||
|
expect(env).toBe(someEnv);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const store = new Store({ state: {}, actions, env: someEnv });
|
||||||
|
|
||||||
|
store.dispatch("someaction");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("can have getters from store", async () => {
|
||||||
|
const state = {
|
||||||
|
beers: {
|
||||||
|
1: {
|
||||||
|
id: 1,
|
||||||
|
name: "bertinchamps",
|
||||||
|
tasterID: 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tasters: {
|
||||||
|
1: {
|
||||||
|
id: 1,
|
||||||
|
name: "aaron"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const getters: { [key: string]: Getter } = {
|
||||||
|
beerTasterName({ state }, beerID) {
|
||||||
|
return state.tasters[state.beers[beerID].tasterID].name;
|
||||||
|
},
|
||||||
|
bestBeerName({ state }) {
|
||||||
|
return state.beers[1].name;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const store = new Store({ state, actions: {}, getters });
|
||||||
|
expect(store.getters).toBeDefined();
|
||||||
|
expect((<any>store.getters).bestBeerName()).toBe("bertinchamps");
|
||||||
|
expect((<any>store.getters).beerTasterName(1)).toBe("aaron");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getters given to actions", async () => {
|
||||||
|
expect.assertions(3);
|
||||||
|
const state = {
|
||||||
|
beers: {
|
||||||
|
1: {
|
||||||
|
id: 1,
|
||||||
|
name: "bertinchamps",
|
||||||
|
tasterID: 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tasters: {
|
||||||
|
1: {
|
||||||
|
id: 1,
|
||||||
|
name: "aaron"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const getters = {
|
||||||
|
beerTasterName({ state }, beerID) {
|
||||||
|
return state.tasters[state.beers[beerID].tasterID].name;
|
||||||
|
},
|
||||||
|
bestBeerName({ state }) {
|
||||||
|
return state.beers[1].name;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const actions = {
|
||||||
|
action({ getters }) {
|
||||||
|
expect(getters).toBeDefined();
|
||||||
|
expect(getters.bestBeerName()).toBe("bertinchamps");
|
||||||
|
expect(getters.beerTasterName(1)).toBe("aaron");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const store = new Store({ state, actions, getters });
|
||||||
|
store.dispatch("action");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("can use getters inside a getter", () => {
|
||||||
|
const getters = {
|
||||||
|
a({ getters }) {
|
||||||
|
return `${getters.b()}${getters.c(1)}`;
|
||||||
|
},
|
||||||
|
b() {
|
||||||
|
return "b";
|
||||||
|
},
|
||||||
|
c({}, i) {
|
||||||
|
return `c${i}`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const store = new Store({ getters, state: {}, actions: {} });
|
||||||
|
|
||||||
|
expect(store.getters.a()).toBe("bc1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("advanced state properties", () => {
|
||||||
|
test("state in the store is reference equal after mutation", async () => {
|
||||||
|
const actions = {
|
||||||
|
donothing() {}
|
||||||
|
};
|
||||||
|
const store = new Store({ state: {}, actions });
|
||||||
|
const state = store.state;
|
||||||
|
store.dispatch("donothing");
|
||||||
|
expect(store.state).toBe(state);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("can use array properties in mutations", () => {
|
||||||
|
expect.assertions(3);
|
||||||
|
const state = { a: [1, 2, 3] };
|
||||||
|
const actions = {
|
||||||
|
m({ state }) {
|
||||||
|
expect(state.a.length).toBe(3);
|
||||||
|
const l = state.a.push(53);
|
||||||
|
expect(l).toBe(4);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const store = new Store({ state, actions });
|
||||||
|
store.dispatch("m");
|
||||||
|
expect(store.state.a).toEqual([1, 2, 3, 53]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("can use object assign in store", async () => {
|
||||||
|
const actions = {
|
||||||
|
dosomething({ state }) {
|
||||||
|
Object.assign(state.westmalle, { a: 3, b: 4 });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const store = new Store({
|
||||||
|
state: { westmalle: { a: 1, b: 2 } },
|
||||||
|
actions
|
||||||
|
});
|
||||||
|
store.dispatch("dosomething");
|
||||||
|
expect(store.state.westmalle).toEqual({ a: 3, b: 4 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("aku reactive store state 1", async () => {
|
||||||
|
const actions = {
|
||||||
|
inc({ state }) {
|
||||||
|
state.counter++;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const state = { counter: 0 };
|
||||||
|
const store = new Store({ state, actions });
|
||||||
|
expect(store.state.counter).toBe(0);
|
||||||
|
store.dispatch("inc", {});
|
||||||
|
expect(store.state.counter).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("updates triggered by the store", () => {
|
||||||
|
test("multiple commits trigger one update", async () => {
|
||||||
|
let updateCounter = 0;
|
||||||
|
const state = { n: 1 };
|
||||||
|
const actions = {
|
||||||
|
inc({ state }, delta) {
|
||||||
|
state.n += delta;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const store = new Store({ state, actions });
|
||||||
|
store.on("update", null, () => updateCounter++);
|
||||||
|
|
||||||
|
store.dispatch("inc", 14);
|
||||||
|
expect(updateCounter).toBe(0);
|
||||||
|
store.dispatch("inc", 50);
|
||||||
|
expect(updateCounter).toBe(0);
|
||||||
|
await nextMicroTick();
|
||||||
|
expect(updateCounter).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("empty commits do not trigger updates", async () => {
|
||||||
|
let updateCounter = 0;
|
||||||
|
const state = { n: 1 };
|
||||||
|
const actions = {
|
||||||
|
inc({ state }, delta) {
|
||||||
|
state.n += delta;
|
||||||
|
},
|
||||||
|
noop() {},
|
||||||
|
noop2({ state }) {
|
||||||
|
const val = state.n;
|
||||||
|
state.n = val;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const store = new Store({ state, actions });
|
||||||
|
store.on("update", null, () => updateCounter++);
|
||||||
|
|
||||||
|
store.dispatch("noop");
|
||||||
|
await nextMicroTick();
|
||||||
|
expect(updateCounter).toBe(0);
|
||||||
|
|
||||||
|
store.dispatch("inc", 50);
|
||||||
|
await nextMicroTick();
|
||||||
|
expect(updateCounter).toBe(1);
|
||||||
|
|
||||||
|
store.dispatch("noop2");
|
||||||
|
await nextMicroTick();
|
||||||
|
expect(updateCounter).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
|
||||||
|
|
||||||
exports[`connecting a component to store connecting a component to a local store 1`] = `"<div></div>"`;
|
|
||||||
|
|
||||||
exports[`connecting a component to store connecting a component to a local store 2`] = `"<div><span>hello</span></div>"`;
|
|
||||||
|
|
||||||
exports[`connecting a component to store connecting a component works 1`] = `"<div></div>"`;
|
|
||||||
|
|
||||||
exports[`connecting a component to store connecting a component works 2`] = `"<div><span>hello</span></div>"`;
|
|
||||||
|
|
||||||
exports[`connecting a component to store deep and shallow connecting a component 1`] = `"<div><span>Kasteel</span></div>"`;
|
|
||||||
|
|
||||||
exports[`connecting a component to store deep and shallow connecting a component 2`] = `"<div><span>Kasteel</span></div>"`;
|
|
||||||
|
|
||||||
exports[`connecting a component to store deep and shallow connecting a component 3`] = `"<div><span>Bertinchamps</span></div>"`;
|
|
||||||
|
|
||||||
exports[`connecting a component to store deep and shallow connecting a component 4`] = `"<div><span>Kasteel</span></div>"`;
|
|
||||||
@@ -1,912 +0,0 @@
|
|||||||
import { Component, Env } from "../../src/component/component";
|
|
||||||
import { ConnectedComponent } from "../../src/store/connected_component";
|
|
||||||
import { Store } from "../../src/store/store";
|
|
||||||
import { makeTestEnv, makeTestFixture, nextTick } from "../helpers";
|
|
||||||
|
|
||||||
describe("connecting a component to store", () => {
|
|
||||||
let fixture: HTMLElement;
|
|
||||||
let env: Env;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
fixture = makeTestFixture();
|
|
||||||
env = makeTestEnv();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
fixture.remove();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("connecting a component works", async () => {
|
|
||||||
env.qweb.addTemplates(`
|
|
||||||
<templates>
|
|
||||||
<div t-name="App">
|
|
||||||
<t t-foreach="props.todos" t-as="todo" >
|
|
||||||
<Todo msg="todo.msg" t-key="todo"/>
|
|
||||||
</t>
|
|
||||||
</div>
|
|
||||||
<span t-name="Todo"><t t-esc="props.msg"/></span>
|
|
||||||
</templates>
|
|
||||||
`);
|
|
||||||
class App extends ConnectedComponent<any, any, any> {
|
|
||||||
components = { Todo };
|
|
||||||
static mapStoreToProps(s) {
|
|
||||||
return { todos: s.todos };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
class Todo extends Component<any, any, any> {}
|
|
||||||
const state = { todos: [] };
|
|
||||||
const mutations = {
|
|
||||||
addTodo({ state }, msg) {
|
|
||||||
state.todos.push({ msg });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const store = new Store({ state, mutations });
|
|
||||||
(<any>env).store = store;
|
|
||||||
const app = new App(env);
|
|
||||||
|
|
||||||
await app.mount(fixture);
|
|
||||||
expect(fixture.innerHTML).toMatchSnapshot();
|
|
||||||
|
|
||||||
store.commit("addTodo", "hello");
|
|
||||||
await nextTick();
|
|
||||||
expect(fixture.innerHTML).toMatchSnapshot();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("deep and shallow connecting a component", async () => {
|
|
||||||
env.qweb.addTemplates(`
|
|
||||||
<templates>
|
|
||||||
<div t-name="App">
|
|
||||||
<span t-foreach="props.todos" t-as="todo" t-key="todo">
|
|
||||||
<t t-esc="todo.title"/>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</templates>
|
|
||||||
`);
|
|
||||||
const state = { todos: [{ title: "Kasteel" }] };
|
|
||||||
const mutations = {
|
|
||||||
edit({ state }, title) {
|
|
||||||
state.todos[0].title = title;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const store = new Store({ state, mutations });
|
|
||||||
|
|
||||||
class App extends ConnectedComponent<any, any, any> {
|
|
||||||
static mapStoreToProps(s) {
|
|
||||||
return { todos: s.todos };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
class DeepTodoApp extends App {
|
|
||||||
deep = true;
|
|
||||||
}
|
|
||||||
class ShallowTodoApp extends App {
|
|
||||||
deep = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
(<any>env).store = store;
|
|
||||||
const deepTodoApp = new DeepTodoApp(env);
|
|
||||||
const shallowTodoApp = new ShallowTodoApp(env);
|
|
||||||
|
|
||||||
await deepTodoApp.mount(fixture);
|
|
||||||
|
|
||||||
const shallowFix = makeTestFixture();
|
|
||||||
await shallowTodoApp.mount(shallowFix);
|
|
||||||
|
|
||||||
expect(fixture.innerHTML).toMatchSnapshot();
|
|
||||||
expect(shallowFix.innerHTML).toMatchSnapshot();
|
|
||||||
|
|
||||||
store.commit("edit", "Bertinchamps");
|
|
||||||
await nextTick();
|
|
||||||
expect(fixture.innerHTML).toMatchSnapshot();
|
|
||||||
expect(shallowFix.innerHTML).toMatchSnapshot();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("connecting a component to a local store", async () => {
|
|
||||||
env.qweb.addTemplates(`
|
|
||||||
<templates>
|
|
||||||
<div t-name="App">
|
|
||||||
<t t-foreach="props.todos" t-as="todo">
|
|
||||||
<Todo msg="todo.msg" t-key="todo" />
|
|
||||||
</t>
|
|
||||||
</div>
|
|
||||||
<span t-name="Todo"><t t-esc="props.msg"/></span>
|
|
||||||
</templates>
|
|
||||||
`);
|
|
||||||
class Todo extends Component<any, any, any> {}
|
|
||||||
|
|
||||||
(<any>env).store = new Store({});
|
|
||||||
const store = new Store({
|
|
||||||
state: { todos: [] },
|
|
||||||
mutations: {
|
|
||||||
addTodo({ state }, msg) {
|
|
||||||
state.todos.push({ msg });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
class App extends ConnectedComponent<any, any, any> {
|
|
||||||
components = { Todo };
|
|
||||||
static mapStoreToProps(s) {
|
|
||||||
return { todos: s.todos };
|
|
||||||
}
|
|
||||||
getStore() {
|
|
||||||
return store;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const app = new App(env);
|
|
||||||
|
|
||||||
await app.mount(fixture);
|
|
||||||
expect(fixture.innerHTML).toMatchSnapshot();
|
|
||||||
|
|
||||||
(<any>app.__owl__).store.commit("addTodo", "hello");
|
|
||||||
await nextTick();
|
|
||||||
expect(fixture.innerHTML).toMatchSnapshot();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("connected child components with custom hooks", async () => {
|
|
||||||
let steps: any = [];
|
|
||||||
env.qweb.addTemplates(`
|
|
||||||
<templates>
|
|
||||||
<div t-name="Parent">
|
|
||||||
<Child t-if="state.child" />
|
|
||||||
</div>
|
|
||||||
<div t-name="Child"/>
|
|
||||||
</templates>
|
|
||||||
`);
|
|
||||||
class Child extends ConnectedComponent<any, any, any> {
|
|
||||||
static mapStoreToProps(s) {
|
|
||||||
return s;
|
|
||||||
}
|
|
||||||
mounted() {
|
|
||||||
steps.push("child:mounted");
|
|
||||||
}
|
|
||||||
willUnmount() {
|
|
||||||
steps.push("child:willUnmount");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class Parent extends Component<any, any, any> {
|
|
||||||
components = { Child };
|
|
||||||
|
|
||||||
constructor(env: Env) {
|
|
||||||
super(env);
|
|
||||||
this.state = { child: true };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const store = new Store({ state: {} });
|
|
||||||
(<any>env).store = store;
|
|
||||||
const parent = new Parent(env);
|
|
||||||
|
|
||||||
await parent.mount(fixture);
|
|
||||||
expect(steps).toEqual(["child:mounted"]);
|
|
||||||
|
|
||||||
parent.state.child = false;
|
|
||||||
await nextTick();
|
|
||||||
expect(steps).toEqual(["child:mounted", "child:willUnmount"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("mapStoreToProps receives ownprops as second argument", async () => {
|
|
||||||
const state = { todos: [{ id: 1, text: "jupiler" }] };
|
|
||||||
let nextId = 2;
|
|
||||||
const mutations = {
|
|
||||||
addTodo({ state }, text) {
|
|
||||||
state.todos.push({ text, id: nextId++ });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const store = new Store({ state, mutations });
|
|
||||||
|
|
||||||
env.qweb.addTemplates(`
|
|
||||||
<templates>
|
|
||||||
<span t-name="TodoItem"><t t-esc="props.text"/></span>
|
|
||||||
<div t-name="TodoList">
|
|
||||||
<t t-foreach="props.todos" t-as="todo">
|
|
||||||
<TodoItem id="todo.id" t-key="todo.id"/>
|
|
||||||
</t>
|
|
||||||
</div>
|
|
||||||
</templates>
|
|
||||||
`);
|
|
||||||
class TodoItem extends ConnectedComponent<any, any, any> {
|
|
||||||
static mapStoreToProps(state, props) {
|
|
||||||
const todo = state.todos.find(t => t.id === props.id);
|
|
||||||
return todo;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class TodoList extends ConnectedComponent<any, any, any> {
|
|
||||||
components = { TodoItem };
|
|
||||||
static mapStoreToProps(state) {
|
|
||||||
return { todos: state.todos };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
(<any>env).store = store;
|
|
||||||
const app = new TodoList(env);
|
|
||||||
|
|
||||||
await app.mount(fixture);
|
|
||||||
expect(fixture.innerHTML).toBe("<div><span>jupiler</span></div>");
|
|
||||||
|
|
||||||
store.commit("addTodo", "hoegaarden");
|
|
||||||
await nextTick();
|
|
||||||
expect(fixture.innerHTML).toBe("<div><span>jupiler</span><span>hoegaarden</span></div>");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("mapStoreToProps receives store getters as third argument", async () => {
|
|
||||||
const state = {
|
|
||||||
importantID: 1,
|
|
||||||
todos: [{ id: 1, text: "jupiler" }, { id: 2, text: "bertinchamps" }]
|
|
||||||
};
|
|
||||||
const getters = {
|
|
||||||
importantTodoText({ state }) {
|
|
||||||
return state.todos.find(todo => todo.id === state.importantID).text;
|
|
||||||
},
|
|
||||||
text({ state }, id) {
|
|
||||||
return state.todos.find(todo => todo.id === id).text;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const store = new Store({ state, getters });
|
|
||||||
|
|
||||||
env.qweb.addTemplates(`
|
|
||||||
<templates>
|
|
||||||
<div t-name="TodoItem">
|
|
||||||
<span><t t-esc="props.activeTodoText"/></span>
|
|
||||||
<span><t t-esc="props.importantTodoText"/></span>
|
|
||||||
</div>
|
|
||||||
<div t-name="TodoList">
|
|
||||||
<t t-foreach="props.todos" t-as="todo">
|
|
||||||
<TodoItem id="todo.id" t-key="todo.id"/>
|
|
||||||
</t>
|
|
||||||
</div>
|
|
||||||
</templates>
|
|
||||||
`);
|
|
||||||
|
|
||||||
class TodoItem extends ConnectedComponent<any, any, any> {
|
|
||||||
static mapStoreToProps(state, props, getters) {
|
|
||||||
const todo = state.todos.find(t => t.id === props.id);
|
|
||||||
return {
|
|
||||||
activeTodoText: getters.text(todo.id),
|
|
||||||
importantTodoText: getters.importantTodoText()
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class TodoList extends ConnectedComponent<any, any, any> {
|
|
||||||
components = { TodoItem };
|
|
||||||
static mapStoreToProps(state) {
|
|
||||||
return { todos: state.todos };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
(<any>env).store = store;
|
|
||||||
const app = new TodoList(env);
|
|
||||||
|
|
||||||
await app.mount(fixture);
|
|
||||||
expect(fixture.innerHTML).toBe(
|
|
||||||
"<div><div><span>jupiler</span><span>jupiler</span></div><div><span>bertinchamps</span><span>jupiler</span></div></div>"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("connected component is updated when props are updated", async () => {
|
|
||||||
env.qweb.addTemplates(`
|
|
||||||
<templates>
|
|
||||||
<span t-name="Beer"><t t-esc="props.name"/></span>
|
|
||||||
<div t-name="App">
|
|
||||||
<Beer id="state.beerId"/>
|
|
||||||
</div>
|
|
||||||
</templates>
|
|
||||||
`);
|
|
||||||
|
|
||||||
class Beer extends ConnectedComponent<any, any, any> {
|
|
||||||
static mapStoreToProps(state, props) {
|
|
||||||
return state.beers[props.id];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class App extends Component<any, any, any> {
|
|
||||||
components = { Beer };
|
|
||||||
state = { beerId: 1 };
|
|
||||||
}
|
|
||||||
|
|
||||||
const state = { beers: { 1: { name: "jupiler" }, 2: { name: "kwak" } } };
|
|
||||||
const store = new Store({ state });
|
|
||||||
(<any>env).store = store;
|
|
||||||
const app = new App(env);
|
|
||||||
|
|
||||||
await app.mount(fixture);
|
|
||||||
expect(fixture.innerHTML).toBe("<div><span>jupiler</span></div>");
|
|
||||||
|
|
||||||
app.state.beerId = 2;
|
|
||||||
await nextTick();
|
|
||||||
expect(fixture.innerHTML).toBe("<div><span>kwak</span></div>");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("connected component is updated when store is changed", async () => {
|
|
||||||
env.qweb.addTemplates(`
|
|
||||||
<templates>
|
|
||||||
<div t-name="App">
|
|
||||||
<span t-foreach="props.beers" t-as="beer" t-key="beer.name"><t t-esc="beer.name"/></span>
|
|
||||||
</div>
|
|
||||||
</templates>
|
|
||||||
`);
|
|
||||||
|
|
||||||
class App extends ConnectedComponent<any, any, any> {
|
|
||||||
static mapStoreToProps(state) {
|
|
||||||
return { beers: state.beers, otherKey: 1 };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const mutations = {
|
|
||||||
addBeer({ state }, name) {
|
|
||||||
state.beers.push({ name });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const state = { beers: [{ name: "jupiler" }] };
|
|
||||||
const store = new Store({ state, mutations });
|
|
||||||
(<any>env).store = store;
|
|
||||||
|
|
||||||
const app = new App(env);
|
|
||||||
|
|
||||||
await app.mount(fixture);
|
|
||||||
expect(fixture.innerHTML).toBe("<div><span>jupiler</span></div>");
|
|
||||||
|
|
||||||
store.commit("addBeer", "kwak");
|
|
||||||
await nextTick();
|
|
||||||
expect(fixture.innerHTML).toBe("<div><span>jupiler</span><span>kwak</span></div>");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("connected component with undefined, null and string props", async () => {
|
|
||||||
env.qweb.addTemplates(`
|
|
||||||
<templates>
|
|
||||||
<div t-name="Beer">
|
|
||||||
<span>taster:<t t-esc="props.taster"/></span>
|
|
||||||
<span t-if="props.selected">selected:<t t-esc="props.selected.name"/></span>
|
|
||||||
<span t-if="props.consumed">consumed:<t t-esc="props.consumed.name"/></span>
|
|
||||||
</div>
|
|
||||||
<div t-name="App">
|
|
||||||
<Beer id="state.beerId"/>
|
|
||||||
</div>
|
|
||||||
</templates>
|
|
||||||
`);
|
|
||||||
|
|
||||||
class Beer extends ConnectedComponent<any, any, any> {
|
|
||||||
static mapStoreToProps(state, props) {
|
|
||||||
return {
|
|
||||||
selected: state.beers[props.id],
|
|
||||||
consumed: state.beers[state.consumedID] || null,
|
|
||||||
taster: state.taster
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class App extends Component<any, any, any> {
|
|
||||||
components = { Beer };
|
|
||||||
state = { beerId: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
const mutations = {
|
|
||||||
consume({ state }, beerId) {
|
|
||||||
state.consumedID = beerId;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const state = {
|
|
||||||
beers: {
|
|
||||||
1: { name: "jupiler" }
|
|
||||||
},
|
|
||||||
consumedID: null,
|
|
||||||
taster: "aaron"
|
|
||||||
};
|
|
||||||
const store = new Store({ state, mutations });
|
|
||||||
(<any>env).store = store;
|
|
||||||
const app = new App(env);
|
|
||||||
|
|
||||||
await app.mount(fixture);
|
|
||||||
expect(fixture.innerHTML).toBe("<div><div><span>taster:aaron</span></div></div>");
|
|
||||||
|
|
||||||
app.state.beerId = 1;
|
|
||||||
await nextTick();
|
|
||||||
expect(fixture.innerHTML).toBe(
|
|
||||||
"<div><div><span>taster:aaron</span><span>selected:jupiler</span></div></div>"
|
|
||||||
);
|
|
||||||
|
|
||||||
store.commit("consume", 1);
|
|
||||||
await nextTick();
|
|
||||||
expect(fixture.innerHTML).toBe(
|
|
||||||
"<div><div><span>taster:aaron</span><span>selected:jupiler</span><span>consumed:jupiler</span></div></div>"
|
|
||||||
);
|
|
||||||
|
|
||||||
app.state.beerId = 0;
|
|
||||||
await nextTick();
|
|
||||||
expect(fixture.innerHTML).toBe(
|
|
||||||
"<div><div><span>taster:aaron</span><span>consumed:jupiler</span></div></div>"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("connected component deeply reactive with undefined, null and string props", async () => {
|
|
||||||
env.qweb.addTemplates(`
|
|
||||||
<templates>
|
|
||||||
<div t-name="Beer">
|
|
||||||
<span>taster:<t t-esc="props.taster"/></span>
|
|
||||||
<span t-if="props.selected">selected:<t t-esc="props.selected.name"/></span>
|
|
||||||
<span t-if="props.consumed">consumed:<t t-esc="props.consumed.name"/></span>
|
|
||||||
</div>
|
|
||||||
<div t-name="App">
|
|
||||||
<Beer id="state.beerId"/>
|
|
||||||
</div>
|
|
||||||
</templates>
|
|
||||||
`);
|
|
||||||
|
|
||||||
class Beer extends ConnectedComponent<any, any, any> {
|
|
||||||
static mapStoreToProps(storeState, props) {
|
|
||||||
return {
|
|
||||||
selected: storeState.beers[props.id],
|
|
||||||
consumed: storeState.beers[storeState.consumedID] || null,
|
|
||||||
taster: storeState.taster
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class App extends Component<any, any, any> {
|
|
||||||
components = { Beer };
|
|
||||||
state = { beerId: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
const mutations = {
|
|
||||||
changeTaster({ state }, newTaster) {
|
|
||||||
state.taster = newTaster;
|
|
||||||
},
|
|
||||||
consume({ state }, beerId) {
|
|
||||||
state.consumedID = beerId;
|
|
||||||
},
|
|
||||||
renameBeer({ state }, { beerId, name }) {
|
|
||||||
state.beers[beerId].name = name;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const state = {
|
|
||||||
beers: {
|
|
||||||
1: { name: "jupiler" }
|
|
||||||
},
|
|
||||||
consumedID: null,
|
|
||||||
taster: "aaron"
|
|
||||||
};
|
|
||||||
const store = new Store({ state, mutations });
|
|
||||||
(<any>env).store = store;
|
|
||||||
const app = new App(env);
|
|
||||||
|
|
||||||
await app.mount(fixture);
|
|
||||||
expect(fixture.innerHTML).toBe("<div><div><span>taster:aaron</span></div></div>");
|
|
||||||
|
|
||||||
app.state.beerId = 1;
|
|
||||||
await nextTick();
|
|
||||||
expect(fixture.innerHTML).toBe(
|
|
||||||
"<div><div><span>taster:aaron</span><span>selected:jupiler</span></div></div>"
|
|
||||||
);
|
|
||||||
|
|
||||||
store.commit("renameBeer", { beerId: 1, name: "kwak" });
|
|
||||||
await nextTick();
|
|
||||||
expect(fixture.innerHTML).toBe(
|
|
||||||
"<div><div><span>taster:aaron</span><span>selected:kwak</span></div></div>"
|
|
||||||
);
|
|
||||||
|
|
||||||
store.commit("consume", 1);
|
|
||||||
await nextTick();
|
|
||||||
expect(fixture.innerHTML).toBe(
|
|
||||||
"<div><div><span>taster:aaron</span><span>selected:kwak</span><span>consumed:kwak</span></div></div>"
|
|
||||||
);
|
|
||||||
|
|
||||||
app.state.beerId = 0;
|
|
||||||
await nextTick();
|
|
||||||
expect(fixture.innerHTML).toBe(
|
|
||||||
"<div><div><span>taster:aaron</span><span>consumed:kwak</span></div></div>"
|
|
||||||
);
|
|
||||||
|
|
||||||
store.commit("renameBeer", { beerId: 1, name: "jupiler" });
|
|
||||||
await nextTick();
|
|
||||||
expect(fixture.innerHTML).toBe(
|
|
||||||
"<div><div><span>taster:aaron</span><span>consumed:jupiler</span></div></div>"
|
|
||||||
);
|
|
||||||
|
|
||||||
store.commit("changeTaster", "matthieu");
|
|
||||||
await nextTick();
|
|
||||||
expect(fixture.innerHTML).toBe(
|
|
||||||
"<div><div><span>taster:matthieu</span><span>consumed:jupiler</span></div></div>"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("correct update order when parent/children are connected", async () => {
|
|
||||||
const steps: string[] = [];
|
|
||||||
|
|
||||||
env.qweb.addTemplates(`
|
|
||||||
<templates>
|
|
||||||
<div t-name="Parent">
|
|
||||||
<Child key="props.current"/>
|
|
||||||
</div>
|
|
||||||
<span t-name="Child"><t t-esc="props.msg"/></span>
|
|
||||||
</templates>
|
|
||||||
`);
|
|
||||||
|
|
||||||
class Parent extends ConnectedComponent<any, any, any> {
|
|
||||||
components = { Child };
|
|
||||||
static mapStoreToProps(s) {
|
|
||||||
steps.push("parent");
|
|
||||||
return { current: s.current, isvisible: s.isvisible };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class Child extends ConnectedComponent<any, any, any> {
|
|
||||||
static mapStoreToProps(s, props) {
|
|
||||||
steps.push("child");
|
|
||||||
return { msg: s.msg[props.key] };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const state = { current: "a", msg: { a: "a", b: "b" } };
|
|
||||||
const mutations = {
|
|
||||||
setCurrent({ state }, c) {
|
|
||||||
state.current = c;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const store = new Store({ state, mutations });
|
|
||||||
(<any>env).store = store;
|
|
||||||
const app = new Parent(env);
|
|
||||||
|
|
||||||
await app.mount(fixture);
|
|
||||||
expect(fixture.innerHTML).toBe("<div><span>a</span></div>");
|
|
||||||
expect(steps).toEqual(["parent", "child"]);
|
|
||||||
|
|
||||||
store.commit("setCurrent", "b");
|
|
||||||
await nextTick();
|
|
||||||
expect(fixture.innerHTML).toBe("<div><span>b</span></div>");
|
|
||||||
expect(steps).toEqual(["parent", "child", "parent", "child"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("connected parent/children: no double rendering", async () => {
|
|
||||||
const mutations = {
|
|
||||||
editTodo({ state }) {
|
|
||||||
state.todos[1].title = "abc";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const todos = { 1: { id: 1, title: "kikoou" } };
|
|
||||||
const state = {
|
|
||||||
todos
|
|
||||||
};
|
|
||||||
const store = new Store({
|
|
||||||
state,
|
|
||||||
mutations
|
|
||||||
});
|
|
||||||
|
|
||||||
env.qweb.addTemplates(`
|
|
||||||
<templates>
|
|
||||||
<div t-name="TodoApp" class="todoapp">
|
|
||||||
<t t-foreach="Object.values(props.todos)" t-as="todo">
|
|
||||||
<TodoItem t-key="todo.id" id="todo.id"/>
|
|
||||||
</t>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div t-name="TodoItem" class="todo">
|
|
||||||
<t t-esc="props.todo.title"/>
|
|
||||||
<button class="destroy" t-on-click="editTodo">x</button>
|
|
||||||
</div>
|
|
||||||
</templates>
|
|
||||||
`);
|
|
||||||
|
|
||||||
class TodoApp extends ConnectedComponent<any, any, any> {
|
|
||||||
components = { TodoItem };
|
|
||||||
static mapStoreToProps(state) {
|
|
||||||
return {
|
|
||||||
todos: state.todos
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let renderCount = 0;
|
|
||||||
let fCount = 0;
|
|
||||||
|
|
||||||
class TodoItem extends ConnectedComponent<any, any, any> {
|
|
||||||
state = { isEditing: false };
|
|
||||||
static mapStoreToProps(state, ownProps) {
|
|
||||||
fCount++;
|
|
||||||
return {
|
|
||||||
todo: state.todos[ownProps.id]
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
editTodo() {
|
|
||||||
this.env.store.commit("editTodo");
|
|
||||||
}
|
|
||||||
__render(...args) {
|
|
||||||
renderCount++;
|
|
||||||
return super.__render(...args);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
(<any>env).store = store;
|
|
||||||
const app = new TodoApp(env);
|
|
||||||
|
|
||||||
await app.mount(fixture);
|
|
||||||
expect(fixture.innerHTML).toBe(
|
|
||||||
'<div class="todoapp"><div class="todo">kikoou<button class="destroy">x</button></div></div>'
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(renderCount).toBe(1);
|
|
||||||
expect(fCount).toBe(1);
|
|
||||||
fixture.querySelector("button")!.click();
|
|
||||||
await nextTick();
|
|
||||||
expect(renderCount).toBe(2);
|
|
||||||
expect(fCount).toBe(2);
|
|
||||||
expect(fixture.innerHTML).toBe(
|
|
||||||
'<div class="todoapp"><div class="todo">abc<button class="destroy">x</button></div></div>'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("connected parent/children: no rendering if child is destroyed", async () => {
|
|
||||||
const mutations = {
|
|
||||||
removeTodo({ state }) {
|
|
||||||
delete state.todos[1];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const todos = { 1: { id: 1, title: "kikoou" } };
|
|
||||||
const state = {
|
|
||||||
todos
|
|
||||||
};
|
|
||||||
const store = new Store({
|
|
||||||
state,
|
|
||||||
mutations
|
|
||||||
});
|
|
||||||
|
|
||||||
env.qweb.addTemplates(`
|
|
||||||
<templates>
|
|
||||||
<div t-name="TodoApp" class="todoapp">
|
|
||||||
<t t-foreach="Object.values(props.todos)" t-as="todo">
|
|
||||||
<TodoItem t-key="todo.id" id="todo.id"/>
|
|
||||||
</t>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div t-name="TodoItem" class="todo">
|
|
||||||
<t t-esc="props.todo.title"/>
|
|
||||||
<button class="destroy" t-on-click="removeTodo">x</button>
|
|
||||||
</div>
|
|
||||||
</templates>
|
|
||||||
`);
|
|
||||||
|
|
||||||
class TodoApp extends ConnectedComponent<any, any, any> {
|
|
||||||
components = { TodoItem };
|
|
||||||
static mapStoreToProps(state) {
|
|
||||||
return {
|
|
||||||
todos: state.todos
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let renderCount = 0;
|
|
||||||
let fCount = 0;
|
|
||||||
|
|
||||||
class TodoItem extends ConnectedComponent<any, any, any> {
|
|
||||||
state = { isEditing: false };
|
|
||||||
|
|
||||||
static mapStoreToProps(state, ownProps) {
|
|
||||||
fCount++;
|
|
||||||
return {
|
|
||||||
todo: state.todos[ownProps.id]
|
|
||||||
};
|
|
||||||
}
|
|
||||||
removeTodo() {
|
|
||||||
this.env.store.commit("removeTodo");
|
|
||||||
}
|
|
||||||
__render(...args) {
|
|
||||||
renderCount++;
|
|
||||||
return super.__render(...args);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
(<any>env).store = store;
|
|
||||||
const app = new TodoApp(env);
|
|
||||||
|
|
||||||
await app.mount(fixture);
|
|
||||||
expect(fixture.innerHTML).toBe(
|
|
||||||
'<div class="todoapp"><div class="todo">kikoou<button class="destroy">x</button></div></div>'
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(renderCount).toBe(1);
|
|
||||||
expect(fCount).toBe(1);
|
|
||||||
fixture.querySelector("button")!.click();
|
|
||||||
await nextTick();
|
|
||||||
expect(renderCount).toBe(1);
|
|
||||||
expect(fCount).toBe(1);
|
|
||||||
expect(fixture.innerHTML).toBe('<div class="todoapp"></div>');
|
|
||||||
});
|
|
||||||
|
|
||||||
test("connected component willpatch/patch hooks are called on store updates", async () => {
|
|
||||||
const steps: string[] = [];
|
|
||||||
|
|
||||||
env.qweb.addTemplates(`
|
|
||||||
<templates>
|
|
||||||
<div t-name="App"><t t-esc="props.msg"/></div>
|
|
||||||
</templates>
|
|
||||||
`);
|
|
||||||
|
|
||||||
class App extends ConnectedComponent<any, any, any> {
|
|
||||||
static mapStoreToProps(s) {
|
|
||||||
return { msg: s.msg };
|
|
||||||
}
|
|
||||||
willPatch() {
|
|
||||||
steps.push("willpatch");
|
|
||||||
}
|
|
||||||
patched() {
|
|
||||||
steps.push("patched");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const state = { msg: "a" };
|
|
||||||
const mutations = {
|
|
||||||
setMsg({ state }, c) {
|
|
||||||
state.msg = c;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const store = new Store({ state, mutations });
|
|
||||||
(<any>env).store = store;
|
|
||||||
const app = new App(env);
|
|
||||||
|
|
||||||
await app.mount(fixture);
|
|
||||||
expect(fixture.innerHTML).toBe("<div>a</div>");
|
|
||||||
|
|
||||||
store.commit("setMsg", "b");
|
|
||||||
await nextTick();
|
|
||||||
expect(fixture.innerHTML).toBe("<div>b</div>");
|
|
||||||
expect(steps).toEqual(["willpatch", "patched"]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("connected components and default values", () => {
|
|
||||||
let fixture: HTMLElement;
|
|
||||||
let env: Env;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
fixture = makeTestFixture();
|
|
||||||
env = makeTestEnv();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
fixture.remove();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("can set default values", async () => {
|
|
||||||
env.qweb.addTemplates(`
|
|
||||||
<templates>
|
|
||||||
<div t-name="Greeter">Hello, <t t-esc="props.recipient"/></div>
|
|
||||||
<div t-name="App"><Greeter/></div>
|
|
||||||
</templates>
|
|
||||||
`);
|
|
||||||
|
|
||||||
class Greeter extends ConnectedComponent<any, any, any> {
|
|
||||||
static defaultProps = { recipient: "John" };
|
|
||||||
}
|
|
||||||
|
|
||||||
class App extends Component<any, any, any> {
|
|
||||||
components = { Greeter };
|
|
||||||
}
|
|
||||||
|
|
||||||
const store = new Store({ state: {} });
|
|
||||||
(<any>env).store = store;
|
|
||||||
const app = new App(env);
|
|
||||||
|
|
||||||
await app.mount(fixture);
|
|
||||||
expect(fixture.innerHTML).toBe("<div><div>Hello, John</div></div>");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("can set default values", async () => {
|
|
||||||
env.qweb.addTemplates(`
|
|
||||||
<templates>
|
|
||||||
<div t-name="Greeter">Hello, <t t-esc="props.recipient"/></div>
|
|
||||||
<div t-name="App"><Greeter recipient="props.initialRecipient"/></div>
|
|
||||||
</templates>
|
|
||||||
`);
|
|
||||||
|
|
||||||
class Greeter extends ConnectedComponent<any, any, any> {
|
|
||||||
static defaultProps = { recipient: "John" };
|
|
||||||
}
|
|
||||||
|
|
||||||
class App extends Component<any, any, any> {
|
|
||||||
components = { Greeter };
|
|
||||||
}
|
|
||||||
|
|
||||||
const store = new Store({ state: {} });
|
|
||||||
(<any>env).store = store;
|
|
||||||
const app = new App(env);
|
|
||||||
|
|
||||||
await app.mount(fixture);
|
|
||||||
expect(fixture.innerHTML).toBe("<div><div>Hello, John</div></div>");
|
|
||||||
|
|
||||||
await app.__updateProps({ initialRecipient: "James" }, true);
|
|
||||||
expect(fixture.innerHTML).toBe("<div><div>Hello, James</div></div>");
|
|
||||||
|
|
||||||
await app.__updateProps({ initialRecipient: undefined }, true);
|
|
||||||
expect(fixture.innerHTML).toBe("<div><div>Hello, John</div></div>");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("can set default values (v2)", async () => {
|
|
||||||
env.qweb.addTemplates(`
|
|
||||||
<templates>
|
|
||||||
<div t-name="Message">
|
|
||||||
<t t-if="props.showId"><t t-esc="props.messageId"/></t>
|
|
||||||
<t t-esc="props.message.content"/>
|
|
||||||
</div>
|
|
||||||
<div t-name="Thread">
|
|
||||||
<t t-if="props.showMessages">
|
|
||||||
<Message t-foreach="props.thread.messages" t-as="messageId" messageId="messageId" t-key="messageId"/>
|
|
||||||
</t>
|
|
||||||
</div>
|
|
||||||
<div t-name="App"><Thread threadId="props.threadId"/></div>
|
|
||||||
</templates>
|
|
||||||
`);
|
|
||||||
|
|
||||||
class Message extends ConnectedComponent<any, any, any> {
|
|
||||||
static defaultProps = { showId: true };
|
|
||||||
static mapStoreToProps = function(state, ownProps) {
|
|
||||||
return {
|
|
||||||
message: state.messages[ownProps.messageId]
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
class Thread extends ConnectedComponent<any, any, any> {
|
|
||||||
components = { Message };
|
|
||||||
static defaultProps = { showMessages: true };
|
|
||||||
static mapStoreToProps = function(state, ownProps) {
|
|
||||||
const thread = state.threads[ownProps.threadId];
|
|
||||||
return {
|
|
||||||
thread
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
class App extends Component<any, any, any> {
|
|
||||||
components = { Thread };
|
|
||||||
static defaultProps = { threadId: 1 };
|
|
||||||
}
|
|
||||||
|
|
||||||
const state = {
|
|
||||||
threads: {
|
|
||||||
1: {
|
|
||||||
messages: [100, 101]
|
|
||||||
},
|
|
||||||
2: {
|
|
||||||
messages: [200]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
messages: {
|
|
||||||
100: {
|
|
||||||
content: "Message100"
|
|
||||||
},
|
|
||||||
101: {
|
|
||||||
content: "Message101"
|
|
||||||
},
|
|
||||||
200: {
|
|
||||||
content: "Message200"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const mutations = {
|
|
||||||
changeMessageContent({ state }, messageId, newContent) {
|
|
||||||
state.messages[messageId].content = newContent;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const store = new Store({ state, mutations });
|
|
||||||
(<any>env).store = store;
|
|
||||||
const app = new App(env);
|
|
||||||
|
|
||||||
await app.mount(fixture);
|
|
||||||
expect(fixture.innerHTML).toBe(
|
|
||||||
"<div><div><div>100Message100</div><div>101Message101</div></div></div>"
|
|
||||||
);
|
|
||||||
|
|
||||||
await app.__updateProps({ threadId: 2 }, true);
|
|
||||||
expect(fixture.innerHTML).toBe("<div><div><div>200Message200</div></div></div>");
|
|
||||||
|
|
||||||
store.commit("changeMessageContent", 200, "UpdatedMessage200");
|
|
||||||
await nextTick();
|
|
||||||
expect(fixture.innerHTML).toBe("<div><div><div>200UpdatedMessage200</div></div></div>");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,578 +0,0 @@
|
|||||||
import { Env } from "../../src/component/component";
|
|
||||||
import { Store } from "../../src/store/store";
|
|
||||||
import { nextMicroTick, nextTick } from "../helpers";
|
|
||||||
|
|
||||||
|
|
||||||
describe("basic use", () => {
|
|
||||||
test("commit a mutation", () => {
|
|
||||||
const state = { n: 1 };
|
|
||||||
const mutations = {
|
|
||||||
inc({ state }, delta) {
|
|
||||||
state.n += delta;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const store = new Store({ state, mutations });
|
|
||||||
|
|
||||||
expect(store.state.n).toBe(1);
|
|
||||||
store.commit("inc", 14);
|
|
||||||
expect(store.state.n).toBe(15);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("dispatch an action", () => {
|
|
||||||
const state = { n: 1 };
|
|
||||||
const mutations = {
|
|
||||||
inc({ state }, delta) {
|
|
||||||
state.n += delta;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const actions = {
|
|
||||||
inc({ commit }, delta) {
|
|
||||||
commit("inc", delta);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const store = new Store({ state, mutations, actions });
|
|
||||||
|
|
||||||
expect(store.state.n).toBe(1);
|
|
||||||
store.dispatch("inc", 14);
|
|
||||||
expect(store.state.n).toBe(15);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("dispatch an action + commit a mutation with positional arguments", () => {
|
|
||||||
const state = { n1: 1, n2: 1, n3: 1 };
|
|
||||||
const mutations = {
|
|
||||||
batchInc({ state }, delta1, delta2, delta3) {
|
|
||||||
state.n1 += delta1;
|
|
||||||
state.n2 += delta2;
|
|
||||||
state.n3 += delta3;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const actions = {
|
|
||||||
batchInc({ commit }, delta1, delta2, delta3) {
|
|
||||||
commit("batchInc", delta1, delta2, delta3);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const store = new Store({ state, mutations, actions });
|
|
||||||
|
|
||||||
expect(store.state.n1).toBe(1);
|
|
||||||
expect(store.state.n2).toBe(1);
|
|
||||||
expect(store.state.n3).toBe(1);
|
|
||||||
store.dispatch("batchInc", 14, 30, 88);
|
|
||||||
expect(store.state.n1).toBe(15);
|
|
||||||
expect(store.state.n2).toBe(31);
|
|
||||||
expect(store.state.n3).toBe(89);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("modifying state outside of mutations trigger error", () => {
|
|
||||||
const state = { n: 1 };
|
|
||||||
const actions = {
|
|
||||||
inc({ state }) {
|
|
||||||
state.n++;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const store = new Store({ state, mutations: {}, actions });
|
|
||||||
|
|
||||||
expect(() => store.dispatch("inc")).toThrow();
|
|
||||||
expect(() => (store.state.n = 15)).toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("can dispatch an action in an action", () => {
|
|
||||||
const state = { n: 1 };
|
|
||||||
const mutations = {
|
|
||||||
inc({ state }, delta) {
|
|
||||||
state.n += delta;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const actions = {
|
|
||||||
inc({ commit }, delta) {
|
|
||||||
commit("inc", delta);
|
|
||||||
},
|
|
||||||
inc100({ dispatch }) {
|
|
||||||
dispatch("inc", 100);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const store = new Store({ state, mutations, actions });
|
|
||||||
|
|
||||||
expect(store.state.n).toBe(1);
|
|
||||||
store.dispatch("inc100");
|
|
||||||
expect(store.state.n).toBe(101);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("can commit a mutation in a mutation", () => {
|
|
||||||
const state = { n: 1 };
|
|
||||||
const mutations = {
|
|
||||||
inc({ state }) {
|
|
||||||
state.n++;
|
|
||||||
},
|
|
||||||
inc10({ commit }) {
|
|
||||||
for (let i = 0; i < 10; i++) {
|
|
||||||
commit("inc");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const store = new Store({ state, mutations });
|
|
||||||
|
|
||||||
expect(store.state.n).toBe(1);
|
|
||||||
store.commit("inc10");
|
|
||||||
expect(store.state.n).toBe(11);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("return data from committing a mutation", () => {
|
|
||||||
const state = { n: 1 };
|
|
||||||
const mutations = {
|
|
||||||
inc({ state }) {
|
|
||||||
return ++state.n;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const store = new Store({ state, mutations });
|
|
||||||
|
|
||||||
expect(store.state.n).toBe(1);
|
|
||||||
const res = store.commit("inc");
|
|
||||||
expect(store.state.n).toBe(2);
|
|
||||||
expect(res).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("dispatch allow synchronizing between actions", async () => {
|
|
||||||
const state = { n: 1 };
|
|
||||||
const mutations = {
|
|
||||||
inc({ state }, delta) {
|
|
||||||
state.n += delta;
|
|
||||||
},
|
|
||||||
setN({ state }, n) {
|
|
||||||
state.n = n;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const actions = {
|
|
||||||
async dosomething({ commit, dispatch }) {
|
|
||||||
await dispatch("setTo10");
|
|
||||||
commit("inc", 3);
|
|
||||||
},
|
|
||||||
async setTo10({ commit }) {
|
|
||||||
await Promise.resolve();
|
|
||||||
commit("setN", 10);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const store = new Store({ state, mutations, actions });
|
|
||||||
|
|
||||||
expect(store.state.n).toBe(1);
|
|
||||||
store.dispatch("dosomething");
|
|
||||||
expect(store.state.n).toBe(1);
|
|
||||||
await nextTick();
|
|
||||||
expect(store.state.n).toBe(13);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("return data from dispatching an action", async () => {
|
|
||||||
const state = { n: 1 };
|
|
||||||
const mutations = {
|
|
||||||
inc({ state }, delta) {
|
|
||||||
state.n += delta;
|
|
||||||
},
|
|
||||||
setN({ state }, n) {
|
|
||||||
state.n = n;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const actions = {
|
|
||||||
async dosomething({ commit, dispatch }) {
|
|
||||||
const val = await dispatch("setTo10");
|
|
||||||
commit("inc", val);
|
|
||||||
},
|
|
||||||
async setTo10({ commit }) {
|
|
||||||
await Promise.resolve();
|
|
||||||
commit("setN", 10);
|
|
||||||
return 5;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const store = new Store({ state, mutations, actions });
|
|
||||||
|
|
||||||
expect(store.state.n).toBe(1);
|
|
||||||
store.dispatch("dosomething");
|
|
||||||
expect(store.state.n).toBe(1);
|
|
||||||
await nextTick();
|
|
||||||
expect(store.state.n).toBe(15);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("env is given to actions", () => {
|
|
||||||
expect.assertions(1);
|
|
||||||
const someEnv = <Env>{};
|
|
||||||
const actions = {
|
|
||||||
someaction({ env }) {
|
|
||||||
expect(env).toBe(someEnv);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const store = new Store({ state: {}, actions, env: someEnv });
|
|
||||||
|
|
||||||
store.dispatch("someaction");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("can have getters from store", async () => {
|
|
||||||
const state = {
|
|
||||||
beers: {
|
|
||||||
1: {
|
|
||||||
id: 1,
|
|
||||||
name: "bertinchamps",
|
|
||||||
tasterID: 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
tasters: {
|
|
||||||
1: {
|
|
||||||
id: 1,
|
|
||||||
name: "aaron"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const getters = {
|
|
||||||
beerTasterName({ state }, beerID) {
|
|
||||||
return state.tasters[state.beers[beerID].tasterID].name;
|
|
||||||
},
|
|
||||||
bestBeerName({ state }) {
|
|
||||||
return state.beers[1].name;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const store = new Store({ state, mutations: {}, actions: {}, getters });
|
|
||||||
expect(store.getters).toBeDefined();
|
|
||||||
expect((<any>store.getters).bestBeerName()).toBe("bertinchamps");
|
|
||||||
expect((<any>store.getters).beerTasterName(1)).toBe("aaron");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("getters are memoized", async () => {
|
|
||||||
const state = {
|
|
||||||
beers: {
|
|
||||||
1: {
|
|
||||||
id: 1,
|
|
||||||
name: "bertinchamps",
|
|
||||||
tasterID: 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
tasters: {
|
|
||||||
1: {
|
|
||||||
id: 1,
|
|
||||||
name: "aaron"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let n = 0;
|
|
||||||
const getters = {
|
|
||||||
beerTasterName({ state }, beerID) {
|
|
||||||
n++;
|
|
||||||
return state.tasters[state.beers[beerID].tasterID].name;
|
|
||||||
},
|
|
||||||
bestBeerName({ state }) {
|
|
||||||
n++;
|
|
||||||
return state.beers[1].name;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const store = new Store({ state, mutations: {}, actions: {}, getters });
|
|
||||||
expect((<any>store.getters).bestBeerName()).toBe("bertinchamps");
|
|
||||||
expect((<any>store.getters).beerTasterName(1)).toBe("aaron");
|
|
||||||
expect(n).toBe(2);
|
|
||||||
expect((<any>store.getters).bestBeerName()).toBe("bertinchamps");
|
|
||||||
expect((<any>store.getters).beerTasterName(1)).toBe("aaron");
|
|
||||||
expect(n).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("getters taking Array as argument aren't memoized", async () => {
|
|
||||||
const state = {
|
|
||||||
beers: {
|
|
||||||
1: {
|
|
||||||
id: 1,
|
|
||||||
name: "bertinchamps",
|
|
||||||
tasterID: 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let n = 0;
|
|
||||||
const getters = {
|
|
||||||
getBeerNames({ state }, beerIDs) {
|
|
||||||
n++;
|
|
||||||
return beerIDs.map(beerID => {
|
|
||||||
return state.beers[beerID].name;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const store = new Store({ state, mutations: {}, actions: {}, getters });
|
|
||||||
expect((<any>store.getters).getBeerNames([1])).toEqual(["bertinchamps"]);
|
|
||||||
expect(n).toBe(1);
|
|
||||||
expect((<any>store.getters).getBeerNames([1])).toEqual(["bertinchamps"]);
|
|
||||||
expect(n).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("getters cache is nuked on store changes", async () => {
|
|
||||||
const state = {
|
|
||||||
beers: {
|
|
||||||
1: {
|
|
||||||
id: 1,
|
|
||||||
name: "bertinchamps",
|
|
||||||
tasterID: 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
tasters: {
|
|
||||||
1: {
|
|
||||||
id: 1,
|
|
||||||
name: "aaron"
|
|
||||||
},
|
|
||||||
2: {
|
|
||||||
id: 2,
|
|
||||||
name: "gery"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const mutations = {
|
|
||||||
changeTaster({ state }, { beerID, tasterID }) {
|
|
||||||
state.beers[beerID].tasterID = tasterID;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let n = 0;
|
|
||||||
const getters = {
|
|
||||||
beerTasterName({ state }, beerID) {
|
|
||||||
n++;
|
|
||||||
return state.tasters[state.beers[beerID].tasterID].name;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const store = new Store({
|
|
||||||
state,
|
|
||||||
mutations: mutations,
|
|
||||||
actions: {},
|
|
||||||
getters
|
|
||||||
});
|
|
||||||
expect((<any>store.getters).beerTasterName(1)).toBe("aaron");
|
|
||||||
expect(n).toBe(1);
|
|
||||||
expect((<any>store.getters).beerTasterName(1)).toBe("aaron");
|
|
||||||
expect(n).toBe(1);
|
|
||||||
|
|
||||||
store.commit("changeTaster", { beerID: 1, tasterID: 2 });
|
|
||||||
await nextTick();
|
|
||||||
|
|
||||||
expect((<any>store.getters).beerTasterName(1)).toBe("gery");
|
|
||||||
expect(n).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("getters cache is disabled during a mutation", async () => {
|
|
||||||
const state = {
|
|
||||||
beers: {
|
|
||||||
1: {
|
|
||||||
id: 1,
|
|
||||||
name: "bertinchamps"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const mutations = {
|
|
||||||
renameBeer({ state, getters }, beerID) {
|
|
||||||
expect(getters.beerName(beerID)).toBe("bertinchamps");
|
|
||||||
state.beers[1].name = "chouffe";
|
|
||||||
expect(getters.beerName(beerID)).toBe("chouffe");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let n = 0;
|
|
||||||
const getters = {
|
|
||||||
beerName({ state }, beerID) {
|
|
||||||
n++;
|
|
||||||
return state.beers[beerID].name;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const store = new Store({
|
|
||||||
state,
|
|
||||||
mutations: mutations,
|
|
||||||
actions: {},
|
|
||||||
getters
|
|
||||||
});
|
|
||||||
|
|
||||||
store.commit("renameBeer", 1);
|
|
||||||
expect((<any>store.getters).beerName(1)).toBe("chouffe");
|
|
||||||
await nextTick();
|
|
||||||
|
|
||||||
expect(n).toBe(3);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("getters given to actions", async () => {
|
|
||||||
expect.assertions(3);
|
|
||||||
const state = {
|
|
||||||
beers: {
|
|
||||||
1: {
|
|
||||||
id: 1,
|
|
||||||
name: "bertinchamps",
|
|
||||||
tasterID: 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
tasters: {
|
|
||||||
1: {
|
|
||||||
id: 1,
|
|
||||||
name: "aaron"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const getters = {
|
|
||||||
beerTasterName({ state }, beerID) {
|
|
||||||
return state.tasters[state.beers[beerID].tasterID].name;
|
|
||||||
},
|
|
||||||
bestBeerName({ state }) {
|
|
||||||
return state.beers[1].name;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const actions = {
|
|
||||||
action({ getters }) {
|
|
||||||
expect(getters).toBeDefined();
|
|
||||||
expect(getters.bestBeerName()).toBe("bertinchamps");
|
|
||||||
expect(getters.beerTasterName(1)).toBe("aaron");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const store = new Store({ state, mutations: {}, actions, getters });
|
|
||||||
store.dispatch("action");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("getters given to mutations", async () => {
|
|
||||||
expect.assertions(3);
|
|
||||||
const state = {
|
|
||||||
beers: {
|
|
||||||
1: {
|
|
||||||
id: 1,
|
|
||||||
name: "bertinchamps",
|
|
||||||
tasterID: 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
tasters: {
|
|
||||||
1: {
|
|
||||||
id: 1,
|
|
||||||
name: "aaron"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const getters = {
|
|
||||||
beerTasterName({ state }, beerID) {
|
|
||||||
return state.tasters[state.beers[beerID].tasterID].name;
|
|
||||||
},
|
|
||||||
bestBeerName({ state }) {
|
|
||||||
return state.beers[1].name;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const mutations = {
|
|
||||||
mutation({ getters }) {
|
|
||||||
expect(getters).toBeDefined();
|
|
||||||
expect(getters.bestBeerName()).toBe("bertinchamps");
|
|
||||||
expect(getters.beerTasterName(1)).toBe("aaron");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const store = new Store({ state, mutations, actions: {}, getters });
|
|
||||||
store.commit("mutation");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("can use getters inside a getter", () => {
|
|
||||||
const getters = {
|
|
||||||
a({ getters }) {
|
|
||||||
return `${getters.b()}${getters.c(1)}`;
|
|
||||||
},
|
|
||||||
b() {
|
|
||||||
return "b";
|
|
||||||
},
|
|
||||||
c({}, i) {
|
|
||||||
return `c${i}`;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const store = new Store({ getters });
|
|
||||||
|
|
||||||
expect(store.getters.a()).toBe("bc1");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("advanced state properties", () => {
|
|
||||||
test("state in the store is reference equal after mutation", async () => {
|
|
||||||
const mutations = {
|
|
||||||
donothing() {}
|
|
||||||
};
|
|
||||||
const store = new Store({ state:{}, mutations });
|
|
||||||
const state = store.state;
|
|
||||||
store.commit("donothing");
|
|
||||||
expect(store.state).toBe(state);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("can use array properties in mutations", () => {
|
|
||||||
expect.assertions(3);
|
|
||||||
const state = { a: [1, 2, 3] };
|
|
||||||
const mutations = {
|
|
||||||
m({ state }) {
|
|
||||||
expect(state.a.length).toBe(3);
|
|
||||||
const l = state.a.push(53);
|
|
||||||
expect(l).toBe(4);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const store = new Store({ state, mutations });
|
|
||||||
store.commit("m");
|
|
||||||
expect(store.state.a).toEqual([1, 2, 3, 53]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("can use object assign in store", async () => {
|
|
||||||
const mutations = {
|
|
||||||
dosomething({ state }) {
|
|
||||||
Object.assign(state.westmalle, { a: 3, b: 4 });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const store = new Store({
|
|
||||||
state: { westmalle: { a: 1, b: 2 } },
|
|
||||||
mutations
|
|
||||||
});
|
|
||||||
store.commit("dosomething");
|
|
||||||
expect(store.state.westmalle).toEqual({ a: 3, b: 4 });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("aku reactive store state 1", async () => {
|
|
||||||
const mutations = {
|
|
||||||
inc({ state }) {
|
|
||||||
state.counter++;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const state = { counter: 0 };
|
|
||||||
const store = new Store({ state, mutations });
|
|
||||||
expect(store.state.counter).toBe(0);
|
|
||||||
store.commit("inc", {});
|
|
||||||
expect(store.state.counter).toBe(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("updates triggered by the store", () => {
|
|
||||||
test("multiple commits trigger one update", async () => {
|
|
||||||
let updateCounter = 0;
|
|
||||||
const state = { n: 1 };
|
|
||||||
const mutations = {
|
|
||||||
inc({ state }, delta) {
|
|
||||||
state.n += delta;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const store = new Store({ state, mutations });
|
|
||||||
store.on("update", null, () => updateCounter++);
|
|
||||||
|
|
||||||
store.commit("inc", 14);
|
|
||||||
expect(updateCounter).toBe(0);
|
|
||||||
store.commit("inc", 50);
|
|
||||||
expect(updateCounter).toBe(0);
|
|
||||||
await nextMicroTick();
|
|
||||||
expect(updateCounter).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("empty commits do not trigger updates", async () => {
|
|
||||||
let updateCounter = 0;
|
|
||||||
const state = { n: 1 };
|
|
||||||
const mutations = {
|
|
||||||
inc({ state }, delta) {
|
|
||||||
state.n += delta;
|
|
||||||
},
|
|
||||||
noop() {},
|
|
||||||
noop2({ state }) {
|
|
||||||
const val = state.n;
|
|
||||||
state.n = val;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const store = new Store({ state, mutations });
|
|
||||||
store.on("update", null, () => updateCounter++);
|
|
||||||
|
|
||||||
store.commit("noop");
|
|
||||||
await nextMicroTick();
|
|
||||||
expect(updateCounter).toBe(0);
|
|
||||||
|
|
||||||
store.commit("inc", 50);
|
|
||||||
await nextMicroTick();
|
|
||||||
expect(updateCounter).toBe(1);
|
|
||||||
|
|
||||||
store.commit("noop2");
|
|
||||||
await nextMicroTick();
|
|
||||||
expect(updateCounter).toBe(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,57 @@
|
|||||||
|
/**
|
||||||
|
* We can only make one test per file, since the debug tool modify in place
|
||||||
|
* the owl object in a way that is difficult to undo.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { debugOwl } from "../../tools/debug";
|
||||||
|
import * as owl from "../../src/index";
|
||||||
|
|
||||||
|
import { Component, Env } from "../../src/component/component";
|
||||||
|
import { xml } from "../../src/tags";
|
||||||
|
import { useState } from "../../src/hooks";
|
||||||
|
import { makeTestFixture, makeTestEnv, nextTick } from "../helpers";
|
||||||
|
|
||||||
|
let fixture: HTMLElement = makeTestFixture();
|
||||||
|
let env: Env = makeTestEnv();
|
||||||
|
Component.env = env;
|
||||||
|
|
||||||
|
debugOwl(owl, {});
|
||||||
|
|
||||||
|
test("can log full lifecycle", async () => {
|
||||||
|
const steps: string[] = [];
|
||||||
|
const log = console.log;
|
||||||
|
console.log = arg => steps.push(arg);
|
||||||
|
|
||||||
|
class Child extends Component<any, any> {
|
||||||
|
static template = xml`<div>child</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
class Parent extends Component<any, any> {
|
||||||
|
static template = xml`<div><Child t-if="state.flag"/></div>`;
|
||||||
|
static components = { Child };
|
||||||
|
state = useState({ flag: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
const parent = new Parent(null, {});
|
||||||
|
await parent.mount(fixture);
|
||||||
|
|
||||||
|
parent.state.flag = true;
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(steps).toEqual([
|
||||||
|
"[OWL_DEBUG] Parent<id=1> constructor, props={}",
|
||||||
|
"[OWL_DEBUG] Parent<id=1> mount",
|
||||||
|
"[OWL_DEBUG] Parent<id=1> willStart",
|
||||||
|
"[OWL_DEBUG] Parent<id=1> rendering template",
|
||||||
|
"[OWL_DEBUG] Parent<id=1> mounted",
|
||||||
|
"[OWL_DEBUG] Parent<id=1> render",
|
||||||
|
"[OWL_DEBUG] Parent<id=1> rendering template",
|
||||||
|
"[OWL_DEBUG] Child<id=2> constructor, props={}",
|
||||||
|
"[OWL_DEBUG] Child<id=2> willStart",
|
||||||
|
"[OWL_DEBUG] Child<id=2> rendering template",
|
||||||
|
"[OWL_DEBUG] Parent<id=1> willPatch",
|
||||||
|
"[OWL_DEBUG] Child<id=2> mounted",
|
||||||
|
"[OWL_DEBUG] Parent<id=1> patched"
|
||||||
|
]);
|
||||||
|
console.log = log;
|
||||||
|
});
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
/**
|
||||||
|
* We can only make one test per file, since the debug tool modify in place
|
||||||
|
* the owl object in a way that is difficult to undo.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { debugOwl } from "../../tools/debug";
|
||||||
|
import * as owl from "../../src/index";
|
||||||
|
|
||||||
|
import { Component, Env } from "../../src/component/component";
|
||||||
|
import { xml } from "../../src/tags";
|
||||||
|
import { makeTestFixture, makeTestEnv } from "../helpers";
|
||||||
|
|
||||||
|
let fixture: HTMLElement = makeTestFixture();
|
||||||
|
let env: Env = makeTestEnv();
|
||||||
|
Component.env = env;
|
||||||
|
|
||||||
|
debugOwl(owl, { logScheduler: true });
|
||||||
|
|
||||||
|
test("can log scheduler start and stop", async () => {
|
||||||
|
const steps: string[] = [];
|
||||||
|
const log = console.log;
|
||||||
|
console.log = arg => steps.push(arg);
|
||||||
|
|
||||||
|
class Child extends Component<any, any> {
|
||||||
|
static template = xml`<div>child</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
class Parent extends Component<any, any> {
|
||||||
|
static template = xml`<div><Child /></div>`;
|
||||||
|
static components = { Child };
|
||||||
|
}
|
||||||
|
|
||||||
|
const parent = new Parent(null, {});
|
||||||
|
await parent.mount(fixture);
|
||||||
|
|
||||||
|
expect(steps).toEqual([
|
||||||
|
"[OWL_DEBUG] Parent<id=1> constructor, props={}",
|
||||||
|
"[OWL_DEBUG] Parent<id=1> mount",
|
||||||
|
"[OWL_DEBUG] Parent<id=1> willStart",
|
||||||
|
"[OWL_DEBUG] scheduler: start running tasks queue",
|
||||||
|
"[OWL_DEBUG] Parent<id=1> rendering template",
|
||||||
|
"[OWL_DEBUG] Child<id=2> constructor, props={}",
|
||||||
|
"[OWL_DEBUG] Child<id=2> willStart",
|
||||||
|
"[OWL_DEBUG] Child<id=2> rendering template",
|
||||||
|
"[OWL_DEBUG] Child<id=2> mounted",
|
||||||
|
"[OWL_DEBUG] Parent<id=1> mounted",
|
||||||
|
"[OWL_DEBUG] scheduler: stop running tasks queue"
|
||||||
|
]);
|
||||||
|
console.log = log;
|
||||||
|
});
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
/**
|
||||||
|
* We can only make one test per file, since the debug tool modify in place
|
||||||
|
* the owl object in a way that is difficult to undo.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { debugOwl } from "../../tools/debug";
|
||||||
|
import * as owl from "../../src/index";
|
||||||
|
|
||||||
|
import { Component, Env } from "../../src/component/component";
|
||||||
|
import { xml } from "../../src/tags";
|
||||||
|
import { makeTestFixture, makeTestEnv } from "../helpers";
|
||||||
|
|
||||||
|
let fixture: HTMLElement = makeTestFixture();
|
||||||
|
let env: Env = makeTestEnv();
|
||||||
|
Component.env = env;
|
||||||
|
|
||||||
|
debugOwl(owl, { logScheduler: true });
|
||||||
|
|
||||||
|
test("log a specific message for render method calls if component is not mounted", async () => {
|
||||||
|
const steps: string[] = [];
|
||||||
|
const log = console.log;
|
||||||
|
console.log = arg => steps.push(arg);
|
||||||
|
|
||||||
|
class Parent extends Component<any, any> {
|
||||||
|
static template = xml`<div><t t-esc="state.value"/></div>`;
|
||||||
|
state = owl.hooks.useState({ value: 1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const parent = new Parent(null, {});
|
||||||
|
await parent.mount(fixture);
|
||||||
|
parent.unmount();
|
||||||
|
parent.state.value = 2;
|
||||||
|
|
||||||
|
expect(steps).toEqual([
|
||||||
|
"[OWL_DEBUG] Parent<id=1> constructor, props={}",
|
||||||
|
"[OWL_DEBUG] Parent<id=1> mount",
|
||||||
|
"[OWL_DEBUG] Parent<id=1> willStart",
|
||||||
|
"[OWL_DEBUG] scheduler: start running tasks queue",
|
||||||
|
"[OWL_DEBUG] Parent<id=1> rendering template",
|
||||||
|
"[OWL_DEBUG] Parent<id=1> mounted",
|
||||||
|
"[OWL_DEBUG] scheduler: stop running tasks queue",
|
||||||
|
"[OWL_DEBUG] Parent<id=1> willUnmount",
|
||||||
|
"[OWL_DEBUG] Parent<id=1> render (warning: component is not mounted, this render has no effect)"
|
||||||
|
]);
|
||||||
|
console.log = log;
|
||||||
|
});
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
/**
|
||||||
|
* Doc Link Checker
|
||||||
|
*
|
||||||
|
* We define here a test to make sure that there are no dead link in the Owl
|
||||||
|
* documentation.
|
||||||
|
*/
|
||||||
|
import * as fs from "fs";
|
||||||
|
|
||||||
|
//--------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
//--------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface MarkDownLink {
|
||||||
|
name: string;
|
||||||
|
link: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MarkDownSection {
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FileData {
|
||||||
|
name: string;
|
||||||
|
path: string[];
|
||||||
|
fullName: string;
|
||||||
|
links: MarkDownLink[];
|
||||||
|
sections: MarkDownSection[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const LINK_REGEXP = /\[([^\[]+)\]\(([^\)]+)\)/g;
|
||||||
|
const HEADING_REGEXP = /\n(#+\s*)(.*)/g;
|
||||||
|
|
||||||
|
export function addMardownData(fileData): void {
|
||||||
|
const sep = fileData.path.length > 0 ? "/" : "";
|
||||||
|
const fullName = fileData.path.join("/") + sep + fileData.name;
|
||||||
|
const content = fs.readFileSync(fullName, { encoding: "utf8" });
|
||||||
|
let m;
|
||||||
|
// get links info
|
||||||
|
do {
|
||||||
|
m = LINK_REGEXP.exec(content);
|
||||||
|
if (m) {
|
||||||
|
fileData.links.push({ name: m[0], link: m[2] });
|
||||||
|
}
|
||||||
|
} while (m);
|
||||||
|
// get sections info
|
||||||
|
do {
|
||||||
|
m = HEADING_REGEXP.exec(content);
|
||||||
|
if (m) {
|
||||||
|
fileData.sections.push({ name: m[0], slug: slugify(m[2]) });
|
||||||
|
}
|
||||||
|
} while (m);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a list of FileData corresponding to all files that need to be
|
||||||
|
* validated.
|
||||||
|
*/
|
||||||
|
function getFiles(path: string[] = []): FileData[] {
|
||||||
|
if (path.length === 0) {
|
||||||
|
const baseFiles: FileData[] = [
|
||||||
|
{ name: "README.md", path: [], links: [], sections: [], fullName: "README.md" },
|
||||||
|
{ name: "roadmap.md", path: [], links: [], sections: [], fullName: "roadmap.md" }
|
||||||
|
];
|
||||||
|
const rest = getFiles(["doc"]);
|
||||||
|
const result = baseFiles.concat(rest);
|
||||||
|
result.forEach(addMardownData);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
const files = fs.readdirSync(path.join("/"), { withFileTypes: true }).map(f => {
|
||||||
|
if (f.isDirectory()) {
|
||||||
|
return getFiles(path.concat(f.name));
|
||||||
|
}
|
||||||
|
const fullName = path.join("/") + (path.length > 0 ? "/" : "") + f.name;
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
name: f.name,
|
||||||
|
path,
|
||||||
|
links: [],
|
||||||
|
sections: [],
|
||||||
|
fullName
|
||||||
|
}
|
||||||
|
];
|
||||||
|
});
|
||||||
|
return Array.prototype.concat(...files);
|
||||||
|
}
|
||||||
|
|
||||||
|
const LOCAL_FILES = ["LICENSE", "tools/debug.js"];
|
||||||
|
export function isLinkValid(link: MarkDownLink, current: FileData, files: FileData[]): boolean {
|
||||||
|
if (link.link.startsWith("http")) {
|
||||||
|
// no check on external links
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Step 1: extract path, name, hash
|
||||||
|
// path = ['doc', 'architecture]
|
||||||
|
// name = 'rendering.md'
|
||||||
|
// hash = 'blabla' (or '' if no hash)
|
||||||
|
|
||||||
|
const parts = link.link.split("#");
|
||||||
|
const hash = parts[1] || "";
|
||||||
|
let name;
|
||||||
|
let path;
|
||||||
|
if (parts[0]) {
|
||||||
|
let temp = parts[0].split("/");
|
||||||
|
name = temp[temp.length - 1];
|
||||||
|
temp.splice(-1);
|
||||||
|
path = current.path.slice();
|
||||||
|
for (let elem of temp) {
|
||||||
|
if (elem === "..") {
|
||||||
|
path.splice(-1);
|
||||||
|
} else if (elem !== ".") {
|
||||||
|
path.push(elem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// there are no file name, so this is a relative link to the current file
|
||||||
|
name = current.name;
|
||||||
|
path = current.path;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: build normalized link file name
|
||||||
|
const linkFullName = path.join("/") + (path.length > 0 ? "/" : "") + name;
|
||||||
|
|
||||||
|
// Step 3: check link name against white list of local files
|
||||||
|
if (LOCAL_FILES.includes(linkFullName)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: check if there is a matching file
|
||||||
|
let target: FileData | undefined = files.find(f => f.fullName === linkFullName);
|
||||||
|
if (!target) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 5: if necessary, check if there is a corresponding link inside the target
|
||||||
|
// link name
|
||||||
|
if (hash) {
|
||||||
|
if (!target.sections.find(s => s.slug === hash)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// adapted from https://medium.com/@mhagemann/the-ultimate-way-to-slugify-a-url-string-in-javascript-b8e4a0d849e1
|
||||||
|
function slugify(str) {
|
||||||
|
const a = "àáäâãåăæçèéëêǵḧìíïîḿńǹñòóöôœøṕŕßśșțùúüûǘẃẍÿź·_,:;";
|
||||||
|
const b = "aaaaaaaaceeeeghiiiimnnnooooooprssstuuuuuwxyz-----";
|
||||||
|
const p = new RegExp(a.split("").join("|"), "g");
|
||||||
|
return str
|
||||||
|
.toString()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/\//g, "") // remove /
|
||||||
|
.replace(/\s+/g, "-") // Replace spaces with -
|
||||||
|
.replace(p, c => b.charAt(a.indexOf(c))) // Replace special characters
|
||||||
|
.replace(/&/g, "-and-") // Replace & with ‘and’
|
||||||
|
.replace(/[^\w\-]+/g, "") // Remove all non-word characters
|
||||||
|
.replace(/\-\-+/g, "-") // Replace multiple - with single -
|
||||||
|
.replace(/^-+/, "") // Trim - from start of text
|
||||||
|
.replace(/-+$/, ""); // Trim - from end of text
|
||||||
|
}
|
||||||
|
|
||||||
|
//--------------------------------------------------------------------------
|
||||||
|
// Test
|
||||||
|
//--------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test("All markdown links work", () => {
|
||||||
|
let linkNumber = 0;
|
||||||
|
let invalidLinkNumber = 0;
|
||||||
|
const data = getFiles();
|
||||||
|
for (let file of data) {
|
||||||
|
for (let link of file.links) {
|
||||||
|
linkNumber++;
|
||||||
|
if (!isLinkValid(link, file, data)) {
|
||||||
|
console.warn(`Invalid Link: "${link.name}" in "${file.name}"`);
|
||||||
|
invalidLinkNumber++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(invalidLinkNumber).toBe(0);
|
||||||
|
expect(linkNumber).toBeGreaterThan(10);
|
||||||
|
});
|
||||||
+57
-59
@@ -1,5 +1,6 @@
|
|||||||
import { h, patch } from "../src/vdom";
|
import { h, patch } from "../src/vdom";
|
||||||
import { init } from "../src/vdom/vdom";
|
import { htmlToVDOM } from "../src/vdom/html_to_vdom";
|
||||||
|
import { init, addNS } from "../src/vdom/vdom";
|
||||||
|
|
||||||
function map(list, fn) {
|
function map(list, fn) {
|
||||||
var ret: any[] = [];
|
var ret: any[] = [];
|
||||||
@@ -184,20 +185,6 @@ describe("snabbdom", function() {
|
|||||||
expect(elm.tagName).toBe("DIV");
|
expect(elm.tagName).toBe("DIV");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("has different tag and id", function() {
|
|
||||||
let elm: any = document.createElement("div");
|
|
||||||
vnode0.appendChild(elm);
|
|
||||||
const vnode1 = h("span#id");
|
|
||||||
elm = patch(elm, vnode1).elm;
|
|
||||||
expect(elm.tagName).toBe("SPAN");
|
|
||||||
expect(elm.id).toBe("id");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("has id", function() {
|
|
||||||
elm = patch(vnode0, h("div", [h("div#unique")])).elm;
|
|
||||||
expect(elm.firstChild.id).toBe("unique");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("has correct namespace", function() {
|
test("has correct namespace", function() {
|
||||||
const SVGNamespace = "http://www.w3.org/2000/svg";
|
const SVGNamespace = "http://www.w3.org/2000/svg";
|
||||||
const XHTMLNamespace = "http://www.w3.org/1999/xhtml";
|
const XHTMLNamespace = "http://www.w3.org/1999/xhtml";
|
||||||
@@ -206,33 +193,15 @@ describe("snabbdom", function() {
|
|||||||
expect(elm.firstChild.namespaceURI).toBe(SVGNamespace);
|
expect(elm.firstChild.namespaceURI).toBe(SVGNamespace);
|
||||||
|
|
||||||
// verify that svg tag automatically gets svg namespace
|
// verify that svg tag automatically gets svg namespace
|
||||||
elm = patch(vnode0, h("svg", [h("foreignObject", [h("div", ["I am HTML embedded in SVG"])])]))
|
const vnode = h("svg", [h("foreignObject", [h("div", ["I am HTML embedded in SVG"])])]);
|
||||||
.elm;
|
// need to add namespace manually. it is usually done by the template
|
||||||
|
// compiler
|
||||||
|
addNS(vnode.data, (vnode as any).children, vnode.sel);
|
||||||
|
|
||||||
|
elm = patch(vnode0, vnode).elm;
|
||||||
expect(elm.namespaceURI).toBe(SVGNamespace);
|
expect(elm.namespaceURI).toBe(SVGNamespace);
|
||||||
expect(elm.firstChild.namespaceURI).toBe(SVGNamespace);
|
expect(elm.firstChild.namespaceURI).toBe(SVGNamespace);
|
||||||
expect(elm.firstChild.firstChild.namespaceURI).toBe(XHTMLNamespace);
|
expect(elm.firstChild.firstChild.namespaceURI).toBe(XHTMLNamespace);
|
||||||
|
|
||||||
// verify that svg tag with extra selectors gets svg namespace
|
|
||||||
elm = patch(vnode0, h("svg#some-id")).elm;
|
|
||||||
expect(elm.namespaceURI).toBe(SVGNamespace);
|
|
||||||
|
|
||||||
// verify that non-svg tag beginning with 'svg' does NOT get namespace
|
|
||||||
elm = patch(vnode0, h("svg-custom-el")).elm;
|
|
||||||
expect(elm.namespaceURI).not.toBe(SVGNamespace);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("receives classes in selector", function() {
|
|
||||||
elm = patch(vnode0, h("div", [h("i.am.a.class")])).elm;
|
|
||||||
expect(elm.firstChild.classList.contains("am")).toBeTruthy();
|
|
||||||
expect(elm.firstChild.classList.contains("a")).toBeTruthy();
|
|
||||||
expect(elm.firstChild.classList.contains("class")).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("receives classes in selector when namespaced", function() {
|
|
||||||
elm = patch(vnode0, h("svg", [h("g.am.a.class.too")])).elm;
|
|
||||||
expect(elm.firstChild.classList.contains("am")).toBeTruthy();
|
|
||||||
expect(elm.firstChild.classList.contains("a")).toBeTruthy();
|
|
||||||
expect(elm.firstChild.classList.contains("class")).toBeTruthy();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("can create elements with text content", function() {
|
test("can create elements with text content", function() {
|
||||||
@@ -271,26 +240,6 @@ describe("snabbdom", function() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("patching an element", function() {
|
describe("patching an element", function() {
|
||||||
test("changes the elements classes", function() {
|
|
||||||
const vnode1 = h("i.i.am.horse");
|
|
||||||
const vnode2 = h("i.i.am");
|
|
||||||
patch(vnode0, vnode1);
|
|
||||||
elm = patch(vnode1, vnode2).elm;
|
|
||||||
expect(elm.classList.contains("i")).toBeTruthy();
|
|
||||||
expect(elm.classList.contains("am")).toBeTruthy();
|
|
||||||
expect(!elm.classList.contains("horse")).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("removes missing classes", function() {
|
|
||||||
const vnode1 = h("i.i.am.horse");
|
|
||||||
const vnode2 = h("i.i.am");
|
|
||||||
patch(vnode0, vnode1);
|
|
||||||
elm = patch(vnode1, vnode2).elm;
|
|
||||||
expect(elm.classList.contains("i")).toBeTruthy();
|
|
||||||
expect(elm.classList.contains("am")).toBeTruthy();
|
|
||||||
expect(!elm.classList.contains("horse")).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("changes an elements props", function() {
|
test("changes an elements props", function() {
|
||||||
const vnode1 = h("a", { props: { src: "http://other/" } });
|
const vnode1 = h("a", { props: { src: "http://other/" } });
|
||||||
const vnode2 = h("a", { props: { src: "http://localhost/" } });
|
const vnode2 = h("a", { props: { src: "http://localhost/" } });
|
||||||
@@ -1108,3 +1057,52 @@ describe("snabbdom", function() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
//------------------------------------------------------------------------------
|
||||||
|
// Html to vdom
|
||||||
|
//------------------------------------------------------------------------------
|
||||||
|
describe("html to vdom", function() {
|
||||||
|
let elm, vnode0;
|
||||||
|
beforeEach(function() {
|
||||||
|
elm = document.createElement("div");
|
||||||
|
vnode0 = elm;
|
||||||
|
});
|
||||||
|
|
||||||
|
test("empty strings return empty list", function() {
|
||||||
|
expect(htmlToVDOM("")).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("just text", function() {
|
||||||
|
const nodeList = htmlToVDOM("simple text");
|
||||||
|
expect(nodeList).toHaveLength(1);
|
||||||
|
expect(nodeList[0]).toEqual({ text: "simple text" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("empty tag", function() {
|
||||||
|
const nodeList = htmlToVDOM("<span></span>");
|
||||||
|
expect(nodeList).toHaveLength(1);
|
||||||
|
elm = patch(vnode0, nodeList[0]).elm;
|
||||||
|
expect(elm.outerHTML).toEqual("<span></span>");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tag with text", function() {
|
||||||
|
const nodeList = htmlToVDOM("<span>abc</span>");
|
||||||
|
expect(nodeList).toHaveLength(1);
|
||||||
|
elm = patch(vnode0, nodeList[0]).elm;
|
||||||
|
expect(elm.outerHTML).toEqual("<span>abc</span>");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tag with attribute", function() {
|
||||||
|
const nodeList = htmlToVDOM(`<span a="1" b="2">abc</span>`);
|
||||||
|
expect(nodeList).toHaveLength(1);
|
||||||
|
elm = patch(vnode0, nodeList[0]).elm;
|
||||||
|
expect(elm.outerHTML).toEqual(`<span a="1" b="2">abc</span>`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("misc", function() {
|
||||||
|
const nodeList = htmlToVDOM(`<span a="1" b="2">abc<div>1</div></span>`);
|
||||||
|
expect(nodeList).toHaveLength(1);
|
||||||
|
elm = patch(vnode0, nodeList[0]).elm;
|
||||||
|
expect(elm.outerHTML).toEqual(`<span a="1" b="2">abc<div>1</div></span>`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user