Compare commits

...

53 Commits

Author SHA1 Message Date
Géry Debongnie 9e65b0686f [REL] bump to v0.23.0 2019-10-16 22:31:04 +02:00
Géry Debongnie 1d72164015 [FIX] component: support component reduced to a slot 2019-10-16 21:48:49 +02:00
Géry Debongnie 2825a5a55d [FIX] qweb: properly handle xml comments 2019-10-15 15:47:50 +02:00
Géry Debongnie d40ff53f5a [DOC] doc: add more information in store section 2019-10-14 15:00:31 +02:00
Aaron Bohy 23246c42aa [REF] *: snakecase filenames 2019-10-14 11:06:59 +02:00
Géry Debongnie 895fe7c60f [REF] store: replace ConnectedComponent by useStore hook
Closes #304
2019-10-14 11:06:59 +02:00
Géry Debongnie 19b10f7c1d [IMP] playground: update todoapp sample 2019-10-14 11:06:59 +02:00
Géry Debongnie 0788d9fe75 [REF] qweb: rename Context into CompilationContext 2019-10-14 11:06:59 +02:00
Géry Debongnie 4a346d0615 [FIX] qweb: properly remove t-ref reference
when the node is removed from the dom
2019-10-14 11:06:59 +02:00
Géry Debongnie 1337aa0107 [IMP] component: add current key for easier hook creation
closes #319
2019-10-14 10:25:34 +02:00
Géry Debongnie 3e8f60cefa [FIX] qweb: properly handle t-raw in various situations
closes #325
closes #327
2019-10-14 09:59:26 +02:00
Géry Debongnie 41b0618c93 [FIX] qweb: improve generated compiled code 2019-10-11 14:10:44 +02:00
Aaron Bohy 0928c189f4 [FIX] qweb: allow to combine t-esc with other directives
With this rev., when a t-esc is not set on a <t> tag, we create
it inside the node on which it is set, to ensure that the t-esc
directive is always set on <t> tags.

Same applies for t-raw.

Closes #324
2019-10-11 14:10:44 +02:00
Aaron Bohy 2f27768be2 [FIX] qweb: event modifier with arg in t-foreach
Closes #266
2019-10-11 14:01:07 +02:00
Aaron Bohy aa1b0f502d [FIX] tests: fix import 2019-10-11 14:01:07 +02:00
Géry Debongnie da1824f4dd [DOC] doc: improve navigation to main doc page
also, fixes a small error
2019-10-10 12:28:36 +02:00
Géry Debongnie 07dac970f7 [IMP[ component/hooks: add onWillStart and onWillUpdateProps 2019-10-09 15:09:26 +02:00
Géry Debongnie 5f6f081ae2 [IMP] playground: save changes to local storage
closes #316
closes #101
2019-10-09 10:15:09 +02:00
Géry Debongnie 1c3c2e5c51 [IMP] hooks/Context: add Context and useContext
Closes #310
2019-10-08 15:56:46 +02:00
Aaron Bohy 17ce73e6a9 [REF] hooks: improve makeLifecycleHook 2019-10-08 15:52:44 +02:00
Géry Debongnie cedb1f411f [REF] component: move template check in constructor 2019-10-08 13:51:55 +02:00
Géry Debongnie e7516be95c [DOC] component: remove doc for removed updateEnv method 2019-10-07 16:07:56 +02:00
Géry Debongnie 683993dbc4 [FIX] component/hooks: onWillUnmount was not properly called 2019-10-07 16:01:28 +02:00
Géry Debongnie 50d569411c [DOC] hooks: fixing broken link 2019-10-07 15:51:45 +02:00
Géry Debongnie c3453d35b9 [IMP] hooks/component: remove updateEnv, add useSubEnv
closes #182
2019-10-04 22:54:27 +02:00
Géry Debongnie b0dd0252a4 [REF] component: move method at proper place 2019-10-04 22:54:27 +02:00
Géry Debongnie e2b2e54e29 [DOC] tooling: add missing link 2019-10-04 22:12:56 +02:00
Géry Debongnie 766c378ac9 [DOC] hooks: add missing doc for onWillPatch/onPatched 2019-10-03 11:57:58 +02:00
Géry Debongnie 022b29b6a0 [IMP] component/qweb: remove t-mounted directive
The t-mounted directive main goals can be achieved with hooks, in a
better and more intuitive way.

closes #308
2019-10-03 10:57:24 +02:00
Géry Debongnie 367725d194 [IMP] hooks: add onWillPatch and onPatched
This commit also changes the behaviour of willPatch and patched: they no
longer transfer data from one to the other, because it can be done
cleanly by hooks and a closure.

closes #307
2019-10-03 10:20:28 +02:00
Géry Debongnie e82ed7dc8c [DOC] component: add information on svg components 2019-10-02 16:40:13 +02:00
Géry Debongnie 63c3d4f6b0 [IMP] playground: slightly improve default text 2019-10-01 21:37:36 +02:00
Géry Debongnie b859fcb8f0 [REL] bump to v0.22.0 2019-10-01 21:05:04 +02:00
Géry Debongnie 3005756d89 [LEGAL] add copyright file 2019-10-01 20:44:39 +02:00
Géry Debongnie f6820bb8ac [REF] hooks: better implementation of onMounted/onWillUnmount 2019-09-28 21:14:40 +02:00
Géry Debongnie 090880f478 [DOC] readme: add quick overview section 2019-09-27 17:34:27 +02:00
Géry Debongnie 5cdaa7b473 [IMP] hooks: implement useRef hook
closes #194
2019-09-27 11:20:08 +02:00
Géry Debongnie 91b9e78182 [DOC] update component, tooling doc 2019-09-27 11:20:08 +02:00
Géry Debongnie 5335fb8fba [IMP] components: add hooks mechanism
part of #194
2019-09-27 11:20:08 +02:00
Géry Debongnie 4513e3f31c [FIX] qweb: improved svg support for components
If we want to generate dynamically svg components, it is useful to be
able to define components with a <g> tag as root, and then getting the
correct namespace.

closes #302
2019-09-26 15:46:42 +02:00
Géry Debongnie ecf12f6eee [FIX] qweb: properly supports svg
Snabbdom supports svg, but it adds the namespace at the creation of the
virtual node.  However, owl works slightly differently: it adds children
after creating the parent virtual node.

So, we need to actually call the addNS method after the children nodes
have been created.

As a bonus, this is slightly faster than snabbdom: we only check at
template compilation time once if a node is a svg.
2019-09-24 21:40:46 +02:00
Géry Debongnie 82a6961b3a [FIX] component: properly handle wrapper component and slots
closes #297
2019-09-23 07:13:51 +02:00
Géry Debongnie 78a4550ebb [IMP] component tags: allow injecting values in xml tag string
With this commit, we can now do this:

      const SUBTEMPLATE = xml`<span><t t-esc="state.n"/></span>`
      class Parent extends Widget {
          static template = xml`<div><t t-call="${SUBTEMPLATE}"/></div>`
          state = {n: 42};
      }
2019-09-21 23:05:44 +02:00
Géry Debongnie cc82b3ffcd [IMP] component: support dynamic t-component 2019-09-21 21:58:19 +02:00
Géry Debongnie bbbaf95c8e Merge pull request #296 from odoo/issue-293-owl-fibers
[MERGE] component: introduce owl fiber
2019-09-20 13:54:59 +02:00
Géry Debongnie caa63628dc [REF] component: refactor fiber creation methods 2019-09-20 11:14:50 +02:00
Géry Debongnie 7a49b9d94e [REF] component: move render props and promise to fiber
part of #293
2019-09-20 10:48:10 +02:00
Géry Debongnie 01eb338e69 [REF] component: move patchqueue management to fiber
part of #293
2019-09-20 10:47:58 +02:00
Géry Debongnie 3048c6f961 [REF] component: introduce fiber
part of #293
2019-09-20 10:47:55 +02:00
Géry Debongnie 8edc637033 [FIX] qweb: remove comments in xml strings before parsing
It was done in loadTemplates, but this does not work if we want to add
templates from another source.
2019-09-19 15:18:59 +02:00
Géry Debongnie c9910077a4 [FIX] qweb: do not capture current qweb instance in closure
it is not needed, and has a bad effect on globally registered templates.
2019-09-12 23:23:55 +02:00
Géry Debongnie 6bf70f016c [DOC] doc: update readme.md 2019-09-12 23:23:39 +02:00
Géry Debongnie 905619f80f [ADD] tools: add benchmark for 0.21.0 2019-09-12 14:50:56 +02:00
69 changed files with 9436 additions and 3170 deletions
+14
View File
@@ -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.
+177 -25
View File
@@ -1,26 +1,32 @@
<h1 align="center">🦉 <a href="https://odoo.github.io/owl/">Odoo Web Library</a> 🦉</h1>
_A web framework for structured, dynamic and maintainable applications_
_A no nonsense web framework for structured, dynamic and maintainable applications_
## Project Overview
The Odoo Web Library (OWL) is a small (~16kb gzipped) UI framework intended to be the basis for
the [Odoo](https://www.odoo.com/) Web Client, and hopefully many other Odoo
related projects. OWL's main features are:
The Odoo Web Library (OWL) is a smallish (~17kb gzipped) UI framework intended to
be the basis for the [Odoo](https://www.odoo.com/) Web Client. Owl is a modern
framework, written in Typescript, taking the best ideas from React and Vue in a
simple and consistent way. Owl's main features are:
- a _declarative component system_, (template based, with asynchronous rendering and a virtual dom)
- a store implementation (for state management)
- a declarative component system,
- a reactivity system based on hooks,
- a store implementation (for state management),
- a small frontend router
**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.
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.
## Example
Here is a short example to illustrate interactive components:
```javascript
import { Component, QWeb } from 'owl'
import { xml } from 'owl/tags'
import { Component, QWeb, useState } from "owl";
import { xml } from "owl/tags";
class Counter extends Component {
static template = xml`
@@ -28,7 +34,7 @@ class Counter extends Component {
Click Me! [<t t-esc="state.value"/>]
</button>`;
state = { value: 0 };
state = useState({ value: 0 });
increment() {
this.state.value++;
@@ -43,17 +49,20 @@ class App extends Component {
</div>`;
static components = { Counter };
}
const app = new App({ qweb: new QWeb() });
app.mount(document.body);
```
Note that the counter component is made reactive with the [`useState`](doc/hooks.md#usestate)
hook. Also, all examples here uses the `xml` helper 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.
## OWL's Design Principles
## Design Principles
OWL is designed to be used in highly dynamic applications where changing
requirements are common and code needs to be maintained by large teams.
@@ -78,11 +87,15 @@ find some more information [here](doc/comparison.md).
## Documentation
The complete documentation can be found [here](doc/readme.md). The most important sections are:
A complete documentation for Owl can be found here:
- [Main documentation page](doc/readme.md).
The most important sections are:
- [Quick Start](doc/quick_start.md)
- [Component](doc/component.md)
- [QWeb](doc/qweb.md)
- [Hooks](doc/hooks.md)
Found an issue in the documentation? A broken link? Some outdated information?
Submit a PR!
@@ -91,20 +104,159 @@ Submit a PR!
If you want to use a simple `<script>` tag, the last release can be downloaded here:
- [owl-0.21.0.js](https://github.com/odoo/owl/releases/download/v0.21.0/owl.js)
- [owl-0.21.0.min.js](https://github.com/odoo/owl/releases/download/v0.21.0/owl.min.js)
- [owl-0.23.0.js](https://github.com/odoo/owl/releases/download/v0.23.0/owl.js)
- [owl-0.23.0.min.js](https://github.com/odoo/owl/releases/download/v0.23.0/owl.min.js)
Some npm scripts are available:
| Command | Description |
| --------------------- | ---------------------------------------------------------------------------- |
| `npm install` | install every dependency required for this project |
| `npm run build` | build a bundle of _owl_ in the _/dist/_ folder |
| `npm run minify` | minify the prebuilt owl.js file |
| `npm run test` | run all 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 |
| Command | Description |
| ---------------- | -------------------------------------------------- |
| `npm install` | install every dependency required for this project |
| `npm run build` | build a bundle of _owl_ in the _/dist/_ folder |
| `npm run minify` | minify the prebuilt owl.js file |
| `npm run test` | run all (owl) tests |
## Quick Overview
Owl components in an application are used to define a (dynamic) tree of components.
```
Root
/ \
A B
/ \
C D
```
**Environment:** the root component is special: it is created with an environment,
which should contain a `QWeb` instance. The environment is then automatically
propagated to each sub components (and accessible in the `this.env` property).
```js
const env = { qweb: new QWeb() };
const app = new App(env);
app.mount(document.body);
```
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. Some good use case for that is some configuration keys, session
information or 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.
**State:** each component can manage its own local state. It is a simple ES6
class, there are no special rules:
```js
class Counter extends Component {
static template = xml`
<button t-on-click="increment">
Click Me! [<t t-esc="state.value"/>]
</button>`;
state = { value: 0 };
increment() {
this.state.value++;
this.render();
}
}
```
The example above shows a component with a local state. Note that since there
is nothing magical to the `state` object, we need to manually call the `render`
function whenever we update it. This can quickly become annoying (and not
efficient if we do it too much). There is a better way: using the `useState`
hook, which transforms an object into a reactive version of itself:
```js
const { useState } = owl.hooks;
class Counter extends Component {
static template = xml`
<button t-on-click="increment">
Click Me! [<t t-esc="state.value"/>]
</button>`;
state = useState({ value: 0 });
increment() {
this.state.value++;
}
}
```
**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/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
+198 -94
View File
@@ -5,6 +5,7 @@
- [Overview](#overview)
- [Example](#example)
- [Reference](#reference)
- [Reactive System](#reactive-system)
- [Properties](#properties)
- [Static Properties](#static-properties)
- [Methods](#methods)
@@ -15,13 +16,14 @@
- [Event Handling](#event-handling)
- [Form Input Bindings](#form-input-bindings)
- [`t-key` Directive](#t-key-directive)
- [`t-mounted` Directive](#t-mounted-directive)
- [Semantics](#semantics)
- [Props Validation](#props-validation)
- [References](#references)
- [Slots](#slots)
- [Asynchronous Rendering](#asynchronous-rendering)
- [Error Handling](#error-handling)
- [Functional Components](#functional-components)
- [SVG components](#svg-components)
## Overview
@@ -45,16 +47,15 @@ exclusively done by a [QWeb](qweb.md) template (which needs to be preloaded in Q
Rendering a component generates a virtual dom representation
of the component, which is then patched to the DOM, in order to apply the changes in an efficient way.
OWL components observe their states, and rerender themselves whenever it is
changed. This is done by an [observer](observer.md).
## Example
Let us have a look at a simple component:
```javascript
const { useState } = owl.hooks;
class ClickCounter extends owl.Component {
state = { value: 0 };
state = useState({ value: 0 });
increment() {
this.state.value++;
@@ -74,9 +75,8 @@ latest browsers without a transpilation step.
This example show how a component should be defined: it simply subclasses the
Component class. If no static `template` key is defined, then
Owl will use the component's name as template name. Here,
a state object is defined. It is not mandatory to use the state object, but it
is certainly encouraged. The state object is [observed](observer.md), and any
change to it will cause a rerendering.
a state object is defined, by using the `useState` hook. It is not mandatory to use the state object, but it is certainly encouraged. The result of the `useState` call is
[observed](observer.md), and any change to it will cause a rerendering.
## Reference
@@ -89,6 +89,25 @@ Be aware that the name of the component may be significant: if a component does
not define a `template` key, then Owl will lookup in QWeb to
find a template with the component name (or one of its ancestor).
### Reactive system
OWL components can be made reactive by observing some part of their state. See the [hooks](hooks.md) section for more details.
The main idea is that the `useState` hook generate a proxy version of an object
(this is done by an [observer](observer.md)), which allows the component to
react to any change.
```javascript
const { useState } = owl.hooks;
class SomeComponent extends owl.Component {
state = useState({ a: 0, b: 1 });
}
```
Note that there is an important limitation: hooks need to be called in the
constructor.
### Properties
- **`el`** (HTMLElement | null): reference to the DOM root node of the element. It is `null` when the
@@ -96,18 +115,11 @@ find a template with the component name (or one of its ancestor).
- **`env`** (Object): the component environment, which contains a QWeb instance.
- **`state`** (Object): this is the location of the component's state, if there is
any. After the willStart method, the `state` property is observed, and each
change will cause the component to rerender itself.
- **`props`** (Object): this is an object given (in the constructor) by the parent
to configure the component. It can be dynamically changed later by the parent,
in some case. Note that `props` are owned by the parent, not by the component.
As such, it should not ever be modified by the component!!
- **`refs`** (Object): the `refs` object contains all references to sub DOM nodes
or sub components defined by a `t-ref` directive in the component's template.
### Static Properties
- **`template`** (string, optional): if given, this is the name of the QWeb template that will render the component. Note that there is a helper `xml` to
@@ -150,6 +162,11 @@ find a template with the component name (or one of its ancestor).
}
```
There is another static property defined on the `Component` class: `current`.
This property is set to the currently being defined component (in the constructor).
This is the way [hooks](hooks.md) are able to get a reference to the target
component.
### Methods
We explain here all the public methods of the `Component` class.
@@ -183,11 +200,6 @@ We explain here all the public methods of the `Component` class.
always return true. This is an optimization, similar to React's `shouldComponentUpdate`. Most of the time, this should not be used, but it
can be useful if we are handling large number of components.
- **`updateEnv(nextEnv)`**: update the environment of a component and all its
children. This forces a complete rerender. For example, this could be useful
if we have a `isMobile` key in the environment, to decide if we want a mobile
interface or a destkop one.
- **`destroy()`**. As its name suggests, this method will remove the component,
and perform all necessary cleanup, such as unmounting the component, its children,
removing the parent/children relationship. This method should almost never be
@@ -236,7 +248,7 @@ component.
```javascript
constructor(parent, props) {
super(parent, props);
this.state = {someValue: true};
this.state = useState({someValue: true});
this.template = 'mytemplate';
}
```
@@ -246,7 +258,7 @@ implemented in most cases:
```javascript
class ClickCounter extends owl.Component {
state = { value: 0 };
state = useState({ value: 0 });
...
}
@@ -271,9 +283,6 @@ At this point, the component is not yet rendered. Note that a slow `willStart` m
interface. Therefore, some care should be made to make this method as
fast as possible.
After the `willStart` method is completed, the state will be observed with a
new `Observer`. Then, the component will be rendered by `QWeb`.
#### `mounted()`
`mounted` is called each time a component is attached to the
@@ -287,10 +296,9 @@ always be unmounted at some point in the future.
The mounted method will be called recursively on each of its children. First,
the parent, then all its children.
Note that the state is now observed. It is however allowed (but not encouraged)
to modify the state in the `mounted` hook. Doing so will cause a rerender,
which will not be perceptible by the user, but will slightly slow down the
component.
It is allowed (but not encouraged) to modify the state in the `mounted` hook.
Doing so will cause a rerender, which will not be perceptible by the user, but
will slightly slow down the component.
#### `willUpdateProps(nextProps)`
@@ -315,14 +323,11 @@ It is not called on the initial render. This is useful to read some
information from the DOM. For example, the current position of the
scrollbar.
Note that modifying the state object is not allowed here. This method is called just
Note that modifying the state is not allowed here. This method is called just
before an actual DOM patch, and is only intended to be used to save some local
DOM state. Also, it will not be called if the component is not in the DOM (this can
happen with components with `t-keepalive`).
The return value of this method will be given as the first argument of the
corresponding `patched` call.
#### `patched(snapshot)`
This hook is called whenever a component did actually update its DOM (most
@@ -333,8 +338,6 @@ with the DOM (for example, through an external library) whenever the
component was patched. Note that this hook will not be called if the compoent is
not in the DOM (this can happen with components with `t-keepalive`).
The `snapshot` parameter is the result of the previous `willPatch` call.
Updating the compoent state in this hook is possible, but not encouraged.
One need to be careful, because updates here will cause rerender, which in
turn will cause other calls to patched. So, we need to be particularly
@@ -397,9 +400,9 @@ easy to test a component.
Updating the environment is not as simple as changing a component's state: its
content is not observed, so updates will not be reflected immediately in the
user interface. There is however a mechanism to force root widgets to rerender
themselves whenever the environment is modified: one only needs to trigger the
`update` event on the QWeb instance. For example, a responsive environment
could be programmed like this:
themselves whenever the environment is modified: one only needs to call the
`forceUpdate` method on the QWeb instance. For example, a responsive environment
could be done like this:
```js
function setupResponsivePlugin(env) {
@@ -408,7 +411,7 @@ function setupResponsivePlugin(env) {
const updateEnv = owl.utils.debounce(() => {
if (env.isMobile !== isMobile()) {
env.isMobile = !env.isMobile;
env.qweb.trigger("update");
env.qweb.forceUpdate();
}
}, 15);
window.addEventListener("resize", updateEnv);
@@ -442,7 +445,8 @@ dynamic. If it is necessary to give a string, this can be done by quoting it:
`someString="'somevalue'"`.
Note that the rendering context for the template is the component itself. This means
that the template can access `state`, `props`, `env`, or any methods defined in the component.
that the template can access `state` (if it exists), `props`, `env`, or any
methods defined in the component.
```xml
<div t-name="ParentComponent">
@@ -453,7 +457,7 @@ that the template can access `state`, `props`, `env`, or any methods defined in
```js
class ParentComponent {
static components = { ChildComponent };
state = { val: 4 };
state = useState({ val: 4 });
}
```
@@ -502,6 +506,31 @@ class ParentComponent {
}
```
There is an even more dynamic way to use `t-component`: its value can be an
expression evaluating to an actual component class. In that case, this is the
class that will be used to create the component:
```js
class A extends Component<any, any, any> {
static template = xml`<span>child a</span>`;
}
class B extends Component<any, any, any> {
static template = xml`<span>child b</span>`;
}
class App extends Component<any, any, any> {
static template = xml`<t t-component="myComponent" t-key="state.child"/>`;
state = { child: "a" };
get myComponent() {
return this.state.child === "a" ? A : B;
}
}
```
In this example, the component `App` selects dynamically the concrete sub
component class.
**CSS and style:** there is some specific support to allow the parent to declare
additional css classes or style for the sub component: css declared in `class`, `style`, `t-att-class` or `t-att-style` will be added to the
root component element.
@@ -617,7 +646,7 @@ form!). A possible way to do this is to do it by hand:
```js
class Form extends owl.Component {
state = { text: "" };
state = useState({ text: "" });
_updateInputValue(event) {
this.state.text = event.target.value;
@@ -637,8 +666,9 @@ plumbing code is slightly different if you need to interact with a checkbox,
or with radio buttons, or with select tags.
To help with this situation, Owl has a builtin directive `t-model`: its value
is the (top-level) name in the state object. With the `t-model` directive, we
can write a shorter code, equivalent to the previous example:
should be an observed value in the component (usually `state.someValue`). With
the `t-model` directive, we can write a shorter code, equivalent to the previous
example:
```js
class Form extends owl.Component {
@@ -648,7 +678,7 @@ class Form extends owl.Component {
```xml
<div>
<input t-model="text" />
<input t-model="state.text" />
<span t-esc="state.text" />
</div>
```
@@ -658,11 +688,11 @@ The `t-model` directive works with `<input>`, `<input type="checkbox">`,
```xml
<div>
<div>Text in an input: <input t-model="someVal"/></div>
<div>Textarea: <textarea t-model="otherVal"/></div>
<div>Boolean value: <input type="checkbox" t-model="someFlag"/></div>
<div>Text in an input: <input t-model="state.someVal"/></div>
<div>Textarea: <textarea t-model="state.otherVal"/></div>
<div>Boolean value: <input type="checkbox" t-model="state.someFlag"/></div>
<div>Selection:
<select t-model="color">
<select t-model="state.color">
<option value="">Select a color</option>
<option value="red">Red</option>
<option value="blue">Blue</option>
@@ -671,11 +701,11 @@ The `t-model` directive works with `<input>`, `<input type="checkbox">`,
<div>
Selection with radio buttons:
<span>
<input type="radio" name="color" id="red" value="red" t-model="color"/>
<input type="radio" name="color" id="red" value="red" t-model="state.color"/>
<label for="red">Red</label>
</span>
<span>
<input type="radio" name="color" id="blue" value="blue" t-model="color" />
<input type="radio" name="color" id="blue" value="blue" t-model="state.color" />
<label for="blue">Blue</label>
</span>
</div>
@@ -693,7 +723,7 @@ Like event handling, the `t-model` directive accepts some modifiers:
For example:
```xml
<input t-model.lazy="someVal" />
<input t-model.lazy="state.someVal" />
```
These modifiers can be combined. For instance, `t-model.lazy.number` will only
@@ -725,24 +755,6 @@ There are three main use cases:
- _animations_: give a different identity to a component. Ex: thread id with
animations on add/remove message.
### `t-mounted` Directive
The `t-mounted` directive allows to register a callback to execute whenever the node
is inserted into the DOM.
```xml
<div><input t-ref="someInput" t-mounted="focusMe"/></div>
```
```js
class MyComponent extends owl.Component {
...
focusMe() {
this.refs.someInput.focus();
}
}
```
### Semantics
We give here an informal description of the way components are created/updated
@@ -927,44 +939,60 @@ Examples:
### References
The `t-ref` directive helps a component keep reference to some inside part of it.
Like the `t-on` directive, it can work either on a DOM node, or on a component:
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. See the [hooks section](hooks.md#useref) for
more detail.
As a short example, here is how we could set the focus on a given input:
```xml
<div>
<div t-ref="someDiv"/>
<SubComponent t-ref="someComponent"/>
<input t-ref="input"/>
<button t-on-click="focusInput">Click</button>
</div>
```
In this example, the component will be able to access the `div` and the component
inside the special `refs` variable:
```js
this.refs.someDiv;
this.refs.someComponent;
import { useRef } from "owl/hooks";
class SomeComponent extends Component {
inputRef = useRef("input");
focusInput() {
this.inputRef.el.focus();
}
}
```
This is useful for various usecases: for example, integrating with an external
library that needs to render itself inside an actual DOM node. Or for calling
some method on a sub component.
Note: if used on a component, the reference will be set in the `refs`
variable between `willPatch` and `patched`.
The `t-ref` directive also accepts dynamic values with string interpolation
(like the [`t-attf-`](qweb.md#dynamic-attributes) and
`t-component` directives). For example, if we have
`id` set to 44 in the rendering context,
The `useRef` hook can also be used to get a reference to an instance of a sub
component rendered by Owl. In that case, we need to access it with the `comp`
property instead of `el`:
```xml
<div t-ref="component_{{id}}"/>
<div>
<SubComponent t-ref="sub"/>
<button t-on-click="doSomething">Click</button>
</div>
```
```js
this.refs.component_44;
import { useRef } from "owl/hooks";
class SomeComponent extends Component {
static components = { SubComponent };
subRef = useRef("sub");
doSomething() {
this.subRef.comp.doSomeThingElse();
}
}
```
Note that these two examples uses the suffix `ref` to name the reference. This
is not mandatory, but it is a useful convention, so we do not forget to access
it with the `el` or `comp` suffix.
### Slots
To make generic components, it is useful to be able for a parent component to _inject_
@@ -1092,8 +1120,8 @@ For example, here is how we could implement an `ErrorBoundary` component:
```
```js
class ErrorBoundary extends Widget {
state = { error: false };
class ErrorBoundary extends Component {
state = useState({ error: false });
catchError() {
this.state.error = true;
@@ -1120,3 +1148,79 @@ env.qweb.on("error", null, function(error) {
// react to the error
});
```
### Functional Components
Owl does not exactly have functional components. However, there is an extremely
close alternative: calling sub templates.
A stateless functional component in react is usually some kind of function that
maps props to a virtual dom (often with `jsx`). So, basically, almost like a
template rendered with `props`. In Owl, this can be done by
simply defining a template, that will access the `props` object:
```js
const Welcome = xml`<h1>Hello, {props.name}</h1>`;
class MyComponent extends Component {
static template = xml`
<div>
<t t-call=${Welcome}/>
<div>something</div>
</div>
`;
}
```
The way this works is that sub templates are inlined, and have access to the
ambient context. They can therefore access `props`, and any other part of the
caller component.
### SVG Components
Owl components can be used to generate dynamic SVG graphs:
```js
class Node extends Component {
static template = xml`
<g>
<circle t-att-cx="props.x" t-att-cy="props.y" r="4" fill="black"/>
<text t-att-x="props.x - 5" t-att-y="props.y + 18"><t t-esc="props.node.label"/></text>
<t t-set="childx" t-value="props.x + 100"/>
<t t-set="height" t-value="props.height/(props.node.children || []).length"/>
<t t-foreach="props.node.children || []" t-as="child">
<t t-set="childy" t-value="props.y + child_index*height"/>
<line t-att-x1="props.x" t-att-y1="props.y" t-att-x2="childx" t-att-y2="childy" stroke="black" />
<Node x="childx" y="childy" node="child" height="height"/>
</t>
</g>
`;
static components = { Node };
}
class RootNode extends Component {
static template = xml`
<svg height="180">
<Node node="graph" x="10" y="20" height="180"/>
</svg>
`;
static components = { Node };
graph = {
label: "a",
children: [
{ label: "b" },
{ label: "c", children: [{ label: "d" }, { label: "e" }] },
{ label: "f", children: [{ label: "g" }] }
]
};
}
```
This `RootNode` component will then display a live SVG representation of the
graph described by the `graph` property. Note that there is a recursive structure
here: the `Node` component uses itself as a subcomponent.
Note that since SVG needs to be handled in a specific way (its namespace needs
to be properly set), there is a small constraint for Owl components: if an owl
component is supposed to be a part of an svg graph, then its root node needs to
be a `g` tag, so Owl can properly set the namespace.
+108
View File
@@ -0,0 +1,108 @@
# 🦉 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 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.
## 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 });
const env = {
qweb: new QWeb(TEMPLATES),
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">
some more sopthisticated user interface
</t>
`;
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.
+1 -1
View File
@@ -5,7 +5,7 @@ parts of the code. Owl has a very simple bus class, which manages subscriptions,
triggering events, and callbacks.
```js
const bus = new owl.EventBus();
const bus = new owl.core.EventBus();
bus.on("some-event", null, function(...args) {
console.log(...args);
+422
View File
@@ -0,0 +1,422 @@
# 🦉 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 works 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 have 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 });
}
}
```
Hooks can get a reference to the currently being defined component with the
`Component.current` static property. This is why they need to be called in the
constructor.
### `useState`
The `useState` hook is certainly the most important hooks for Owl components:
this is what enables 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++;
}
}
```
### `onMounted`
`onMounted` is not an 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 an 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 an 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 an 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
}
}
```
As shown by the example above, html elements are accessed by using the `el`
key, and components references are accessed with `comp`.
Note: if used on a component, the reference will be set in the `refs`
variable between `willPatch` and `patched`.
The `t-ref` directive also accepts dynamic values with string interpolation
(like the [`t-attf-`](qweb.md#dynamic-attributes) and
`t-component` directives). For example,
```xml
<div t-ref="component_{{someCondition ? '1' : '2'}}"/>
```
Here, the references needs 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 component, but not to the whole application.
This is where the `useSubEnv` hook may be useful: it let 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 him (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 inject 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.
+3 -33
View File
@@ -11,40 +11,10 @@ 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 = observer.observe({ a: { b: 1 } });
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
```
The observer is implemented with the native `Proxy` object. Note that this
means that it will not work on older browsers.
+3 -1
View File
@@ -72,11 +72,13 @@ 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`:
```javascript
const useState = owl.hooks.useState;
class ClickCounter extends owl.Component {
static template = "clickcounter";
constructor() {
super(...arguments);
this.state = { value: 0 };
this.state = useState({ value: 0 });
}
increment() {
+5 -1
View File
@@ -72,7 +72,6 @@ needs. Here is a list of all Owl specific directives:
| `t-key` | [Defining a key (to help virtual dom reconciliation)](component.md#t-key-directive) |
| `t-on-*` | [Event handling](component.md#event-handling) |
| `t-transition` | [Defining an animation](animations.md#css-transitions) |
| `t-mounted` | [Callback when a node or component is mounted](component.md#t-mounted-directive) |
| `t-slot` | [Rendering a slot](component.md#slots) |
| `t-model` | [Form input bindings](component.md#form-input-bindings) |
@@ -286,6 +285,11 @@ rendered with the value `value` set to `<span>foo</span>` in the rendering conte
<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
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, ...
+22 -3
View File
@@ -8,17 +8,31 @@ build applications. Here is a complete representation of its content:
```
owl
Component
Context
QWeb
Store
useState
core
EventBus
Observer
hooks
onWillStart
onMounted
onWillUpdateProps
onWillPatch
onPatched
onWillUnmount
useContext
useState
useRef
useSubEnv
useStore
useDispatch
useGetters
router
Link
RouteComponent
Router
store
Store
ConnectedComponent
tags
xml
utils
@@ -29,11 +43,16 @@ owl
whenReady
```
Note that for convenience, the `useState` hook is also exported at the root of the `owl` object.
## Reference
- [Animations](animations.md)
- [Component](component.md)
- [Context](context.md)
- [Event Bus](event_bus.md)
- [Hooks](hooks.md)
- [Observer](observer.md)
- [QWeb](qweb.md)
- [Router](router.md)
+53 -52
View File
@@ -9,6 +9,7 @@
- [Actions](#actions)
- [Getters](#getters)
- [Connecting a Component](#connecting-a-component)
- [`useStore`](#usestore)
- [Semantics](#semantics)
- [Good Practices](#good-practices)
@@ -21,8 +22,9 @@ 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, with `actions`.
Owl components can then connect to the store, and will be updated if necessary.
some (or all) state, and let the developer update it in a structured way, with
`actions`. Owl components can then connect to the store, and will be updated if
necessary.
Note: Owl store is inspired by React Redux and VueX.
@@ -47,7 +49,7 @@ const state = {
};
const store = new owl.Store({ state, actions });
store.on("update", () => console.log(store.state));
store.on("update", null, () => console.log(store.state));
// updating the state
store.dispatch("addTodo", "fix all bugs");
@@ -110,6 +112,14 @@ const actions = {
};
```
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.
@@ -195,76 +205,66 @@ 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`:
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` to subscribe a component to some part of the store state,
- `useDispatch` to get a reference to a dispatch function
- `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 += val;
state.counter.value += val;
}
};
const state = {
counter: 0
counter: {value: 0}
};
const store = new owl.Store({ state, actions });
```
class Counter extends owl.ConnectedComponent {
static mapStoreToProps(state) {
return {
value: state.counter
};
}
increment() {
this.env.store.dispatch("increment");
}
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="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)
Note that the class `ConnectedComponent` has a `dispatch` method. This means
that the previous example could be simplified like this:
```javascript
class Counter extends owl.ConnectedComponent {
static mapStoreToProps(state) {
return {
value: state.counter
};
}
}
```
```xml
<button t-name="Counter" t-on-click="dispatch('increment')">
Click Me! [<t t-esc="props.value"/>]
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 returns
an object or an array (which will be then observed)
- optionally, an object with a `store` key (if we want to override the default
store) and an equality function (if we want to specialize the comparison)
If the `useStore` callback selects 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 and updates the component every time this
check fails.
### Semantics
The `Store` and the `ConnectedComponent` try to be smart and to optimize as much
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
@@ -282,8 +282,9 @@ as possible the rendering and update process. What is important to know is:
- 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
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 `mapStoreToProps` function is called for each connected component,
- 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.
+29
View File
@@ -6,6 +6,7 @@
- [Development Mode](#development-mode)
- [Playground](#playground)
- [Benchmarks](#benchmarks)
- [Single File Component](#single-file-component)
## Overview
@@ -56,3 +57,31 @@ Note: This is more an internal tool, useful for people working on Owl.
The benchmarks application is a very small application, implemented in different
frameworks, and in different versions of Owl. This is a simple internal tool,
useful to compare various performance metrics on some tasks.
## Single File Component
If you want to have `xml` syntax highlighting while using the `xml` helper which
helps you define inline templates, there is a VS Code addon `Comment tagged template`
which, if installed, does exactly that. To enable it, you need to add a comment,
like this:
```js
// -----------------------------------------------------------------------------
// 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...
}
```
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "owl-framework",
"version": "0.21.0",
"version": "0.23.0",
"description": "Odoo Web Library (OWL)",
"main": "src/index.ts",
"scripts": {
+156 -140
View File
@@ -11,7 +11,8 @@ import "./props_validation";
* contains:
*
* - the Env interface (generic type for the environment)
* - the Meta interface (the owl specific metadata attached to a component)
* - the Fiber interface (owl metadata attached to a rendering)
* - the Internal interface (the owl specific metadata attached to a component)
* - the Component class
*/
@@ -33,6 +34,27 @@ export interface Env {
[key: string]: any;
}
/**
* Fibers are small abstractions designed to contain all the internal state
* associated to a "rendering work unit", relative to a specific component.
*
* A rendering will cause the creation of a fiber for each impacted components.
*/
export interface Fiber<Props> {
force: boolean;
rootFiber: Fiber<any> | null;
isCancelled: boolean;
scope: any;
vars: any;
patchQueue: Fiber<any>[];
component: Component<any, any>;
vnode: VNode | null;
props: Props;
promise: Promise<VNode> | null;
// handlers?: any;
// mountedHandlers?: any;
}
/**
* 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
@@ -48,26 +70,26 @@ interface Internal<T extends Env, Props> {
// parent and children keys are obviously useful to setup the parent-children
// relationship.
parent: Component<T, any, any> | null;
children: { [key: number]: Component<T, any, any> };
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 };
renderId: number;
// the renderProps and renderPromise keys are only useful for the "prepare"
// step of the lifecycle of a component. Once a component has been rendered
// and patched, it is no longer useful.
renderProps: Props | null;
renderPromise: Promise<VNode> | null;
currentFiber: Fiber<Props> | null;
boundHandlers: { [key: number]: any };
observer: Observer | null;
render: CompiledTemplate | null;
mountedHandlers: { [key: number]: Function };
render: CompiledTemplate;
mountedCB: Function | null;
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;
}
//------------------------------------------------------------------------------
@@ -75,10 +97,14 @@ interface Internal<T extends Env, Props> {
//------------------------------------------------------------------------------
let nextId = 1;
export class Component<T extends Env, Props extends {}, State extends {}> {
export class Component<T extends Env, Props extends {}> {
readonly __owl__: Internal<Env, Props>;
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;
/**
* The `el` is the root element of the component. Note that it could be null:
@@ -87,20 +113,10 @@ export class Component<T extends Env, Props extends {}, State extends {}> {
get el(): HTMLElement | null {
return this.__owl__.vnode ? (<any>this).__owl__.vnode.elm : null;
}
static components = {};
env: T;
state?: State;
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
//--------------------------------------------------------------------------
@@ -124,8 +140,9 @@ export class Component<T extends Env, Props extends {}, State extends {}> {
* hand. Other components should be created automatically by the framework (with
* the t-component directive in a template)
*/
constructor(parent: Component<T, any, any> | T, props?: Props) {
constructor(parent: Component<T, any> | T, props?: Props) {
const defaultProps = (<any>this.constructor).defaultProps;
Component.current = this;
if (defaultProps) {
props = this.__applyDefaultProps(props, defaultProps);
}
@@ -135,7 +152,7 @@ export class Component<T extends Env, Props extends {}, State extends {}> {
// Pro: but creating component (by a template) is always unsafe anyway
this.props = <Props>props || <Props>{};
let id: number = nextId++;
let p: Component<T, any, any> | null = null;
let p: Component<T, any> | null = null;
if (parent instanceof Component) {
p = parent;
this.env = parent.env;
@@ -161,6 +178,8 @@ export class Component<T extends Env, Props extends {}, State extends {}> {
}
});
}
const qweb = this.env.qweb;
this.__owl__ = {
id: id,
vnode: null,
@@ -169,14 +188,18 @@ export class Component<T extends Env, Props extends {}, State extends {}> {
parent: p,
children: {},
cmap: {},
renderId: 1,
renderPromise: null,
renderProps: props || null,
currentFiber: null,
boundHandlers: {},
mountedHandlers: {},
mountedCB: null,
willUnmountCB: null,
willPatchCB: null,
patchedCB: null,
willStartCB: null,
willUpdatePropsCB: null,
observer: null,
render: null,
classObj: null
render: qweb.render.bind(qweb, this.__getTemplate(qweb)),
classObj: null,
refs: null
};
}
@@ -223,8 +246,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
* information which are in the DOM. For example, the current position of the
* scrollbar
*
* The return value of willPatch will be given to the patched function.
*/
willPatch(): any {}
@@ -240,10 +261,8 @@ export class Component<T extends Env, Props extends {}, State extends {}> {
* 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
* 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
@@ -279,17 +298,20 @@ export class Component<T extends Env, Props extends {}, State extends {}> {
if (__owl__.isMounted) {
return;
}
const fiber = this.__createFiber(false, undefined, undefined, undefined);
if (!__owl__.vnode) {
const vnode = await this.__prepare();
fiber.promise = this.__prepareAndRender(fiber);
const vnode = await fiber.promise;
if (__owl__.isDestroyed) {
// component was destroyed before we get here...
return;
}
this.__patch(vnode);
} else if (renderBeforeRemount) {
const patchQueue = [];
await this.__render(false, patchQueue, undefined, undefined);
this.__applyPatchQueue(<any[]>patchQueue);
fiber.patchQueue.push(fiber);
fiber.promise = this.__render(fiber);
await fiber.promise;
this.__applyPatchQueue(fiber);
}
target.appendChild(this.el!);
@@ -323,15 +345,15 @@ export class Component<T extends Env, Props extends {}, State extends {}> {
if (!__owl__.isMounted) {
return;
}
const patchQueue = [];
const fiber = this.__createFiber(force, undefined, undefined, undefined);
fiber.patchQueue.push(fiber);
fiber.promise = this.__render(fiber);
await fiber.promise;
const renderId = ++__owl__.renderId;
await this.__render(force, patchQueue, undefined, undefined);
if (__owl__.isMounted && renderId === __owl__.renderId) {
if (__owl__.isMounted && fiber === __owl__.currentFiber) {
// 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);
this.__applyPatchQueue(fiber);
}
}
@@ -364,26 +386,6 @@ export class Component<T extends Env, Props extends {}, State extends {}> {
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
* component's el, if it exists. However, note that the event will only bubble
@@ -405,6 +407,27 @@ export class Component<T extends Env, Props extends {}, State extends {}> {
// Private
//--------------------------------------------------------------------------
/**
* This method is a helper to create a fiber element.
*/
__createFiber(force, scope, vars, parent?: Fiber<any>): Fiber<Props> {
const fiber: Fiber<Props> = {
force,
scope,
vars,
rootFiber: null,
isCancelled: false,
component: this,
vnode: null,
patchQueue: parent ? parent.patchQueue : [],
props: this.props,
promise: null
};
fiber.rootFiber = parent ? parent.rootFiber : fiber;
this.__owl__.currentFiber = fiber;
return fiber;
}
/**
* 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
@@ -416,10 +439,13 @@ export class Component<T extends Env, Props extends {}, State extends {}> {
* Note that it does not call the __callWillUnmount method to avoid visiting
* all children many times.
*/
__destroy(parent: Component<any, any, any> | null) {
__destroy(parent: Component<any, any> | null) {
const __owl__ = this.__owl__;
const isMounted = __owl__.isMounted;
if (isMounted) {
if (__owl__.willUnmountCB) {
__owl__.willUnmountCB();
}
this.willUnmount();
__owl__.isMounted = false;
}
@@ -446,20 +472,22 @@ export class Component<T extends Env, Props extends {}, State extends {}> {
}
}
__owl__.isMounted = true;
const handlers = __owl__.mountedHandlers;
for (let key in handlers) {
handlers[key]();
}
try {
this.mounted();
if (__owl__.mountedCB) {
__owl__.mountedCB();
}
} catch (e) {
errorHandler(e, this);
}
}
__callWillUnmount() {
this.willUnmount();
const __owl__ = this.__owl__;
if (__owl__.willUnmountCB) {
__owl__.willUnmountCB();
}
this.willUnmount();
__owl__.isMounted = false;
const children = __owl__.children;
for (let id in children) {
@@ -476,20 +504,25 @@ export class Component<T extends Env, Props extends {}, State extends {}> {
*/
async __updateProps(
nextProps: Props,
forceUpdate: boolean = false,
patchQueue?: any[],
parentFiber: Fiber<any>,
scope?: any,
vars?: any
): Promise<void> {
const shouldUpdate = forceUpdate || this.shouldUpdate(nextProps);
const shouldUpdate = parentFiber.force || this.shouldUpdate(nextProps);
if (shouldUpdate) {
const defaultProps = (<any>this.constructor).defaultProps;
if (defaultProps) {
nextProps = this.__applyDefaultProps(nextProps, defaultProps);
}
await this.willUpdateProps(nextProps);
await Promise.all([
this.willUpdateProps(nextProps),
this.__owl__.willUpdatePropsCB && this.__owl__.willUpdatePropsCB(nextProps)
]);
this.props = nextProps;
await this.__render(forceUpdate, patchQueue, scope, vars);
const fiber = this.__createFiber(parentFiber.force, scope, vars, parentFiber);
fiber.patchQueue.push(fiber);
await this.__render(fiber);
}
}
@@ -503,27 +536,19 @@ export class Component<T extends Env, Props extends {}, State extends {}> {
__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;
/**
* The __prepare method is only called by the t-component directive, when a
* subcomponent is created. It gets its scope and vars, if any, from the
* parent template.
*/
__prepare(parentFiber: Fiber<any>, scope: any, vars: any): Promise<VNode> {
const fiber = this.__createFiber(parentFiber.force, scope, vars, parentFiber);
fiber.promise = this.__prepareAndRender(fiber);
return fiber.promise;
}
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;
__getTemplate(qweb: QWeb): string {
let p = (<any>this).constructor;
// console.warn(p, p.template, p._template, 'template' in p, p.hasOwnProperty('template'))
if (!p.hasOwnProperty("_template")) {
if (p.template) {
p._template = p.template;
@@ -543,22 +568,25 @@ export class Component<T extends Env, Props extends {}, State extends {}> {
}
}
}
__owl__.render = qweb.render.bind(qweb, p._template);
this.__observeState();
return this.__render(false, [], scope, vars);
return p._template;
}
async __prepareAndRender(fiber: Fiber<Props>): Promise<VNode> {
try {
await Promise.all([this.willStart(), this.__owl__.willStartCB && this.__owl__.willStartCB()]);
} catch (e) {
errorHandler(e, this);
return Promise.resolve(h("div"));
}
const __owl__ = this.__owl__;
if (__owl__.isDestroyed) {
return Promise.resolve(h("div"));
}
return this.__render(fiber);
}
__render(
force: boolean = false,
patchQueue: any[] = [],
scope?: Object,
vars?: any
): Promise<VNode> {
__render(fiber: Fiber<Props>): Promise<VNode> {
const __owl__ = this.__owl__;
const promises: Promise<void>[] = [];
const patch: any[] = [this];
patchQueue.push(patch);
if (__owl__.observer) {
__owl__.observer.allowMutations = false;
}
@@ -567,17 +595,13 @@ export class Component<T extends Env, Props extends {}, State extends {}> {
vnode = __owl__.render!(this, {
promises,
handlers: __owl__.boundHandlers,
mountedHandlers: __owl__.mountedHandlers,
forceUpdate: force,
patchQueue,
scope,
vars
fiber: fiber
});
} catch (e) {
vnode = __owl__.vnode || h("div");
errorHandler(e, this);
}
patch.push(vnode);
fiber.vnode = vnode;
if (__owl__.observer) {
__owl__.observer.allowMutations = true;
}
@@ -625,19 +649,6 @@ export class Component<T extends Env, Props extends {}, State extends {}> {
}
}
/**
* Enable the observe feature on the state. We only create an observer if
* there is some state to be observed.
*/
__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).
*
@@ -655,29 +666,34 @@ export class Component<T extends Env, Props extends {}, State extends {}> {
}
/**
* Apply the given patch queue. A patch is a pair [c, vn], where c is a
* Component instance and vn a VNode.
* Apply the given patch queue from a fiber.
* 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
* 3) Call 'patched' on the component of each patch, in reverse order
*/
__applyPatchQueue(patchQueue: any[]) {
let component = this;
__applyPatchQueue(fiber: Fiber<Props>) {
const patchQueue = fiber.patchQueue;
let component: Component<any, any> = 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());
component = patchQueue[i].component;
if (component.__owl__.willPatchCB) {
component.__owl__.willPatchCB();
}
component.willPatch();
}
for (let i = 0; i < patchLen; i++) {
const patch = patchQueue[i];
patch[0].__patch(patch[1]);
const fiber = patchQueue[i];
component = fiber.component;
component.__patch(fiber.vnode);
}
for (let i = patchLen - 1; i >= 0; i--) {
const patch = patchQueue[i];
component = patch[0];
patch[0].patched(patch[2]);
component = patchQueue[i].component;
component.patched();
if (component.__owl__.patchedCB) {
component.__owl__.patchedCB();
}
}
} catch (e) {
errorHandler(e, component);
@@ -697,7 +713,7 @@ export class Component<T extends Env, Props extends {}, State extends {}> {
* If there are no such component, we destroy everything. This is better than
* being in a corrupted state.
*/
function errorHandler(error: Error, component: Component<any, any, any>) {
function errorHandler(error: Error, component: Component<any, any>) {
let canCatch = false;
let qweb = component.env.qweb;
let root = component;
+33 -18
View File
@@ -1,4 +1,5 @@
import { QWeb } from "../qweb/index";
import { INTERP_REGEXP } from "../qweb/compilation_context";
import { MODS_CODE } from "../qweb/extensions";
//------------------------------------------------------------------------------
@@ -262,9 +263,10 @@ QWeb.addDirective({
let refExpr = "";
let refKey: string = "";
if (ref) {
ctx.rootContext.shouldDefineRefs = true;
refKey = `ref${ctx.generateID()}`;
ctx.addLine(`const ${refKey} = ${ctx.interpolate(ref)};`);
refExpr = `context.refs[${refKey}] = w${componentID};`;
refExpr = `context.__owl__.refs[${refKey}] = w${componentID};`;
}
let transitionsInsertCode = "";
if (transition) {
@@ -272,7 +274,7 @@ QWeb.addDirective({
}
let finalizeComponentCode = `w${componentID}.${keepAlive ? "unmount" : "destroy"}();`;
if (ref && !keepAlive) {
finalizeComponentCode += `delete context.refs[${refKey}];`;
finalizeComponentCode += `delete context.__owl__.refs[${refKey}];`;
}
if (transition) {
finalizeComponentCode = `let finalize = () => {
@@ -356,8 +358,12 @@ QWeb.addDirective({
ctx.addLine(`let _${dummyID}_index = c${ctx.parentNode}.length;`);
}
let shouldProxy = false;
if (async || keepAlive) {
ctx.addLine(
`const fiber${componentID} = Object.assign(Object.create(extra.fiber), {patchQueue: []});`
);
}
if (async) {
ctx.addLine(`const patchQueue${componentID} = [];`);
ctx.addLine(
`c${ctx.parentNode}.push(w${componentID} && w${componentID}.__owl__.pvnode || null);`
);
@@ -380,10 +386,12 @@ QWeb.addDirective({
ctx.addLine(`let props${componentID} = {${propStr}};`);
}
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.addIf(
`utils.shallowEqual(props${componentID}, w${componentID}.__owl__.currentFiber.props)`
);
ctx.addLine(`def${defID} = w${componentID}.__owl__.currentFiber.promise;`);
ctx.addElse();
ctx.addLine(`w${componentID}.destroy();`);
ctx.addLine(`w${componentID} = false;`);
@@ -392,10 +400,16 @@ QWeb.addDirective({
ctx.addIf(`!w${componentID}`);
// new component
ctx.addLine(`let componentKey${componentID} = ${ctx.interpolate(value)};`);
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}];`
`let W${componentID} = context.constructor.components[componentKey${componentID}] || QWeb.components[componentKey${componentID}]${dynamicFallback};`
);
// maybe only do this in dev mode...
ctx.addLine(
`if (!W${componentID}) {throw new Error('Cannot find the definition of component "' + componentKey${componentID} + '"')}`
@@ -441,14 +455,15 @@ QWeb.addDirective({
}
}
let scopeVars = "";
let scopeVars;
if (hasSlots) {
scopeVars += ctx.scopeVars.length ? `Object.assign({}, scope)` : varDefs.length ? `{}` : "";
if (varDefs.length) {
scopeVars += `, {${varDefs.join(",")}}`;
}
let scope = ctx.scopeVars.length ? `Object.assign({}, scope)` : `{}`;
let vars = varDefs.length ? `{${varDefs.join(",")}}` : "undefined";
scopeVars = `${scope}, ${vars}`;
} else {
scopeVars = "undefined, undefined";
}
ctx.addLine(`def${defID} = w${componentID}.__prepare(${scopeVars});`);
ctx.addLine(`def${defID} = w${componentID}.__prepare(extra.fiber, ${scopeVars});`);
// hack: specify empty remove hook to prevent the node from being removed from the DOM
let registerCode = `c${ctx.parentNode}[_${dummyID}_index]=pvnode;`;
if (shouldProxy) {
@@ -460,19 +475,19 @@ QWeb.addDirective({
ctx.addElse();
// need to update component
let patchQueueCode = async ? `patchQueue${componentID}` : "extra.patchQueue";
let patchQueueCode = async || keepAlive ? `fiber${componentID}` : "extra.fiber";
if (keepAlive) {
// if we have t-keepalive="1", the component could be unmounted, but then
// we __updateProps is called. This is ok, but we do not want to call
// the willPatch/patched hooks of the component in this case, so we
// disable the patch queue
patchQueueCode = `w${componentID}.__owl__.isMounted ? ${patchQueueCode} : []`;
patchQueueCode = `w${componentID}.__owl__.isMounted ? extra.fiber : fiber${componentID}`;
}
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 &&
`def${defID} = def${defID} || w${componentID}.__updateProps(props${componentID}, ${patchQueueCode}${scopeVars &&
", " + scopeVars});`
);
let keepAliveCode = "";
@@ -492,7 +507,7 @@ QWeb.addDirective({
if (async) {
ctx.addLine(
`def${defID}.then(w${componentID}.__applyPatchQueue.bind(w${componentID}, patchQueue${componentID}));`
`def${defID}.then(w${componentID}.__applyPatchQueue.bind(w${componentID}, fiber${componentID}));`
);
} else {
ctx.addLine(`extra.promises.push(def${defID});`);
+95
View File
@@ -0,0 +1,95 @@
import { Component } from "./component/component";
import { Observer } from "./core/observer";
import { EventBus } from "./core/event_bus";
import { onWillUnmount } from "./hooks";
/**
* 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.
*/
export class Context extends EventBus {
state: any;
observer: Observer;
id: 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 = this.__notifyComponents.bind(this);
this.state = this.observer.observe(state);
}
/**
* 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.
*
* A potential cheap way to improve this situation is to keep track of the
* depth of a component in the component tree. A root component has a depth of
* 1, then its children of 2 and so on... Then, we can update all components
* with the same depth in parallel.
*/
async __notifyComponents() {
const id = ++this.id;
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, id);
}
}
}
}
/**
* 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, method): any {
const __owl__ = component.__owl__;
const id = __owl__.id;
const mapping = ctx.mapping;
if (id in mapping) {
return ctx.state;
}
mapping[id] = 0;
const renderFn = __owl__.render;
__owl__.render = function(comp, params) {
mapping[id] = ctx.id;
return renderFn(comp, params);
};
ctx.on("update", component, async contextId => {
if (mapping[id] < contextId) {
mapping[id] = contextId;
await method();
}
});
onWillUnmount(() => {
ctx.off("update", component);
delete mapping[id];
});
return ctx.state;
}
+1 -1
View File
@@ -33,7 +33,7 @@ export class Observer {
}
}
observe(value: any, parent?: any): any {
observe<T>(value: T, parent?: any): T {
if (value === null || typeof value !== "object" || value instanceof Date) {
// fun fact: typeof null === 'object'
return value;
+128
View File
@@ -0,0 +1,128 @@
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];
return val instanceof HTMLElement ? val : 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);
}
+16 -6
View File
@@ -7,21 +7,31 @@
import { EventBus } from "./core/event_bus";
import { Observer } from "./core/observer";
import { QWeb } from "./qweb/index";
import { ConnectedComponent } from "./store/connected_component";
import { Store } from "./store/store";
import * as _store from "./store";
import * as _utils from "./utils";
import * as _tags from "./tags";
import { Link } from "./router/Link";
import { RouteComponent } from "./router/RouteComponent";
import { Router } from "./router/Router";
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 { QWeb };
export const Context = _context.Context;
export const useState = _hooks.useState;
export const core = { EventBus, Observer };
export const router = { Router, RouteComponent, Link };
export const store = { Store, ConnectedComponent };
export const Store = _store.Store;
export const utils = _utils;
export const tags = _tags;
export const hooks = Object.assign({}, _hooks, {
useContext: _context.useContext,
useDispatch: _store.useDispatch,
useGetters: _store.useGetters,
useStore: _store.useStore
});
export const __info__ = {};
Object.defineProperty(__info__, "mode", {
+11 -23
View File
@@ -1,6 +1,7 @@
import { Context } from "./context";
import { CompilationContext } from "./compilation_context";
import { QWebExprVar } from "./expression_parser";
import { QWeb } from "./qweb";
import { htmlToVDOM } from "../vdom/html_to_vdom";
/**
* Owl QWeb Directives
@@ -25,7 +26,9 @@ QWeb.utils.getFragment = function(str: string): DocumentFragment {
return temp.content;
};
function compileValueNode(value: any, node: Element, qweb: QWeb, ctx: Context) {
QWeb.utils.htmlToVDOM = htmlToVDOM;
function compileValueNode(value: any, node: Element, qweb: QWeb, ctx: CompilationContext) {
if (value === "0" && ctx.caller) {
qweb._compileNode(ctx.caller, ctx);
return;
@@ -55,18 +58,13 @@ function compileValueNode(value: any, node: Element, qweb: QWeb, ctx: Context) {
ctx.rootContext.rootNode = nodeID;
ctx.rootContext.parentTextNode = nodeID;
ctx.addLine(`var vn${nodeID} = {text: ${exprID}};`);
ctx.addLine(`result = vn${nodeID}`);
if (ctx.rootContext.shouldDefineResult) {
ctx.addLine(`result = vn${nodeID}`);
}
}
} else {
let fragID = ctx.generateID();
ctx.rootContext.shouldDefineUtils = true;
ctx.addLine(`var frag${fragID} = utils.getFragment(${exprID})`);
let tempNodeID = ctx.generateID();
ctx.addLine(`var p${tempNodeID} = {hook: {`);
ctx.addLine(` insert: n => n.elm.parentNode.replaceChild(frag${fragID}, n.elm),`);
ctx.addLine(`}};`);
ctx.addLine(`var vn${tempNodeID} = h('div', p${tempNodeID})`);
ctx.addLine(`c${ctx.parentNode}.push(vn${tempNodeID});`);
ctx.addLine(`c${ctx.parentNode}.push(...utils.htmlToVDOM(${exprID}));`);
}
if (node.childNodes.length) {
ctx.addElse();
@@ -80,11 +78,6 @@ QWeb.addDirective({
name: "esc",
priority: 70,
atNodeEncounter({ node, qweb, ctx }): boolean {
if (node.nodeName !== "t") {
let nodeID = qweb._compileGenericNode(node, ctx);
ctx = ctx.withParent(nodeID);
ctx = ctx.subContext("currentKey", ctx.lastNodeKey);
}
let value = ctx.getValue(node.getAttribute("t-esc")!);
compileValueNode(value, node, qweb, ctx.subContext("escaping", true));
return true;
@@ -95,11 +88,6 @@ QWeb.addDirective({
name: "raw",
priority: 80,
atNodeEncounter({ node, qweb, ctx }): boolean {
if (node.nodeName !== "t") {
let nodeID = qweb._compileGenericNode(node, ctx);
ctx = ctx.withParent(nodeID);
ctx = ctx.subContext("currentKey", ctx.lastNodeKey);
}
let value = ctx.getValue(node.getAttribute("t-raw")!);
compileValueNode(value, node, qweb, ctx);
return true;
@@ -199,7 +187,7 @@ QWeb.addDirective({
nodeCopy.removeAttribute("t-call");
// extract variables from nodecopy
const tempCtx = new Context();
const tempCtx = new CompilationContext();
tempCtx.nextID = ctx.rootContext.nextID;
tempCtx.allowMultipleRoots = true;
qweb._compileNode(nodeCopy, tempCtx);
@@ -244,7 +232,7 @@ QWeb.addDirective({
varCode = `{${content}}`;
}
ctx.addLine(
`this.recursiveFns['${subTemplateName}'].call(this, context, Object.assign({}, extra, {parentNode: c${ctx.parentNode}, vars: ${varCode}, scope}));`
`this.recursiveFns['${subTemplateName}'].call(this, context, Object.assign({}, extra, {parentNode: c${ctx.parentNode}, fiber: {vars: ${varCode}, scope}}));`
);
return true;
}
@@ -1,10 +1,11 @@
import { compileExpr, QWebVar } from "./expression_parser";
export const INTERP_REGEXP = /\{\{.*?\}\}/g;
//------------------------------------------------------------------------------
// Compilation Context
//------------------------------------------------------------------------------
export class Context {
export class CompilationContext {
nextID: number = 1;
code: string[] = [];
variables: { [key: string]: QWebVar } = {};
@@ -13,12 +14,13 @@ export class Context {
parentTextNode: number | null = null;
rootNode: number | null = null;
indentLevel: number = 0;
rootContext: Context;
rootContext: CompilationContext;
caller: Element | undefined;
shouldDefineOwner: boolean = false;
shouldDefineParent: boolean = false;
shouldDefineQWeb: boolean = false;
shouldDefineUtils: boolean = false;
shouldDefineRefs: boolean = false;
shouldDefineResult: boolean = true;
shouldProtectContext: boolean = false;
shouldTrackScope: boolean = false;
@@ -61,6 +63,9 @@ export class Context {
if (this.shouldDefineResult) {
this.code.unshift(" let result;");
}
if (this.shouldDefineRefs) {
this.code.unshift(" context.__owl__.refs = context.__owl__.refs || {};");
}
if (this.shouldDefineOwner) {
// this is necessary to prevent some directives (t-forach for ex) to
// pollute the rendering context by adding some keys in it.
@@ -82,7 +87,7 @@ export class Context {
return this.code;
}
withParent(node: number): Context {
withParent(node: number): CompilationContext {
if (
!this.allowMultipleRoots &&
this === this.rootContext &&
@@ -93,13 +98,13 @@ export class Context {
if (!this.rootContext.rootNode) {
this.rootContext.rootNode = node;
}
if (!this.parentNode) {
if (!this.parentNode && this.rootContext.shouldDefineResult) {
this.addLine(`result = vn${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);
newContext[key] = value;
return newContext;
@@ -163,7 +168,7 @@ export class Context {
* '{{x ? 'a': 'b'}}' -> (x ? 'a' : 'b')
*/
interpolate(s: string): string {
let matches = s.match(/\{\{.*?\}\}/g);
let matches = s.match(INTERP_REGEXP);
if (matches && matches[0].length === s.length) {
return `(${this.formatExpression(s.slice(2, -2))})`;
}
+38 -51
View File
@@ -49,25 +49,35 @@ QWeb.addDirective({
);
ctx.closeIf();
let params = extraArgs ? `owner, ${ctx.formatExpression(extraArgs)}` : "owner";
let handler;
if (mods.length > 0) {
handler = `function (e) {`;
let handler = `function (e) {`;
handler += mods
.map(function(mod) {
return MODS_CODE[mod];
})
.join("");
handler += `context['${handlerName}'].call(${params}, e);}`;
if (!extraArgs) {
handler += `context['${handlerName}'].call(${params}, e);}`;
ctx.addLine(
`extra.handlers['${eventName}' + ${nodeID}] = extra.handlers['${eventName}' + ${nodeID}] || ${handler};`
);
ctx.addLine(`p${nodeID}.on['${eventName}'] = extra.handlers['${eventName}' + ${nodeID}];`);
} else {
const handlerKey = `handler${ctx.generateID()}`;
ctx.addLine(`const ${handlerKey} = context['${handlerName}'].bind(${params});`);
handler += `${handlerKey}(e);}`;
ctx.addLine(`p${nodeID}.on['${eventName}'] = ${handler};`);
}
} else {
handler = `context['${handlerName}'].bind(${params})`;
}
if (extraArgs) {
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}];`);
const handler = `context['${handlerName}'].bind(${params})`;
if (extraArgs) {
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 +89,11 @@ QWeb.addDirective({
name: "ref",
priority: 95,
atNodeCreation({ ctx, value, addNodeHook }) {
ctx.rootContext.shouldDefineRefs = true;
const refKey = `ref${ctx.generateID()}`;
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}];`);
}
});
@@ -182,42 +194,6 @@ 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
//------------------------------------------------------------------------------
@@ -231,9 +207,20 @@ QWeb.addDirective({
`const slot${slotKey} = this.constructor.slots[context.__owl__.slotId + '_' + '${value}'];`
);
ctx.addIf(`slot${slotKey}`);
let parentNode = `c${ctx.parentNode}`;
if (!ctx.parentNode) {
ctx.rootContext.shouldDefineResult = true;
ctx.rootContext.shouldDefineUtils = true;
parentNode = `children${ctx.nextID++}`;
ctx.addLine(`let ${parentNode}= []`);
ctx.addLine(`result = {}`);
}
ctx.addLine(
`slot${slotKey}.call(this, context.__owl__.parent, Object.assign({}, extra, {parentNode: c${ctx.parentNode}, vars: extra.vars, parent: owner}));`
`slot${slotKey}.call(this, context.__owl__.parent, Object.assign({}, extra, {parentNode: ${parentNode}, vars: extra.vars, parent: owner}));`
);
if (!ctx.parentNode) {
ctx.addLine(`Promise.all(extra.promises).then(() => utils.defineProxy(result, ${parentNode}[0]))`);
}
ctx.closeIf();
return true;
}
@@ -254,7 +241,7 @@ QWeb.addDirective({
const type = node.getAttribute("type");
let handler;
let event = fullName.includes(".lazy") ? "change" : "input";
const expr = ctx.formatExpression(`state.${value}`);
const expr = ctx.formatExpression(value);
if (node.tagName === "select") {
ctx.addLine(`p${nodeID}.props = {value: ${expr}};`);
addNodeHook("create", `n.elm.value=${expr};`);
+52 -19
View File
@@ -1,7 +1,8 @@
import { EventBus } from "../core/event_bus";
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
@@ -35,7 +36,7 @@ interface Template {
interface CompilationInfo {
node: Element;
qweb: QWeb;
ctx: Context;
ctx: CompilationContext;
fullName: string;
value: string;
}
@@ -68,7 +69,8 @@ const whitespaceRE = /\s+/g;
const NODE_HOOKS_PARAMS = {
create: "(_, n)",
insert: "vn",
remove: "(vn, rm)"
remove: "(vn, rm)",
destroy: "()"
};
interface Utils {
@@ -93,11 +95,15 @@ const UTILS: Utils = {
}
return expr;
},
shallowEqual
shallowEqual,
addNameSpace(vnode) {
addNS(vnode.data, vnode.children, vnode.sel);
}
};
function parseXML(xml: string): Document {
const parser = new DOMParser();
const doc = parser.parseFromString(xml, "text/xml");
if (doc.getElementsByTagName("parsererror").length) {
let msg = "Invalid XML in template.";
@@ -241,7 +247,7 @@ export class QWeb extends EventBus {
this._processTemplate(elem);
const template = {
elem,
fn: (context, extra) => {
fn: function(this: QWeb, context, extra) {
const compiledFunction = this._compile(name, elem);
template.fn = compiledFunction;
return compiledFunction.call(this, context, extra);
@@ -309,8 +315,8 @@ export class QWeb extends EventBus {
* to render a full component tree, since this is an asynchronous operation.
* This method can only render templates without components.
*/
renderToString(name: string, context: EvalContext = {}): string {
const vnode = this.render(name, context);
renderToString(name: string, context: EvalContext = {}, extra?: any): string {
const vnode = this.render(name, context, extra);
if (vnode.sel === undefined) {
return vnode.text!;
}
@@ -335,17 +341,17 @@ 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 ctx = new Context(name);
const ctx = new CompilationContext(name);
if (elem.tagName !== "t") {
ctx.shouldDefineResult = false;
}
if (parentContext) {
ctx.templates = Object.create(parentContext.templates);
ctx.variables = Object.create(parentContext.variables);
ctx.nextID = parentContext.parentNode! + 1;
ctx.parentNode = parentContext.parentNode!;
ctx.nextID = parentContext.nextID + 1;
ctx.parentNode = parentContext.parentNode || ctx.nextID++;
ctx.allowMultipleRoots = true;
ctx.hasParentWidget = true;
ctx.shouldDefineResult = false;
@@ -354,12 +360,12 @@ export class QWeb extends EventBus {
for (let v in parentContext.variables) {
let variable = <any>parentContext.variables[v];
if (variable.id) {
ctx.addLine(`let ${variable.id} = extra.vars.${variable.id}`);
ctx.addLine(`let ${variable.id} = extra.fiber.vars.${variable.id}`);
}
}
}
if (parentContext) {
ctx.addLine(" Object.assign(context, extra.scope);");
ctx.addLine(" Object.assign(context, extra.fiber.scope);");
}
this._compileNode(elem, ctx);
@@ -402,7 +408,7 @@ export class QWeb extends EventBus {
* Generate code from an xml node
*
*/
_compileNode(node: ChildNode, ctx: Context) {
_compileNode(node: ChildNode, ctx: CompilationContext) {
if (!(node instanceof Element)) {
// this is a text node, there are no directive to apply
let text = node.textContent!;
@@ -413,7 +419,11 @@ export class QWeb extends EventBus {
text = text.replace(whitespaceRE, " ");
}
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) {
ctx.addLine(`vn${ctx.parentTextNode}.text += \`${text}\`;`);
} else {
@@ -442,8 +452,6 @@ export class QWeb extends EventBus {
fullName: string;
}[] = [];
let withHandlers = false;
// maybe this is not optimal: we iterate on all attributes here, and again
// just after for each directive.
for (let i = 0; i < attributes.length; i++) {
@@ -453,11 +461,21 @@ export class QWeb extends EventBus {
if (!(dName in QWeb.DIRECTIVE_NAMES)) {
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 ATTR_N = attributes.length;
let withHandlers = false;
for (let i = 0; i < DIR_N; i++) {
let directive = QWeb.DIRECTIVES[i];
let fullName;
@@ -534,6 +552,17 @@ export class QWeb extends EventBus {
}
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) {
if (directive.finalize) {
@@ -542,7 +571,11 @@ export class QWeb extends EventBus {
}
}
_compileGenericNode(node: ChildNode, ctx: Context, withHandlers: boolean = true): number {
_compileGenericNode(
node: ChildNode,
ctx: CompilationContext,
withHandlers: boolean = true
): number {
// nodeType 1 is generic tag
if (node.nodeType !== 1) {
throw new Error("unsupported node type");
@@ -705,7 +738,7 @@ export class QWeb extends EventBus {
return nodeID;
}
_compileChildren(node: ChildNode, ctx: Context) {
_compileChildren(node: ChildNode, ctx: CompilationContext) {
if (node.childNodes.length > 0) {
for (let child of Array.from(node.childNodes)) {
this._compileNode(child, ctx);
-27
View File
@@ -1,27 +0,0 @@
import { Component } from "../component/component";
import { xml } from "../tags";
export class RouteComponent extends Component<any, {}, {}> {
static template = xml`
<t t-foreach="routes" t-as="route">
<t t-if="env.router.currentRouteName === route.name">
<t t-component="{{route.component}}" t-props="env.router.currentParams"/>
</t>
</t>
`;
routes: any[] = [];
constructor(parent, props) {
super(parent, props);
const router = this.env.router;
for (let name of router.routeIds) {
const route = router.routes[name];
if (route.component) {
this.routes.push({
name: route.name,
component: "__component__" + route.name
});
}
}
}
}
+2 -2
View File
@@ -1,10 +1,10 @@
import { Component } from "../component/component";
import { xml } from "../tags";
import { Destination, RouterEnv } from "./Router";
import { Destination, RouterEnv } from "./router";
type Props = Destination;
export class Link<Env extends RouterEnv> extends Component<Env, Props, {}> {
export class Link<Env extends RouterEnv> extends Component<Env, Props> {
static template = xml`
<a t-att-class="{'router-link-active': isActive }"
t-att-href="href"
+18
View File
@@ -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;
}
}
+142
View File
@@ -0,0 +1,142 @@
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;
}
const isStrictEqual = (a, b) => a === b;
export function useStore(selector, options: SelectorOptions = {}): any {
const component: Component<any, any> = Component.current!;
const store = options.store || (component.env.store as Store);
let result = selector(store.state, component.props);
const hashFn = store.observer.deepRevNumber.bind(store.observer);
let revNumber = hashFn(result) || result;
const isEqual = options.isEqual || isStrictEqual;
if (!store.updateFunctions[component.__owl__.id]) {
store.updateFunctions[component.__owl__.id] = [];
}
const updateFunctions = store.updateFunctions[component.__owl__.id];
updateFunctions.push(function(): boolean {
const oldResult = result;
result = selector(store!.state, component.props);
const newRevNumber = hashFn(result);
if (
(newRevNumber > 0 && revNumber !== newRevNumber) ||
(newRevNumber === 0 && !isEqual(oldResult, result))
) {
revNumber = newRevNumber;
return true;
}
return false;
})
useContextWithCB(store, component, function(): Promise<void> | void {
let shouldRender = false;
updateFunctions.forEach(function (updateFn) {
shouldRender = updateFn() || shouldRender;
});
if (shouldRender) {
return component.render();
}
});
onWillUpdateProps(props => {
// FIXME: only do that if not keepalive + do it in destroy in that case
delete store.updateFunctions[component.__owl__.id];
result = selector(store.state, props);
});
return new Proxy(result, {
get(target, k) {
return result[k];
},
set(target, k, v) {
result[k] = v;
return true;
}
});
}
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;
}
-149
View File
@@ -1,149 +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;
}
storeProps: any;
hashFunction: HashFunction = (storeProps, options) => {
const revFn = (this.__owl__ as any).revFn;
const rev = revFn(storeProps);
if (rev > 0) {
return rev;
}
let hash = 0;
for (let key in storeProps) {
const val = storeProps[key];
const hashVal = revFn(val);
if (hashVal === 0) {
if (val !== options.prevStoreProps[key]) {
options.didChange = true;
}
} else {
hash += hashVal;
}
}
return hash;
};
static mapStoreToProps(storeState, ownProps, getters) {
return {};
}
dispatch(name, ...payload) {
return (this.__owl__ as any).store.dispatch(name, ...payload);
}
/**
* Need to do this here so 'deep' can be overrided by subcomponent easily
*/
async __prepareAndRender(
scope?: Object,
vars?: any
): ReturnType<Component<any, any, any>["__prepareAndRender"]> {
const store = this.getStore(this.env);
const ownProps = this.props || {};
this.storeProps = (<any>this.constructor).mapStoreToProps(store.state, ownProps, store.getters);
const observer = store.observer;
const revFn = this.deep ? observer.deepRevNumber : observer.revNumber;
(this.__owl__ as any).store = store;
(this.__owl__ as any).ownProps = this.props;
(this.__owl__ as any).revFn = revFn.bind(observer);
(this.__owl__ as any).storeHash = this.hashFunction(this.storeProps, {
prevStoreProps: this.storeProps
});
(this.__owl__ as any).rev = observer.rev;
return super.__prepareAndRender(scope, vars);
}
/**
* 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() {
(this.__owl__ as any).store.on("update", this, this.__checkUpdate);
super.__callMounted();
}
__callWillUnmount() {
(this.__owl__ as any).store.off("update", this);
super.__callWillUnmount();
}
__destroy(parent: any) {
(this.__owl__ as any).store.off("update", this);
super.__destroy(parent);
}
async render(force: boolean = false) {
this.__updateStoreProps(this.props);
// this is quite technical, so this deserves some explanation.
// When we have a connected component, it can be updated for 3 reasons:
// - some internal state changes (this will go through this method)
// - some props changes (if a parent is changed and need to rerender itself)
// - a store update
//
// It is possible (with connected component and parent) to have the following
// situation: the parent component is rendered first (from its state change),
// then immediately after, it is rendered (from store update). Then, if the
// __checkUpdate method is immediately over, the children component will
// be rendered again by the store update, even though it is supposed to be
// destroyed by the first rendering.
//
// So, the solution is to keep the information that there is a current
// rendering occuring with the same store state, the same props, and return
// that in the __checkUpdate method. To do this, we use the renderPromise
// deferred, which is not used by the component system once the
// component is ready, so we can use it for our own purpose.
(this.__owl__ as any).renderPromise = super.render(force);
return (this.__owl__ as any).renderPromise;
}
async __updateProps(nextProps: P, f, p, s, v) {
this.__updateStoreProps(nextProps);
return super.__updateProps(nextProps, f, p, s, v);
}
__updateStoreProps(nextProps): boolean {
const __owl__ = this.__owl__ as any;
const store = __owl__.store;
const observer = store.observer;
if (observer.rev === __owl__.rev && nextProps === __owl__.ownProps) {
return false;
}
const storeProps = (<any>this.constructor).mapStoreToProps(
store.state,
nextProps,
store.getters
);
const options = { prevStoreProps: this.storeProps, didChange: false };
const storeHash = this.hashFunction(storeProps, options);
this.storeProps = storeProps;
let didChange = options.didChange;
if (storeHash !== __owl__.storeHash) {
__owl__.storeHash = storeHash;
didChange = true;
}
__owl__.rev = store.observer.rev;
__owl__.ownProps = nextProps;
return didChange;
}
async __checkUpdate() {
const didChange = this.__updateStoreProps(this.props);
if (didChange) {
return this.render();
}
// see note in render method
return (this.__owl__ as any).renderPromise;
}
}
-107
View File
@@ -1,107 +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, 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 };
}
interface StoreOption {
debug?: boolean;
}
export class Store extends EventBus {
state: any;
actions: any;
mutations: any;
debug: boolean;
env: any;
observer: Observer;
getters: { [name: string]: (payload?) => any };
constructor(config: StoreConfig, options: StoreOption = {}) {
super();
this.debug = options.debug || false;
this.actions = config.actions;
this.env = config.env;
this.observer = new Observer();
this.observer.notifyCB = this.__notifyComponents.bind(this);
this.state = this.observer.observe(config.state || {});
this.getters = {};
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;
}
/**
* 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() {
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);
}
}
}
}
+3 -4
View File
@@ -10,8 +10,6 @@ import { QWeb } from "./qweb/index";
* 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:
@@ -21,8 +19,9 @@ import { QWeb } from "./qweb/index";
* }
* ```
*/
export function xml(strings) {
export function xml(strings, ...args) {
const name = `__template__${QWeb.nextId++}`;
QWeb.registerTemplate(name, strings[0]);
const value = String.raw(strings, ...args);
QWeb.registerTemplate(name, value);
return name;
}
+1 -3
View File
@@ -48,9 +48,7 @@ export async function loadTemplates(url: string): Promise<string> {
if (!result.ok) {
throw new Error("Error while fetching xml templates");
}
let templates = await result.text();
templates = templates.replace(/<!--[\s\S]*?-->/g, "");
return templates;
return await result.text();
}
export function escape(str: string | number | undefined): string {
+29
View File
@@ -0,0 +1,29 @@
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[] = [];
if (node.hasChildNodes) {
for (let c of node.childNodes) {
children.push(htmlToVNode(c));
}
}
return h((node as Element).tagName, { attrs }, children);
}
+7 -11
View File
@@ -564,13 +564,17 @@ type VNodeChildElement = VNode | string | number | undefined | null;
type ArrayOrElement<T> = T | T[];
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 {
data.ns = "http://www.w3.org/2000/svg";
if (sel !== "foreignObject" && children !== undefined) {
for (let i = 0, iLen = children.length; i < iLen; ++i) {
let childData = children[i].data;
const child = children[i];
if (child === null) {
continue;
}
let childData = child.data;
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);
}
}
}
@@ -612,13 +616,5 @@ export function h(sel: any, b?: any, c?: any): VNode {
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);
}
+12 -16
View File
@@ -10,16 +10,15 @@ exports[`animations t-transition combined with component 1`] = `
var h = this.h;
let c1 = [], p1 = {key:1};
var vn1 = h('div', p1, c1);
result = vn1;
//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;
if (w4 && w4.__owl__.currentFiber && !w4.__owl__.vnode) {
if (utils.shallowEqual(props4, w4.__owl__.currentFiber.props)) {
def3 = w4.__owl__.currentFiber.promise;
} else {
w4.destroy();
w4 = false;
@@ -27,17 +26,17 @@ exports[`animations t-transition combined with component 1`] = `
}
if (!w4) {
let componentKey4 = \`Child\`;
let W4 = context.constructor.components[componentKey4] || QWeb.components[componentKey4];
let W4 = context.constructor.components[componentKey4] || QWeb.components[componentKey4]|| context['Child'];
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 = w4.__prepare(extra.fiber, undefined, undefined);
def3 = def3.then(vnode=>{if (w4.__owl__.isDestroyed){return}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 {
def3 = def3 || w4.__updateProps(props4, extra.forceUpdate, extra.patchQueue);
def3 = def3 || w4.__updateProps(props4, extra.fiber, undefined, undefined);
def3 = def3.then(()=>{if (w4.__owl__.isDestroyed) {return};let pvnode=w4.__owl__.pvnode;c1[_2_index]=pvnode;});
}
extra.promises.push(def3);
@@ -55,7 +54,6 @@ exports[`animations t-transition combined with t-component and t-if 1`] = `
var h = this.h;
let c1 = [], p1 = {key:1};
var vn1 = h('div', p1, c1);
result = vn1;
if (context['state'].display) {
//COMPONENT
let def3;
@@ -63,9 +61,9 @@ exports[`animations t-transition combined with t-component and t-if 1`] = `
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;
if (w4 && w4.__owl__.currentFiber && !w4.__owl__.vnode) {
if (utils.shallowEqual(props4, w4.__owl__.currentFiber.props)) {
def3 = w4.__owl__.currentFiber.promise;
} else {
w4.destroy();
w4 = false;
@@ -73,17 +71,17 @@ exports[`animations t-transition combined with t-component and t-if 1`] = `
}
if (!w4) {
let componentKey4 = \`Child\`;
let W4 = context.constructor.components[componentKey4] || QWeb.components[componentKey4];
let W4 = context.constructor.components[componentKey4] || QWeb.components[componentKey4]|| context['Child'];
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 = w4.__prepare(extra.fiber, undefined, undefined);
def3 = def3.then(vnode=>{if (w4.__owl__.isDestroyed){return}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 {
def3 = def3 || w4.__updateProps(props4, extra.forceUpdate, extra.patchQueue);
def3 = def3 || w4.__updateProps(props4, extra.fiber, undefined, undefined);
def3 = def3.then(()=>{if (w4.__owl__.isDestroyed) {return};let pvnode=w4.__owl__.pvnode;c1[_2_index]=pvnode;});
}
extra.promises.push(def3);
@@ -99,7 +97,6 @@ exports[`animations t-transition with no delay/duration 1`] = `
var h = this.h;
let c1 = [], p1 = {key:1};
var vn1 = h('span', p1, c1);
result = vn1;
p1.hook = {
insert: vn => {
utils.transitionInsert(vn, 'jupiler');
@@ -120,7 +117,6 @@ exports[`animations t-transition, on a simple node (insert) 1`] = `
var h = this.h;
let c1 = [], p1 = {key:1};
var vn1 = h('span', p1, c1);
result = vn1;
p1.hook = {
insert: vn => {
utils.transitionInsert(vn, 'chimay');
@@ -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>"`;
+11 -36
View File
@@ -1,10 +1,10 @@
import { Component, Env } from "../src/component/component";
import { QWeb } from "../src/qweb/index";
import { useState, useRef } from "../src/hooks";
import {
makeDeferred,
makeTestFixture,
makeTestEnv,
nextTick,
patchNextFrame,
renderToDOM,
unpatchNextFrame
@@ -36,7 +36,7 @@ afterEach(() => {
fixture.remove();
});
class Widget extends Component<any, any, any> {}
class Widget extends Component<any, any> {}
//------------------------------------------------------------------------------
// Tests
@@ -106,7 +106,7 @@ describe("animations", () => {
`<div><span t-if="!state.hide" t-transition="chimay">blue</span></div>`
);
class TestWidget extends Widget {
state = { hide: false };
state = useState({ hide: false });
}
const widget = new TestWidget(env);
@@ -148,7 +148,8 @@ describe("animations", () => {
`<div><span t-ref="span" t-transition="chimay">blue</span></div>`
);
class TestWidget extends Widget {
state = { hide: false };
state = useState({ hide: false });
span = useRef("span");
}
const widget = new TestWidget(env);
@@ -163,7 +164,7 @@ describe("animations", () => {
});
await widget.mount(fixture);
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");
await def; // wait for the mocked repaint to be done
spanNode.dispatchEvent(new Event("transitionend")); // mock end of css transition
@@ -217,7 +218,7 @@ describe("animations", () => {
class Child extends Widget {}
class Parent extends Widget {
static components = { Child: Child };
state = { display: true };
state = useState({ display: true });
}
const widget = new Parent(env);
@@ -263,28 +264,6 @@ describe("animations", () => {
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 () => {
expect.assertions(11);
@@ -297,10 +276,8 @@ describe("animations", () => {
</templates>`
);
class Parent extends Widget {
constructor(parent) {
super(parent);
this.state = { flag: false };
}
state = useState({ flag: false });
toggle() {
this.state.flag = !this.state.flag;
}
@@ -357,10 +334,8 @@ describe("animations", () => {
class Child extends Widget {}
class Parent extends Widget {
static components = { Child };
constructor(parent) {
super(parent);
this.state = { flag: false };
}
state = useState({ flag: false });
toggle() {
this.state.flag = !this.state.flag;
}
File diff suppressed because it is too large Load Diff
@@ -14,16 +14,15 @@ exports[`props validation props are validated in dev mode (code snapshot) 1`] =
var h = this.h;
let c1 = [], p1 = {key:1};
var vn1 = h('div', p1, c1);
result = vn1;
//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 = {message:1};
if (w4 && w4.__owl__.renderPromise && !w4.__owl__.vnode) {
if (utils.shallowEqual(props4, w4.__owl__.renderProps)) {
def3 = w4.__owl__.renderPromise;
if (w4 && w4.__owl__.currentFiber && !w4.__owl__.vnode) {
if (utils.shallowEqual(props4, w4.__owl__.currentFiber.props)) {
def3 = w4.__owl__.currentFiber.promise;
} else {
w4.destroy();
w4 = false;
@@ -31,16 +30,16 @@ exports[`props validation props are validated in dev mode (code snapshot) 1`] =
}
if (!w4) {
let componentKey4 = \`Child\`;
let W4 = context.constructor.components[componentKey4] || QWeb.components[componentKey4];
let W4 = context.constructor.components[componentKey4] || QWeb.components[componentKey4]|| context['Child'];
if (!W4) {throw new Error('Cannot find the definition of component \\"' + componentKey4 + '\\"')}
utils.validateProps(W4, props4)
w4 = new W4(parent, props4);
parent.__owl__.cmap[4] = w4.__owl__.id;
def3 = w4.__prepare();
def3 = w4.__prepare(extra.fiber, undefined, undefined);
def3 = def3.then(vnode=>{if (w4.__owl__.isDestroyed){return}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 {
utils.validateProps(w4.constructor, props4)
def3 = def3 || w4.__updateProps(props4, extra.forceUpdate, extra.patchQueue);
def3 = def3 || w4.__updateProps(props4, extra.fiber, undefined, undefined);
def3 = def3.then(()=>{if (w4.__owl__.isDestroyed) {return};let pvnode=w4.__owl__.pvnode;c1[_2_index]=pvnode;});
}
extra.promises.push(def3);
File diff suppressed because it is too large Load Diff
+15 -2
View File
@@ -1,6 +1,7 @@
import { Component, Env } from "../../src/component/component";
import { makeTestFixture, makeTestEnv } from "../helpers";
import { QWeb } from "../../src/qweb";
import { xml } from "../../src/tags";
//------------------------------------------------------------------------------
// Setup and helpers
@@ -22,7 +23,7 @@ afterEach(() => {
QWeb.dev = dev;
});
class Widget extends Component<any, any, any> {}
class Widget extends Component<any, any> {}
//------------------------------------------------------------------------------
// Tests
@@ -31,6 +32,7 @@ describe("props validation", () => {
test("validation is only done in dev mode", async () => {
class TestWidget extends Widget {
static props = ["message"];
static template = xml`<div>hey</div>`;
}
QWeb.dev = true;
@@ -48,6 +50,7 @@ describe("props validation", () => {
test("props: list of strings", async () => {
class TestWidget extends Widget {
static props = ["message"];
static template = xml`<div>hey</div>`;
}
expect(() => {
@@ -67,6 +70,7 @@ describe("props validation", () => {
for (let test of Tests) {
let TestWidget = class extends Widget {
static template = xml`<div>hey</div>`;
static props = { p: test.type };
};
@@ -96,6 +100,7 @@ describe("props validation", () => {
for (let test of Tests) {
let TestWidget = class extends Widget {
static template = xml`<div>hey</div>`;
static props = { p: { type: test.type } };
};
@@ -115,6 +120,7 @@ describe("props validation", () => {
test("can validate a prop with multiple types", async () => {
let TestWidget = class extends Widget {
static template = xml`<div>hey</div>`;
static props = { p: [String, Boolean] };
};
@@ -130,6 +136,7 @@ describe("props validation", () => {
test("can validate an optional props", async () => {
let TestWidget = class extends Widget {
static template = xml`<div>hey</div>`;
static props = { p: { type: String, optional: true } };
};
@@ -145,6 +152,7 @@ describe("props validation", () => {
test("can validate an array with given primitive type", async () => {
let TestWidget = class extends Widget {
static template = xml`<div>hey</div>`;
static props = { p: { type: Array, element: String } };
};
@@ -164,6 +172,7 @@ describe("props validation", () => {
test("can validate an array with multiple sub element types", async () => {
let TestWidget = class extends Widget {
static template = xml`<div>hey</div>`;
static props = { p: { type: Array, element: [String, Boolean] } };
};
@@ -180,6 +189,7 @@ describe("props validation", () => {
test("can validate an object with simple shape", async () => {
let TestWidget = class extends Widget {
static template = xml`<div>hey</div>`;
static props = {
p: { type: Object, shape: { id: Number, url: String } }
};
@@ -201,6 +211,7 @@ describe("props validation", () => {
test("can validate recursively complicated prop def", async () => {
let TestWidget = class extends Widget {
static template = xml`<div>hey</div>`;
static props = {
p: {
type: Object,
@@ -316,6 +327,7 @@ describe("default props", () => {
test("can set default values", async () => {
class TestWidget extends Widget {
static defaultProps = { p: 4 };
static template = xml`<div>hey</div>`;
}
const w = new TestWidget(env, {});
@@ -334,7 +346,8 @@ describe("default props", () => {
const w = new TestWidget(env, { p: 1 });
await w.mount(fixture);
expect(fixture.innerHTML).toMatchSnapshot();
await w.__updateProps({});
const fiber = w.__createFiber(false, undefined, undefined, undefined);
await w.__updateProps({}, fiber);
await w.render();
expect(w.props.p).toBe(4);
expect(fixture.innerHTML).toMatchSnapshot();
+176
View File
@@ -0,0 +1,176 @@
import { makeTestEnv, makeTestFixture, nextTick } from "./helpers";
import { Component, Env } 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.
// - env: a WEnv, necessary to create new components
let fixture: HTMLElement;
let env: Env;
beforeEach(() => {
fixture = makeTestFixture();
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(env);
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(env);
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(env);
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("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(env);
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(env);
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(env);
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);
});
});
+8 -3
View File
@@ -99,10 +99,15 @@ export function renderToDOM(
* Note that the result of renderToString is guaranteed to be the same as the
* one from QWeb.
*/
export function renderToString(qweb: QWeb, t: string, context: EvalContext = {}): string {
const node = renderToDOM(qweb, t, context);
export function renderToString(
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;
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");
}
return result;
+493
View File
@@ -0,0 +1,493 @@
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();
});
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(env);
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(env) {
super(env);
useMyHook();
}
}
const component = new MyComponent(env);
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(env) {
super(env);
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(env);
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(env) {
super(env);
useMyHook();
}
mounted() {
steps.push("comp:mounted");
}
willUnmount() {
steps.push("comp:willunmount");
}
}
const component = new MyComponent(env);
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(env) {
super(env);
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(env);
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(env) {
super(env);
useMyHook(1);
useMyHook(2);
}
}
const component = new MyComponent(env);
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(env);
await counter.mount(fixture);
expect(fixture.innerHTML).toBe("<div><button>0</button></div>");
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(env);
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("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(env) {
super(env);
useMyHook();
}
}
const component = new MyComponent(env);
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(env) {
super(env);
useMyHook();
}
willPatch() {
steps.push("comp:willPatch");
}
patched() {
steps.push("comp:patched");
}
}
const component = new MyComponent(env);
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(env) {
super(env);
useMyHook(1);
useMyHook(2);
}
}
const component = new MyComponent(env);
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(env) {
super(env);
useAutofocus("input2");
}
}
const component = new SomeComponent(env);
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(env) {
super(env);
useAutofocus("input2");
}
}
const component = new SomeComponent(env);
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(env) {
super(env);
useSubEnv({ val: 3 });
}
}
const component = new TestComponent(env);
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(env) {
super(env);
useSubEnv({ val: 5 });
}
}
class Parent extends Component<any, any> {
static template = xml`<div><t t-esc="env.val"/><Child/></div>`;
static components = { Child };
constructor(env) {
super(env);
useSubEnv({ val: 3 });
}
}
const component = new Parent(env);
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(env);
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"]);
});
});
File diff suppressed because it is too large Load Diff
+115 -6
View File
@@ -49,6 +49,11 @@ describe("static templates", () => {
qweb.addTemplate("test", "<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", () => {
@@ -631,7 +636,9 @@ describe("t-call (template calling", () => {
`);
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 })).toBe(expected);
expect(renderToString(qweb, "Parent", { root }, { fiber: { vars: {}, scope: {} } })).toBe(
expected
);
const recursiveFn = Object.values(qweb.recursiveFns)[0];
expect(recursiveFn.toString()).toMatchSnapshot();
});
@@ -658,10 +665,17 @@ describe("t-call (template calling", () => {
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 })).toBe(expected);
expect(renderToString(qweb, "Parent", { root }, { fiber: {} })).toBe(expected);
const recursiveFn = Object.values(qweb.recursiveFns)[0];
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);
});
});
describe("foreach", () => {
@@ -764,7 +778,7 @@ describe("foreach", () => {
describe("misc", () => {
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-asc-toto", `<div t-raw="toto">toto default</div>`);
qweb.addTemplate(
@@ -1072,20 +1086,92 @@ describe("t-on", () => {
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 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", () => {
test("can get a ref on a node", () => {
qweb.addTemplate("test", `<div><span t-ref="myspan"/></div>`);
let refs: any = {};
renderToDOM(qweb, "test", { refs });
renderToDOM(qweb, "test", { __owl__: { refs } });
expect(refs.myspan.tagName).toBe("SPAN");
});
test("can get a dynamic ref on a node", () => {
qweb.addTemplate("test", `<div><span t-ref="myspan{{id}}"/></div>`);
let refs: any = {};
renderToDOM(qweb, "test", { refs, id: 3 });
renderToDOM(qweb, "test", { id: 3, __owl__: { refs } });
expect(refs.myspan3.tagName).toBe("SPAN");
});
@@ -1100,7 +1186,7 @@ describe("t-ref", () => {
</div>`
);
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"]);
});
});
@@ -1298,3 +1384,26 @@ describe("global template registration", () => {
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>`
);
});
});
@@ -1,60 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`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;
let result;
context = Object.create(context);
var h = this.h;
var _1 = context['routes'];
if (!_1) { throw new Error('QWeb error: Invalid loop expression')}
var _2 = _3 = _1;
if (!(_1 instanceof Array)) {
_2 = Object.keys(_1);
_3 = Object.values(_1);
}
var _length2 = _2.length;
for (let i = 0; i < _length2; i++) {
context.route_first = i === 0;
context.route_last = i === _length2 - 1;
context.route_index = i;
context.route = _2[i];
context.route_value = _3[i];
if (context['env'].router.currentRouteName===context['route'].name) {
//COMPONENT
let def5;
let templateId7 = String(-6 - i);
let w6 = templateId7 in parent.__owl__.cmap ? parent.__owl__.children[parent.__owl__.cmap[templateId7]] : false;
let vn8 = {};
result = vn8;
let props6 = Object.assign({}, context['env'].router.currentParams);
if (w6 && w6.__owl__.renderPromise && !w6.__owl__.vnode) {
if (utils.shallowEqual(props6, w6.__owl__.renderProps)) {
def5 = w6.__owl__.renderPromise;
} else {
w6.destroy();
w6 = false;
}
}
if (!w6) {
let componentKey6 = (context['route'].component);
let W6 = context.constructor.components[componentKey6] || QWeb.components[componentKey6];
if (!W6) {throw new Error('Cannot find the definition of component \\"' + componentKey6 + '\\"')}
w6 = new W6(parent, props6);
parent.__owl__.cmap[templateId7] = w6.__owl__.id;
def5 = w6.__prepare();
def5 = def5.then(vnode=>{if (w6.__owl__.isDestroyed){return}let pvnode=h(vnode.sel, {key: templateId7, hook: {insert(vn) {let nvn=w6.__mount(vnode, pvnode.elm);pvnode.elm=nvn.elm;},remove() {},destroy(vn) {w6.destroy();}}});utils.defineProxy(vn8, pvnode);w6.__owl__.pvnode = pvnode;});
} else {
def5 = def5 || w6.__updateProps(props6, extra.forceUpdate, extra.patchQueue);
def5 = def5.then(()=>{if (w6.__owl__.isDestroyed) {return};let pvnode=w6.__owl__.pvnode;utils.defineProxy(vn8, pvnode);});
}
extra.promises.push(def5);
}
}
return result;
}"
`;
@@ -10,7 +10,6 @@ exports[`Link component can render simple cases 1`] = `
var _2 = context['href'];
let c3 = [], p3 = {key:3,attrs:{href: _2},class:_1,on:{}};
var vn3 = h('a', p3, c3);
result = vn3;
if (!context['navigate']) {
throw new Error('Missing handler \\\\'' + 'navigate' + \`\\\\' when evaluating template '__template__1'\`)
}
@@ -0,0 +1,45 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`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;
let result;
var h = this.h;
if (context['routeComponent']) {
//COMPONENT
let key4 = 'key' + context['env'].router.currentRouteName;
let def2;
let templateId5 = key4;
let w3 = templateId5 in parent.__owl__.cmap ? parent.__owl__.children[parent.__owl__.cmap[templateId5]] : false;
let vn6 = {};
result = vn6;
let props3 = Object.assign({}, context['env'].router.currentParams);
if (w3 && w3.__owl__.currentFiber && !w3.__owl__.vnode) {
if (utils.shallowEqual(props3, w3.__owl__.currentFiber.props)) {
def2 = w3.__owl__.currentFiber.promise;
} else {
w3.destroy();
w3 = false;
}
}
if (!w3) {
let componentKey3 = \`routeComponent\`;
let W3 = context.constructor.components[componentKey3] || QWeb.components[componentKey3]|| context['routeComponent'];
if (!W3) {throw new Error('Cannot find the definition of component \\"' + componentKey3 + '\\"')}
w3 = new W3(parent, props3);
parent.__owl__.cmap[templateId5] = w3.__owl__.id;
def2 = w3.__prepare(extra.fiber, undefined, undefined);
def2 = def2.then(vnode=>{if (w3.__owl__.isDestroyed){return}let pvnode=h(vnode.sel, {key: templateId5, hook: {insert(vn) {let nvn=w3.__mount(vnode, pvnode.elm);pvnode.elm=nvn.elm;},remove() {},destroy(vn) {w3.destroy();}}});utils.defineProxy(vn6, pvnode);w3.__owl__.pvnode = pvnode;});
} else {
def2 = def2 || w3.__updateProps(props3, extra.fiber, undefined, undefined);
def2 = def2.then(()=>{if (w3.__owl__.isDestroyed) {return};let pvnode=w3.__owl__.pvnode;utils.defineProxy(vn6, pvnode);});
}
extra.promises.push(def2);
}
return result;
}"
`;
@@ -1,8 +1,8 @@
import { Component } from "../../src/component/component";
import { Link } from "../../src/router/Link";
import { RouterEnv } from "../../src/router/Router";
import { Link } from "../../src/router/link";
import { RouterEnv } from "../../src/router/router";
import { makeTestEnv, makeTestFixture, nextTick } from "../helpers";
import { TestRouter } from "./TestRouter";
import { TestRouter } from "./test_router";
describe("Link component", () => {
let fixture: HTMLElement;
@@ -30,7 +30,7 @@ describe("Link component", () => {
</div>
</templates>
`);
class App extends Component<any, any, any> {
class App extends Component<any, any> {
static components = { Link: Link };
}
@@ -61,7 +61,7 @@ describe("Link component", () => {
</div>
</templates>
`);
class App extends Component<any, any, any> {
class App extends Component<any, any> {
static components = { Link: Link };
}
@@ -1,8 +1,8 @@
import { Component } from "../../src/component/component";
import { RouterEnv } from "../../src/router/Router";
import { RouteComponent } from "../../src/router/RouteComponent";
import { RouterEnv } from "../../src/router/router";
import { RouteComponent } from "../../src/router/route_component";
import { makeTestEnv, makeTestFixture, nextTick } from "../helpers";
import { TestRouter } from "./TestRouter";
import { TestRouter } from "./test_router";
describe("RouteComponent", () => {
let fixture: HTMLElement;
@@ -32,9 +32,9 @@ describe("RouteComponent", () => {
<span t-name="Users">Users</span>
</templates>
`);
class About extends Component<any, any, any> {}
class Users extends Component<any, any, any> {}
class App extends Component<any, any, any> {
class About extends Component<any, any> {}
class Users extends Component<any, any> {}
class App extends Component<any, any> {
static components = { RouteComponent };
}
@@ -64,8 +64,8 @@ describe("RouteComponent", () => {
<span t-name="Book">Book <t t-esc="props.title"/></span>
</templates>
`);
class Book extends Component<any, any, any> {}
class App extends Component<any, any, any> {
class Book extends Component<any, any> {}
class App extends Component<any, any> {
static components = { RouteComponent };
}
@@ -86,12 +86,12 @@ describe("RouteComponent", () => {
<span t-name="Book">Book <t t-esc="props.title"/>|<t t-esc="incVal"/></span>
</templates>
`);
class Book extends Component<any, any, any> {
class Book extends Component<any, any> {
get incVal() {
return this.props.val + 1;
}
}
class App extends Component<any, any, any> {
class App extends Component<any, any> {
static components = { RouteComponent };
}
+2 -2
View File
@@ -1,6 +1,6 @@
import { Destination, RouterEnv, Route } from "../../src/router/Router";
import { Destination, RouterEnv, Route } from "../../src/router/router";
import { makeTestEnv, nextTick } from "../helpers";
import { TestRouter } from "./TestRouter";
import { TestRouter } from "./test_router";
let env: RouterEnv;
let router: TestRouter | null = null;
@@ -1,4 +1,4 @@
import { Router } from "../../src/router/Router";
import { Router } from "../../src/router/router";
import { QWeb } from "../../src/qweb/index";
export class TestRouter extends Router {
@@ -1,6 +1,6 @@
import { Env } from "../../src/component/component";
import { Store, Getter } from "../../src/store/store";
import { nextTick, nextMicroTick } from "../helpers";
import { Env } from "../src/component/component";
import { Store, Getter } from "../src/store";
import { nextTick, nextMicroTick } from "./helpers";
describe("basic use", () => {
test("dispatch an action", () => {
@@ -1,21 +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>"`;
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>"`;
File diff suppressed because it is too large Load Diff
+990
View File
@@ -0,0 +1,990 @@
import { Component, Env } from "../src/component/component";
import { Store, useStore, useDispatch, useGetters } from "../src/store";
import { useState } from "../src/hooks";
import { xml } from "../src/tags";
import { shallowEqual } from "../src/utils";
import { makeTestEnv, makeTestFixture, nextTick, makeDeferred } 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, with useStore", async () => {
let nextId = 1;
const state = { todos: [] };
const actions = {
addTodo({ state }, msg) {
state.todos.push({ msg, id: nextId++ });
}
};
const store = new Store({ state, actions });
class App extends Component<any, any> {
static template = xml`
<div>
<span t-foreach="todos" t-key="todo.id" t-as="todo"><t t-esc="todo.msg"/></span>
</div>`;
todos = useStore(state => state.todos);
}
(<any>env).store = store;
const app = new App(env);
await app.mount(fixture);
expect(fixture.innerHTML).toBe("<div></div>");
await store.dispatch("addTodo", "hello");
await nextTick();
expect(fixture.innerHTML).toBe("<div><span>hello</span></div>");
});
test("can use useStore twice in a component", async () => {
const state = { a: 1, b: 2 };
const actions = {
doSomething({ state }) {
state.a = 2;
state.b = 3;
}
};
const store = new Store({ state, actions });
class App extends Component<any, any> {
static template = xml`
<div>
<span t-esc="a.value"/>
<span t-esc="b.value"/>
</div>`;
a = useStore(state => ({value: state.a}));
b = useStore(state => ({value: state.b}));
}
App.prototype.__render = jest.fn(App.prototype.__render);
(<any>env).store = store;
const app = new App(env);
await app.mount(fixture);
expect(fixture.innerHTML).toBe("<div><span>1</span><span>2</span></div>");
expect(App.prototype.__render).toBeCalledTimes(1);
await store.dispatch("doSomething");
await nextTick();
expect(fixture.innerHTML).toBe("<div><span>2</span><span>3</span></div>");
expect(App.prototype.__render).toBeCalledTimes(2);
});
test("useStore: do not re-render if not changed", async () => {
let nextId = 1;
const state = { todos: [], a: 1 };
const actions = {
addTodo({ state }, msg) {
state.todos.push({ msg, id: nextId++ });
}
};
const store = new Store({ state, actions });
class App extends Component<any, any> {
static template = xml`
<div>
<span t-foreach="todos" t-key="todo.id" t-as="todo"><t t-esc="todo.msg"/></span>
</div>`;
todos = useStore(state => state.todos);
}
App.prototype.__render = jest.fn(App.prototype.__render);
(<any>env).store = store;
const app = new App(env);
await app.mount(fixture);
expect(fixture.innerHTML).toBe("<div></div>");
expect(App.prototype.__render).toBeCalledTimes(1);
store.state.todos.push({ id: 3, msg: "hello" });
await nextTick();
expect(fixture.innerHTML).toBe("<div><span>hello</span></div>");
expect(App.prototype.__render).toBeCalledTimes(2);
store.state.a = 2;
await nextTick();
expect(fixture.innerHTML).toBe("<div><span>hello</span></div>");
expect(App.prototype.__render).toBeCalledTimes(2);
});
test("connecting a component with useStore returning number", async () => {
let nextId = 1;
const state = { todos: [] };
const actions = {
addTodo({ state }, msg) {
state.todos.push({ msg, id: nextId++ });
}
};
const store = new Store({ state, actions });
class App extends Component<any, any> {
static template = xml`<div><t t-esc="nbrTodos.value"/></div>`;
nbrTodos = useStore(state => ({ value: state.todos.length }));
}
(<any>env).store = store;
const app = new App(env);
await app.mount(fixture);
expect(fixture.innerHTML).toBe("<div>0</div>");
await store.dispatch("addTodo", "hello");
await nextTick();
expect(fixture.innerHTML).toBe("<div>1</div>");
});
test("connecting a component with useStore returning number", async () => {
let nextId = 1;
const state = { todos: [], a: 1 };
const actions = {
addTodo({ state }, msg) {
state.todos.push({ msg, id: nextId++ });
},
incrementA({ state }) {
state.a++;
}
};
const store = new Store({ state, actions });
class App extends Component<any, any> {
static template = xml`<div><t t-esc="nbrTodos.value"/></div>`;
nbrTodos = useStore(state => ({ value: state.todos.length }), { isEqual: shallowEqual });
}
App.prototype.__render = jest.fn(App.prototype.__render);
(<any>env).store = store;
const app = new App(env);
await app.mount(fixture);
expect(fixture.innerHTML).toBe("<div>0</div>");
expect(App.prototype.__render).toBeCalledTimes(1);
await store.dispatch("addTodo", "hello");
await nextTick();
expect(fixture.innerHTML).toBe("<div>1</div>");
expect(App.prototype.__render).toBeCalledTimes(2);
await store.dispatch("incrementA");
await nextTick();
expect(fixture.innerHTML).toBe("<div>1</div>");
expect(App.prototype.__render).toBeCalledTimes(2);
});
test("connecting a component to a local store", async () => {
let nextId = 1;
const state = { todos: [] };
const actions = {
addTodo({ state }, msg) {
state.todos.push({ msg, id: nextId++ });
}
};
const store = new Store({ state, actions });
class App extends Component<any, any> {
static template = xml`
<div>
<span t-foreach="todos" t-key="todo.id" t-as="todo"><t t-esc="todo.msg"/></span>
</div>`;
todos = useStore(state => state.todos, { store });
}
const app = new App(env);
await app.mount(fixture);
expect(fixture.innerHTML).toBe("<div></div>");
await store.dispatch("addTodo", "hello");
await nextTick();
expect(fixture.innerHTML).toBe("<div><span>hello</span></div>");
});
test("can dispatch actions from a connected component", async () => {
const store = new Store({
state: { value: 1 },
actions: {
inc({ state }) {
state.value++;
}
}
});
(<any>env).store = store;
class App extends Component<any, any> {
static template = xml`
<div>
<button t-on-click="dispatch('inc')">Inc</button>
<span><t t-esc="storeState.value"/></span>
</div>`;
storeState = useStore(state => state);
dispatch = useDispatch();
}
const app = new App(env);
await app.mount(fixture);
expect(fixture.innerHTML).toBe("<div><button>Inc</button><span>1</span></div>");
fixture.querySelector("button")!.click();
await nextTick();
expect(fixture.innerHTML).toBe("<div><button>Inc</button><span>2</span></div>");
});
test("useStore can use props", async () => {
const state = { todos: [{ id: 1, text: "jupiler" }, { id: 2, text: "chimay" }] };
const store = new Store({ state, actions: {} });
class TodoItem extends Component<any, any> {
static template = xml`<span><t t-esc="todo.text"/></span>`;
todo = useStore((state, props) => {
return state.todos.find(t => t.id === props.todoId);
});
}
class App extends Component<any, any> {
static template = xml`<div><TodoItem todoId="state.currentId"/></div>`;
static components = { TodoItem };
state = useState({ currentId: 1 });
}
(<any>env).store = store;
const app = new App(env);
await app.mount(fixture);
expect(fixture.innerHTML).toBe("<div><span>jupiler</span></div>");
app.state.currentId = 2;
await nextTick();
expect(fixture.innerHTML).toBe("<div><span>chimay</span></div>");
});
test("useStore receives props as second argument", async () => {
const state = { todos: [{ id: 1, text: "jupiler" }] };
let nextId = 2;
const actions = {
addTodo({ state }, text) {
state.todos.push({ text, id: nextId++ });
}
};
const store = new Store({ state, actions });
class TodoItem extends Component<any, any> {
static template = xml`<span><t t-esc="todo.text"/></span>`;
todo = useStore((state, props) => {
return state.todos.find(t => t.id === props.id);
});
}
class TodoList extends Component<any, any> {
static template = xml`
<div>
<TodoItem t-foreach="todos" t-as="todo" id="todo.id" t-key="todo.id"/>
</div>`;
static components = { TodoItem };
todos = useStore(state => 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.dispatch("addTodo", "hoegaarden");
await nextTick();
expect(fixture.innerHTML).toBe("<div><span>jupiler</span><span>hoegaarden</span></div>");
});
test("can call useGetters to receive store getters", 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 });
class TodoItem extends Component<any, any> {
static template = xml`
<div>
<span><t t-esc="storeProps.activeTodoText"/></span>
<span><t t-esc="storeProps.importantTodoText"/></span>
</div>`;
getters = useGetters();
storeProps = useStore((state, props) => {
const todo = state.todos.find(t => t.id === props.id);
return {
activeTodoText: this.getters.text(todo.id),
importantTodoText: this.getters.importantTodoText()
};
});
}
class TodoList extends Component<any, any> {
static components = { TodoItem };
static template = xml`
<div>
<t t-foreach="todos" t-as="todo">
<TodoItem id="todo.id" t-key="todo.id"/>
</t>
</div>`;
todos = useStore(state => 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 () => {
class Beer extends Component<any, any> {
static template = xml`<span><t t-esc="beer.name"/></span>`;
beer = useStore((state, props) => state.beers[props.id]);
}
class App extends Component<any, any> {
static template = xml`<div><Beer id="state.beerId"/></div>`;
static components = { Beer };
state = useState({ 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 () => {
class App extends Component<any, any> {
static template = xml`
<div>
<span t-foreach="data.beers" t-as="beer" t-key="beer.name"><t t-esc="beer.name"/></span>
</div>`;
// we have here a new object
data = useStore(state => ({ beers: state.beers, otherKey: 1 }));
}
const actions = {
addBeer({ state }, name) {
state.beers.push({ name });
}
};
const state = { beers: [{ name: "jupiler" }] };
const store = new Store({ state, actions });
(<any>env).store = store;
const app = new App(env);
await app.mount(fixture);
expect(fixture.innerHTML).toBe("<div><span>jupiler</span></div>");
store.dispatch("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 () => {
class Beer extends Component<any, any> {
static template = xml`
<div t-name="Beer">
<span>taster:<t t-esc="data.taster"/></span>
<span t-if="data.selected">selected:<t t-esc="data.selected.name"/></span>
<span t-if="data.consumed">consumed:<t t-esc="data.consumed.name"/></span>
</div>`;
data = useStore((state, props) => ({
selected: state.beers[props.id],
consumed: state.beers[state.consumedID] || null,
taster: state.taster
}));
}
class App extends Component<any, any> {
static template = xml`<div><Beer id="state.beerId"/></div>`;
static components = { Beer };
state = useState({ beerId: 0 });
}
const actions = {
consume({ state }, beerId) {
state.consumedID = beerId;
}
};
const state = {
beers: {
1: { name: "jupiler" }
},
consumedID: null,
taster: "aaron"
};
const store = new Store({ state, actions });
(<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.dispatch("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 () => {
class Beer extends Component<any, any> {
static template = xml`
<div>
<span>taster:<t t-esc="info.taster"/></span>
<span t-if="info.selected">selected:<t t-esc="info.selected.name"/></span>
<span t-if="info.consumed">consumed:<t t-esc="info.consumed.name"/></span>
</div>`;
info = useStore(function(state, props) {
return {
selected: state.beers[props.id],
consumed: state.beers[state.consumedID] || null,
taster: state.taster
};
});
}
class App extends Component<any, any> {
static template = xml`<div><Beer id="state.beerId"/></div>`;
static components = { Beer };
state = useState({ beerId: 0 });
}
const actions = {
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, actions });
(<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.dispatch("renameBeer", { beerId: 1, name: "kwak" });
await nextTick();
expect(fixture.innerHTML).toBe(
"<div><div><span>taster:aaron</span><span>selected:kwak</span></div></div>"
);
store.dispatch("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.dispatch("renameBeer", { beerId: 1, name: "jupiler" });
await nextTick();
expect(fixture.innerHTML).toBe(
"<div><div><span>taster:aaron</span><span>consumed:jupiler</span></div></div>"
);
store.dispatch("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[] = [];
class Child extends Component<any, any> {
static template = xml`<span><t t-esc="state.msg"/></span>`;
state = useStore((state, props) => {
steps.push("child");
return { msg: state.msg[props.key] };
});
}
class Parent extends Component<any, any> {
static template = xml`<div><Child key="state.current"/></div>`;
static components = { Child };
state = useStore(state => {
steps.push("parent");
return {
current: state.current,
isvisible: state.isvisible
};
});
}
const state = { current: "a", msg: { a: "a", b: "b" } };
const actions = {
setCurrent({ state }, c) {
state.current = c;
}
};
const store = new Store({ state, actions });
(<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.dispatch("setCurrent", "b");
await nextTick();
expect(fixture.innerHTML).toBe("<div><span>b</span></div>");
expect(steps).toEqual(["parent", "child", "parent", "child"]);
});
test("correct update order when parent/children are connected, part 2", async () => {
const steps: string[] = [];
let def = makeDeferred();
def.resolve();
class Child extends Component<any, any> {
static template = xml`<span><t t-esc="state.msg"/></span>`;
state = useStore((s, props) => {
steps.push("child");
return { msg: s.messages[props.someId] };
});
}
class Parent extends Component<any, any> {
static template = xml`
<div>
<Child t-if="state.flag" someId="state.someId"/>
</div>`;
static components = { Child };
state = useStore(s => {
steps.push("parent");
return { flag: s.flag, someId: s.someId };
});
async render(force) {
await def;
return super.render(force);
}
}
const state = { someId: 1, flag: true, messages: { 1: "abc" } };
const actions = {
setFlagToFalse({ state }) {
state.flag = false;
}
};
const store = new Store({ state, actions });
(<any>env).store = store;
const app = new Parent(env);
await app.mount(fixture);
expect(fixture.innerHTML).toBe("<div><span>abc</span></div>");
expect(steps).toEqual(["parent", "child"]);
def = makeDeferred();
store.dispatch("setFlagToFalse");
await nextTick();
expect(fixture.innerHTML).toBe("<div><span>abc</span></div>");
expect(steps).toEqual(["parent", "child", "parent"]);
def.resolve();
await nextTick();
expect(steps).toEqual(["parent", "child", "parent"]);
expect(fixture.innerHTML).toBe("<div></div>");
});
test("connected parent/children: no double rendering", async () => {
let steps: string[] = [];
const actions = {
editTodo({ state }) {
state.todos[1].title = "abc";
}
};
const todos = { 1: { id: 1, title: "kikoou" } };
const state = {
todos
};
const store = new Store({
state,
actions
});
class TodoItem extends Component<any, any> {
static template = xml`
<div class="todo">
<t t-esc="state.todo.title"/>
<button class="destroy" t-on-click="editTodo">x</button>
</div>`;
state = useStore((state, props) => {
steps.push("item:usestore");
return {
todo: state.todos[props.id]
};
});
editTodo() {
this.env.store.dispatch("editTodo");
}
__render(f) {
steps.push("item:render");
return super.__render(f);
}
}
class TodoApp extends Component<any, any> {
static template = xml`
<div class="todoapp">
<t t-foreach="Object.values(state.todos)" t-as="todo">
<TodoItem t-key="todo.id" id="todo.id"/>
</t>
</div>`;
static components = { TodoItem };
state = useStore(state => {
steps.push("app:usestore");
return { todos: state.todos };
});
__render(f) {
steps.push("app:render");
return super.__render(f);
}
}
(<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(steps).toEqual(["app:usestore", "app:render", "item:usestore", "item:render"]);
steps = [];
fixture.querySelector("button")!.click();
await nextTick();
expect(steps).toEqual(["app:usestore", "app:render", "item:usestore", "item:render"]);
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 () => {
let steps: string[] = [];
const actions = {
removeTodo({ state }) {
delete state.todos[1];
}
};
const todos = { 1: { id: 1, title: "kikoou" } };
const state = {
todos
};
const store = new Store({
state,
actions
});
class TodoItem extends Component<any, any> {
static template = xml`
<div class="todo">
<t t-esc="state.todo.title"/>
<button class="destroy" t-on-click="removeTodo">x</button>
</div>`;
state = useStore((state, props) => {
steps.push("item:usestore");
return {
todo: state.todos[props.id]
};
});
removeTodo() {
this.env.store.dispatch("removeTodo");
}
__render(f) {
steps.push("item:render");
return super.__render(f);
}
}
class TodoApp extends Component<any, any> {
static template = xml`
<div class="todoapp">
<t t-foreach="Object.values(state.todos)" t-as="todo">
<TodoItem t-key="todo.id" id="todo.id"/>
</t>
</div>`;
static components = { TodoItem };
state = useStore(state => {
steps.push("app:usestore");
return { todos: state.todos };
});
__render(f) {
steps.push("app:render");
return super.__render(f);
}
}
(<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(steps).toEqual(["app:usestore", "app:render", "item:usestore", "item:render"]);
fixture.querySelector("button")!.click();
await nextTick();
expect(steps).toEqual([
"app:usestore",
"app:render",
"item:usestore",
"item:render",
"app:usestore",
"app:render"
]);
expect(fixture.innerHTML).toBe('<div class="todoapp"></div>');
});
test("connected component willpatch/patch hooks are called on store updates", async () => {
const steps: string[] = [];
class App extends Component<any, any> {
static template = xml`<div><t t-esc="store.msg"/></div>`;
store = useStore(s => ({ msg: s.msg }));
willPatch() {
steps.push("willpatch");
}
patched() {
steps.push("patched");
}
}
const state = { msg: "a" };
const actions = {
setMsg({ state }, c) {
state.msg = c;
}
};
const store = new Store({ state, actions });
(<any>env).store = store;
const app = new App(env);
await app.mount(fixture);
expect(fixture.innerHTML).toBe("<div>a</div>");
store.dispatch("setMsg", "b");
await nextTick();
expect(fixture.innerHTML).toBe("<div>b</div>");
expect(steps).toEqual(["willpatch", "patched"]);
});
test("connected child components stop listening to store when destroyed", async () => {
let steps: any = [];
class Child extends Component<any, any> {
static template = xml`<div><t t-esc="store.val"/></div>`;
store = useStore(s => s);
}
class Parent extends Component<any, any> {
static template = xml`<div><Child t-if="state.child" /></div>`;
static components = { Child };
state = useState({ child: true });
}
class TestStore extends Store {
on(eventType, owner, callback) {
steps.push(`on:${eventType}`);
super.on(eventType, owner, callback);
}
off(eventType, owner) {
steps.push(`off:${eventType}`);
super.off(eventType, owner);
}
}
const store = new TestStore({ state: { val: 1 } });
(<any>env).store = store;
const parent = new Parent(env);
await parent.mount(fixture);
expect(steps).toEqual(["on:update"]);
expect(fixture.innerHTML).toBe("<div><div>1</div></div>");
parent.state.child = false;
await nextTick();
expect(fixture.innerHTML).toBe("<div></div>");
expect(steps).toEqual(["on:update", "off:update"]);
});
test("dispatch an action", async () => {
class App extends Component<any, any> {
static template = xml`<div><t t-esc="store.counter"/></div>`;
store = useStore(state => state);
dispatch = useDispatch();
}
const state = {
counter: 0
};
const actions = {
inc({ state }) {
return ++state.counter;
}
};
const store = new Store({ state, actions });
(<any>env).store = store;
const app = new App(env);
await app.mount(fixture);
expect(fixture.innerHTML).toBe("<div>0</div>");
const res = app.dispatch("inc");
expect(res).toBe(1);
await nextTick();
expect(fixture.innerHTML).toBe("<div>1</div>");
});
});
describe("various scenarios", () => {
let fixture: HTMLElement;
let env: Env;
beforeEach(() => {
fixture = makeTestFixture();
env = makeTestEnv();
});
afterEach(() => {
fixture.remove();
});
test("scenarios with async store updates and some components events", async () => {
const actions = {
async deleteAttachment({ state }) {
await Promise.resolve();
delete state.attachments[100];
state.messages[10].attachmentIds = [];
}
};
const state = {
attachments: {
100: {
id: 100,
name: "text.txt"
}
},
messages: {
10: {
attachmentIds: [100],
id: 10
}
}
};
const store = new Store({ actions, state });
class Attachment extends Component<any, any> {
static template = xml`
<div>
<span>Attachment <t t-esc="props.id"/></span>
<span>Name: <t t-esc="attachment.name"/></span>
</div>`;
attachment = useStore((state, props) => ({ name: state.attachments[props.id].name }));
}
class Message extends Component<any, any> {
static template = xml`
<div>
<button t-on-click="doStuff">Do stuff</button>
<Attachment t-foreach="store.attachmentIds" t-key="attachmentId" t-as="attachmentId" id="attachmentId"/>
</div>`;
store = useStore(state => ({ attachmentIds: state.messages[10].attachmentIds }));
static components = { Attachment };
state = { isAttachmentDeleted: false };
dispatch = useDispatch();
doStuff() {
this.dispatch("deleteAttachment", 100);
this.state.isAttachmentDeleted = true;
}
}
(<any>env).store = store;
const message = new Message(env);
await message.mount(fixture);
expect(fixture.innerHTML).toMatchSnapshot();
fixture.querySelector("button")!.click();
await nextTick();
expect(fixture.innerHTML).toMatchSnapshot();
});
});
+57 -11
View File
@@ -1,5 +1,6 @@
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) {
var ret: any[] = [];
@@ -206,19 +207,15 @@ describe("snabbdom", function() {
expect(elm.firstChild.namespaceURI).toBe(SVGNamespace);
// verify that svg tag automatically gets svg namespace
elm = patch(vnode0, h("svg", [h("foreignObject", [h("div", ["I am HTML embedded in SVG"])])]))
.elm;
const vnode = h("svg", [h("foreignObject", [h("div", ["I am HTML embedded in SVG"])])]);
// 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.firstChild.namespaceURI).toBe(SVGNamespace);
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() {
@@ -1108,3 +1105,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>`);
});
});
+153
View File
@@ -0,0 +1,153 @@
import { buildData, startMeasure, stopMeasure, formatNumber } from "../shared/utils.js";
//------------------------------------------------------------------------------
// Likes Counter Widget
//------------------------------------------------------------------------------
class Counter extends owl.Component {
state = { counter: 0 };
increment() {
this.state.counter++;
}
}
//------------------------------------------------------------------------------
// Message Widget
//------------------------------------------------------------------------------
class Message extends owl.Component {
static components = { Counter };
shouldUpdate(nextProps) {
return nextProps.message !== this.props.message;
}
removeMessage() {
this.trigger("remove-message", {
id: this.props.message.id
});
}
}
//------------------------------------------------------------------------------
// Root Widget
//------------------------------------------------------------------------------
class App extends owl.Component {
static components = { Message };
state = { messages: [], multipleFlag: false, clearAfterFlag: false };
mounted() {
this.log(`Benchmarking Owl v${owl.__info__.version} (build date: ${owl.__info__.date})`);
}
benchmark(message, fn, callback) {
if (this.state.multipleFlag) {
const N = 20;
let n = N;
let total = 0;
let cb = info => {
let finalize = () => {
n--;
total += info.delta;
if (n === 0) {
const avg = total / N;
this.log(`Average: ${formatNumber(avg)}ms`, true);
if (callback) {
callback();
}
} else {
this._benchmark(message, fn, cb);
}
};
if (this.state.clearAfterFlag) {
this._benchmark(
"clear",
() => {
this.state.messages = [];
},
finalize,
false
);
} else {
finalize();
}
};
this._benchmark(message, fn, cb);
} else {
this._benchmark(message, fn, callback);
}
}
_benchmark(message, fn, cb, log = true) {
setTimeout(() => {
startMeasure(message);
fn();
stopMeasure(info => {
if (log) {
this.log(info.msg);
}
if (cb) {
cb(info);
}
});
}, 10);
}
addMessages(n) {
this.benchmark("add " + n, () => {
const newMessages = buildData(n);
this.state.messages.push.apply(this.state.messages, newMessages);
});
}
clear() {
this._benchmark("clear", () => {
this.state.messages = [];
});
}
updateSomeMessages() {
this.benchmark("update every 10th", () => {
const messages = this.state.messages;
for (let i = 0; i < messages.length; i += 10) {
const msg = Object.assign({}, messages[i]);
msg.author += "!!!";
messages[i] = msg;
}
});
}
removeMessage(event) {
this.benchmark("remove message", () => {
const index = this.state.messages.findIndex(m => m.id === event.detail.id);
this.state.messages.splice(index, 1);
});
}
log(str, isBold) {
const div = document.createElement("div");
if (isBold) {
div.classList.add("bold");
}
div.textContent = `> ${str}`;
this.refs.log.appendChild(div);
this.refs.log.scrollTop = this.refs.log.scrollHeight;
}
clearLog() {
this.refs.log.innerHTML = "";
}
}
//------------------------------------------------------------------------------
// Application initialization
//------------------------------------------------------------------------------
async function start() {
const templates = await owl.utils.loadTemplates("templates.xml");
const env = {
qweb: new owl.QWeb(templates)
};
const app = new App(env);
app.mount(document.body);
}
start();
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>OWL 0.21.0 Benchmark</title>
<link href="../shared/main.css" rel="stylesheet"/>
<script src='owl.js'></script>
</head>
<body>
<script src='app.js' type="module"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+50
View File
@@ -0,0 +1,50 @@
<templates>
<div t-name="App" class="main">
<div class="left-thing">
<div class="title">Actions</div>
<div class="panel">
<button t-on-click="addMessages(100)">Add 100 messages</button>
<button t-on-click="addMessages(1000)">Add 1k messages</button>
<button t-on-click="addMessages(10000)">Add 10k messages</button>
<button t-on-click="addMessages(30000)">Add 30k messages</button>
<button t-on-click="updateSomeMessages">Update every 10th messages</button>
<button t-on-click="clear">Clear</button>
</div>
<div class="flags">
<div>
<input type="checkbox" id="multipleflag" t-model="multipleFlag"/>
<label for="multipleflag">Do it 20x</label>
</div>
<div>
<input type="checkbox" id="clearFlag" t-model="clearAfterFlag" />
<label for="clearFlag">Clear after</label>
</div>
</div>
<div class="info">Number of messages: <t t-esc="state.messages.length"/></div>
<hr/>
<div class="title">Log <span class="clear-log" t-on-click="clearLog">(clear)</span></div>
<div class="log">
<div class="log-content" t-ref="log"/>
</div>
</div>
<div class="right-thing">
<div class="content" t-on-remove-message="removeMessage">
<t t-foreach="state.messages" t-as="message">
<Message t-key="message.id" message="message"/>
</t>
</div>
</div>
</div>
<div t-name="Message" class="message">
<span class="author"><t t-esc="props.message.author"/></span>
<span class="msg"><t t-esc="props.message.msg"/></span>
<button class="remove" t-on-click="removeMessage">Remove</button>
<Counter/>
</div>
<div t-name="Counter">
<button t-on-click="increment">Value: <t t-esc="state.counter"/></button>
</div>
</templates>
+7 -5
View File
@@ -1,10 +1,11 @@
import { buildData, startMeasure, stopMeasure, formatNumber } from "../shared/utils.js";
const { useState, useRef } = owl.hooks;
//------------------------------------------------------------------------------
// Likes Counter Widget
//------------------------------------------------------------------------------
class Counter extends owl.Component {
state = { counter: 0 };
state = useState({ counter: 0 });
increment() {
this.state.counter++;
@@ -32,7 +33,8 @@ class Message extends owl.Component {
//------------------------------------------------------------------------------
class App extends owl.Component {
static components = { Message };
state = { messages: [], multipleFlag: false, clearAfterFlag: false };
state = useState({ messages: [], multipleFlag: false, clearAfterFlag: false });
logRef = useRef("log");
mounted() {
this.log(`Benchmarking Owl v${owl.__info__.version} (build date: ${owl.__info__.date})`);
@@ -129,12 +131,12 @@ class App extends owl.Component {
div.classList.add("bold");
}
div.textContent = `> ${str}`;
this.refs.log.appendChild(div);
this.refs.log.scrollTop = this.refs.log.scrollHeight;
this.logRef.el.appendChild(div);
this.logRef.el.scrollTop = this.logRef.el.scrollHeight;
}
clearLog() {
this.refs.log.innerHTML = "";
this.logRef.el.innerHTML = "";
}
}
+2 -2
View File
@@ -12,11 +12,11 @@
</div>
<div class="flags">
<div>
<input type="checkbox" id="multipleflag" t-model="multipleFlag"/>
<input type="checkbox" id="multipleflag" t-model="state.multipleFlag"/>
<label for="multipleflag">Do it 20x</label>
</div>
<div>
<input type="checkbox" id="clearFlag" t-model="clearAfterFlag" />
<input type="checkbox" id="clearFlag" t-model="state.clearAfterFlag" />
<label for="clearFlag">Clear after</label>
</div>
</div>
+1
View File
@@ -39,6 +39,7 @@
<li><a href="benchmarks/owl-0.16.0">OWL 0.16.0</a></li>
<li><a href="benchmarks/owl-0.17.0">OWL 0.17.0</a></li>
<li><a href="benchmarks/owl-0.18.0">OWL 0.18.0</a></li>
<li><a href="benchmarks/owl-0.21.0">OWL 0.21.0</a></li>
<li><a href="benchmarks/owl-master">OWL Master</a></li>
</ul>
<ul>
+94 -33
View File
@@ -1,5 +1,5 @@
import { SAMPLES } from "./samples.js";
const { useState, useRef, onMounted, onWillUnmount } = owl.hooks;
//------------------------------------------------------------------------------
// Constants, helpers, utils
//------------------------------------------------------------------------------
@@ -150,24 +150,70 @@ Promise.all([loadTemplates(), owl.utils.whenReady()]).then(start);
return zip.generateAsync({ type: "blob" });
}
//------------------------------------------------------------------------------
// SAMPLES
//------------------------------------------------------------------------------
function loadSamples() {
let result = SAMPLES.slice();
const localSample = localStorage.getItem("owl-playground-local-sample");
if (localSample) {
const { js, css, xml } = JSON.parse(localSample);
result.unshift({
description: "Local Storage Code",
code: js,
xml,
css
});
}
return result;
}
function saveLocalSample(js, css, xml) {
const str = JSON.stringify({ js, css, xml });
localStorage.setItem("owl-playground-local-sample", str);
}
function deleteLocalSample() {
localStorage.removeItem("owl-playground-local-sample");
}
function useSamples() {
const samples = loadSamples();
const component = owl.Component.current;
let interval;
onMounted(() => {
const state = component.state;
interval = setInterval(() => {
if (component.isDirty) {
saveLocalSample(state.js, state.css, state.xml);
}
}, 1000);
});
onWillUnmount(() => {
clearInterval(interval);
});
return samples;
}
//------------------------------------------------------------------------------
// Tabbed editor
//------------------------------------------------------------------------------
class TabbedEditor extends owl.Component {
constructor(parent, props) {
super(parent, props);
this.state = {
currentTab: props.js ? "js" : props.xml ? "xml" : "css"
};
this.state = useState({
currentTab: props.js !== false ? "js" : props.xml ? "xml" : "css"
});
this.setTab = owl.utils.debounce(this.setTab, 250, true);
this.sessions = {};
this._setupSessions(props);
this.editor = null;
this.editorNode = useRef("editor");
this._updateCode = this._updateCode.bind(this);
}
mounted() {
this.editor = this.editor || ace.edit(this.refs.editor);
this.editor = this.editor || ace.edit(this.editorNode.el);
this.editor.setValue(this.props[this.state.currentTab], -1);
this.editor.setFontSize("12px");
@@ -175,18 +221,14 @@ class TabbedEditor extends owl.Component {
this.editor.setSession(this.sessions[this.state.currentTab]);
const tabSize = this.state.currentTab === "xml" ? 2 : 4;
this.editor.session.setOption("tabSize", tabSize);
this.editor.on("blur", () => {
const editorValue = this.editor.getValue();
const propsValue = this.props[this.state.currentTab];
if (editorValue !== propsValue) {
this.trigger("updateCode", {
type: this.state.currentTab,
value: editorValue
});
}
});
this.editor.on("blur", this._updateCode);
this.interval = setInterval(this._updateCode, 3000);
}
willUnmount() {
clearInterval(this.interval);
this.editor.off("blur", this._updateCode);
}
willUpdateProps(nextProps) {
this._setupSessions(nextProps);
}
@@ -195,13 +237,15 @@ class TabbedEditor extends owl.Component {
const session = this.sessions[this.state.currentTab];
let content = this.props[this.state.currentTab];
if (content === false) {
const tab = this.props.js ? "js" : this.props.xml ? "xml" : "css";
const tab = this.props.js !== false ? "js" : this.props.xml ? "xml" : "css";
content = this.props[tab];
this.state.currentTab = tab;
}
session.setValue(content, -1);
this.editor.setSession(session);
this.editor.resize();
if (this.editor.getValue() !== content) {
session.setValue(content, -1);
this.editor.setSession(session);
this.editor.resize();
}
}
setTab(tab) {
@@ -230,7 +274,7 @@ class TabbedEditor extends owl.Component {
_setupSessions(props) {
for (let tab of ["js", "xml", "css"]) {
if (props[tab] && !this.sessions[tab]) {
if (props[tab] !== false && !this.sessions[tab]) {
this.sessions[tab] = new ace.EditSession(props[tab], MODES[tab]);
this.sessions[tab].setOption("useWorker", false);
const tabSize = tab === "xml" ? 2 : 4;
@@ -239,6 +283,17 @@ class TabbedEditor extends owl.Component {
}
}
}
_updateCode() {
const editorValue = this.editor.getValue();
const propsValue = this.props[this.state.currentTab];
if (editorValue !== propsValue) {
this.trigger("updateCode", {
type: this.state.currentTab,
value: editorValue
});
}
}
}
//------------------------------------------------------------------------------
@@ -248,29 +303,31 @@ class App extends owl.Component {
constructor(...args) {
super(...args);
this.version = owl.__info__.version;
this.SAMPLES = SAMPLES;
this.SAMPLES = useSamples();
this.isDirty = false;
this.state = {
js: SAMPLES[0].code,
css: SAMPLES[0].css || "",
xml: SAMPLES[0].xml || DEFAULT_XML,
this.state = useState({
js: this.SAMPLES[0].code,
css: this.SAMPLES[0].css || "",
xml: this.SAMPLES[0].xml || DEFAULT_XML,
error: false,
displayWelcome: true,
splitLayout: true,
leftPaneWidth: Math.ceil(window.innerWidth / 2),
topPanelHeight: null
};
});
this.toggleLayout = owl.utils.debounce(this.toggleLayout, 250, true);
this.runCode = owl.utils.debounce(this.runCode, 250, true);
this.downloadCode = owl.utils.debounce(this.downloadCode, 250, true);
this.content = useRef("content");
}
displayError(error) {
this.state.error = error;
if (error) {
setTimeout(() => {
this.refs.content.innerHTML = "";
this.content.el.innerHTML = "";
});
return;
}
@@ -296,15 +353,17 @@ class App extends owl.Component {
} else {
this.state.error = false;
}
this.refs.content.innerHTML = "";
this.refs.content.appendChild(subiframe);
this.content.el.innerHTML = "";
this.content.el.appendChild(subiframe);
}
setSample(ev) {
const sample = SAMPLES.find(s => s.description === ev.target.value);
const sample = this.SAMPLES.find(s => s.description === ev.target.value);
this.state.js = sample.code;
this.state.css = sample.css || "";
this.state.xml = sample.xml || DEFAULT_XML;
deleteLocalSample();
this.isDirty = false;
}
get leftPaneStyle() {
@@ -337,7 +396,10 @@ class App extends owl.Component {
});
}
updateCode(ev) {
this.state[ev.detail.type] = ev.detail.value;
if (this.state[ev.detail.type] !== ev.detail.value) {
this.state[ev.detail.type] = ev.detail.value;
this.isDirty = true;
}
}
toggleLayout() {
this.state.splitLayout = !this.state.splitLayout;
@@ -362,7 +424,6 @@ class App extends owl.Component {
}
App.components = { TabbedEditor };
//------------------------------------------------------------------------------
// Application initialization
//------------------------------------------------------------------------------
+3 -1
View File
@@ -173,8 +173,10 @@ body {
}
.right-pane .note {
font-size: 20px;
font-size: 18px;
text-align: left;
margin-top: 20px;
padding: 5%;
}
.right-pane .error {
+284 -140
View File
@@ -1,17 +1,18 @@
const COMPONENTS = `// In this example, we show how components can be defined and created.
const { Component, useState } = owl;
class Greeter extends owl.Component {
state = { word: 'Hello' };
class Greeter extends Component {
state = useState({ word: 'Hello' });
toggle() {
this.state.word = this.state.word === 'Hi' ? 'Hello' : 'Hi'
this.state.word = this.state.word === 'Hi' ? 'Hello' : 'Hi';
}
}
// Main root component
class App extends owl.Component {
class App extends Component {
static components = { Greeter };
state = { name: 'World'};
state = useState({ name: 'World'});
}
// Application setup
@@ -45,17 +46,18 @@ const COMPONENTS_CSS = `.greeter {
const ANIMATION = `// The goal of this component is to see how the t-transition directive can be
// used to generate simple transition effects.
const { Component, useState } = owl;
class Counter extends owl.Component {
state = { value: 0 };
class Counter extends Component {
state = useState({ value: 0 });
increment() {
this.state.value++;
}
}
class App extends owl.Component {
state = { flag: false, componentFlag: false, numbers: [] };
class App extends Component {
state = useState({ flag: false, componentFlag: false, numbers: [] });
static components = { Counter };
toggle(key) {
@@ -183,11 +185,12 @@ const LIFECYCLE_DEMO = `// This example shows all the possible lifecycle hooks
// methods in the console. Try modifying its state by clicking on it, or by
// clicking on the two main buttons, and look into the console to see what
// happens.
const { Component, useState } = owl;
class DemoComponent extends owl.Component {
class DemoComponent extends Component {
constructor() {
super(...arguments);
this.state = { n: 0 };
this.state = useState({ n: 0 });
console.log("constructor");
}
async willStart() {
@@ -213,9 +216,9 @@ class DemoComponent extends owl.Component {
}
}
class App extends owl.Component {
class App extends Component {
static components = { DemoComponent };
state = { n: 0, flag: true };
state = useState({ n: 0, flag: true });
increment() {
this.state.n++;
@@ -259,103 +262,217 @@ const LIFECYCLE_CSS = `button {
width: 250px;
}`;
const HOOKS_DEMO = `// In this example, we show how hooks can be used or defined.
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 {
// simple state hook (reactive object)
counter = useState({ value: 0 });
// this hooks is bound to the 'mouse' property.
mouse = useMouse();
increment() {
this.counter.value++;
}
}
// Application setup
const qweb = new owl.QWeb(TEMPLATES);
const app = new App({ qweb });
app.mount(document.body);
`;
const HOOKS_DEMO_XML = `<templates>
<div t-name="App">
<button t-on-click="increment">Click! <t t-esc="counter.value"/></button>
<div>Mouse: <t t-esc="mouse.x"/>, <t t-esc="mouse.y"/></div>
</div>
</templates>
`;
const HOOKS_CSS = `button {
width: 120px;
height: 35px;
font-size: 16px;
}`;
const CONTEXT_JS = `// In this example, we show how components can use the Context and 'useContext'
// hook to share information between them.
const { Component, Context } = owl;
const { useContext } = owl.hooks;
class ToolbarButton extends Component {
theme = useContext(this.env.themeContext);
get style () {
const theme = this.theme;
return \`background-color: \${theme.background}; color: \${theme.foreground}\`;
}
}
class Toolbar extends Component {
static components = { ToolbarButton };
}
// Main root component
class App extends Component {
static components = { Toolbar };
toggleTheme() {
const { background, foreground } = this.env.themeContext.state;
this.env.themeContext.state.background = foreground;
this.env.themeContext.state.foreground = background;
}
}
// Application setup
const themeContext = new Context({
background: '#000',
foreground: '#fff',
});
const env = {
qweb: new owl.QWeb(TEMPLATES),
themeContext: themeContext,
};
const app = new App(env);
app.mount(document.body);
`;
const CONTEXT_XML = `<templates>
<button t-name="ToolbarButton" t-att-style="style">
<t t-esc="props.name"/>
</button>
<div t-name="Toolbar">
<ToolbarButton name="'A'"/>
<ToolbarButton name="'B'"/>
<ToolbarButton name="'C'"/>
</div>
<div t-name="App">
<button t-on-click="toggleTheme">Toggle Mode</button>
<Toolbar/>
</div>
</templates>
`;
const TODO_APP_STORE = `// This example is an implementation of the TodoList application, from the
// www.todomvc.com project. This is a non trivial application with some
// interesting user interactions. It uses the local storage for persistence.
//
// In this implementation, we use the owl Store class to manage the state. It
// is very similar to the VueX store.
const { Component, useState } = owl;
const { useRef, useStore, useDispatch, onPatched, onMounted } = owl.hooks;
//------------------------------------------------------------------------------
// Constants, helpers
//------------------------------------------------------------------------------
const ENTER_KEY = 13;
const ESC_KEY = 27;
const LOCALSTORAGE_KEY = "todomvc";
function useAutofocus(name) {
let ref = useRef(name);
let isInDom = false;
function updateFocus() {
if (!isInDom && ref.el) {
isInDom = true;
const current = ref.el.value;
ref.el.value = "";
ref.el.focus();
ref.el.value = current;
} else if (isInDom && !ref.el) {
isInDom = false;
}
}
onPatched(updateFocus);
onMounted(updateFocus);
}
//------------------------------------------------------------------------------
// Store Definition
// Store
//------------------------------------------------------------------------------
const initialState = { todos: [], nextId: 1};
const actions = {
addTodo({ state }, title) {
state.todos.push({
const todo = {
id: state.nextId++,
title,
completed: false
});
}
state.todos.push(todo);
},
removeTodo({ state }, id) {
const index = state.todos.findIndex(t => t.id === id);
state.todos.splice(index, 1);
},
toggleTodo({ state, dispatch }, id) {
updateTodo({state, dispatch}, {id, title}) {
const value = title.trim();
if (!value) {
dispatch('removeTodo', id);
} else {
const todo = state.todos.find(t => t.id === id);
todo.title = value;
}
},
toggleTodo({ state }, id) {
const todo = state.todos.find(t => t.id === id);
dispatch("editTodo", { id, completed: !todo.completed });
todo.completed = !todo.completed;
},
clearCompleted({ state, dispatch }) {
state.todos
.filter(todo => todo.completed)
.forEach(todo => {
for (let todo of state.todos) {
if (todo.completed) {
dispatch("removeTodo", todo.id);
});
}
}
},
toggleAll({ state, dispatch }, completed) {
state.todos.forEach(todo => {
dispatch("editTodo", {
id: todo.id,
completed
});
});
},
editTodo({ state }, { id, title, completed }) {
const todo = state.todos.find(t => t.id === id);
if (title !== undefined) {
todo.title = title;
}
if (completed !== undefined) {
for (let todo of state.todos) {
todo.completed = completed;
}
}
},
};
function saveState(state) {
const str = JSON.stringify(state);
window.localStorage.setItem(LOCALSTORAGE_KEY, str);
}
function loadState() {
const localState = window.localStorage.getItem(LOCALSTORAGE_KEY);
return localState ? JSON.parse(localState) : { todos: [], nextId: 1};
}
function makeStore() {
const state = loadState();
const store = new owl.store.Store({ state, actions });
store.on("update", null, () => saveState(store.state));
return store;
}
//------------------------------------------------------------------------------
// TodoItem
//------------------------------------------------------------------------------
class TodoItem extends owl.Component {
state = { isEditing: false };
class TodoItem extends Component {
state = useState({ isEditing: false });
dispatch = useDispatch();
removeTodo() {
this.env.store.dispatch("removeTodo", this.props.id);
constructor(...args) {
super(...args);
useAutofocus("input");
}
toggleTodo() {
this.env.store.dispatch("toggleTodo", this.props.id);
}
async editTodo() {
editTodo() {
this.state.isEditing = true;
}
focusInput() {
this.refs.input.value = "";
this.refs.input.focus();
this.refs.input.value = this.props.title;
}
handleKeyup(ev) {
if (ev.keyCode === ENTER_KEY) {
this.updateTitle(ev.target.value);
@@ -371,48 +488,34 @@ class TodoItem extends owl.Component {
}
updateTitle(title) {
const value = title.trim();
if (!value) {
this.removeTodo(this.props.id);
} else {
this.env.store.dispatch("editTodo", {
id: this.props.id,
title: value
});
this.state.isEditing = false;
}
this.dispatch("updateTodo", {title, id: this.props.id});
this.state.isEditing = false;
}
}
//------------------------------------------------------------------------------
// TodoApp
//------------------------------------------------------------------------------
class TodoApp extends owl.store.ConnectedComponent {
class TodoApp extends Component {
static components = { TodoItem };
state = { filter: "all" };
state = useState({ filter: "all" });
todos = useStore(state => state.todos);
dispatch = useDispatch();
static mapStoreToProps(state) {
return {
todos: state.todos
};
}
get visibleTodos() {
let todos = this.storeProps.todos;
if (this.state.filter === "active") {
todos = todos.filter(t => !t.completed);
switch (this.state.filter) {
case "active": return this.todos.filter(t => !t.completed);
case "completed": return this.todos.filter(t => t.completed);
case "all": return this.todos;
}
if (this.state.filter === "completed") {
todos = todos.filter(t => t.completed);
}
return todos;
}
get allChecked() {
return this.storeProps.todos.every(todo => todo.completed);
return this.todos.every(todo => todo.completed);
}
get remaining() {
return this.storeProps.todos.filter(todo => !todo.completed).length;
return this.todos.filter(todo => !todo.completed).length;
}
get remainingText() {
@@ -438,12 +541,25 @@ class TodoApp extends owl.store.ConnectedComponent {
//------------------------------------------------------------------------------
// App Initialization
//------------------------------------------------------------------------------
const store = makeStore();
const qweb = new owl.QWeb(TEMPLATES);
const env = {
qweb,
store,
};
function saveState(state) {
const str = JSON.stringify(state);
window.localStorage.setItem(LOCALSTORAGE_KEY, str);
}
function loadState() {
const localState = window.localStorage.getItem(LOCALSTORAGE_KEY);
return localState ? JSON.parse(localState) : initialState;
}
function makeEnv() {
const state = loadState();
const store = new owl.Store({ state, actions });
store.on("update", null, () => saveState(store.state));
const qweb = new owl.QWeb(TEMPLATES);
return { qweb, store };
}
const env = makeEnv();
const app = new TodoApp(env);
app.mount(document.body);
`;
@@ -454,7 +570,7 @@ const TODO_APP_STORE_XML = `<templates>
<h1>todos</h1>
<input class="new-todo" autofocus="true" autocomplete="off" placeholder="What needs to be done?" t-on-keyup="addTodo"/>
</header>
<section class="main" t-if="storeProps.todos.length">
<section class="main" t-if="todos.length">
<input class="toggle-all" id="toggle-all" type="checkbox" t-att-checked="allChecked" t-on-click="dispatch('toggleAll', !allChecked)"/>
<label for="toggle-all"></label>
<ul class="todo-list">
@@ -463,7 +579,7 @@ const TODO_APP_STORE_XML = `<templates>
</t>
</ul>
</section>
<footer class="footer" t-if="storeProps.todos.length">
<footer class="footer" t-if="todos.length">
<span class="todo-count">
<strong>
<t t-esc="remaining"/>
@@ -481,7 +597,7 @@ const TODO_APP_STORE_XML = `<templates>
<a t-on-click="setFilter('completed')" t-att-class="{selected: state.filter === 'completed'}">Completed</a>
</li>
</ul>
<button class="clear-completed" t-if="storeProps.todos.length gt remaining" t-on-click="dispatch('clearCompleted')">
<button class="clear-completed" t-if="todos.length gt remaining" t-on-click="dispatch('clearCompleted')">
Clear completed
</button>
</footer>
@@ -489,13 +605,13 @@ const TODO_APP_STORE_XML = `<templates>
<li t-name="TodoItem" class="todo" t-att-class="{completed: props.completed, editing: state.isEditing}">
<div class="view">
<input class="toggle" type="checkbox" t-on-change="toggleTodo" t-att-checked="props.completed"/>
<input class="toggle" type="checkbox" t-on-change="dispatch('toggleTodo', props.id)" t-att-checked="props.completed"/>
<label t-on-dblclick="editTodo">
<t t-esc="props.title"/>
</label>
<button class="destroy" t-on-click="removeTodo"></button>
<button class="destroy" t-on-click="dispatch('removeTodo', props.id)"></button>
</div>
<input class="edit" t-ref="input" t-if="state.isEditing" t-att-value="props.title" t-on-keyup="handleKeyup" t-mounted="focusInput" t-on-blur="handleBlur"/>
<input class="edit" t-ref="input" t-if="state.isEditing" t-att-value="props.title" t-on-keyup="handleKeyup" t-on-blur="handleBlur"/>
</li>
</templates>`;
@@ -1052,17 +1168,18 @@ const SLOTS = `// We show here how slots can be used to create generic component
//
// Note that the t-on-click event, defined in the App template, is executed in
// the context of the App component, even though it is inside the Card component
const { Component, useState } = owl;
class Card extends owl.Component {
state = { showContent: true };
class Card extends Component {
state = useState({ showContent: true });
toggleDisplay() {
this.state.showContent = !this.state.showContent;
}
}
class Counter extends owl.Component {
state = {val: 1};
class Counter extends Component {
state = useState({val: 1});
inc() {
this.state.val++;
@@ -1070,9 +1187,9 @@ class Counter extends owl.Component {
}
// Main root component
class App extends owl.Component {
class App extends Component {
static components = {Card, Counter};
state = {a: 1, b: 3};
state = useState({a: 1, b: 3});
inc(key, delta) {
this.state[key] += delta;
@@ -1168,8 +1285,9 @@ const ASYNC_COMPONENTS = `// This example will not work if your browser does not
// However, we don't want renderings of the other sub component to be delayed
// because of the slow component. We use the 't-asyncroot' directive for this
// purpose. Try removing it to see the difference.
const { Component, useState } = owl;
class SlowComponent extends owl.Component {
class SlowComponent extends Component {
willUpdateProps() {
// simulate a component that needs to perform async stuff (e.g. an RPC)
// with the updated props before re-rendering itself
@@ -1177,11 +1295,11 @@ class SlowComponent extends owl.Component {
}
}
class NotificationList extends owl.Component {}
class NotificationList extends Component {}
class App extends owl.Component {
class App extends Component {
static components = {SlowComponent, NotificationList};
state = { value: 0, notifs: [] };
state = useState({ value: 0, notifs: [] });
increment() {
this.state.value++;
@@ -1250,15 +1368,16 @@ const FORM = `// This example illustrate how the t-model directive can be used t
// data between html inputs (and select/textareas) and the state of a component.
// Note that there are two controls with t-model="color": they are totally
// synchronized.
const { Component, useState } = owl;
class Form extends owl.Component {
state = {
class Form extends Component {
state = useState({
text: "",
othertext: "",
number: 11,
color: "",
bool: false
};
});
}
// Application setup
@@ -1271,20 +1390,20 @@ const FORM_XML = `<templates>
<div t-name="Form">
<h1>Form</h1>
<div>
Text (immediate): <input t-model="text"/>
Text (immediate): <input t-model="state.text"/>
</div>
<div>
Other text (lazy): <input t-model.lazy="othertext"/>
Other text (lazy): <input t-model.lazy="state.othertext"/>
</div>
<div>
Number: <input t-model.number="number"/>
Number: <input t-model.number="state.number"/>
</div>
<div>
Boolean: <input type="checkbox" t-model="bool"/>
Boolean: <input type="checkbox" t-model="state.bool"/>
</div>
<div>
Color, with a select:
<select t-model="color">
<select t-model="state.color">
<option value="">Select a color</option>
<option value="red">Red</option>
<option value="blue">Blue</option>
@@ -1292,8 +1411,8 @@ const FORM_XML = `<templates>
</div>
<div>
Color, with radio buttons:
<span><input type="radio" name="color" id="red" value="red" t-model="color"/><label for="red">Red</label></span>
<span><input type="radio" name="color" id="blue" value="blue" t-model="color"/><label for="blue">Blue</label></span>
<span><input type="radio" name="color" id="red" value="red" t-model="state.color"/><label for="red">Red</label></span>
<span><input type="radio" name="color" id="blue" value="blue" t-model="state.color"/><label for="blue">Blue</label></span>
</div>
<hr/>
<h1>State</h1>
@@ -1316,26 +1435,31 @@ const WMS = `// This example is slightly more complex than usual. We demonstrate
// - minimal width/height
// - better heuristic for initial window position
// - ...
const { Component, useState } = owl;
const { useRef } = owl.hooks;
class HelloWorld extends owl.Component {}
class HelloWorld extends Component {}
class Counter extends owl.Component {
state = { value: 0 };
class Counter extends Component {
state = useState({ value: 0 });
inc() {
this.state.value++;
}
}
class Window extends owl.Component {
class Window extends Component {
get style() {
let { width, height, top, left, zindex } = this.props.info;
return \`width: \${width}px;height: \${height}px;top:\${top}px;left:\${left}px;z-index:\${zindex}\`;
}
close() {
this.trigger("close-window", { id: this.props.info.id });
}
startDragAndDrop(ev) {
this.updateZIndex();
this.el.classList.add('dragging');
@@ -1361,31 +1485,37 @@ class Window extends owl.Component {
self.trigger("set-window-position", options);
}
}
updateZIndex() {
this.trigger("update-z-index", { id: this.props.info.id });
}
}
class WindowManager extends owl.Component {
class WindowManager extends Component {
static components = { Window };
windows = [];
nextId = 1;
currentZindex = 1;
nextLeft = 0;
nextTop = 0;
addWindow(name) {
const info = this.env.windows.find(w => w.name === name);
const id = \`w\${this.nextId++}\`;
this.nextLeft = this.nextLeft + 30;
this.nextTop = this.nextTop + 30;
this.windows.push({
id: id,
id: this.nextId++,
title: info.title,
width: info.defaultWidth,
height: info.defaultHeight,
top: 0,
left: 0,
zindex: this.currentZindex++
top: this.nextTop,
left: this.nextLeft,
zindex: this.currentZindex++,
component: info.component
});
this.constructor.components[id] = info.component;
this.render();
}
closeWindow(ev) {
const id = ev.detail.id;
delete this.constructor.components[id];
@@ -1393,12 +1523,14 @@ class WindowManager extends owl.Component {
this.windows.splice(index, 1);
this.render();
}
setWindowPosition(ev) {
const id = ev.detail.id;
const w = this.windows.find(w => w.id === id);
w.top = ev.detail.top;
w.left = ev.detail.left;
}
updateZIndex(ev) {
const id = ev.detail.id;
const w = this.windows.find(w => w.id === id);
@@ -1407,11 +1539,12 @@ class WindowManager extends owl.Component {
}
}
class App extends owl.Component {
class App extends Component {
static components = { WindowManager };
wmRef = useRef("wm");
addWindow(name) {
this.refs.wm.addWindow(name);
this.wmRef.comp.addWindow(name);
}
}
@@ -1452,7 +1585,7 @@ const WMS_XML = `<templates>
t-on-update-z-index="updateZIndex"
t-on-set-window-position="setWindowPosition">
<Window t-foreach="windows" t-as="w" t-key="w.id" info="w">
<t t-component="{{w.id}}"/>
<t t-component="w.component"/>
</Window>
</div>
@@ -1570,6 +1703,17 @@ export const SAMPLES = [
xml: LIFECYCLE_DEMO_XML,
css: LIFECYCLE_CSS
},
{
description: "Hooks",
code: HOOKS_DEMO,
xml: HOOKS_DEMO_XML,
css: HOOKS_CSS
},
{
description: "Context",
code: CONTEXT_JS,
xml: CONTEXT_XML,
},
{
description: "Todo List App (with store)",
code: TODO_APP_STORE,
+5 -1
View File
@@ -45,7 +45,11 @@
<div>🦉 Odoo Web Library 🦉</div>
<div>v<t t-esc="version"/></div>
<div class="url"><a href="https://github.com/odoo/owl">https://github.com/odoo/owl</a></div>
<div class="note">Note: these examples require a recent browser to work without a transpilation step. </div>
<div class="note">
<p>Note: these examples are using recent features of Javascript, and require a recent browser to work without a transpilation step!
For example, it makes use of class fields and class static fields. These examples should work in a recent Chrome version.
</p>
</div>
</div>
<div t-if="state.error" class="error">
<h3>Error</h3>