Compare commits

..

17 Commits

Author SHA1 Message Date
Géry Debongnie f289cb17ec [REL] bump to v0.19.0 2019-08-28 11:55:36 +02:00
Géry Debongnie e6a3ede4a6 [FIX] component: improve unmount/remount behaviour 2019-08-28 11:41:10 +02:00
Alexandre Kühn 10cfe0b740 [IMP] store: dispatch method returns data from action 2019-08-27 22:26:16 +02:00
Géry Debongnie a3d563ebb4 [FIX] connected_component: some misc issues
- did not properly stop listening to the store after being destroyed
- did not properly synchronize store updates in some async cases
2019-08-27 17:11:54 +02:00
Géry Debongnie 3b1247d476 [REF] vdom: small cleanup 2019-08-27 16:01:01 +02:00
Géry Debongnie beb6c2bc82 [DOC] improve readme and comparison
closes #244
2019-08-27 14:48:02 +02:00
Géry Debongnie f0cd6f61cd [FIX] playground: fix crash in todo app example 2019-08-27 14:09:20 +02:00
Géry Debongnie c950b385a0 [ADD] playground: add window management system example
closes #231
2019-08-27 14:09:20 +02:00
Géry Debongnie c172b31481 [DOC] router: add documentation 2019-08-26 15:45:55 +02:00
Géry Debongnie 9b3a97e732 [DOC] component: improve internal documentation 2019-08-26 12:19:11 +02:00
Géry Debongnie 670c98916c [DOC] store: update documentation and add examples 2019-08-26 10:31:11 +02:00
Géry Debongnie c59abef374 [IMP] connected component: add dispatch method 2019-08-26 10:31:11 +02:00
Géry Debongnie bb4c2506c0 [REF] store: merge actions and mutations
closes #256
2019-08-26 10:31:11 +02:00
Géry Debongnie 9843d16585 [REF] component: improve render method 2019-08-26 10:31:11 +02:00
Géry Debongnie 34e463ad0a [REF] connected component: separate storeprops from props
closes #259
2019-08-26 10:31:11 +02:00
Géry Debongnie 2294c1967f [IMP] qweb: allow xml documents as template definition
Before this commit, the addTemplates method only accepts a string.  But
whenever we perform a $.get call, we get a parsed xml document. So, to
make Owl more convenient to use in that situation, we simply accept xml
documents in addition to strings.
2019-08-23 15:17:28 +02:00
Géry Debongnie 4b53e48e91 [ADD] tools: add benchmarks for 0.18.0 2019-08-22 15:26:17 +02:00
26 changed files with 5571 additions and 759 deletions
+15 -8
View File
@@ -4,16 +4,20 @@ _A web framework for structured, dynamic and maintainable applications_
## Project Overview
The Odoo Web Library (OWL) is a small
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 feature is a _declarative component system_, with QWeb as a template engine, asynchronous rendering, and an underlying virtual dom.
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:
- a _declarative component system_, (template based, with asynchronous rendering and a virtual dom)
- 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's Design Principles
OWL is designed to be used in highly dynamic applications where changing
requirements are common, code needs to be maintained by large teams.
requirements are common and code needs to be maintained by large teams.
- **XML based**: templates are based on the XML format, which allows interesting
applications. For example, they could be stored in a database and modified
@@ -26,8 +30,11 @@ requirements are common, code needs to be maintained by large teams.
for various reasons (security/deployment/dynamic modules/specific assets tools),
it is not ok to use standard web tools based on `npm`.
Owl is not designed to be fast or small (even though it is quite good on those
two topics). If you are interested in a comparison with React or Vue, you will
Owl is not designed to be fast nor small (even though it is quite good on those
two topics). It is a no nonsense framework to build applications. There is only
one way to define components (with classes).
If you are interested in a comparison with React or Vue, you will
find some more information [here](doc/comparison.md).
## Example
@@ -64,8 +71,8 @@ string. More interesting examples can be found on the
If you want to use a simple `<script>` tag, the last release can be downloaded here:
- [owl-0.18.0.js](https://github.com/odoo/owl/releases/download/v0.18.0/owl.js)
- [owl-0.18.0.min.js](https://github.com/odoo/owl/releases/download/v0.18.0/owl.min.js)
- [owl-0.19.0.js](https://github.com/odoo/owl/releases/download/v0.19.0/owl.js)
- [owl-0.19.0.min.js](https://github.com/odoo/owl/releases/download/v0.19.0/owl.min.js)
Some npm scripts are available:
+53 -13
View File
@@ -10,6 +10,7 @@ discussed, feel free to open an issue/submit a PR to correct this text.
## Content
- [Size](#size)
- [Class Based](#class-based)
- [Tooling/Build Step](#toolingbuild-step)
- [Templating](#templating)
- [Asynchronous rendering](#asynchronous-rendering)
@@ -21,12 +22,23 @@ discussed, feel free to open an issue/submit a PR to correct this text.
OWL is intended to be small and to work at a slightly lower level of abstraction
than React and Vue. Also, jQuery is not the same kind of framework, but it is interesting to compare.
| Framework | Size (minified) | Size (minified, gzipped) |
| ------------------------ | --------------- | ------------------------ |
| OWL | 32kb | 11kb |
| Vue + VueX | | 30kb |
| React + ReactDOM + Redux | | 40kb |
| jQuery | 86kb | 30kb |
| Framework | Size (minified, gzipped) |
| ------------------------ | ------------------------ |
| OWL | 16kb |
| Vue + VueX | 30kb |
| React + ReactDOM + Redux | 40kb |
| jQuery | 30kb |
## Class Based
Both React and Vue moved away from defining components with classes. They prefer
a more functional approach, in particular, with the new `hooks` mechanisms.
This has some advantages and disadvantages. But the end result is that React
and Vue both offers multiple different ways of defining new components. In
contrast, Owl has only one mechanism: class-based components. We believe that Owl
components are fast enough for all our usecases, and making it as simple as
possible for developers is more valuable (for us).
## Tooling/Build step
@@ -45,7 +57,7 @@ components, which also necessitate a build step.
On the flipside, external tooling may make it harder to use in some case, but it
also brings a lot of benefits. And React/Vue have both a large ecosystem.
### Templating
## Templating
OWL uses its own QWeb engine, which compiles templates on the
frontend, as they are needed. This is extremely convenient for our use case, in
@@ -128,8 +140,10 @@ by getters/setters. With that, it can notify components whenever the state that
they read was changed.
Owl is closer to vue: it also tracks magically the state properties, but it does
only increment a counter whenever it changes (and a _deep_ counter for each of
its parents). This assumes that the state is actually a tree.
only increment an internal counter whenever it changes. Note that it is done
with a `Proxy`, which means that it is totally transparent to the developers.
Adding new keys is supported. Once any part of the state has been changed, a
rendering is scheduled in the next microtask tick (promise queue).
## State Management
@@ -190,7 +204,33 @@ keeps track of who get data, and retrigger a render when it was changed.
**Owl**
Owl store is a little bit like a mix of redux and vuex: it has mutations and
actions, like VueX, it keeps track of the state changes, but it does not notify
a component when the state changes. Instead, components need to connect to the
store like in redux, with a function that will listen to the relevant state.
Owl store is a little bit like a mix of redux and vuex: it has actions (but not
mutations), and like VueX, it keeps track of the state changes. However, it does
not notify a component when the state changes. Instead, components need to connect
to the store like in redux, by inheriting the `ConnectedComponent` class.
```javascript
const actions = {
increment({ state }, val) {
state.counter += val;
}
};
const state = {
counter: 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");
}
}
const counter = new Counter({ store, qweb });
```
+10 -6
View File
@@ -144,12 +144,16 @@ find a template with the component name (or one of its ancestor).
We explain here all the public methods of the `Component` class.
- **`mount(target)`** (async): this is the main way a component's hierarchy is added to the
DOM: the root component is mounted to a target HTMLElement. Obviously, this
is asynchronous, since each children need to be created as well. Most applications
will need to call `mount` exactly once, on the root component.
- **`mount(target, renderBeforeRemount=false)`** (async): this is the main way a
component is added to the DOM: the root component is mounted to a target
HTMLElement. Obviously, this is asynchronous, since each children need to be
created as well. Most applications will need to call `mount` exactly once, on
the root component.
Note that a component can be mounted and unmounted multiple times if needed.
The `renderBeforeRemount` argument is useful when a component is unmounted and remounted.
In that case, we may want to rerender the component _before_ it is remounted, if
we know that its state (or something in the environment, or ...) has changed.
In that case, it should simply set to `true`.
- **`unmount()`**: in case a component need to be detached/removed from the DOM, this
method can be used. Most applications should not call `unmount`, this is more
@@ -258,7 +262,7 @@ 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`.
new `Observer`. Then, the component will be rendered by `QWeb`.
#### `mounted()`
+8 -3
View File
@@ -12,15 +12,18 @@ owl
core
EventBus
Observer
router
Link
Router
store
Store
ConnectedComponent
utils
whenReady
debounce
escape
loadJS
loadTemplates
escape
debounce
whenReady
```
## Reference
@@ -30,6 +33,7 @@ owl
- [Event Bus](event_bus.md)
- [Observer](observer.md)
- [QWeb](qweb.md)
- [Router](router.md)
- [Store](store.md)
- [Utils](utils.md)
- [Virtual DOM](vdom.md)
@@ -42,3 +46,4 @@ owl
- [Comparison with React/Vue](comparison.md)
- [Tooling](tooling.md)
- [Templates to start Owl applications (external link)](https://github.com/ged-odoo/owl-templates)
+172
View File
@@ -0,0 +1,172 @@
# 🦉 Router 🦉
## Content
- [Overview](#overview)
- [Example](#example)
- [Reference](#reference)
- [Route Definition](#route-definition)
- [Router](#router)
- [Navigation Guards](#navigation-guards)
- [`t-routecomponent`](#t-routecomponent)
- [Link](#link)
## Overview
It is often useful to organize an application around urls. If the application is
a single page application, then we need a way to manage those urls in the browser.
This is why there are many different routers for different frameworks. A generic
router can do the job just fine, but a specialized router for Owl can give a
better developer experience.
The Owl router support the following features:
- `history` or `hash` mode
- declarative routes
- route redirection
- navigation guards
- parameterized routes
- a `<Link>` component
- a `t-routecomponent` directive
Note that it is still in early stage of developments, and there are probably
still some issues.
## Example
To use the Owl router, there are some steps that needs to be done:
- declare some routes
- create a router
- add it to the environment
```js
async function protectRoute({ env, to }) {
if (!env.session.authUser) {
env.session.setNextRoute(to.name);
return { to: "SIGN_IN" };
}
return true;
}
export const ROUTES = [
{ name: "LANDING", path: "/", component: Landing },
{ name: "TASK", path: "/tasks/{{id}}", component: Task },
{ name: "SIGN_UP", path: "/signup", component: SignUp },
{ name: "SIGN_IN", path: "/signin", component: SignIn },
{ name: "ADMIN", path: "/admin", component: Admin, beforeRouteEnter: protectRoute },
{ name: "ACCOUNT", path: "/account", component: Account, beforeRouteEnter: protectRoute },
{ name: "UNKNOWN", path: "*", redirect: { to: "LANDING" } }
];
function makeEnvironment() {
...
const env = { qweb };
env.session = new Session(env);
env.router = new owl.router.Router(env, ROUTES);
await env.router.start();
return env;
}
```
Notice that the router needs to be started. This is an asynchronous operation
because it needs to apply the potential navigation guards on the current route
(which may or may not mean that the application is redirected to another route).
## Reference
### Route definition
A route need to be defined as an object with the following keys:
- `name` (optional): a (unique) string useful to identify the current route. If not
given, it will be assigned an automatic name,
- `path`: a string describing the url. It can be static: `/admin` or dynamic: `/users/{{id}}`.
It also can be `*`, to catch all remaining routes.
- `component` (optional): an Owl component that will be used by the `t-routecomponent`
directive if the route is active
- `redirect` (optional): should be destination object (with optional keys `path`, `to` and `params`) if given, the application will be redirected to the destination whenever we match this route
- `beforeRouteEnter`: defines a [navigation guard](#navigation-guards).
### `Router`
The `Router` constructor takes three arguments:
- `env`: a valid environment,
- a list of routes,
- an optional object (with the only key `mode` which can be `history` (default
value) or `hash`).
```js
const ROUTES = [...];
const router = new owl.router.Router(env, ROUTES, {mode: 'history'});
```
Note that the route are defined in a list, and the order matters: the router
tries to find a match by going down the list.
The router needs to be added to the environment in the `router` sub key.
Once a router is created, it needs to be started. This is necessary to initialize
its current state to the current URL (and also, to potentially apply any
navigation guards and/or redirecting).
```js
await router.start();
```
Once started, the router will keep track of the current url and reflect its
value in two keys:
- `router.currentRoute`
- `router.currentParams`
The router also has a `navigate` method, useful to programmatically change the
application to another state (and the url):
```js
router.navigate({to: 'USER', params: {id: 51}});
```
### Navigation Guards
Navigation guards are very useful to be able to execute some business logic/
perform some actions or redirect to other routes whenever the application is
entering a new route. For example, the following guard checks if there is an
authenticated user, and if it is not the case, redirect to the sign in route.
```js
async function protectRoute({ env, to }) {
if (!env.session.authUser) {
env.session.setNextRoute(to.name);
return { to: "SIGN_IN" };
}
return true;
}
```
A navigation guard is a function that returns a promise, which either resolves
to `true` (the navigation is accepted), or to another destination object.
### `t-routecomponent`
The `t-routecomponent` directive directs Owl to render the component associated
to the currently active route (if any):
```xml
<div t-name="App">
<NavBar />
<t t-routecomponent="1"/>
</div>
```
### `Link`
The `Link` component is a Owl component which render as a `<a>` tag with any
content. It will compute the proper href from its props, and allow Owl to
properly navigate to a given url if clicked on it.
```xml
<Link to="'HOME'">Home</Link>
```
+88 -53
View File
@@ -5,8 +5,7 @@
- [Overview](#overview)
- [Example](#example)
- [Reference](#reference)
- [Public API](#public-api)
- [Mutations](#mutations)
- [Store](#store)
- [Actions](#actions)
- [Getters](#getters)
- [Connecting a Component](#connecting-a-component)
@@ -22,11 +21,10 @@ in various parts of the user interface, and then, it is not obvious which
component should own which part of the state.
Owl's solution to this issue is a centralized store. It is a class that owns
some state, and let the developer update it in a structured way (through
mutations and actions). Owl components can then connect to the store, and will
be updated if necessary.
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.
Note: Owl's store is inspired by React Redux and VueX.
Note: Owl store is inspired by React Redux and VueX.
## Example
@@ -34,19 +32,12 @@ Here is what a simple store looks like:
```js
const actions = {
addTodo({ commit }, message) {
commit("addTodo", message);
}
};
const mutations = {
addTodo({ state }, message) {
const todo = {
state.todos.push({
id: state.nextId++,
message,
isCompleted: false
};
state.todos.push(todo);
});
}
};
@@ -55,66 +46,65 @@ const state = {
nextId: 1
};
const store = new owl.Store({ state, actions, mutations });
const store = new owl.Store({ state, actions });
store.on("update", () => console.log(store.state));
// updating the state
store.dispatch("addTodo", "fix all bugs");
```
This example shows how a store can be defined and used. Note that in most cases,
actions will be dispatched by connected components.
## Reference
The store is a simple [`owl.EventBus`](event_bus.md) that triggers `update` events whenever its
state is changed. Note that these events are triggered only after a microtask
tick, so only one event will be triggered for any number of state changes in a
### `Store`
The store is a simple [`owl.EventBus`](event_bus.md) that triggers `update` events
whenever its state is changed. Note that these events are triggered only after a
microtask tick, so only one event will be triggered for any number of state changes in a
call stack.
Also, it is important to mention that the state is observed (with an `owl.Observer`),
which is the reason why it is able to know if it was changed. This implies that
state changes need to be done carefully in some cases (adding a new key to an
object, or modifying an array with the `arr[i] = newValue` syntax). See the
which is the reason why it is able to know if it was changed. See the
[Observer](observer.md)'s documentation for more details.
### Public API
The `Store` class is quite small. It has two public methods:
1. `constructor`
2. `commit`
3. `dispatch`
- its constructor
- `dispatch`
### Mutations
The constructor takes a configuration object with four (optional) keys:
Mutations are the only way to modify the state. Changing the state outside a
mutation is not allowed (and should throw an error). Mutations are synchronous.
- the initial state
- the actions
- the getters
- the environment
```js
const mutations = {
setLoginState({ state }, loginState) {
state.loginState = loginState;
}
```javascript
const config = {
state,
actions,
getters,
env
};
```
Mutations are called with the `commit` method on the store, and can receive an arbitrary number of arguments.
```js
store.commit("setLoginState", "error");
const store = new Store(config);
```
### Actions
Actions are used to coordinate state changes. It is also useful whenever some
asynchronous logic is necessary. For example, fetching data should be done
in an action.
Actions are used to coordinate state changes. It can be used for both synchronous
and asynchronous logic.
```js
const actions = {
async login({ commit }, info) {
commit("setLoginState", "pending");
async login({ state }, info) {
state.loginState = "pending";
try {
const loginInfo = await doSomeRPC("/login/", info);
commit("setLoginState", loginInfo);
state.loginState = loginInfo;
} catch {
commit("setLoginState", "error");
state.loginState = "error";
}
}
};
@@ -130,6 +120,36 @@ store.dispatch("login", someInfo);
Note that anything returned by an action will also be returned by the `dispatch`
call.
Also, it is important to be aware that we need to be careful with asynchronous
logic. Each state change will potentially trigger a rerendering, so we need to
make sure that we do not have a partial corrupted state. Here is an example that
is likely not a good idea:
```javascript
const actions = {
async fetchSomeData({ state }, recordId) {
state.recordId = recordId;
const data = await doSomeRPC("/read/", recordId);
state.recordData = data;
}
};
```
In the previous example, there is a period of time in which the state has a
`recordId` which does not correspond to the `recordData`. It is more likely that
we want an atomic update: updating the `recordId` at the same time as the `recordData`
values:
```javascript
const actions = {
async fetchSomeData({ state }, recordId) {
const data = await doSomeRPC("/read/", recordId);
state.recordId = recordId;
state.recordData = data;
}
};
```
### Getters
Usually, data contained in the store will be stored in a normalized way. For
@@ -181,19 +201,15 @@ need to create a component inheriting from `OwlComponent`:
```javascript
const actions = {
increment({ commit }) {
commit("increment", 1);
}
};
const mutations = {
increment({ state }, val) {
state.counter += val;
}
};
const state = {
counter: 0
};
const store = new owl.Store({ state, actions, mutations });
const store = new owl.Store({ state, actions });
class Counter extends owl.ConnectedComponent {
static mapStoreToProps(state) {
@@ -227,6 +243,25 @@ The `ConnectedComponent` class can be configured with the following fields:
- `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"/>]
</button>
```
### Semantics
The `Store` and the `ConnectedComponent` try to be smart and to optimize as much
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "owl",
"version": "0.18.0",
"version": "0.19.0",
"description": "Odoo Web Library (OWL)",
"main": "src/index.ts",
"scripts": {
+89 -32
View File
@@ -38,15 +38,21 @@ export interface Env {
* useful to typecheck and describe the internal keys used by Owl to manage the
* component tree.
*/
export interface Meta<T extends Env, Props> {
interface Internal<T extends Env, Props> {
// each component has a unique id, useful mostly to handle parent/child
// relationships
readonly id: number;
vnode: VNode | null;
isMounted: boolean;
isDestroyed: boolean;
// 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> };
// children mapping: from templateID to componentID
// should it be a map number => Component?
// 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;
@@ -58,10 +64,10 @@ export interface Meta<T extends Env, Props> {
renderPromise: Promise<VNode> | null;
boundHandlers: { [key: number]: any };
observer?: Observer;
render?: CompiledTemplate;
observer: Observer | null;
render: CompiledTemplate | null;
mountedHandlers: { [key: number]: Function };
classObj?: { [key: string]: boolean };
classObj: { [key: string]: boolean } | null;
}
// If a component does not define explicitely a template
@@ -76,7 +82,7 @@ const TEMPLATE_MAP: { [key: number]: { [name: string]: string } } = {};
let nextId = 1;
export class Component<T extends Env, Props extends {}, State extends {}> {
readonly __owl__: Meta<Env, Props>;
readonly __owl__: Internal<Env, Props>;
template?: string;
/**
@@ -171,7 +177,10 @@ export class Component<T extends Env, Props extends {}, State extends {}> {
renderPromise: null,
renderProps: props || null,
boundHandlers: {},
mountedHandlers: {}
mountedHandlers: {},
observer: null,
render: null,
classObj: null
};
}
@@ -269,19 +278,22 @@ export class Component<T extends Env, Props extends {}, State extends {}> {
*
* Note that a component can be mounted an unmounted several times
*/
async mount(target: HTMLElement): Promise<void> {
if (this.__owl__.isMounted) {
async mount(target: HTMLElement, renderBeforeRemount: boolean = false): Promise<void> {
const __owl__ = this.__owl__;
if (__owl__.isMounted) {
return;
}
if (!this.__owl__.vnode ) {
// we use the fact that renderId === 1 as a way to determine that the
// component is mounted for the first time
if (!__owl__.vnode) {
const vnode = await this.__prepare();
if (this.__owl__.isDestroyed) {
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);
}
target.appendChild(this.el!);
@@ -290,6 +302,10 @@ export class Component<T extends Env, Props extends {}, State extends {}> {
}
}
/**
* The unmount method is the opposite of the mount method. It is useful
* to call willUnmount calls and remove the component from the DOM.
*/
unmount() {
if (this.__owl__.isMounted) {
this.__callWillUnmount();
@@ -297,20 +313,26 @@ export class Component<T extends Env, Props extends {}, State extends {}> {
}
}
async render(force: boolean = false, patchQueue?: any[], scope?: any, vars?: any): Promise<void> {
/**
* The render method is the main entry point to render a component (once it
* is ready. This method is not initially called when the component is
* rendered the first time).
*
* This method will cause all its sub components to potentially rerender
* themselves. Note that `render` is not called if a component is updated via
* its props.
*/
async render(force: boolean = false): Promise<void> {
const __owl__ = this.__owl__;
if (!__owl__.isMounted) {
return;
}
const shouldPatch: boolean = !patchQueue;
if (shouldPatch) {
patchQueue = [];
}
__owl__.renderId++
const renderId = __owl__.renderId;
await this.__render(force, patchQueue, scope, vars);
const patchQueue = [];
if (shouldPatch && __owl__.isMounted && renderId === __owl__.renderId) {
const renderId = ++__owl__.renderId;
await this.__render(force, patchQueue, undefined, undefined);
if (__owl__.isMounted && renderId === __owl__.renderId) {
// we only update the vnode and the actual DOM if no other rendering
// occurred between now and when the render method was initially called.
this.__applyPatchQueue(<any[]>patchQueue);
@@ -387,6 +409,17 @@ export class Component<T extends Env, Props extends {}, State extends {}> {
// Private
//--------------------------------------------------------------------------
/**
* Private helper to perform a full destroy, from the point of view of an Owl
* component. It does not remove the el (this is done only once on the top
* level destroyed component, for performance reasons).
*
* The job of this method is mostly to call willUnmount hooks, and to perform
* all necessary internal cleanup.
*
* Note that it does not call the __callWillUnmount method to avoid visiting
* all children many times.
*/
__destroy(parent: Component<any, any, any> | null) {
const __owl__ = this.__owl__;
const isMounted = __owl__.isMounted;
@@ -441,6 +474,10 @@ export class Component<T extends Env, Props extends {}, State extends {}> {
}
}
/**
* The __updateProps method is called by the t-component directive whenever
* it updates a component (so, when the parent template is rerendered).
*/
async __updateProps(
nextProps: Props,
forceUpdate: boolean = false,
@@ -456,16 +493,17 @@ export class Component<T extends Env, Props extends {}, State extends {}> {
}
await this.willUpdateProps(nextProps);
this.props = nextProps;
await this.render(forceUpdate, patchQueue, scope, vars);
await this.__render(forceUpdate, patchQueue, scope, vars);
}
}
/**
* Main patching method. We call the virtual dom patch method here to convert
* a virtual dom vnode into some actual dom.
*/
__patch(vnode) {
const __owl__ = this.__owl__;
const target = __owl__.vnode || document.createElement(vnode.sel!);
if (this.__owl__.classObj) {
(<any>vnode).data.class = Object.assign((<any>vnode).data.class || {}, this.__owl__.classObj);
}
__owl__.vnode = patch(target, vnode);
}
@@ -513,6 +551,7 @@ export class Component<T extends Env, Props extends {}, State extends {}> {
}
__owl__.render = qweb.render.bind(qweb, this.template);
this.__observeState();
return this.__render(false, [], scope, vars);
}
@@ -525,9 +564,7 @@ export class Component<T extends Env, Props extends {}, State extends {}> {
const __owl__ = this.__owl__;
const promises: Promise<void>[] = [];
const patch: any[] = [this];
if (__owl__.isMounted) {
patchQueue.push(patch);
}
patchQueue.push(patch);
if (__owl__.observer) {
__owl__.observer.allowMutations = false;
}
@@ -557,6 +594,14 @@ export class Component<T extends Env, Props extends {}, State extends {}> {
// parent component. With this, we make sure that the parent component will be
// able to patch itself properly after
vnode.key = __owl__.id;
// we applly here the class information described on the component by the
// template (so, something like <MyComponent class="..."/>) to the actual
// root vnode
if (__owl__.classObj) {
vnode.data.class = Object.assign(vnode.data.class || {}, __owl__.classObj);
}
return Promise.all(promises).then(() => vnode);
}
@@ -586,6 +631,10 @@ 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__;
@@ -646,13 +695,21 @@ export class Component<T extends Env, Props extends {}, State extends {}> {
// Error handling
//------------------------------------------------------------------------------
function errorHandler(error, component) {
/**
* This is the global error handler for errors occurring in Owl main lifecycle
* methods. Caught errors are triggered on the QWeb instance, and are
* potentially given to some parent component which implements `catchError`.
*
* If there are no such component, we destroy everything. This is better than
* being in a corrupted state.
*/
function errorHandler(error: Error, component: Component<any, any, any>) {
let canCatch = false;
let qweb = component.env.qweb;
let root = component;
while (component && !(canCatch = component.catchError !== Component.prototype.catchError)) {
root = component;
component = component.__owl__.parent;
component = component.__owl__.parent!;
}
console.error(error);
// we trigger error on QWeb so it can be logged/handled
+8 -1
View File
@@ -454,7 +454,14 @@ QWeb.addDirective({
ctx.addElse();
// need to update component
const patchQueueCode = async ? `patchQueue${componentID}` : "extra.patchQueue";
let patchQueueCode = async ? `patchQueue${componentID}` : "extra.patchQueue";
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} : []`;
}
if (QWeb.dev) {
ctx.addLine(`utils.validateProps(w${componentID}.constructor, props${componentID})`);
}
+5 -5
View File
@@ -207,12 +207,12 @@ export class QWeb extends EventBus {
}
/**
* Load templates from a xml (as a string). This will look up for the first
* <templates> tag, and will consider each child of this as a template, with
* the name given by the t-name attribute.
* Load templates from a xml (as a string or xml document). This will look up
* for the first <templates> tag, and will consider each child of this as a
* template, with the name given by the t-name attribute.
*/
addTemplates(xmlstr: string) {
const doc = parseXML(xmlstr);
addTemplates(xmlstr: string | Document) {
const doc = typeof xmlstr === 'string' ? parseXML(xmlstr) : xmlstr;
const templates = doc.getElementsByTagName("templates")[0];
if (!templates) {
return;
+68 -74
View File
@@ -12,19 +12,20 @@ export class ConnectedComponent<T extends Env, P, S> extends Component<T, P, S>
return env.store;
}
hashFunction: HashFunction = ({ storeProps }, options) => {
const observer = (this.__owl__ as any).store.observer;
let refFunction = this.deep ? observer.deepRevNumber : observer.revNumber;
if ("__owl__" in storeProps) {
return refFunction.call(observer, storeProps);
storeProps: any;
hashFunction: HashFunction = (storeProps, options) => {
const revFn = (this.__owl__ as any).revFn;
const rev = revFn(storeProps);
if (rev > 0) {
return rev;
}
const { currentStoreProps } = options;
let hash = 0;
for (let key in storeProps) {
const val = storeProps[key];
const hashVal = refFunction.call(observer, val);
const hashVal = revFn(val);
if (hashVal === 0) {
if (val !== currentStoreProps[key]) {
if (val !== options.prevStoreProps[key]) {
options.didChange = true;
}
} else {
@@ -37,32 +38,30 @@ export class ConnectedComponent<T extends Env, P, S> extends Component<T, P, S>
static mapStoreToProps(storeState, ownProps, getters) {
return {};
}
constructor(parent, props?: any) {
super(parent, props);
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 || {};
const storeProps = (<any>this.constructor).mapStoreToProps(
store.state,
ownProps,
store.getters
);
const mergedProps = Object.assign({}, ownProps, storeProps);
this.props = mergedProps;
(<any>this.__owl__).ownProps = ownProps;
(<any>this.__owl__).currentStoreProps = storeProps;
(<any>this.__owl__).store = store;
(<any>this.__owl__).storeHash = this.hashFunction(
{
state: store.state,
storeProps: storeProps
},
{
currentStoreProps: storeProps
}
);
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).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
@@ -70,56 +69,51 @@ export class ConnectedComponent<T extends Env, P, S> extends Component<T, P, S>
* if we use the mounted hook, this will be done in the reverse order.
*/
__callMounted() {
(<any>this.__owl__).store.on("update", this, this.__checkUpdate);
(this.__owl__ as any).store.on("update", this, this.__checkUpdate);
super.__callMounted();
}
willUnmount() {
(<any>this.__owl__).store.off("update", this);
super.willUnmount();
__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 __checkUpdate(updateId) {
if (updateId === (<any>this.__owl__).currentUpdateId) {
async __updateProps(nextProps: P, f, p, s, v) {
this.__updateStoreProps(nextProps);
return super.__updateProps(nextProps, f, p, s, v);
}
__updateStoreProps(nextProps): boolean {
const store = (this.__owl__ as any).store;
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 !== (this.__owl__ as any).storeHash) {
(this.__owl__ as any).storeHash = storeHash;
didChange = true;
}
(this.__owl__ as any).rev = store.observer.rev;
return didChange;
}
async __checkUpdate() {
const observer = (this.__owl__ as any).store.observer;
if (observer.rev === (this.__owl__ as any).rev) {
// update was already done by updateProps, from parent
return;
}
const ownProps = (<any>this.__owl__).ownProps;
const storeProps = (<any>this.constructor).mapStoreToProps(
(<any>this.__owl__).store.state,
ownProps,
(<any>this.__owl__).store.getters
);
const options: any = {
currentStoreProps: (<any>this.__owl__).currentStoreProps
};
const storeHash = this.hashFunction(
{
state: (<any>this.__owl__).store.state,
storeProps: storeProps
},
options
);
let didChange = options.didChange;
if (storeHash !== (<any>this.__owl__).storeHash) {
didChange = true;
(<any>this.__owl__).storeHash = storeHash;
}
const didChange = this.__updateStoreProps(this.props);
if (didChange) {
(<any>this.__owl__).currentStoreProps = storeProps;
await this.__updateProps(ownProps, false);
return this.render();
}
}
__updateProps(nextProps, forceUpdate, patchQueue?: any[]) {
const __owl__ = <any>this.__owl__;
__owl__.currentUpdateId = __owl__.store._updateId;
if (__owl__.ownProps !== nextProps) {
__owl__.currentStoreProps = (<any>this.constructor).mapStoreToProps(
__owl__.store.state,
nextProps,
__owl__.store.getters
);
}
__owl__.ownProps = nextProps;
const mergedProps = Object.assign({}, nextProps, __owl__.currentStoreProps);
return super.__updateProps(mergedProps, forceUpdate, patchQueue);
}
}
+12 -67
View File
@@ -11,7 +11,7 @@ import { Observer } from "../core/observer";
*
* The Owl store is our answer to the problem of managing complex state across
* components. The main idea is that the store owns some state, allow external
* code to modify it through actions/mutations, and for each state changes,
* 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
@@ -21,16 +21,14 @@ import { Observer } from "../core/observer";
// Store Definition
//------------------------------------------------------------------------------
type Mutation = ({ state, commit, getters }, ...payload: any) => void;
type Action = ({ commit, state, dispatch, env, getters }, ...payload: any) => void;
type Getter = ({ state, getters }, payload) => any;
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 };
mutations?: { [name: string]: Mutation };
}
interface StoreOption {
@@ -41,46 +39,28 @@ export class Store extends EventBus {
state: any;
actions: any;
mutations: any;
_commitLevel: number = 0;
history: any[] = [];
debug: boolean;
env: any;
observer: Observer;
getters: { [name: string]: (payload?) => any };
_gettersCache: { [name: string]: {} };
_updateId: number = 1;
getters: { [name: string]: (payload?) => any };
constructor(config: StoreConfig, options: StoreOption = {}) {
super();
this.debug = options.debug || false;
this.actions = config.actions;
this.mutations = config.mutations;
this.env = config.env;
this.observer = new Observer();
this.observer.notifyCB = this.__notifyComponents.bind(this);
this.observer.allowMutations = false;
this.state = this.observer.observe(config.state || {});
this.getters = {};
this._gettersCache = {};
if (this.debug) {
this.history.push({ state: this.state });
}
const cTypes = ["undefined", "number", "string"];
for (let entry of Object.entries(config.getters || {})) {
const name: string = entry[0];
const func: (...any) => any = entry[1];
this.getters[name] = payload => {
if (this._commitLevel === 0 && cTypes.indexOf(typeof payload) >= 0) {
this._gettersCache[name] = this._gettersCache[name] || {};
this._gettersCache[name][payload] =
this._gettersCache[name][payload] ||
func({ state: this.state, getters: this.getters }, payload);
return this._gettersCache[name][payload];
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);
}
return func({ state: this.state, getters: this.getters }, payload);
};
}
}
@@ -90,7 +70,6 @@ export class Store extends EventBus {
}
const result = this.actions[action](
{
commit: this.commit.bind(this),
dispatch: this.dispatch.bind(this),
env: this.env,
state: this.state,
@@ -101,37 +80,6 @@ export class Store extends EventBus {
return result;
}
commit(type: string, ...payload: any): any {
if (!this.mutations[type]) {
throw new Error(`[Error] mutation ${type} is undefined`);
}
this._commitLevel++;
this.observer.allowMutations = true;
const res = this.mutations[type].call(
null,
{
commit: this.commit.bind(this),
state: this.state,
getters: this.getters
},
...payload
);
if (this._commitLevel === 1) {
this.observer.allowMutations = false;
if (this.debug) {
this.history.push({
state: this.state,
mutation: type,
payload: [...payload]
});
}
}
this._commitLevel--;
return res;
}
/**
* Instead of using trigger to emit an update event, we actually implement
* our own function to do that. The reason is that we need to be smarter than
@@ -147,15 +95,12 @@ export class Store extends EventBus {
* updating all widgets concurrently, except for parents/children.
*/
async __notifyComponents() {
this._updateId++;
const current = this._updateId;
this._gettersCache = {};
const subs = this.subscriptions.update || [];
for (let i = 0, iLen = subs.length; i < iLen; i++) {
const sub = subs[i];
const shouldCallback = sub.owner ? sub.owner.__owl__.isMounted : true;
if (shouldCallback) {
await sub.callback.call(sub.owner, current);
await sub.callback.call(sub.owner);
}
}
}
-24
View File
@@ -469,10 +469,6 @@ interface DOMAPI {
nextSibling: (node: Node) => Node;
tagName: (elm: Element) => string;
setTextContent: (node: Node, text: string | null) => void;
getTextContent: (node: Node) => string | null;
isElement: (node: Node) => node is Element;
isText: (node: Node) => node is Text;
isComment: (node: Node) => node is Comment;
}
function createElement(tagName: any): HTMLElement {
@@ -519,22 +515,6 @@ function setTextContent(node: Node, text: string | null): void {
node.textContent = text;
}
function getTextContent(node: Node): string | null {
return node.textContent;
}
function isElement(node: Node): node is Element {
return node.nodeType === 1;
}
function isText(node: Node): node is Text {
return node.nodeType === 3;
}
function isComment(node: Node): node is Comment {
return node.nodeType === 8;
}
const htmlDomApi = {
createElement,
createElementNS,
@@ -547,10 +527,6 @@ const htmlDomApi = {
nextSibling,
tagName,
setTextContent,
getTextContent,
isElement,
isText,
isComment
} as DOMAPI;
//------------------------------------------------------------------------------
@@ -447,6 +447,49 @@ exports[`class and style attributes with t-component t-att-class is properly add
}"
`;
exports[`composition sub components dom state with t-keepalive is preserved 1`] = `
"function anonymous(context,extra
) {
let utils = this.constructor.utils;
let QWeb = this.constructor;
let parent = context;
let owner = context;
var h = this.h;
let c1 = [], p1 = {key:1};
var vn1 = h('div', p1, c1);
if (context['state'].ok) {
//COMPONENT
let def3;
let w4 = 4 in parent.__owl__.cmap ? parent.__owl__.children[parent.__owl__.cmap[4]] : false;
let _2_index = c1.length;
c1.push(null);
let props4 = {};
if (w4 && w4.__owl__.renderPromise && !w4.__owl__.vnode) {
if (utils.shallowEqual(props4, w4.__owl__.renderProps)) {
def3 = w4.__owl__.renderPromise;
} else {
w4.destroy();
w4 = false;
}
}
if (!w4) {
let componentKey4 = \`InputWidget\`;
let W4 = context.components && context.components[componentKey4] || QWeb.components[componentKey4];
if (!W4) {throw new Error('Cannot find the definition of component \\"' + componentKey4 + '\\"')}
w4 = new W4(parent, props4);
parent.__owl__.cmap[4] = w4.__owl__.id;
def3 = w4.__prepare();
def3 = def3.then(vnode=>{let pvnode=h(vnode.sel, {key: 4, hook: {insert(vn) {let nvn=w4.__mount(vnode, pvnode.elm);pvnode.elm=nvn.elm;},remove() {},destroy(vn) {w4.unmount();}}});c1[_2_index]=pvnode;w4.__owl__.pvnode = pvnode;});
} else {
def3 = def3 || w4.__updateProps(props4, extra.forceUpdate, w4.__owl__.isMounted ? extra.patchQueue : []);
def3 = def3.then(()=>{if (w4.__owl__.isDestroyed) {return};let pvnode=w4.__owl__.pvnode;pvnode.data.hook.insert = vn => {vn.elm.parentNode.replaceChild(w4.el,vn.elm);vn.elm=w4.el;w4.__remount();};c1[_2_index]=pvnode;});
}
extra.promises.push(def3);
}
return vn1;
}"
`;
exports[`composition sub components with some state rendered in a loop 1`] = `
"function anonymous(context,extra
) {
@@ -587,6 +630,49 @@ exports[`composition t-component with dynamic value 2 1`] = `
}"
`;
exports[`lifecycle hooks willPatch/patched hook with t-keepalive 1`] = `
"function anonymous(context,extra
) {
let utils = this.constructor.utils;
let QWeb = this.constructor;
let parent = context;
let owner = context;
var h = this.h;
let c1 = [], p1 = {key:1};
var vn1 = h('div', p1, c1);
if (context['state'].flag) {
//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 = {v:context['state'].n};
if (w4 && w4.__owl__.renderPromise && !w4.__owl__.vnode) {
if (utils.shallowEqual(props4, w4.__owl__.renderProps)) {
def3 = w4.__owl__.renderPromise;
} else {
w4.destroy();
w4 = false;
}
}
if (!w4) {
let componentKey4 = \`ChildWidget\`;
let W4 = context.components && context.components[componentKey4] || QWeb.components[componentKey4];
if (!W4) {throw new Error('Cannot find the definition of component \\"' + componentKey4 + '\\"')}
w4 = new W4(parent, props4);
parent.__owl__.cmap[4] = w4.__owl__.id;
def3 = w4.__prepare();
def3 = def3.then(vnode=>{let pvnode=h(vnode.sel, {key: 4, hook: {insert(vn) {let nvn=w4.__mount(vnode, pvnode.elm);pvnode.elm=nvn.elm;},remove() {},destroy(vn) {w4.unmount();}}});c1[_2_index]=pvnode;w4.__owl__.pvnode = pvnode;});
} else {
def3 = def3 || w4.__updateProps(props4, extra.forceUpdate, w4.__owl__.isMounted ? extra.patchQueue : []);
def3 = def3.then(()=>{if (w4.__owl__.isDestroyed) {return};let pvnode=w4.__owl__.pvnode;pvnode.data.hook.insert = vn => {vn.elm.parentNode.replaceChild(w4.el,vn.elm);vn.elm=w4.el;w4.__remount();};c1[_2_index]=pvnode;});
}
extra.promises.push(def3);
}
return vn1;
}"
`;
exports[`other directives with t-component t-on with handler bound to argument 1`] = `
"function anonymous(context,extra
) {
@@ -1,5 +1,9 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`default props default values are also set whenever component is updated 1`] = `"<div>1</div>"`;
exports[`default props default values are also set whenever component is updated 2`] = `"<div>4</div>"`;
exports[`props validation props are validated in dev mode (code snapshot) 1`] = `
"function anonymous(context,extra
) {
+82 -8
View File
@@ -733,15 +733,16 @@ describe("lifecycle hooks", () => {
// we make sure here that willPatch/patched is only called if widget is in
// dom, mounted
const steps: string[] = [];
env.qweb.addTemplate(
"ParentWidget",
`
<div>
<t t-if="state.flag" t-component="child" v="state.n" t-keepalive="1"/>
</div>`
);
env.qweb.addTemplates(`
<templates>
<div t-name="ParentWidget">
<ChildWidget t-if="state.flag" v="state.n" t-keepalive="1"/>
</div>
</templates>
`);
class ParentWidget extends Widget {
components = { child: ChildWidget };
components = { ChildWidget };
state = { n: 1, flag: true };
}
@@ -761,6 +762,8 @@ describe("lifecycle hooks", () => {
}
const widget = new ParentWidget(env);
await widget.mount(fixture);
expect(env.qweb.templates.ParentWidget.fn.toString()).toMatchSnapshot();
expect(steps).toEqual(["child:mounted"]);
widget.state.flag = false;
await nextTick();
@@ -1124,6 +1127,7 @@ describe("composition", () => {
const input2 = fixture.getElementsByTagName("input")[0];
expect(input).toBe(input2);
expect(input2.value).toBe("test");
expect(env.qweb.templates.ParentWidget.fn.toString()).toMatchSnapshot();
});
test("sub widget with t-ref and t-keepalive", async () => {
@@ -4010,4 +4014,74 @@ describe("unmounting and remounting", () => {
expect(fixture.innerHTML).toBe("<div>Hey</div>");
expect(steps).toEqual(["willstart", "mounted"]);
});
test("state changes in willUnmount do not trigger rerender", async () => {
const steps: string[] = [];
env.qweb.addTemplates(`
<templates>
<div t-name="Parent">
<Child t-if="state.flag" val="state.val"/>
</div>
<span t-name="Child"><t t-esc="props.val"/><t t-esc="state.n"/></span>
</templates>
`);
class Child extends Widget {
state = { n: 2 };
__render(a, b, c, d) {
steps.push("render");
return super.__render(a, b, c, d);
}
willPatch() {
steps.push("willPatch");
}
patched() {
steps.push("patched");
}
willUnmount() {
steps.push("willUnmount");
this.state.n = 3;
}
}
class Parent extends Widget {
components = { Child };
state = { val: 1, flag: true };
}
const widget = new Parent(env);
await widget.mount(fixture);
expect(steps).toEqual(["render"]);
expect(fixture.innerHTML).toBe("<div><span>12</span></div>");
widget.state.flag = false;
await nextTick();
// we make sure here that no call to __render is done
expect(steps).toEqual(["render", "willUnmount"]);
});
test("state changes in willUnmount will be applied on remount", async () => {
env.qweb.addTemplates(`
<templates>
<div t-name="TestWidget"><t t-esc="state.val"/></div>
</templates>
`);
class TestWidget extends Widget {
state = { val: 1 };
willUnmount() {
this.state.val = 3;
}
}
const widget = new TestWidget(env);
await widget.mount(fixture);
expect(fixture.innerHTML).toBe("<div>1</div>");
widget.unmount();
expect(fixture.innerHTML).toBe("");
await widget.mount(fixture);
expect(fixture.innerHTML).toBe("<div>1</div>");
widget.unmount();
await widget.mount(fixture, true);
expect(fixture.innerHTML).toBe("<div>3</div>");
});
});
+8
View File
@@ -327,9 +327,17 @@ describe("default props", () => {
class TestWidget extends Widget {
static defaultProps = { p: 4 };
}
env.qweb.addTemplates(`
<templates>
<div t-name="TestWidget"><t t-esc="props.p"/></div>
</templates>`);
const w = new TestWidget(env, { p: 1 });
await w.mount(fixture);
expect(fixture.innerHTML).toMatchSnapshot();
await w.__updateProps({});
await w.render();
expect(w.props.p).toBe(4);
expect(fixture.innerHTML).toMatchSnapshot();
});
});
+249 -65
View File
@@ -1,7 +1,7 @@
import { Component, Env } from "../../src/component/component";
import { ConnectedComponent } from "../../src/store/connected_component";
import { Store } from "../../src/store/store";
import { makeTestEnv, makeTestFixture, nextTick } from "../helpers";
import { makeTestEnv, makeTestFixture, nextTick, makeDeferred } from "../helpers";
describe("connecting a component to store", () => {
let fixture: HTMLElement;
@@ -20,7 +20,7 @@ describe("connecting a component to store", () => {
env.qweb.addTemplates(`
<templates>
<div t-name="App">
<t t-foreach="props.todos" t-as="todo" >
<t t-foreach="storeProps.todos" t-as="todo" >
<Todo msg="todo.msg" t-key="todo"/>
</t>
</div>
@@ -35,19 +35,19 @@ describe("connecting a component to store", () => {
}
class Todo extends Component<any, any, any> {}
const state = { todos: [] };
const mutations = {
const actions = {
addTodo({ state }, msg) {
state.todos.push({ msg });
}
};
const store = new Store({ state, mutations });
const store = new Store({ state, actions });
(<any>env).store = store;
const app = new App(env);
await app.mount(fixture);
expect(fixture.innerHTML).toMatchSnapshot();
store.commit("addTodo", "hello");
store.dispatch("addTodo", "hello");
await nextTick();
expect(fixture.innerHTML).toMatchSnapshot();
});
@@ -56,19 +56,19 @@ describe("connecting a component to store", () => {
env.qweb.addTemplates(`
<templates>
<div t-name="App">
<span t-foreach="props.todos" t-as="todo" t-key="todo">
<span t-foreach="storeProps.todos" t-as="todo" t-key="todo">
<t t-esc="todo.title"/>
</span>
</div>
</templates>
`);
const state = { todos: [{ title: "Kasteel" }] };
const mutations = {
const actions = {
edit({ state }, title) {
state.todos[0].title = title;
}
};
const store = new Store({ state, mutations });
const store = new Store({ state, actions });
class App extends ConnectedComponent<any, any, any> {
static mapStoreToProps(s) {
@@ -94,7 +94,7 @@ describe("connecting a component to store", () => {
expect(fixture.innerHTML).toMatchSnapshot();
expect(shallowFix.innerHTML).toMatchSnapshot();
store.commit("edit", "Bertinchamps");
store.dispatch("edit", "Bertinchamps");
await nextTick();
expect(fixture.innerHTML).toMatchSnapshot();
expect(shallowFix.innerHTML).toMatchSnapshot();
@@ -104,7 +104,7 @@ describe("connecting a component to store", () => {
env.qweb.addTemplates(`
<templates>
<div t-name="App">
<t t-foreach="props.todos" t-as="todo">
<t t-foreach="storeProps.todos" t-as="todo">
<Todo msg="todo.msg" t-key="todo" />
</t>
</div>
@@ -113,10 +113,9 @@ describe("connecting a component to store", () => {
`);
class Todo extends Component<any, any, any> {}
(<any>env).store = new Store({});
const store = new Store({
state: { todos: [] },
mutations: {
actions: {
addTodo({ state }, msg) {
state.todos.push({ msg });
}
@@ -136,11 +135,48 @@ describe("connecting a component to store", () => {
await app.mount(fixture);
expect(fixture.innerHTML).toMatchSnapshot();
(<any>app.__owl__).store.commit("addTodo", "hello");
(<any>app.__owl__).store.dispatch("addTodo", "hello");
await nextTick();
expect(fixture.innerHTML).toMatchSnapshot();
});
test("can dispatch actions from a connected component", async () => {
env.qweb.addTemplates(`
<templates>
<div t-name="App">
<button t-on-click="dispatch('inc')">Inc</button>
<span><t t-esc="storeProps.value"/></span>
</div>
</templates>
`);
const store = new Store({
state: { value: 1 },
actions: {
inc({ state }) {
state.value++;
}
}
});
(<any>env).store = store;
class App extends ConnectedComponent<any, any, any> {
static mapStoreToProps(s) {
return { value: s.value };
}
}
const app = new App(env);
await app.mount(fixture);
expect(fixture.innerHTML).toBe("<div><button>Inc</button><span>1</span></div>");
const button = (<HTMLElement>app.el).getElementsByTagName("button")[0];
await button.click();
await nextTick();
expect(fixture.innerHTML).toBe("<div><button>Inc</button><span>2</span></div>");
});
test("connected child components with custom hooks", async () => {
let steps: any = [];
env.qweb.addTemplates(`
@@ -187,18 +223,18 @@ describe("connecting a component to store", () => {
test("mapStoreToProps receives ownprops as second argument", async () => {
const state = { todos: [{ id: 1, text: "jupiler" }] };
let nextId = 2;
const mutations = {
const actions = {
addTodo({ state }, text) {
state.todos.push({ text, id: nextId++ });
}
};
const store = new Store({ state, mutations });
const store = new Store({ state, actions });
env.qweb.addTemplates(`
<templates>
<span t-name="TodoItem"><t t-esc="props.text"/></span>
<span t-name="TodoItem"><t t-esc="storeProps.text"/></span>
<div t-name="TodoList">
<t t-foreach="props.todos" t-as="todo">
<t t-foreach="storeProps.todos" t-as="todo">
<TodoItem id="todo.id" t-key="todo.id"/>
</t>
</div>
@@ -224,7 +260,7 @@ describe("connecting a component to store", () => {
await app.mount(fixture);
expect(fixture.innerHTML).toBe("<div><span>jupiler</span></div>");
store.commit("addTodo", "hoegaarden");
store.dispatch("addTodo", "hoegaarden");
await nextTick();
expect(fixture.innerHTML).toBe("<div><span>jupiler</span><span>hoegaarden</span></div>");
});
@@ -247,11 +283,11 @@ describe("connecting a component to store", () => {
env.qweb.addTemplates(`
<templates>
<div t-name="TodoItem">
<span><t t-esc="props.activeTodoText"/></span>
<span><t t-esc="props.importantTodoText"/></span>
<span><t t-esc="storeProps.activeTodoText"/></span>
<span><t t-esc="storeProps.importantTodoText"/></span>
</div>
<div t-name="TodoList">
<t t-foreach="props.todos" t-as="todo">
<t t-foreach="storeProps.todos" t-as="todo">
<TodoItem id="todo.id" t-key="todo.id"/>
</t>
</div>
@@ -287,7 +323,7 @@ describe("connecting a component to store", () => {
test("connected component is updated when props are updated", async () => {
env.qweb.addTemplates(`
<templates>
<span t-name="Beer"><t t-esc="props.name"/></span>
<span t-name="Beer"><t t-esc="storeProps.name"/></span>
<div t-name="App">
<Beer id="state.beerId"/>
</div>
@@ -322,7 +358,7 @@ describe("connecting a component to store", () => {
env.qweb.addTemplates(`
<templates>
<div t-name="App">
<span t-foreach="props.beers" t-as="beer" t-key="beer.name"><t t-esc="beer.name"/></span>
<span t-foreach="storeProps.beers" t-as="beer" t-key="beer.name"><t t-esc="beer.name"/></span>
</div>
</templates>
`);
@@ -333,14 +369,14 @@ describe("connecting a component to store", () => {
}
}
const mutations = {
const actions = {
addBeer({ state }, name) {
state.beers.push({ name });
}
};
const state = { beers: [{ name: "jupiler" }] };
const store = new Store({ state, mutations });
const store = new Store({ state, actions });
(<any>env).store = store;
const app = new App(env);
@@ -348,7 +384,7 @@ describe("connecting a component to store", () => {
await app.mount(fixture);
expect(fixture.innerHTML).toBe("<div><span>jupiler</span></div>");
store.commit("addBeer", "kwak");
store.dispatch("addBeer", "kwak");
await nextTick();
expect(fixture.innerHTML).toBe("<div><span>jupiler</span><span>kwak</span></div>");
});
@@ -357,9 +393,9 @@ describe("connecting a component to store", () => {
env.qweb.addTemplates(`
<templates>
<div t-name="Beer">
<span>taster:<t t-esc="props.taster"/></span>
<span t-if="props.selected">selected:<t t-esc="props.selected.name"/></span>
<span t-if="props.consumed">consumed:<t t-esc="props.consumed.name"/></span>
<span>taster:<t t-esc="storeProps.taster"/></span>
<span t-if="storeProps.selected">selected:<t t-esc="storeProps.selected.name"/></span>
<span t-if="storeProps.consumed">consumed:<t t-esc="storeProps.consumed.name"/></span>
</div>
<div t-name="App">
<Beer id="state.beerId"/>
@@ -382,7 +418,7 @@ describe("connecting a component to store", () => {
state = { beerId: 0 };
}
const mutations = {
const actions = {
consume({ state }, beerId) {
state.consumedID = beerId;
}
@@ -394,7 +430,7 @@ describe("connecting a component to store", () => {
consumedID: null,
taster: "aaron"
};
const store = new Store({ state, mutations });
const store = new Store({ state, actions });
(<any>env).store = store;
const app = new App(env);
@@ -407,7 +443,7 @@ describe("connecting a component to store", () => {
"<div><div><span>taster:aaron</span><span>selected:jupiler</span></div></div>"
);
store.commit("consume", 1);
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>"
@@ -424,9 +460,9 @@ describe("connecting a component to store", () => {
env.qweb.addTemplates(`
<templates>
<div t-name="Beer">
<span>taster:<t t-esc="props.taster"/></span>
<span t-if="props.selected">selected:<t t-esc="props.selected.name"/></span>
<span t-if="props.consumed">consumed:<t t-esc="props.consumed.name"/></span>
<span>taster:<t t-esc="storeProps.taster"/></span>
<span t-if="storeProps.selected">selected:<t t-esc="storeProps.selected.name"/></span>
<span t-if="storeProps.consumed">consumed:<t t-esc="storeProps.consumed.name"/></span>
</div>
<div t-name="App">
<Beer id="state.beerId"/>
@@ -449,7 +485,7 @@ describe("connecting a component to store", () => {
state = { beerId: 0 };
}
const mutations = {
const actions = {
changeTaster({ state }, newTaster) {
state.taster = newTaster;
},
@@ -467,7 +503,7 @@ describe("connecting a component to store", () => {
consumedID: null,
taster: "aaron"
};
const store = new Store({ state, mutations });
const store = new Store({ state, actions });
(<any>env).store = store;
const app = new App(env);
@@ -480,13 +516,13 @@ describe("connecting a component to store", () => {
"<div><div><span>taster:aaron</span><span>selected:jupiler</span></div></div>"
);
store.commit("renameBeer", { beerId: 1, name: "kwak" });
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.commit("consume", 1);
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>"
@@ -498,13 +534,13 @@ describe("connecting a component to store", () => {
"<div><div><span>taster:aaron</span><span>consumed:kwak</span></div></div>"
);
store.commit("renameBeer", { beerId: 1, name: "jupiler" });
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.commit("changeTaster", "matthieu");
store.dispatch("changeTaster", "matthieu");
await nextTick();
expect(fixture.innerHTML).toBe(
"<div><div><span>taster:matthieu</span><span>consumed:jupiler</span></div></div>"
@@ -517,9 +553,9 @@ describe("connecting a component to store", () => {
env.qweb.addTemplates(`
<templates>
<div t-name="Parent">
<Child key="props.current"/>
<Child key="storeProps.current"/>
</div>
<span t-name="Child"><t t-esc="props.msg"/></span>
<span t-name="Child"><t t-esc="storeProps.msg"/></span>
</templates>
`);
@@ -539,13 +575,13 @@ describe("connecting a component to store", () => {
}
const state = { current: "a", msg: { a: "a", b: "b" } };
const mutations = {
const actions = {
setCurrent({ state }, c) {
state.current = c;
}
};
const store = new Store({ state, mutations });
const store = new Store({ state, actions });
(<any>env).store = store;
const app = new Parent(env);
@@ -553,14 +589,74 @@ describe("connecting a component to store", () => {
expect(fixture.innerHTML).toBe("<div><span>a</span></div>");
expect(steps).toEqual(["parent", "child"]);
store.commit("setCurrent", "b");
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 connectedddd", async () => {
const steps: string[] = [];
let def = makeDeferred();
def.resolve();
env.qweb.addTemplates(`
<templates>
<div t-name="Parent">
<Child t-if="storeProps.flag" someId="storeProps.someId"/>
</div>
<span t-name="Child"><t t-esc="storeProps.msg"/></span>
</templates>
`);
class Parent extends ConnectedComponent<any, any, any> {
components = { Child };
static mapStoreToProps(s) {
steps.push("parent");
return { flag: s.flag, someId: s.someId };
}
async render(force) {
await def;
return super.render(force);
}
}
class Child extends ConnectedComponent<any, any, any> {
static mapStoreToProps(s, props) {
steps.push("child");
return { msg: s.messages[props.someId] };
}
}
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 () => {
const mutations = {
const actions = {
editTodo({ state }) {
state.todos[1].title = "abc";
}
@@ -571,19 +667,19 @@ describe("connecting a component to store", () => {
};
const store = new Store({
state,
mutations
actions
});
env.qweb.addTemplates(`
<templates>
<div t-name="TodoApp" class="todoapp">
<t t-foreach="Object.values(props.todos)" t-as="todo">
<t t-foreach="Object.values(storeProps.todos)" t-as="todo">
<TodoItem t-key="todo.id" id="todo.id"/>
</t>
</div>
<div t-name="TodoItem" class="todo">
<t t-esc="props.todo.title"/>
<t t-esc="storeProps.todo.title"/>
<button class="destroy" t-on-click="editTodo">x</button>
</div>
</templates>
@@ -611,7 +707,7 @@ describe("connecting a component to store", () => {
}
editTodo() {
this.env.store.commit("editTodo");
this.env.store.dispatch("editTodo");
}
__render(...args) {
renderCount++;
@@ -639,7 +735,7 @@ describe("connecting a component to store", () => {
});
test("connected parent/children: no rendering if child is destroyed", async () => {
const mutations = {
const actions = {
removeTodo({ state }) {
delete state.todos[1];
}
@@ -650,19 +746,19 @@ describe("connecting a component to store", () => {
};
const store = new Store({
state,
mutations
actions
});
env.qweb.addTemplates(`
<templates>
<div t-name="TodoApp" class="todoapp">
<t t-foreach="Object.values(props.todos)" t-as="todo">
<t t-foreach="Object.values(storeProps.todos)" t-as="todo">
<TodoItem t-key="todo.id" id="todo.id"/>
</t>
</div>
<div t-name="TodoItem" class="todo">
<t t-esc="props.todo.title"/>
<t t-esc="storeProps.todo.title"/>
<button class="destroy" t-on-click="removeTodo">x</button>
</div>
</templates>
@@ -690,7 +786,7 @@ describe("connecting a component to store", () => {
};
}
removeTodo() {
this.env.store.commit("removeTodo");
this.env.store.dispatch("removeTodo");
}
__render(...args) {
renderCount++;
@@ -720,7 +816,7 @@ describe("connecting a component to store", () => {
env.qweb.addTemplates(`
<templates>
<div t-name="App"><t t-esc="props.msg"/></div>
<div t-name="App"><t t-esc="storeProps.msg"/></div>
</templates>
`);
@@ -737,20 +833,20 @@ describe("connecting a component to store", () => {
}
const state = { msg: "a" };
const mutations = {
const actions = {
setMsg({ state }, c) {
state.msg = c;
}
};
const store = new Store({ state, mutations });
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.commit("setMsg", "b");
store.dispatch("setMsg", "b");
await nextTick();
expect(fixture.innerHTML).toBe("<div>b</div>");
expect(steps).toEqual(["willpatch", "patched"]);
@@ -818,9 +914,11 @@ describe("connected components and default values", () => {
expect(fixture.innerHTML).toBe("<div><div>Hello, John</div></div>");
await app.__updateProps({ initialRecipient: "James" }, true);
await app.render();
expect(fixture.innerHTML).toBe("<div><div>Hello, James</div></div>");
await app.__updateProps({ initialRecipient: undefined }, true);
await app.render();
expect(fixture.innerHTML).toBe("<div><div>Hello, John</div></div>");
});
@@ -829,11 +927,11 @@ describe("connected components and default values", () => {
<templates>
<div t-name="Message">
<t t-if="props.showId"><t t-esc="props.messageId"/></t>
<t t-esc="props.message.content"/>
<t t-esc="storeProps.message.content"/>
</div>
<div t-name="Thread">
<t t-if="props.showMessages">
<Message t-foreach="props.thread.messages" t-as="messageId" messageId="messageId" t-key="messageId"/>
<Message t-foreach="storeProps.thread.messages" t-as="messageId" messageId="messageId" t-key="messageId"/>
</t>
</div>
<div t-name="App"><Thread threadId="props.threadId"/></div>
@@ -887,13 +985,13 @@ describe("connected components and default values", () => {
}
};
const mutations = {
const actions = {
changeMessageContent({ state }, messageId, newContent) {
state.messages[messageId].content = newContent;
}
};
const store = new Store({ state, mutations });
const store = new Store({ state, actions });
(<any>env).store = store;
const app = new App(env);
@@ -903,10 +1001,96 @@ describe("connected components and default values", () => {
);
await app.__updateProps({ threadId: 2 }, true);
await app.render();
expect(fixture.innerHTML).toBe("<div><div><div>200Message200</div></div></div>");
store.commit("changeMessageContent", 200, "UpdatedMessage200");
store.dispatch("changeMessageContent", 200, "UpdatedMessage200");
await nextTick();
expect(fixture.innerHTML).toBe("<div><div><div>200UpdatedMessage200</div></div></div>");
});
test("connected child components stop listening to store when destroyed", async () => {
let steps: any = [];
env.qweb.addTemplates(`
<templates>
<div t-name="Parent">
<Child t-if="state.child" />
</div>
<div t-name="Child"><t t-esc="storeProps.val"/></div>
</templates>
`);
class Child extends ConnectedComponent<any, any, any> {
static mapStoreToProps(s) {
return s;
}
}
class Parent extends Component<any, any, any> {
components = { Child };
state = { 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 () => {
env.qweb.addTemplates(`
<templates>
<div t-name="App">
<t t-esc="storeProps.counter"/>
</div>
</templates>
`);
class App extends ConnectedComponent<any, any, any> {
static mapStoreToProps = function(state) {
return {
counter: state.counter
};
};
}
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>");
});
});
+74 -324
View File
@@ -1,57 +1,33 @@
import { Env } from "../../src/component/component";
import { Store } from "../../src/store/store";
import { nextMicroTick, nextTick } from "../helpers";
import { Store, Getter } from "../../src/store/store";
import { nextTick, nextMicroTick } from "../helpers";
describe("basic use", () => {
test("commit a mutation", () => {
const state = { n: 1 };
const mutations = {
inc({ state }, delta) {
state.n += delta;
}
};
const store = new Store({ state, mutations });
expect(store.state.n).toBe(1);
store.commit("inc", 14);
expect(store.state.n).toBe(15);
});
test("dispatch an action", () => {
const state = { n: 1 };
const mutations = {
const actions = {
inc({ state }, delta) {
state.n += delta;
}
};
const actions = {
inc({ commit }, delta) {
commit("inc", delta);
}
};
const store = new Store({ state, mutations, actions });
const store = new Store({ state, actions });
expect(store.state.n).toBe(1);
store.dispatch("inc", 14);
expect(store.state.n).toBe(15);
});
test("dispatch an action + commit a mutation with positional arguments", () => {
test("dispatch an action with positional arguments", () => {
const state = { n1: 1, n2: 1, n3: 1 };
const mutations = {
const actions = {
batchInc({ state }, delta1, delta2, delta3) {
state.n1 += delta1;
state.n2 += delta2;
state.n3 += delta3;
}
};
const actions = {
batchInc({ commit }, delta1, delta2, delta3) {
commit("batchInc", delta1, delta2, delta3);
}
};
const store = new Store({ state, mutations, actions });
const store = new Store({ state, actions });
expect(store.state.n1).toBe(1);
expect(store.state.n2).toBe(1);
@@ -62,96 +38,58 @@ describe("basic use", () => {
expect(store.state.n3).toBe(89);
});
test("modifying state outside of mutations trigger error", () => {
const state = { n: 1 };
const actions = {
inc({ state }) {
state.n++;
}
};
const store = new Store({ state, mutations: {}, actions });
expect(() => store.dispatch("inc")).toThrow();
expect(() => (store.state.n = 15)).toThrow();
});
test("can dispatch an action in an action", () => {
const state = { n: 1 };
const mutations = {
const actions = {
inc({ state }, delta) {
state.n += delta;
}
};
const actions = {
inc({ commit }, delta) {
commit("inc", delta);
},
inc100({ dispatch }) {
dispatch("inc", 100);
}
};
const store = new Store({ state, mutations, actions });
const store = new Store({ state, actions });
expect(store.state.n).toBe(1);
store.dispatch("inc100");
expect(store.state.n).toBe(101);
});
test("can commit a mutation in a mutation", () => {
test("return data from dispatching an action", () => {
const state = { n: 1 };
const mutations = {
inc({ state }) {
state.n++;
},
inc10({ commit }) {
for (let i = 0; i < 10; i++) {
commit("inc");
}
}
};
const store = new Store({ state, mutations });
expect(store.state.n).toBe(1);
store.commit("inc10");
expect(store.state.n).toBe(11);
});
test("return data from committing a mutation", () => {
const state = { n: 1 };
const mutations = {
const actions = {
inc({ state }) {
return ++state.n;
}
};
const store = new Store({ state, mutations });
const store = new Store({ state, actions });
expect(store.state.n).toBe(1);
const res = store.commit("inc");
const res = store.dispatch("inc");
expect(store.state.n).toBe(2);
expect(res).toBe(2);
});
test("dispatch allow synchronizing between actions", async () => {
const state = { n: 1 };
const mutations = {
const actions = {
inc({ state }, delta) {
state.n += delta;
},
setN({ state }, n) {
state.n = n;
}
};
const actions = {
async dosomething({ commit, dispatch }) {
await dispatch("setTo10");
commit("inc", 3);
},
async setTo10({ commit }) {
async dosomething({ dispatch }) {
await dispatch("setTo10");
dispatch("inc", 3);
},
async setTo10({ dispatch }) {
await Promise.resolve();
commit("setN", 10);
dispatch("setN", 10);
}
};
const store = new Store({ state, mutations, actions });
const store = new Store({ state, actions });
expect(store.state.n).toBe(1);
store.dispatch("dosomething");
@@ -162,26 +100,24 @@ describe("basic use", () => {
test("return data from dispatching an action", async () => {
const state = { n: 1 };
const mutations = {
const actions = {
inc({ state }, delta) {
state.n += delta;
},
setN({ state }, n) {
state.n = n;
}
};
const actions = {
async dosomething({ commit, dispatch }) {
const val = await dispatch("setTo10");
commit("inc", val);
},
async setTo10({ commit }) {
async dosomething({ dispatch }) {
const val = await dispatch("setTo10");
dispatch("inc", val);
},
async setTo10({ dispatch }) {
await Promise.resolve();
commit("setN", 10);
dispatch("setN", 10);
return 5;
}
};
const store = new Store({ state, mutations, actions });
const store = new Store({ state, actions });
expect(store.state.n).toBe(1);
store.dispatch("dosomething");
@@ -219,7 +155,7 @@ describe("basic use", () => {
}
}
};
const getters = {
const getters: { [key: string]: Getter } = {
beerTasterName({ state }, beerID) {
return state.tasters[state.beers[beerID].tasterID].name;
},
@@ -227,161 +163,12 @@ describe("basic use", () => {
return state.beers[1].name;
}
};
const store = new Store({ state, mutations: {}, actions: {}, getters });
const store = new Store({ state, actions: {}, getters });
expect(store.getters).toBeDefined();
expect((<any>store.getters).bestBeerName()).toBe("bertinchamps");
expect((<any>store.getters).beerTasterName(1)).toBe("aaron");
});
test("getters are memoized", async () => {
const state = {
beers: {
1: {
id: 1,
name: "bertinchamps",
tasterID: 1
}
},
tasters: {
1: {
id: 1,
name: "aaron"
}
}
};
let n = 0;
const getters = {
beerTasterName({ state }, beerID) {
n++;
return state.tasters[state.beers[beerID].tasterID].name;
},
bestBeerName({ state }) {
n++;
return state.beers[1].name;
}
};
const store = new Store({ state, mutations: {}, actions: {}, getters });
expect((<any>store.getters).bestBeerName()).toBe("bertinchamps");
expect((<any>store.getters).beerTasterName(1)).toBe("aaron");
expect(n).toBe(2);
expect((<any>store.getters).bestBeerName()).toBe("bertinchamps");
expect((<any>store.getters).beerTasterName(1)).toBe("aaron");
expect(n).toBe(2);
});
test("getters taking Array as argument aren't memoized", async () => {
const state = {
beers: {
1: {
id: 1,
name: "bertinchamps",
tasterID: 1
}
}
};
let n = 0;
const getters = {
getBeerNames({ state }, beerIDs) {
n++;
return beerIDs.map(beerID => {
return state.beers[beerID].name;
});
}
};
const store = new Store({ state, mutations: {}, actions: {}, getters });
expect((<any>store.getters).getBeerNames([1])).toEqual(["bertinchamps"]);
expect(n).toBe(1);
expect((<any>store.getters).getBeerNames([1])).toEqual(["bertinchamps"]);
expect(n).toBe(2);
});
test("getters cache is nuked on store changes", async () => {
const state = {
beers: {
1: {
id: 1,
name: "bertinchamps",
tasterID: 1
}
},
tasters: {
1: {
id: 1,
name: "aaron"
},
2: {
id: 2,
name: "gery"
}
}
};
const mutations = {
changeTaster({ state }, { beerID, tasterID }) {
state.beers[beerID].tasterID = tasterID;
}
};
let n = 0;
const getters = {
beerTasterName({ state }, beerID) {
n++;
return state.tasters[state.beers[beerID].tasterID].name;
}
};
const store = new Store({
state,
mutations: mutations,
actions: {},
getters
});
expect((<any>store.getters).beerTasterName(1)).toBe("aaron");
expect(n).toBe(1);
expect((<any>store.getters).beerTasterName(1)).toBe("aaron");
expect(n).toBe(1);
store.commit("changeTaster", { beerID: 1, tasterID: 2 });
await nextTick();
expect((<any>store.getters).beerTasterName(1)).toBe("gery");
expect(n).toBe(2);
});
test("getters cache is disabled during a mutation", async () => {
const state = {
beers: {
1: {
id: 1,
name: "bertinchamps"
}
}
};
const mutations = {
renameBeer({ state, getters }, beerID) {
expect(getters.beerName(beerID)).toBe("bertinchamps");
state.beers[1].name = "chouffe";
expect(getters.beerName(beerID)).toBe("chouffe");
}
};
let n = 0;
const getters = {
beerName({ state }, beerID) {
n++;
return state.beers[beerID].name;
}
};
const store = new Store({
state,
mutations: mutations,
actions: {},
getters
});
store.commit("renameBeer", 1);
expect((<any>store.getters).beerName(1)).toBe("chouffe");
await nextTick();
expect(n).toBe(3);
});
test("getters given to actions", async () => {
expect.assertions(3);
const state = {
@@ -414,46 +201,10 @@ describe("basic use", () => {
expect(getters.beerTasterName(1)).toBe("aaron");
}
};
const store = new Store({ state, mutations: {}, actions, getters });
const store = new Store({ state, actions, getters });
store.dispatch("action");
});
test("getters given to mutations", async () => {
expect.assertions(3);
const state = {
beers: {
1: {
id: 1,
name: "bertinchamps",
tasterID: 1
}
},
tasters: {
1: {
id: 1,
name: "aaron"
}
}
};
const getters = {
beerTasterName({ state }, beerID) {
return state.tasters[state.beers[beerID].tasterID].name;
},
bestBeerName({ state }) {
return state.beers[1].name;
}
};
const mutations = {
mutation({ getters }) {
expect(getters).toBeDefined();
expect(getters.bestBeerName()).toBe("bertinchamps");
expect(getters.beerTasterName(1)).toBe("aaron");
}
};
const store = new Store({ state, mutations, actions: {}, getters });
store.commit("mutation");
});
test("can use getters inside a getter", () => {
const getters = {
a({ getters }) {
@@ -466,7 +217,7 @@ describe("basic use", () => {
return `c${i}`;
}
};
const store = new Store({ getters });
const store = new Store({ getters, state: {}, actions: {} });
expect(store.getters.a()).toBe("bc1");
});
@@ -474,73 +225,73 @@ describe("basic use", () => {
describe("advanced state properties", () => {
test("state in the store is reference equal after mutation", async () => {
const mutations = {
const actions = {
donothing() {}
};
const store = new Store({ state:{}, mutations });
const store = new Store({ state: {}, actions });
const state = store.state;
store.commit("donothing");
store.dispatch("donothing");
expect(store.state).toBe(state);
});
test("can use array properties in mutations", () => {
expect.assertions(3);
const state = { a: [1, 2, 3] };
const mutations = {
const actions = {
m({ state }) {
expect(state.a.length).toBe(3);
const l = state.a.push(53);
expect(l).toBe(4);
}
};
const store = new Store({ state, mutations });
store.commit("m");
const store = new Store({ state, actions });
store.dispatch("m");
expect(store.state.a).toEqual([1, 2, 3, 53]);
});
test("can use object assign in store", async () => {
const mutations = {
dosomething({ state }) {
Object.assign(state.westmalle, { a: 3, b: 4 });
}
};
const store = new Store({
state: { westmalle: { a: 1, b: 2 } },
mutations
test("can use object assign in store", async () => {
const actions = {
dosomething({ state }) {
Object.assign(state.westmalle, { a: 3, b: 4 });
}
};
const store = new Store({
state: { westmalle: { a: 1, b: 2 } },
actions
});
store.dispatch("dosomething");
expect(store.state.westmalle).toEqual({ a: 3, b: 4 });
});
store.commit("dosomething");
expect(store.state.westmalle).toEqual({ a: 3, b: 4 });
});
test("aku reactive store state 1", async () => {
const mutations = {
inc({ state }) {
state.counter++;
}
};
const state = { counter: 0 };
const store = new Store({ state, mutations });
expect(store.state.counter).toBe(0);
store.commit("inc", {});
expect(store.state.counter).toBe(1);
});
test("aku reactive store state 1", async () => {
const actions = {
inc({ state }) {
state.counter++;
}
};
const state = { counter: 0 };
const store = new Store({ state, actions });
expect(store.state.counter).toBe(0);
store.dispatch("inc", {});
expect(store.state.counter).toBe(1);
});
});
describe("updates triggered by the store", () => {
test("multiple commits trigger one update", async () => {
let updateCounter = 0;
const state = { n: 1 };
const mutations = {
const actions = {
inc({ state }, delta) {
state.n += delta;
}
};
const store = new Store({ state, mutations });
const store = new Store({ state, actions });
store.on("update", null, () => updateCounter++);
store.commit("inc", 14);
store.dispatch("inc", 14);
expect(updateCounter).toBe(0);
store.commit("inc", 50);
store.dispatch("inc", 50);
expect(updateCounter).toBe(0);
await nextMicroTick();
expect(updateCounter).toBe(1);
@@ -549,7 +300,7 @@ describe("updates triggered by the store", () => {
test("empty commits do not trigger updates", async () => {
let updateCounter = 0;
const state = { n: 1 };
const mutations = {
const actions = {
inc({ state }, delta) {
state.n += delta;
},
@@ -559,20 +310,19 @@ describe("updates triggered by the store", () => {
state.n = val;
}
};
const store = new Store({ state, mutations });
const store = new Store({ state, actions });
store.on("update", null, () => updateCounter++);
store.commit("noop");
store.dispatch("noop");
await nextMicroTick();
expect(updateCounter).toBe(0);
store.commit("inc", 50);
store.dispatch("inc", 50);
await nextMicroTick();
expect(updateCounter).toBe(1);
store.commit("noop2");
store.dispatch("noop2");
await nextMicroTick();
expect(updateCounter).toBe(1);
});
});
+1 -1
View File
@@ -111,7 +111,7 @@ class App extends owl.Component {
for (let i = 0; i < this.state.messages.length; i += 10) {
const msg = Object.assign({}, messages[i]);
msg.author += "!!!";
this.set(messages, i, msg);
owl.Observer.set(messages, i, msg);
}
});
}
+161
View File
@@ -0,0 +1,161 @@
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 {
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 {
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 < this.state.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 = "";
}
toggleMultiple() {
this.state.multipleFlag = !this.state.multipleFlag;
}
toggleClear() {
this.state.clearAfterFlag = !this.state.clearAfterFlag;
}
}
//------------------------------------------------------------------------------
// 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();
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>OWL 0.18.0 Benchmark</title>
<link href="../shared/main.css" rel="stylesheet"/>
<script src='owl.js'></script>
</head>
<body>
<div id='main'></div>
<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-on-change="toggleMultiple" t-att-checked="state.multipleFlag"/>
<label for="multipleflag">Do it 20x</label>
</div>
<div>
<input type="checkbox" id="clearFlag" t-on-change="toggleClear" t-att-checked="state.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>
+1
View File
@@ -38,6 +38,7 @@
<li><a href="benchmarks/owl-0.15.0">OWL 0.15.0</a></li>
<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-master">OWL Master</a></li>
</ul>
<ul>
+293 -74
View File
@@ -268,56 +268,42 @@ const TODO_APP_STORE = `// This example is an implementation of the TodoList app
const ENTER_KEY = 13;
const ESC_KEY = 27;
const LOCALSTORAGE_KEY = "todos-odoo";
const LOCALSTORAGE_KEY = "todomvc";
//------------------------------------------------------------------------------
// Store Definition
//------------------------------------------------------------------------------
const actions = {
addTodo({ commit }, title) {
commit("addTodo", title);
},
removeTodo({ commit }, id) {
commit("removeTodo", id);
},
toggleTodo({ state, commit }, id) {
const todo = state.todos.find(t => t.id === id);
commit("editTodo", { id, completed: !todo.completed });
},
clearCompleted({ state, commit }) {
state.todos
.filter(todo => todo.completed)
.forEach(todo => {
commit("removeTodo", todo.id);
});
},
toggleAll({ state, commit }, completed) {
state.todos.forEach(todo => {
commit("editTodo", {
id: todo.id,
completed
});
});
},
editTodo({ commit }, { id, title }) {
commit("editTodo", { id, title });
}
};
const mutations = {
addTodo({ state }, title) {
const id = state.nextId++;
const todo = {
id,
state.todos.push({
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) {
const todo = state.todos.find(t => t.id === id);
dispatch("editTodo", { id, completed: !todo.completed });
},
clearCompleted({ state, dispatch }) {
state.todos
.filter(todo => todo.completed)
.forEach(todo => {
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) {
@@ -329,24 +315,20 @@ const mutations = {
}
};
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 todos = JSON.parse(
window.localStorage.getItem(LOCALSTORAGE_KEY) || "[]"
);
const nextId = Math.max(0, ...todos.map(t => t.id || 0)) + 1;
const state = {
todos,
nextId
};
const store = new owl.store.Store({
state,
actions,
mutations
});
store.on("update", null, () => {
const state = JSON.stringify(store.state.todos);
window.localStorage.setItem(LOCALSTORAGE_KEY, state);
});
const state = loadState();
const store = new owl.store.Store({ state, actions });
store.on("update", null, () => saveState(store.state));
return store;
}
@@ -357,11 +339,11 @@ class TodoItem extends owl.Component {
state = { isEditing: false };
removeTodo() {
this.env.dispatch("removeTodo", this.props.id);
this.env.store.dispatch("removeTodo", this.props.id);
}
toggleTodo() {
this.env.dispatch("toggleTodo", this.props.id);
this.env.store.dispatch("toggleTodo", this.props.id);
}
async editTodo() {
@@ -393,7 +375,7 @@ class TodoItem extends owl.Component {
if (!value) {
this.removeTodo(this.props.id);
} else {
this.env.dispatch("editTodo", {
this.env.store.dispatch("editTodo", {
id: this.props.id,
title: value
});
@@ -415,7 +397,7 @@ class TodoApp extends owl.store.ConnectedComponent {
};
}
get visibleTodos() {
let todos = this.props.todos;
let todos = this.storeProps.todos;
if (this.state.filter === "active") {
todos = todos.filter(t => !t.completed);
}
@@ -426,11 +408,11 @@ class TodoApp extends owl.store.ConnectedComponent {
}
get allChecked() {
return this.props.todos.every(todo => todo.completed);
return this.storeProps.todos.every(todo => todo.completed);
}
get remaining() {
return this.props.todos.filter(todo => !todo.completed).length;
return this.storeProps.todos.filter(todo => !todo.completed).length;
}
get remainingText() {
@@ -442,20 +424,12 @@ class TodoApp extends owl.store.ConnectedComponent {
if (ev.keyCode === ENTER_KEY) {
const title = ev.target.value;
if (title.trim()) {
this.env.dispatch("addTodo", title);
this.dispatch("addTodo", title);
}
ev.target.value = "";
}
}
clearCompleted() {
this.env.dispatch("clearCompleted");
}
toggleAll() {
this.env.dispatch("toggleAll", !this.allChecked);
}
setFilter(filter) {
this.state.filter = filter;
}
@@ -469,7 +443,6 @@ const qweb = new owl.QWeb(TEMPLATES);
const env = {
qweb,
store,
dispatch: store.dispatch.bind(store),
};
const app = new TodoApp(env);
app.mount(document.body);
@@ -481,8 +454,8 @@ 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="props.todos.length">
<input class="toggle-all" id="toggle-all" type="checkbox" t-att-checked="allChecked" t-on-click="toggleAll"/>
<section class="main" t-if="storeProps.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">
<t t-foreach="visibleTodos" t-as="todo">
@@ -490,7 +463,7 @@ const TODO_APP_STORE_XML = `<templates>
</t>
</ul>
</section>
<footer class="footer" t-if="props.todos.length">
<footer class="footer" t-if="storeProps.todos.length">
<span class="todo-count">
<strong>
<t t-esc="remaining"/>
@@ -508,7 +481,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="props.todos.length gt remaining" t-on-click="clearCompleted">
<button class="clear-completed" t-if="storeProps.todos.length gt remaining" t-on-click="dispatch('clearCompleted')">
Clear completed
</button>
</footer>
@@ -1334,6 +1307,246 @@ const FORM_XML = `<templates>
</templates>
`;
const WMS = `// This example is slightly more complex than usual. We demonstrate
// here a way to manage sub windows in Owl, declaratively. This is still just a
// demonstration. Managing windows can be as complex as we want. For example,
// we could implement the following features:
// - resizing windows
// - minimizing windows
// - configuration options for windows to make a window non resizeable
// - minimal width/height
// - better heuristic for initial window position
// - ...
class HelloWorld extends owl.Component {}
class Counter extends owl.Component {
state = { value: 0 };
inc() {
this.state.value++;
}
}
class Window extends owl.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');
const offsetX = this.props.info.left - ev.pageX;
const offsetY = this.props.info.top - ev.pageY;
let left, top;
const el = this.el;
const self = this;
window.addEventListener("mousemove", moveWindow);
window.addEventListener("mouseup", stopDnD, { once: true });
function moveWindow(ev) {
left = Math.max(offsetX + ev.pageX, 0);
top = Math.max(offsetY + ev.pageY, 0);
el.style.left = \`\${left}px\`;
el.style.top = \`\${top}px\`;
}
function stopDnD() {
window.removeEventListener("mousemove", moveWindow);
const options = { id: self.props.info.id, left, top };
self.el.classList.remove('dragging');
self.trigger("set-window-position", options);
}
}
updateZIndex() {
this.trigger("update-z-index", { id: this.props.info.id });
}
}
class WindowManager extends owl.Component {
components = { Window };
windows = [];
nextId = 1;
currentZindex = 1;
addWindow(name) {
const info = this.env.windows.find(w => w.name === name);
const id = \`w\${this.nextId++}\`;
this.windows.push({
id: id,
title: info.title,
width: info.defaultWidth,
height: info.defaultHeight,
top: 0,
left: 0,
zindex: this.currentZindex++
});
this.components[id] = info.component;
this.render();
}
closeWindow(ev) {
const id = ev.detail.id;
delete this.components[id];
const index = this.windows.findIndex(w => w.id === id);
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);
w.zindex = this.currentZindex++;
ev.target.style["z-index"] = w.zindex;
}
}
class App extends owl.Component {
components = { WindowManager };
addWindow(name) {
this.refs.wm.addWindow(name);
}
}
const qweb = new owl.QWeb(TEMPLATES);
const windows = [
{
name: "Hello",
title: "Hello",
component: HelloWorld,
defaultWidth: 200,
defaultHeight: 100
},
{
name: "Counter",
title: "Click Counter",
component: Counter,
defaultWidth: 300,
defaultHeight: 120
}
];
const env = { qweb, windows };
const app = new App(env);
app.mount(document.body);
`;
const WMS_XML = `<templates>
<div t-name="Window" class="window" t-att-style="style" t-on-click="updateZIndex">
<div class="header">
<span t-on-mousedown="startDragAndDrop"><t t-esc="props.info.title"/></span>
<span class="close" t-on-click="close">×</span>
</div>
<t t-slot="default"/>
</div>
<div t-name="WindowManager" class="window-manager"
t-on-close-window="closeWindow"
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}}"/>
</Window>
</div>
<div t-name="App" class="app">
<WindowManager t-ref="wm"/>
<div class="menubar">
<button t-on-click="addWindow('Hello')">Say Hello</button>
<button t-on-click="addWindow('Counter')">Counter</button>
</div>
</div>
<div t-name="HelloWorld">
World
</div>
<div t-name="Counter" class="counter">
<button t-on-click="inc">Inc</button>
<span><t t-esc="state.value"/></span>
</div>
</templates>
`;
const WMS_CSS = `body {
margin: 0;
}
.app {
width: 100%;
height: 100%;
display: grid;
grid-template-rows: auto 50px;
}
.window-manager {
position: relative;
width: 100%;
height: 100%;
background-color: #eeeeee;
overflow: hidden;
}
.menubar {
background-color: #875a7b;
color: white;
}
.menubar button {
height: 40px;
font-size: 18px;
margin: 5px;
}
.window {
display: grid;
grid-template-rows: 30px auto;
border: 1px solid gray;
background-color: white;
position: absolute;
box-shadow: 1px 1px 2px 1px grey;
}
.window.dragging {
opacity: 0.75;
}
.window .header {
background-color: #875a7b;
display: grid;
grid-template-columns: auto 24px;
color: white;
line-height: 30px;
padding-left: 5px;
cursor: default;
user-select: none;
}
.window .header .close {
cursor: pointer;
font-size: 22px;
padding-left: 4px;
padding-right: 4px;
font-weight: bold;
}
.counter {
font-size: 20px;
}
.counter button {
width: 80px;
height:40px;
font-size: 20px;
}`;
export const SAMPLES = [
{
description: "Components",
@@ -1376,6 +1589,12 @@ export const SAMPLES = [
xml: SLOTS_XML,
css: SLOTS_CSS
},
{
description: "Window Management System",
code: WMS,
xml: WMS_XML,
css: WMS_CSS,
},
{
description: "Asynchronous components",
code: ASYNC_COMPONENTS,