Compare commits

...

26 Commits

Author SHA1 Message Date
Géry Debongnie c5951cc98e bump to v0.4.0
- rebranding to OWL
- much better documentation
- qweb compiler optimizations/cleanup
- many bug fixes
2019-03-28 09:51:40 +01:00
Géry Debongnie 68731a435b doc: improve the documentation, add a lot of information 2019-03-28 09:49:19 +01:00
Géry Debongnie 8e8d4a28c5 doc: add state management page, and update documentation 2019-03-27 17:02:35 +01:00
Géry Debongnie 8c5919cbc9 doc: fix error in main examples 2019-03-27 16:42:21 +01:00
Géry Debongnie 1efa65ff04 doc: add another example in readme.md 2019-03-27 15:40:24 +01:00
Géry Debongnie 7473d45700 doc: update readme to add a minimal hello world 2019-03-27 15:36:09 +01:00
Géry Debongnie 80e2124cbf imp: rename library into owl 2019-03-27 15:26:20 +01:00
Géry Debongnie e53287b52e ref: reorganize exports into core/extras 2019-03-27 15:18:25 +01:00
Géry Debongnie 2758269139 add patch/unpatch utility methods 2019-03-26 14:30:27 +01:00
Géry Debongnie 766d2f2301 ref: qweb context cleanup
Some code was uselessly duplicated.  We simplify in this commit the
handling of subcontexts.

closes #9
2019-03-22 15:02:07 +01:00
Géry Debongnie 597adb3981 ref: improve qweb implementation 2019-03-22 14:51:02 +01:00
Géry Debongnie 6ea9ccf56f fix: remove exprCache, as formatting is contextual
Formatting expressions now depends on the currently defined variables, so
it cannot be put in a cache.
2019-03-22 14:46:47 +01:00
Géry Debongnie a444e3dcac imp: export utils in main bundle 2019-03-22 12:08:55 +01:00
Géry Debongnie a7d2edd0c9 imp: qweb compiler now condenses whitespaces
This is a pretty big breaking change: the html output by qweb is now
different.

The main goal is to optimize the compiled templates:
- condense all consecutive whitespaces into a single spaces
- if possible, drop completely some text nodes, based on the following
  heuristic: if a text node is only composed of whitespaces, and
contains at least one linebreak, then it can be dropped.

This leads in my benchmark test to an improvement of about 10%, in
rendering speed and in memory consuption.

Note: whitespace here means anything that matches the \s regexp: newlines,
tabs, ...

close #8
2019-03-22 12:04:50 +01:00
Géry Debongnie d81e5d8f4f imp: add debug mode to store 2019-03-22 10:56:34 +01:00
Géry Debongnie d48cb5aae4 imp: work on store, refactor, improve todoapp 2019-03-22 10:47:59 +01:00
Géry Debongnie a236ae396a fix: make sure props are always defined
fix #7
2019-03-22 09:28:56 +01:00
Géry Debongnie add380f59b ref: move PureWidget back to core, use updateEnv 2019-03-21 16:33:08 +01:00
Géry Debongnie ad9fb77458 fix: force children to rerender when env changes 2019-03-21 16:06:06 +01:00
Géry Debongnie 4326d18961 imp: update build scripts 2019-03-21 14:51:54 +01:00
Géry Debongnie 88a3b6d25e add some tests to store class 2019-03-21 12:09:17 +01:00
Géry Debongnie a34fc39100 imp: allow object descriptors for attributes
close #6
2019-03-21 11:15:06 +01:00
Géry Debongnie a8b3ec7726 support using object variables in t-esc 2019-03-21 10:47:08 +01:00
Géry Debongnie d799e3dd47 prevent useless rerenders when updatingEnv on unmounted component
fix #4
2019-03-21 10:39:09 +01:00
Géry Debongnie b446d5d338 add src/ folder to jest config 2019-03-21 10:37:24 +01:00
Géry Debongnie 71c1469124 update examples doc 2019-03-21 10:23:44 +01:00
36 changed files with 903 additions and 694 deletions
+86 -24
View File
@@ -1,37 +1,34 @@
**Warning!**
# 🦉 Odoo Web Lab 🦉
This is currently a proof of concept, definitely not a production-ready codebase.
We hope to use it in the Odoo web client soon.
## Project Overview
---
Odoo Web Lab (OWL) is a project to collect some useful, reusable, (hopefully)
well designed building blocks for building web applications. However, since this is the basis for the Odoo web client, we will not hesitate
to design the code here to better match the Odoo architecture/design principles.
# Core Utility for Odoo Web Client
The most important element of this repository is certainly the component system.
It is designed to be:
## Overview
1. **declarative:** the user interface should be described in term of the state
of the application, not as a sequence of imperative steps.
This repository contains some useful building block for building web applications.
We made some efforts to make sure the code is reusable and generic. However, since this is the basis for the Odoo web client, we will not hesitate to design
the code here to better match the Odoo architecture/design principles.
2. **composable:** each widget can seamlessly be created in a parent widget by
a simple directive in its template.
Currently, this repository contains:
3. **asynchronous rendering:** the framework will transparently wait for each
subwidgets to be ready before applying the rendering. It uses native promises
under the hood.
- some utility functions/classes
- an implementation/extension of the QWeb template engine that outputs a virtual
dom (using the snabbdom library)
- a Component class, which uses the QWeb engine as its underlying rendering
mechanism. The component class is designed to be declarative, with
asynchronous rendering.
4. **uses QWeb as a template system:** the templates are described in XML
and follow the QWeb specification. This is a requirement for Odoo.
In the future, this repository may includes other features. Here are some possible
ideas:
- a (frontend) router could be included.
- a store base class (as in the flux/redux architecture)
5. **with an imperative escape hatch:** if necessary, sub widgets can easily be
manually created/destroyed.
Note: the code is written in typescript. This does not mean that the main web
client will ever be converted to typescript (even though I would really like it).
## Main scripts
## Installing/Building
To install every dependency needed to play with this code:
@@ -56,13 +53,78 @@ Note that the test scripts also run the example tests suites.
## Documentation
The complete documentation can be found [here](doc/readme.md). The most important sections are:
- [Quick Start](doc/quick_start.md)
- [Tutorial](doc/tutorial.md)
- [Component](doc/component.md)
- [QWeb](doc/qweb.md)
# Examples
There are two examples of how to work with this web framework:
Here is a minimal Hello World example:
- [Demo Application](examples/readme.md#demo)
```javascript
class HelloWorld extends owl.core.Component {
inlineTemplate = `<div>Hello <t t-esc="state.name"/></div>`;
}
const env = {
qweb: new owl.core.QWeb()
};
const hello = new HelloWorld(env, { name: "World" });
hello.mount(document.body);
```
The next example show how interactive widgets can be created and how widget
composition works:
```javascript
class Counter extends owl.core.Component {
inlineTemplate = `
<div>
<button t-on-click="increment(-1)">-</button>
<span style="font-weight:bold">Value: <t t-esc="state.value"/></span>
<button t-on-click="increment(1)">+</button>
</div>`;
constructor(parent, props) {
super(parent, props);
this.state = {
value: props.initialState || 0
};
}
increment(delta) {
this.updateState({ value: this.state.value + delta });
}
}
class App extends owl.core.Component {
inlineTemplate = `
<div>
<t t-widget="Counter" t-props="{initialState: 1}"/>
<t t-widget="Counter" t-props="{initialState: 42}"/>
</div>`;
widgets = { Counter };
}
const env = {
qweb: new owl.core.QWeb()
};
const app = new App(env);
app.mount(document.body);
```
More interesting examples on how to work with this web framework can be found in the _examples/_ folder:
- [Todo Application](examples/readme.md#todo-app)
- [Web Client](examples/readme.md#web-client-example)
- [Benchmarks](examples/readme.md#benchmarks)
## License
React is [GPL licensed](./LICENSE).
+8
View File
@@ -25,3 +25,11 @@ result = qweb.render("sometemplate", { name: "world" });
- t-on directive
- t-widget, t-props, t-key
- t-ref
## Note on white spaces
White spaces in a templates are handled in a special way:
- consecutive whitespaces are always condensed to a single whitespace
- if a whitespace-only text node contains a linebreak, it is ignored
- the previous rules do not apply if we are in a `<pre>` tag
+22
View File
@@ -0,0 +1,22 @@
# Odoo Web Lab Documentation
Currently, this repository contains:
1. **core**
- an implementation/extension of the QWeb template engine that outputs a virtual
dom (using the snabbdom library)
- a Component class, which uses the QWeb engine as its underlying rendering
mechanism. The component class is designed to be declarative, with
asynchronous rendering. Also, it uses snabbdom as the virtual dom library.
- some utility functions/classes
2. **extras**
- a Store class and a connect function, to help manage the state of an application (like react-redux)
- a Registry: this is a simple key/store mapping
- [Quick Start](quick_start.md)
- [Tutorial](tutorial.md)
- [Component](component.md)
- [QWeb](qweb.md)
- [State Management](state_management.md)
+8
View File
@@ -0,0 +1,8 @@
# OWL: State Management
Managing the state in an application is not an easy task. Many different
architectures/designs/systems/... have been created. We propose here to use
the idea of a central store.
- Store
- connect
+3
View File
@@ -0,0 +1,3 @@
# Tutorial
todo...
+1 -1
View File
@@ -25,7 +25,7 @@ const template = `
</div>
</div>`;
export class App extends odoo.core.Component {
export class App extends owl.core.Component {
constructor(parent, props) {
super(parent, props);
this.inlineTemplate = template;
+1 -1
View File
@@ -1,4 +1,4 @@
export class Counter extends odoo.core.Component {
export class Counter extends owl.core.Component {
inlineTemplate = `
<div>
<button t-on-click="increment(-1)">-</button>
+1 -1
View File
@@ -2,7 +2,7 @@ import { App } from "./app.js";
function createApp(el) {
const env = {
qweb: new odoo.core.QWeb()
qweb: new owl.core.QWeb()
};
const app = new App(env, { initialState: 13 });
app.mount(el);
+1 -1
View File
@@ -1,6 +1,6 @@
import { Counter } from "./counter.js";
export class Message extends odoo.core.Component {
export class Message extends owl.core.Component {
inlineTemplate = `
<div class="message">
<span class="author"><t t-esc="props.author"/></span>
+35 -13
View File
@@ -1,24 +1,46 @@
# Examples
To play with the examples:
This project features three example to illustrate how to work with the web-core framework:
- _benchmarks_ is a small application to test large number of widgets,
- _todoapp_ is the classical todo application (from the todomvc project),
- _web_ is a rethinking of how the odoo web client could be implemented.
## Benchmarks
This example is just a playground to experiment/showcase some features of the framework, with large number of widgets.
```
npm run example:build # make a build in dist/app/
npm run example:dev # make a build in dist/app/, and make a live server to access it
npm run example:benchmarks:build # make a build in dist/examples/
npm run example:benchmarks:dev # make a build in dist/examples/, and make a live server to access it
```
The _example_:dev\_ task will build the app (without minification), then start a live-server with hot-reloading, and watch the file system to make sure
that assets are properly rebuilt if necessary, and then reloaded.
The benchmarks application generates a large number of demo messages, and display them in a list, with a few buttons that can
be used to alter the number of visible widgets.
Note that each message is itself a widget, with a sub widget. This
example could be made faster (by not using subwidgets), but the point is to observe/measure the overhead of the Component class.
## Todo App
The Todo App is the classical todo application from _http://todomvc.com/_. It is a good mini application with non trivial data structures and interface updates.
```
npm run example:todoapp:build # make a build in dist/examples/
npm run example:todoapp:dev # make a build in dist/examples/, and make a live server to access it
```
It is implemented with the Store class (as in redux/vuex).
## Web Client Example
To test and play with the component class, there is a demo application located
in the _examples/web/_ folder. It is a rethink of what the web client could look like
if it is ever rebuilt from scratch. It is currently an experiment. But
obviously, we hope someday to be able to use this work and actually rewrite
completely the odoo web client.
The _web_ example,located in the _examples/web/_ folder is a rethink of what the
web client could look like if it is ever rebuilt from scratch. It is currently an experiment. But obviously, we hope someday to be able to use this work and
actually rewrite completely the odoo web client.
## Demo
```
npm run example:web:build # make a build in dist/examples/
npm run example:web:dev # make a build in dist/examples/, and make a live server to access it
```
This example is just a playground to experiment/showcase some features of
the framework.
Note that it is currently written in typescript.
+14 -10
View File
@@ -1,20 +1,21 @@
import { TodoItem } from "./TodoItem.js";
const { StoreMixin, Component } = odoo.core;
const { Component } = owl.core;
const { connect } = owl.extras;
const ENTER_KEY = 13;
export class TodoApp extends StoreMixin(Component) {
function mapStateToProps(state) {
return { todos: state.todos };
}
class TodoApp extends Component {
template = "todoapp";
widgets = { TodoItem };
state = { filter: "all" };
get todos() {
return this.env.store.state.todos;
}
get visibleTodos() {
let todos = this.todos;
let todos = this.props.todos;
if (this.state.filter === "active") {
todos = todos.filter(t => !t.completed);
}
@@ -25,15 +26,16 @@ export class TodoApp extends StoreMixin(Component) {
}
get allChecked() {
return this.todos.every(todo => todo.completed);
return this.props.todos.every(todo => todo.completed);
}
get remaining() {
return this.todos.filter(todo => !todo.completed).length;
return this.props.todos.filter(todo => !todo.completed).length;
}
get remainingText() {
return (this.remaining < 2 ? "item" : "items") + " left";
const items = this.remaining < 2 ? "item" : "items";
return ` ${items} left`;
}
addTodo(ev) {
@@ -54,3 +56,5 @@ export class TodoApp extends StoreMixin(Component) {
this.env.store.dispatch("toggleAll", !this.allChecked);
}
}
export default connect(mapStateToProps)(TodoApp);
+1 -1
View File
@@ -1,7 +1,7 @@
const ENTER_KEY = 13;
const ESC_KEY = 27;
export class TodoItem extends odoo.core.Component {
export class TodoItem extends owl.core.Component {
template = "todoitem";
state = { isEditing: false };
+2 -2
View File
@@ -1,4 +1,4 @@
import { TodoApp } from "./components/TodoApp.js";
import TodoApp from "./components/TodoApp.js";
import { makeStore } from "./store.js";
async function makeEnv() {
@@ -8,7 +8,7 @@ async function makeEnv() {
}
let templates = await result.text();
templates = templates.replace(/<!--[\s\S]*?-->/g, "");
const qweb = new odoo.core.QWeb();
const qweb = new owl.core.QWeb();
qweb.loadTemplates(templates);
return {
qweb,
+1 -1
View File
@@ -66,7 +66,7 @@ export function makeStore() {
);
const nextId = Math.max(0, ...todos.map(t => t.id || 0)) + 1;
const state = { todos, nextId };
const store = new odoo.core.Store({ state, actions, mutations });
const store = new owl.extras.Store({ state, actions, mutations });
store.on("update", null, () => {
const state = JSON.stringify(store.state.todos);
window.localStorage.setItem(LOCALSTORAGE_KEY, state);
+7 -7
View File
@@ -8,7 +8,7 @@
<input class="new-todo" autofocus="true" autocomplete="off" placeholder="What needs to be done?" t-on-keyup="addTodo"/>
</header>
<!-- main section -->
<section class="main" t-if="todos.length">
<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"/>
<label for="toggle-all"></label>
<ul class="todo-list">
@@ -18,7 +18,7 @@
</ul>
</section>
<!-- footer -->
<footer class="footer" t-if="todos.length">
<footer class="footer" t-if="props.todos.length">
<span class="todo-count">
<strong>
<t t-esc="remaining"/>
@@ -27,22 +27,22 @@
</span>
<ul class="filters">
<li>
<a href="#/all" t-on-click="updateState({filter:'all'})" t-att-class="state.filter === 'all' ? 'selected' : ''">All</a>
<a href="#/all" t-on-click="updateState({filter:'all'})" t-att-class="{selected: state.filter === 'all'}">All</a>
</li>
<li>
<a href="#/active" t-on-click="updateState({filter:'active'})" t-att-class="state.filter === 'active' ? 'selected' : ''">Active</a>
<a href="#/active" t-on-click="updateState({filter:'active'})" t-att-class="{selected: state.filter === 'active'}">Active</a>
</li>
<li>
<a href="#/completed" t-on-click="updateState({filter:'completed'})" t-att-class="state.filter === 'completed' ? 'selected' : ''">Completed</a>
<a href="#/completed" t-on-click="updateState({filter:'completed'})" t-att-class="{selected: state.filter === 'completed'}">Completed</a>
</li>
</ul>
<button class="clear-completed" t-if="todos.length gt remaining" t-on-click="clearCompleted">
<button class="clear-completed" t-if="props.todos.length gt remaining" t-on-click="clearCompleted">
Clear completed
</button>
</footer>
</section>
<li t-name="todoitem" class="todo" t-att-class="(props.completed ? 'completed ' : ' ') + (state.isEditing ? 'editing' : '')">
<li t-name="todoitem" class="todo" t-att-class="{completed: props.completed, editing: state.isEditing}">
<div class="view">
<input class="toggle" type="checkbox" t-on-change="toggleTodo" t-att-checked="props.completed"/>
<label t-on-dblclick="editTodo">
+1 -2
View File
@@ -40,8 +40,7 @@ export class Root extends Widget<Store, State> {
applyMobileInterface() {
const isMobile = window.innerWidth <= 768;
if (isMobile !== this.env.isMobile) {
this.env.isMobile = isMobile;
this.render();
this.updateEnv({ isMobile });
}
}
+3 -31
View File
@@ -1,38 +1,10 @@
import { Component } from "../../../../../src/component";
import { Component, PureComponent } from "../../../../../src/component";
import { Env } from "./env";
//------------------------------------------------------------------------------
// Widget classes
//------------------------------------------------------------------------------
export class Widget<P, S> extends Component<Env, P, S> {
constructor(parent, props) {
super(parent, props);
(<any>this.__widget__).isMobile = this.env.isMobile;
}
async updateProps(nextProps: P): Promise<void> {
if ((<any>this.__widget__).isMobile !== this.env.isMobile) {
(<any>this.__widget__).isMobile = this.env.isMobile;
return this._updateProps(nextProps);
}
return super.updateProps(nextProps);
}
}
export class Widget<P, S> extends Component<Env, P, S> {}
export class PureWidget<P, S> extends Widget<P, S> {
shouldUpdate(nextProps: P): boolean {
for (let k in nextProps) {
if (nextProps[k] !== this.props[k]) {
return true;
}
}
return false;
}
async updateState(nextState: Partial<S>) {
for (let k in nextState) {
if (nextState[k] !== this.state[k]) {
return super.updateState(nextState);
}
}
}
}
export class PureWidget<P, S> extends PureComponent<Env, P, S> {}
@@ -1,24 +1,3 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`can be rendered 1`] = `
"<div class=\\"o_home_menu\\">
<div class=\\"o_apps\\">
<a href=\\"#menu_id=96&amp;action_id=131\\" class=\\"o_app\\" data-menu=\\"96\\">
<i class=\\"fa fa-comment o_app_icon fa-3x fa-fw\\"></i>
<div class=\\"o_caption\\">
Discuss
</div>
</a><a href=\\"#menu_id=205&amp;action_id=250\\" class=\\"o_app\\" data-menu=\\"205\\">
<i class=\\"fa fa-pen o_app_icon fa-3x fa-fw\\"></i>
<div class=\\"o_caption\\">
Notes
</div>
</a><a href=\\"#menu_id=409&amp;action_id=597\\" class=\\"o_app\\" data-menu=\\"409\\">
<i class=\\"fa fa-handshake o_app_icon fa-3x fa-fw\\"></i>
<div class=\\"o_caption\\">
CRM
</div>
</a>
</div>
</div>"
`;
exports[`can be rendered 1`] = `"<div class=\\"o_home_menu\\"><div class=\\"o_apps\\"><a href=\\"#menu_id=96&amp;action_id=131\\" class=\\"o_app\\" data-menu=\\"96\\"><i class=\\"fa fa-comment o_app_icon fa-3x fa-fw\\"></i><div class=\\"o_caption\\">Discuss</div></a><a href=\\"#menu_id=205&amp;action_id=250\\" class=\\"o_app\\" data-menu=\\"205\\"><i class=\\"fa fa-pen o_app_icon fa-3x fa-fw\\"></i><div class=\\"o_caption\\">Notes</div></a><a href=\\"#menu_id=409&amp;action_id=597\\" class=\\"o_app\\" data-menu=\\"409\\"><i class=\\"fa fa-handshake o_app_icon fa-3x fa-fw\\"></i><div class=\\"o_caption\\">CRM</div></a></div></div>"`;
@@ -1,45 +1,9 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`can be rendered (in home menu, no app) 1`] = `
"<div class=\\"o_navbar o_in_home\\">
</div>"
`;
exports[`can be rendered (in home menu, no app) 1`] = `"<div class=\\"o_navbar o_in_home\\"></div>"`;
exports[`can be rendered (in home menu, one active app) 1`] = `
"<div class=\\"o_navbar o_in_home\\">
<a aria-label=\\"Applications\\" class=\\"o_title fa fa-chevron-left\\" href=\\"#\\" title=\\"Applications\\" accesskey=\\"h\\"></a>
</div>"
`;
exports[`can be rendered (in home menu, one active app) 1`] = `"<div class=\\"o_navbar o_in_home\\"><a aria-label=\\"Applications\\" class=\\"o_title fa fa-chevron-left\\" href=\\"#\\" title=\\"Applications\\" accesskey=\\"h\\"></a></div>"`;
exports[`can render one menu item 1`] = `
"<div class=\\"o_navbar\\">
<a aria-label=\\"Applications\\" class=\\"o_title fa fa-th\\" href=\\"#\\" title=\\"Applications\\" accesskey=\\"h\\"></a>
<a class=\\"o_menu_brand\\" href=\\"\\" role=\\"button\\">
Discuss
</a>
<ul class=\\"o_menu_sections\\">
</ul>
</div>"
`;
exports[`can render one menu item 1`] = `"<div class=\\"o_navbar\\"><a aria-label=\\"Applications\\" class=\\"o_title fa fa-th\\" href=\\"#\\" title=\\"Applications\\" accesskey=\\"h\\"></a><a class=\\"o_menu_brand\\" href=\\"\\" role=\\"button\\">Discuss</a><ul class=\\"o_menu_sections\\"></ul></div>"`;
exports[`mobile mode: navbar is different 1`] = `
"<div class=\\"o_navbar\\">
<a aria-label=\\"Applications\\" class=\\"o_title fa fa-th\\" href=\\"#\\" title=\\"Applications\\" accesskey=\\"h\\"></a>
<a class=\\"o_menu_brand\\" href=\\"\\" role=\\"button\\">
Notes
</a>
<ul class=\\"o_menu_sections\\">
<li>MOBILEMODE</li>
</ul>
</div>"
`;
exports[`mobile mode: navbar is different 1`] = `"<div class=\\"o_navbar\\"><a aria-label=\\"Applications\\" class=\\"o_title fa fa-th\\" href=\\"#\\" title=\\"Applications\\" accesskey=\\"h\\"></a><a class=\\"o_menu_brand\\" href=\\"\\" role=\\"button\\">Notes</a><ul class=\\"o_menu_sections\\"><li>MOBILEMODE</li></ul></div>"`;
@@ -1,27 +1,5 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`can be rendered (type = warning) 1`] = `
"<div class=\\"o_notification o_error\\">
<div class=\\"o_notification_title\\">
<span class=\\"o_icon fa fa-3x fa-exclamation\\" role=\\"img\\" aria-label=\\"Notification title\\" title=\\"Notification title\\"></span>
title
</div>
<div class=\\"o_notification_content\\">
message
</div>
</div>"
`;
exports[`can be rendered (type = warning) 1`] = `"<div class=\\"o_notification o_error\\"><div class=\\"o_notification_title\\"><span class=\\"o_icon fa fa-3x fa-exclamation\\" role=\\"img\\" aria-label=\\"Notification title\\" title=\\"Notification title\\"></span>title</div><div class=\\"o_notification_content\\">message</div></div>"`;
exports[`can be rendered 1`] = `
"<div class=\\"o_notification\\">
<div class=\\"o_notification_title\\">
<span class=\\"o_icon fa fa-3x fa-lightbulb\\" role=\\"img\\" aria-label=\\"Notification title\\" title=\\"Notification title\\"></span>
title
</div>
<div class=\\"o_notification_content\\">
message
</div>
</div>"
`;
exports[`can be rendered 1`] = `"<div class=\\"o_notification\\"><div class=\\"o_notification_title\\"><span class=\\"o_icon fa fa-3x fa-lightbulb\\" role=\\"img\\" aria-label=\\"Notification title\\" title=\\"Notification title\\"></span>title</div><div class=\\"o_notification_content\\">message</div></div>"`;
@@ -1,257 +1,11 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`can be rendered (in home menu) 1`] = `
"<div class=\\"o_web_client\\">
<div class=\\"o_navbar o_in_home\\">
</div>
<div class=\\"o_home_menu\\">
<div class=\\"o_apps\\">
<a href=\\"#menu_id=96&amp;action_id=131\\" class=\\"o_app\\" data-menu=\\"96\\">
<i class=\\"fa fa-comment o_app_icon fa-3x fa-fw\\"></i>
<div class=\\"o_caption\\">
Discuss
</div>
</a><a href=\\"#menu_id=205&amp;action_id=250\\" class=\\"o_app\\" data-menu=\\"205\\">
<i class=\\"fa fa-pen o_app_icon fa-3x fa-fw\\"></i>
<div class=\\"o_caption\\">
Notes
</div>
</a><a href=\\"#menu_id=409&amp;action_id=597\\" class=\\"o_app\\" data-menu=\\"409\\">
<i class=\\"fa fa-handshake o_app_icon fa-3x fa-fw\\"></i>
<div class=\\"o_caption\\">
CRM
</div>
</a>
</div>
</div>
<div class=\\"o_content o_hidden\\"></div>
<div class=\\"o_notification_container\\">
</div>
<div class=\\"o_loading d-none\\">Loading</div>
</div>"
`;
exports[`can be rendered (in home menu) 1`] = `"<div class=\\"o_web_client\\"><div class=\\"o_navbar o_in_home\\"></div><div class=\\"o_home_menu\\"><div class=\\"o_apps\\"><a href=\\"#menu_id=96&amp;action_id=131\\" class=\\"o_app\\" data-menu=\\"96\\"><i class=\\"fa fa-comment o_app_icon fa-3x fa-fw\\"></i><div class=\\"o_caption\\">Discuss</div></a><a href=\\"#menu_id=205&amp;action_id=250\\" class=\\"o_app\\" data-menu=\\"205\\"><i class=\\"fa fa-pen o_app_icon fa-3x fa-fw\\"></i><div class=\\"o_caption\\">Notes</div></a><a href=\\"#menu_id=409&amp;action_id=597\\" class=\\"o_app\\" data-menu=\\"409\\"><i class=\\"fa fa-handshake o_app_icon fa-3x fa-fw\\"></i><div class=\\"o_caption\\">CRM</div></a></div></div><div class=\\"o_content o_hidden\\"></div><div class=\\"o_notification_container\\"></div><div class=\\"o_loading d-none\\">Loading</div></div>"`;
exports[`clicks on client action with invalid key => empty widget is rendered + warning 1`] = `
"<div class=\\"o_web_client\\">
<div class=\\"o_navbar\\">
<a aria-label=\\"Applications\\" class=\\"o_title fa fa-th\\" href=\\"#\\" title=\\"Applications\\" accesskey=\\"h\\"></a>
<a class=\\"o_menu_brand\\" href=\\"\\" role=\\"button\\">
Discuss
</a>
<ul class=\\"o_menu_sections\\">
</ul>
</div>
<div class=\\"o_content\\"><div class=\\"o_action_controller\\"></div></div>
<div class=\\"o_notification_container\\">
<div class=\\"o_notification o_error\\">
<div class=\\"o_notification_title\\">
<span class=\\"o_icon fa fa-3x fa-exclamation\\" role=\\"img\\" aria-label=\\"Notification Invalid Client Action\\" title=\\"Notification Invalid Client Action\\"></span>
Invalid Client Action
</div>
<div class=\\"o_notification_content\\">
Cannot find widget 'mail.discuss' in the action registry
</div>
</div>
</div>
<div class=\\"o_loading d-none\\">Loading</div>
</div>"
`;
exports[`clicks on client action with invalid key => empty widget is rendered + warning 1`] = `"<div class=\\"o_web_client\\"><div class=\\"o_navbar\\"><a aria-label=\\"Applications\\" class=\\"o_title fa fa-th\\" href=\\"#\\" title=\\"Applications\\" accesskey=\\"h\\"></a><a class=\\"o_menu_brand\\" href=\\"\\" role=\\"button\\">Discuss</a><ul class=\\"o_menu_sections\\"></ul></div><div class=\\"o_content\\"><div class=\\"o_action_controller\\"></div></div><div class=\\"o_notification_container\\"><div class=\\"o_notification o_error\\"><div class=\\"o_notification_title\\"><span class=\\"o_icon fa fa-3x fa-exclamation\\" role=\\"img\\" aria-label=\\"Notification Invalid Client Action\\" title=\\"Notification Invalid Client Action\\"></span>Invalid Client Action</div><div class=\\"o_notification_content\\">Cannot find widget 'mail.discuss' in the action registry</div></div></div><div class=\\"o_loading d-none\\">Loading</div></div>"`;
exports[`if url has action_id, will render action and navigate to proper menu_id 1`] = `
"<div class=\\"o_web_client\\">
<div class=\\"o_navbar\\">
<a aria-label=\\"Applications\\" class=\\"o_title fa fa-th\\" href=\\"#\\" title=\\"Applications\\" accesskey=\\"h\\"></a>
<a class=\\"o_menu_brand\\" href=\\"\\" role=\\"button\\">
Discuss
</a>
<ul class=\\"o_menu_sections\\">
</ul>
</div>
<div class=\\"o_content\\"><div class=\\"o_discuss o_action_controller\\">
<div class=\\"o_control_panel\\">
<ol class=\\"breadcrumb\\" role=\\"navigation\\">
<li class=\\"breadcrumb-item active\\">
Discuss
</li>
</ol>
<div class=\\"o_cp_searchview\\" role=\\"search\\">
<div class=\\"o_searchview\\" role=\\"search\\" aria-autocomplete=\\"list\\">
<span class=\\"o_searchview_more fa\\" title=\\"Advanced Search...\\" role=\\"img\\" aria-label=\\"Advanced Search...\\"></span>
</div>
</div>
<div class=\\"o_cp_left\\">
<div class=\\"o_cp_buttons\\" role=\\"toolbar\\" aria-label=\\"Control panel toolbar\\"></div>
<aside class=\\"o_cp_sidebar\\"></aside>
</div>
<div class=\\"o_cp_right\\">
<div class=\\"btn-group o_search_options\\" role=\\"search\\"></div>
<nav class=\\"o_cp_pager\\" role=\\"search\\" aria-label=\\"Pager\\"></nav>
<nav class=\\"btn-group o_cp_switch_buttons\\" role=\\"toolbar\\" aria-label=\\"View switcher\\"></nav>
</div>
</div>
<div class=\\"o_content\\">
<div class=\\"o_discuss_sidebar\\">
<span>DISCUSS!!</span>
</div>
<div class=\\"o_discuss_content\\">
<button>Reset first counter</button>
<button>Reset counter 2 in 3s</button>
<button>Toggle Clock/counters</button>
<button>Toggle Color</button>
<button>Rerender this widget</button>
<input>
<div>
<button>-</button>
<span style=\\"font-weight:bold\\">Value: 4</span>
<button>+</button>
</div>
<div>
<button>-</button>
<span style=\\"font-weight:bold\\">Value: 400</span>
<button>+</button>
</div>
<div>
<span>Current Color: </span>
red
</div>
<button>Add notif</button>
<button>Add sticky notif</button>
<button>Add warning</button>
</div>
</div>
</div></div>
<div class=\\"o_notification_container\\">
</div>
<div class=\\"o_loading d-none\\">Loading</div>
</div>"
`;
exports[`if url has action_id, will render action and navigate to proper menu_id 1`] = `"<div class=\\"o_web_client\\"><div class=\\"o_navbar\\"><a aria-label=\\"Applications\\" class=\\"o_title fa fa-th\\" href=\\"#\\" title=\\"Applications\\" accesskey=\\"h\\"></a><a class=\\"o_menu_brand\\" href=\\"\\" role=\\"button\\">Discuss</a><ul class=\\"o_menu_sections\\"></ul></div><div class=\\"o_content\\"><div class=\\"o_discuss o_action_controller\\"><div class=\\"o_control_panel\\"><ol class=\\"breadcrumb\\" role=\\"navigation\\"><li class=\\"breadcrumb-item active\\">Discuss</li></ol><div class=\\"o_cp_searchview\\" role=\\"search\\"><div class=\\"o_searchview\\" role=\\"search\\" aria-autocomplete=\\"list\\"><span class=\\"o_searchview_more fa\\" title=\\"Advanced Search...\\" role=\\"img\\" aria-label=\\"Advanced Search...\\"></span></div></div><div class=\\"o_cp_left\\"><div class=\\"o_cp_buttons\\" role=\\"toolbar\\" aria-label=\\"Control panel toolbar\\"></div><aside class=\\"o_cp_sidebar\\"></aside></div><div class=\\"o_cp_right\\"><div class=\\"btn-group o_search_options\\" role=\\"search\\"></div><nav class=\\"o_cp_pager\\" role=\\"search\\" aria-label=\\"Pager\\"></nav><nav class=\\"btn-group o_cp_switch_buttons\\" role=\\"toolbar\\" aria-label=\\"View switcher\\"></nav></div></div><div class=\\"o_content\\"><div class=\\"o_discuss_sidebar\\"><span>DISCUSS!!</span></div><div class=\\"o_discuss_content\\"><button>Reset first counter</button><button>Reset counter 2 in 3s</button><button>Toggle Clock/counters</button><button>Toggle Color</button><button>Rerender this widget</button><input><div><button>-</button><span style=\\"font-weight:bold\\">Value: 4</span><button>+</button></div><div><button>-</button><span style=\\"font-weight:bold\\">Value: 400</span><button>+</button></div><div><span>Current Color: </span>red</div><button>Add notif</button><button>Add sticky notif</button><button>Add warning</button></div></div></div></div><div class=\\"o_notification_container\\"></div><div class=\\"o_loading d-none\\">Loading</div></div>"`;
exports[`open act window action with invalid viewtype => empty widget is rendered + warning 1`] = `
"<div class=\\"o_web_client\\">
<div class=\\"o_navbar\\">
<a aria-label=\\"Applications\\" class=\\"o_title fa fa-th\\" href=\\"#\\" title=\\"Applications\\" accesskey=\\"h\\"></a>
<a class=\\"o_menu_brand\\" href=\\"\\" role=\\"button\\">
Notes
</a>
<ul class=\\"o_menu_sections\\">
</ul>
</div>
<div class=\\"o_content\\"><div class=\\"o_action_controller\\"></div></div>
<div class=\\"o_notification_container\\">
<div class=\\"o_notification o_error\\">
<div class=\\"o_notification_title\\">
<span class=\\"o_icon fa fa-3x fa-exclamation\\" role=\\"img\\" aria-label=\\"Notification Invalid View type\\" title=\\"Notification Invalid View type\\"></span>
Invalid View type
</div>
<div class=\\"o_notification_content\\">
Cannot find view of type 'kanban' in the view registry
</div>
</div>
</div>
<div class=\\"o_loading d-none\\">Loading</div>
</div>"
`;
exports[`open act window action with invalid viewtype => empty widget is rendered + warning 1`] = `"<div class=\\"o_web_client\\"><div class=\\"o_navbar\\"><a aria-label=\\"Applications\\" class=\\"o_title fa fa-th\\" href=\\"#\\" title=\\"Applications\\" accesskey=\\"h\\"></a><a class=\\"o_menu_brand\\" href=\\"\\" role=\\"button\\">Notes</a><ul class=\\"o_menu_sections\\"></ul></div><div class=\\"o_content\\"><div class=\\"o_action_controller\\"></div></div><div class=\\"o_notification_container\\"><div class=\\"o_notification o_error\\"><div class=\\"o_notification_title\\"><span class=\\"o_icon fa fa-3x fa-exclamation\\" role=\\"img\\" aria-label=\\"Notification Invalid View type\\" title=\\"Notification Invalid View type\\"></span>Invalid View type</div><div class=\\"o_notification_content\\">Cannot find view of type 'kanban' in the view registry</div></div></div><div class=\\"o_loading d-none\\">Loading</div></div>"`;
exports[`start with no action => clicks on client action => discuss is rendered 1`] = `
"<div class=\\"o_web_client\\">
<div class=\\"o_navbar\\">
<a aria-label=\\"Applications\\" class=\\"o_title fa fa-th\\" href=\\"#\\" title=\\"Applications\\" accesskey=\\"h\\"></a>
<a class=\\"o_menu_brand\\" href=\\"\\" role=\\"button\\">
Discuss
</a>
<ul class=\\"o_menu_sections\\">
</ul>
</div>
<div class=\\"o_content\\"><div class=\\"o_discuss o_action_controller\\">
<div class=\\"o_control_panel\\">
<ol class=\\"breadcrumb\\" role=\\"navigation\\">
<li class=\\"breadcrumb-item active\\">
Discuss
</li>
</ol>
<div class=\\"o_cp_searchview\\" role=\\"search\\">
<div class=\\"o_searchview\\" role=\\"search\\" aria-autocomplete=\\"list\\">
<span class=\\"o_searchview_more fa\\" title=\\"Advanced Search...\\" role=\\"img\\" aria-label=\\"Advanced Search...\\"></span>
</div>
</div>
<div class=\\"o_cp_left\\">
<div class=\\"o_cp_buttons\\" role=\\"toolbar\\" aria-label=\\"Control panel toolbar\\"></div>
<aside class=\\"o_cp_sidebar\\"></aside>
</div>
<div class=\\"o_cp_right\\">
<div class=\\"btn-group o_search_options\\" role=\\"search\\"></div>
<nav class=\\"o_cp_pager\\" role=\\"search\\" aria-label=\\"Pager\\"></nav>
<nav class=\\"btn-group o_cp_switch_buttons\\" role=\\"toolbar\\" aria-label=\\"View switcher\\"></nav>
</div>
</div>
<div class=\\"o_content\\">
<div class=\\"o_discuss_sidebar\\">
<span>DISCUSS!!</span>
</div>
<div class=\\"o_discuss_content\\">
<button>Reset first counter</button>
<button>Reset counter 2 in 3s</button>
<button>Toggle Clock/counters</button>
<button>Toggle Color</button>
<button>Rerender this widget</button>
<input>
<div>
<button>-</button>
<span style=\\"font-weight:bold\\">Value: 4</span>
<button>+</button>
</div>
<div>
<button>-</button>
<span style=\\"font-weight:bold\\">Value: 400</span>
<button>+</button>
</div>
<div>
<span>Current Color: </span>
red
</div>
<button>Add notif</button>
<button>Add sticky notif</button>
<button>Add warning</button>
</div>
</div>
</div></div>
<div class=\\"o_notification_container\\">
</div>
<div class=\\"o_loading d-none\\">Loading</div>
</div>"
`;
exports[`start with no action => clicks on client action => discuss is rendered 1`] = `"<div class=\\"o_web_client\\"><div class=\\"o_navbar\\"><a aria-label=\\"Applications\\" class=\\"o_title fa fa-th\\" href=\\"#\\" title=\\"Applications\\" accesskey=\\"h\\"></a><a class=\\"o_menu_brand\\" href=\\"\\" role=\\"button\\">Discuss</a><ul class=\\"o_menu_sections\\"></ul></div><div class=\\"o_content\\"><div class=\\"o_discuss o_action_controller\\"><div class=\\"o_control_panel\\"><ol class=\\"breadcrumb\\" role=\\"navigation\\"><li class=\\"breadcrumb-item active\\">Discuss</li></ol><div class=\\"o_cp_searchview\\" role=\\"search\\"><div class=\\"o_searchview\\" role=\\"search\\" aria-autocomplete=\\"list\\"><span class=\\"o_searchview_more fa\\" title=\\"Advanced Search...\\" role=\\"img\\" aria-label=\\"Advanced Search...\\"></span></div></div><div class=\\"o_cp_left\\"><div class=\\"o_cp_buttons\\" role=\\"toolbar\\" aria-label=\\"Control panel toolbar\\"></div><aside class=\\"o_cp_sidebar\\"></aside></div><div class=\\"o_cp_right\\"><div class=\\"btn-group o_search_options\\" role=\\"search\\"></div><nav class=\\"o_cp_pager\\" role=\\"search\\" aria-label=\\"Pager\\"></nav><nav class=\\"btn-group o_cp_switch_buttons\\" role=\\"toolbar\\" aria-label=\\"View switcher\\"></nav></div></div><div class=\\"o_content\\"><div class=\\"o_discuss_sidebar\\"><span>DISCUSS!!</span></div><div class=\\"o_discuss_content\\"><button>Reset first counter</button><button>Reset counter 2 in 3s</button><button>Toggle Clock/counters</button><button>Toggle Color</button><button>Rerender this widget</button><input><div><button>-</button><span style=\\"font-weight:bold\\">Value: 4</span><button>+</button></div><div><button>-</button><span style=\\"font-weight:bold\\">Value: 400</span><button>+</button></div><div><span>Current Color: </span>red</div><button>Add notif</button><button>Add sticky notif</button><button>Add warning</button></div></div></div></div><div class=\\"o_notification_container\\"></div><div class=\\"o_loading d-none\\">Loading</div></div>"`;
+1 -1
View File
@@ -1,5 +1,5 @@
module.exports = {
roots: ["<rootDir>/examples/web/static", "<rootDir>/tests"],
roots: ["<rootDir>/src", "<rootDir>/examples/web/static", "<rootDir>/tests"],
transform: {
"^.+\\.ts?$": "ts-jest"
},
+8 -6
View File
@@ -1,12 +1,14 @@
{
"name": "web-core",
"version": "0.3.0",
"description": "Core Utils for Odoo Web Client",
"name": "owl",
"version": "0.4.0",
"description": "Odoo Web Lab",
"main": "src/index.ts",
"scripts": {
"prebuild": "tsc --lib es2017,dom --target esnext --outDir dist/core src/*",
"build": "rollup -c",
"postbuild": "uglifyjs dist/core.js -o dist/core.min.js --compress --mangle",
"build:js": "tsc --lib es2017,dom --target esnext --outDir dist/core src/*",
"build:bundle": "rollup -c",
"build": "npm run build:js && npm run build:bundle",
"minify": "uglifyjs dist/core.js -o dist/core.min.js --compress --mangle",
"dev": "npm-run-all --parallel \"build:* -- --watch\"",
"test": "jest",
"test:watch": "jest --watch",
"example:web:build": "cpx \"examples/web/**/*.{html,js,css,ttf,woff,woff2}\" dist/examples/web && npm-run-all --parallel example:web:build:*",
+2 -2
View File
@@ -7,8 +7,8 @@ export default {
output: {
file: "dist/core.js",
format: "iife",
name: "odoo",
name: "owl",
extend: true,
outro: `exports.core._version = '${version}';\nexports.core._date = '${new Date().toISOString()}';\nexports.core._hash = '${git.short()}';`
outro: `exports._version = '${version}';\nexports._date = '${new Date().toISOString()}';\nexports._hash = '${git.short()}';`
}
};
+34 -10
View File
@@ -48,7 +48,7 @@ export interface Type<T> extends Function {
export class Component<
T extends Env,
Props,
Props extends {},
State extends {}
> extends EventBus {
readonly __widget__: Meta<Env, Props>;
@@ -96,7 +96,7 @@ export class Component<
// Pro: if props is empty, we can create easily a widget
// Con: this is not really safe
// Pro: but creating widget (by a template) is always unsafe anyway
this.props = <Props>props;
this.props = <Props>props || <Props>{};
let id: number = getId();
let p: Component<T, any, any> | null = null;
if (parent instanceof Component) {
@@ -224,11 +224,11 @@ export class Component<
}
}
async render(): Promise<void> {
async render(force: boolean = false): Promise<void> {
if (this.__widget__.isDestroyed) {
return;
}
const renderVDom = this._render();
const renderVDom = this._render(force);
const renderId = this.__widget__.renderId;
const vnode = await renderVDom;
if (renderId === this.__widget__.renderId) {
@@ -280,15 +280,20 @@ export class Component<
this.env = Object.create(this.env);
}
Object.assign(this.env, nextEnv);
return this.render();
if (this.__widget__.isMounted) {
return this.render(true);
}
}
async updateProps(nextProps: Props): Promise<void> {
if (nextProps === this.__widget__.renderProps) {
async updateProps(
nextProps: Props,
forceUpdate: boolean = false
): Promise<void> {
if (nextProps === this.__widget__.renderProps && !forceUpdate) {
await this.__widget__.renderPromise;
return;
}
const shouldUpdate = this.shouldUpdate(nextProps);
const shouldUpdate = forceUpdate || this.shouldUpdate(nextProps);
return shouldUpdate ? this._updateProps(nextProps) : Promise.resolve();
}
@@ -346,13 +351,14 @@ export class Component<
return this.__widget__.renderPromise;
}
async _render(): Promise<VNode> {
async _render(force: boolean = false): Promise<VNode> {
this.__widget__.renderId++;
const promises: Promise<void>[] = [];
const template = this.inlineTemplate || this.template;
let vnode = this.env.qweb.render(template, this, {
promises,
handlers: this.__widget__.boundHandlers
handlers: this.__widget__.boundHandlers,
forceUpdate: force
});
// this part is critical for the patching process to be done correctly. The
@@ -401,3 +407,21 @@ export class Component<
}
}
}
export class PureComponent<E extends Env, P, S> extends Component<E, P, S> {
shouldUpdate(nextProps: P): boolean {
for (let k in nextProps) {
if (nextProps[k] !== this.props[k]) {
return true;
}
}
return false;
}
async updateState(nextState: Partial<S>) {
for (let k in nextState) {
if (nextState[k] !== this.state[k]) {
return super.updateState(nextState);
}
}
}
}
+18 -4
View File
@@ -1,6 +1,20 @@
import { QWeb } from "./qweb";
import { Component, PureComponent } from "./component";
import { EventBus } from "./event_bus";
import { Component } from "./component";
import { Store, StoreMixin } from "./store";
import { QWeb } from "./qweb";
import { Registry } from "./registry";
import { connect, Store } from "./store";
import * as utils from "./utils";
export const core = { QWeb, EventBus, Component, Store, StoreMixin };
export const core = {
QWeb,
EventBus,
Component,
PureComponent,
utils
};
export const extras = {
Store,
connect,
Registry
};
+115 -102
View File
@@ -32,6 +32,9 @@ const DISABLED_TAGS = [
"optgroup"
];
const lineBreakRE = /[\r\n]/;
const whitespaceRE = /\s+/g;
//------------------------------------------------------------------------------
// Compilation Context
//------------------------------------------------------------------------------
@@ -49,6 +52,7 @@ export class Context {
shouldDefineOwner: boolean = false;
shouldProtectContext: boolean = false;
inLoop: boolean = false;
inPreTag: boolean = false;
constructor() {
this.rootContext = this;
@@ -61,38 +65,18 @@ export class Context {
}
withParent(node: number): Context {
const newContext: Context = Object.create(this);
if (this === this.rootContext && this.parentNode) {
throw new Error("A template should not have more than one root node");
}
newContext.parentNode = node;
if (!this.rootContext.rootNode) {
this.rootContext.rootNode = node;
}
return newContext;
return this.subContext("parentNode", node);
}
withVariables(variables: { [key: string]: any }) {
subContext(key: keyof Context, value: any): Context {
const newContext = Object.create(this);
newContext.variables = Object.create(variables);
return newContext;
}
withInLoop(): Context {
const newContext = Object.create(this);
newContext.inLoop = true;
return newContext;
}
withCaller(node: Element): Context {
const newContext = Object.create(this);
newContext.caller = node;
return newContext;
}
withEscaping(): Context {
const newContext = Object.create(this);
newContext.escaping = true;
newContext[key] = value;
return newContext;
}
@@ -128,6 +112,65 @@ export class Context {
getValue(val: any): any {
return val in this.variables ? this.getValue(this.variables[val]) : val;
}
formatExpression(e: string): string {
e = e.trim();
if (e[0] === "{" && e[e.length - 1] === "}") {
const innerExpr = e
.slice(1, -1)
.split(",")
.map(p => {
let [key, val] = p.trim().split(":");
if (key === "") {
return "";
}
if (!val) {
val = key;
}
return `${key}: ${this.formatExpression(val)}`;
})
.join(",");
return "{" + innerExpr + "}";
}
// Thanks CHM for this code...
const chars = e.split("");
let instring = "";
let invar = "";
let invarPos = 0;
let r = "";
chars.push(" ");
for (var i = 0, ilen = chars.length; i < ilen; i++) {
var c = chars[i];
if (instring.length) {
if (c === instring && chars[i - 1] !== "\\") {
instring = "";
}
} else if (c === '"' || c === "'") {
instring = c;
} else if (c.match(/[a-zA-Z_\$]/) && !invar.length) {
invar = c;
invarPos = i;
continue;
} else if (c.match(/\W/) && invar.length) {
// TODO: Should check for possible spaces before dot
if (chars[invarPos - 1] !== "." && RESERVED_WORDS.indexOf(invar) < 0) {
invar =
WORD_REPLACEMENT[invar] ||
(invar in this.variables && this.variables[invar]) ||
"context['" + invar + "']";
}
r += invar;
invar = "";
} else if (invar.length) {
invar += c;
continue;
}
r += c;
}
const result = r.slice(0, -1);
return result;
}
}
//------------------------------------------------------------------------------
@@ -137,7 +180,6 @@ export class Context {
export class QWeb {
processedTemplates: { [name: string]: ProcessedTemplate } = {};
templates: { [name: string]: CompiledTemplate<VNode> } = {};
exprCache: { [key: string]: string } = {};
directives: Directive[] = [];
constructor() {
@@ -162,6 +204,15 @@ export class QWeb {
const temp = document.createElement("template");
temp.innerHTML = str;
return temp.content;
},
objectToAttrString(obj: Object): string {
let classes: string[] = [];
for (let k in obj) {
if (obj[k]) {
classes.push(k);
}
}
return classes.join(" ");
}
};
@@ -327,6 +378,12 @@ export class QWeb {
if (!(node instanceof Element)) {
// this is a text node, there are no directive to apply
let text = node.textContent!;
if (!ctx.inPreTag) {
if (lineBreakRE.test(text) && !text.trim()) {
return;
}
text = text.replace(whitespaceRE, " ");
}
if (ctx.parentNode) {
ctx.addLine(`c${ctx.parentNode}.push({text: \`${text}\`});`);
} else {
@@ -401,6 +458,9 @@ export class QWeb {
}
}
}
if (node.nodeName === "pre") {
ctx = ctx.subContext("inPreTag", true);
}
this._compileChildren(node, ctx);
@@ -469,7 +529,13 @@ export class QWeb {
// dynamic attributes
if (name.startsWith("t-att-")) {
let attName = name.slice(6);
let formattedValue = this._formatExpression(ctx.getValue(value!));
let formattedValue = ctx.formatExpression(ctx.getValue(value!));
if (
formattedValue[0] === "{" &&
formattedValue[formattedValue.length - 1] === "}"
) {
formattedValue = `this.utils.objectToAttrString(${formattedValue})`;
}
const attID = ctx.generateID();
if (!attName.match(/^[a-zA-Z]+$/)) {
// attribute contains 'non letters' => we want to quote it
@@ -482,6 +548,10 @@ export class QWeb {
const attValueID = ctx.generateID();
ctx.addLine(`let _${attValueID} = ${formattedValue};`);
formattedValue = `'${attValue}' + (_${attValueID} ? ' ' + _${attValueID} : '')`;
const attrIndex = attrs.findIndex(att =>
att.startsWith(attName + ":")
);
attrs.splice(attrIndex, 1);
}
ctx.addLine(`let _${attID} = ${formattedValue};`);
attrs.push(`${attName}: _${attID}`);
@@ -496,7 +566,7 @@ export class QWeb {
}
const formattedExpr = value!.replace(
/\{\{.*?\}\}/g,
s => "${" + this._formatExpression(s.slice(2, -2)) + "}"
s => "${" + ctx.formatExpression(s.slice(2, -2)) + "}"
);
const attID = ctx.generateID();
ctx.addLine(`let _${attID} = \`${formattedExpr}\`;`);
@@ -506,7 +576,7 @@ export class QWeb {
// t-att= attributes
if (name === "t-att") {
let id = ctx.generateID();
ctx.addLine(`let _${id} = ${this._formatExpression(value!)};`);
ctx.addLine(`let _${id} = ${ctx.formatExpression(value!)};`);
tattrs.push(id);
}
}
@@ -551,69 +621,6 @@ export class QWeb {
}
}
}
_formatExpression(e: string, ctx?: Context): string {
e = e.trim();
if (e in this.exprCache) {
return this.exprCache[e];
}
if (e[0] === "{" && e[e.length - 1] === "}") {
const innerExpr = e
.slice(1, -1)
.split(",")
.map(p => {
let [key, val] = p.trim().split(":");
if (key === "") {
return "";
}
if (!val) {
val = key;
}
return `${key}: ${this._formatExpression(val, ctx)}`;
})
.join(",");
return "{" + innerExpr + "}";
}
// Thanks CHM for this code...
const chars = e.split("");
let instring = "";
let invar = "";
let invarPos = 0;
let r = "";
chars.push(" ");
for (var i = 0, ilen = chars.length; i < ilen; i++) {
var c = chars[i];
if (instring.length) {
if (c === instring && chars[i - 1] !== "\\") {
instring = "";
}
} else if (c === '"' || c === "'") {
instring = c;
} else if (c.match(/[a-zA-Z_\$]/) && !invar.length) {
invar = c;
invarPos = i;
continue;
} else if (c.match(/\W/) && invar.length) {
// TODO: Should check for possible spaces before dot
if (chars[invarPos - 1] !== "." && RESERVED_WORDS.indexOf(invar) < 0) {
invar =
WORD_REPLACEMENT[invar] ||
(ctx && invar in ctx.variables && ctx.variables[invar]) ||
"context['" + invar + "']";
}
r += invar;
invar = "";
} else if (invar.length) {
invar += c;
continue;
}
r += c;
}
const result = r.slice(0, -1);
this.exprCache[e] = result;
return result;
}
}
//------------------------------------------------------------------------------
@@ -647,7 +654,7 @@ function compileValueNode(value: any, node: Element, qweb: QWeb, ctx: Context) {
if (typeof value === "string") {
const exprID = ctx.generateID();
ctx.addLine(`let e${exprID} = ${qweb._formatExpression(value)};`);
ctx.addLine(`let e${exprID} = ${ctx.formatExpression(value)};`);
ctx.addIf(`e${exprID} || e${exprID} === 0`);
let text = `e${exprID}`;
@@ -691,7 +698,7 @@ const escDirective: Directive = {
ctx = ctx.withParent(nodeID);
}
let value = ctx.getValue(node.getAttribute("t-esc")!);
compileValueNode(value, node, qweb, ctx.withEscaping());
compileValueNode(value, node, qweb, ctx.subContext("escaping", true));
return true;
}
};
@@ -730,7 +737,7 @@ const ifDirective: Directive = {
priority: 20,
atNodeEncounter({ node, qweb, ctx }): boolean {
let cond = ctx.getValue(node.getAttribute("t-if")!);
ctx.addIf(`${qweb._formatExpression(cond)}`);
ctx.addIf(`${ctx.formatExpression(cond)}`);
return false;
},
finalize({ ctx }) {
@@ -743,7 +750,7 @@ const elifDirective: Directive = {
priority: 30,
atNodeEncounter({ node, qweb, ctx }): boolean {
let cond = ctx.getValue(node.getAttribute("t-elif")!);
ctx.addLine(`else if (${qweb._formatExpression(cond)}) {`);
ctx.addLine(`else if (${ctx.formatExpression(cond)}) {`);
ctx.indent();
return false;
},
@@ -784,7 +791,9 @@ const callDirective: Directive = {
const tempCtx = new Context();
qweb._compileNode(nodeCopy, tempCtx);
const vars = Object.assign({}, ctx.variables, tempCtx.variables);
const subCtx = ctx.withCaller(nodeCopy).withVariables(vars);
const subCtx = ctx
.subContext("caller", nodeCopy)
.subContext("variables", Object.create(vars));
qweb._compileNode(nodeTemplate, subCtx);
return true;
@@ -796,11 +805,11 @@ const forEachDirective: Directive = {
priority: 10,
atNodeEncounter({ node, qweb, ctx }): boolean {
ctx.rootContext.shouldProtectContext = true;
ctx = ctx.withInLoop();
ctx = ctx.subContext("inLoop", true);
const elems = node.getAttribute("t-foreach")!;
const name = node.getAttribute("t-as")!;
let arrayID = ctx.generateID();
ctx.addLine(`let _${arrayID} = ${qweb._formatExpression(elems)};`);
ctx.addLine(`let _${arrayID} = ${ctx.formatExpression(elems)};`);
ctx.addLine(
`if (!_${arrayID}) { throw new Error('QWeb error: Invalid loop expression')}`
);
@@ -845,7 +854,7 @@ const onDirective: Directive = {
});
if (extraArgs) {
ctx.addLine(
`p${nodeID}.on['${eventName}'] = context['${handler}'].bind(owner, ${qweb._formatExpression(
`p${nodeID}.on['${eventName}'] = context['${handler}'].bind(owner, ${ctx.formatExpression(
extraArgs
)});`
);
@@ -896,11 +905,11 @@ const widgetDirective: Directive = {
} else {
key = node.getAttribute("t-att-key");
if (key) {
key = qweb._formatExpression(key);
key = ctx.formatExpression(key);
}
}
if (props) {
props = qweb._formatExpression(props, ctx);
props = ctx.formatExpression(props);
}
let dummyID = ctx.generateID();
let defID = ctx.generateID();
@@ -928,7 +937,9 @@ const widgetDirective: Directive = {
// check if we can reuse current rendering promise
ctx.addIf(`w${widgetID} && w${widgetID}.__widget__.renderPromise`);
ctx.addIf(`w${widgetID}.__widget__.isStarted`);
ctx.addLine(`def${defID} = w${widgetID}.updateProps(props${widgetID});`);
ctx.addLine(
`def${defID} = w${widgetID}.updateProps(props${widgetID}, extra.forceUpdate);`
);
ctx.addElse();
ctx.addLine(`isNew${widgetID} = true`);
ctx.addIf(`props${widgetID} === w${widgetID}.__widget__.renderProps`);
@@ -942,7 +953,9 @@ const widgetDirective: Directive = {
ctx.addIf(`!def${defID}`);
ctx.addIf(`w${widgetID}`);
ctx.addLine(`def${defID} = w${widgetID}.updateProps(props${widgetID});`);
ctx.addLine(
`def${defID} = w${widgetID}.updateProps(props${widgetID}, extra.forceUpdate);`
);
ctx.addElse();
ctx.addLine(
`w${widgetID} = new context.widgets['${value}'](owner, props${widgetID});`
+56 -7
View File
@@ -1,10 +1,41 @@
import { EventBus } from "./event_bus";
import { Component } from "./component";
import { shallowEqual } from "./utils";
export function StoreMixin(Component) {
return class extends Component {
mounted() {
this.env.store.on("update", this, this.render);
}
export function connect(mapStateToProps) {
return function(Comp) {
return class extends Comp {
constructor(parent, props?: any) {
const env = parent instanceof Component ? parent.env : parent;
const storeProps = mapStateToProps(env.store.state);
props = Object.assign(props || {}, storeProps);
super(parent, props);
this.__widget__.currentStoreProps = storeProps;
}
mounted() {
this.env.store.on("update", this, () => {
const storeProps = mapStateToProps(this.env.store.state);
if (!shallowEqual(storeProps, this.__widget__.currentStoreProps)) {
this.__widget__.currentStoreProps = storeProps;
// probably not optimal, will do 2 object.assign, one here and
// one in updateProps.
const nextProps = Object.assign(
{},
this.props,
this.__widget__.currentStoreProps
);
this.updateProps(nextProps, false);
}
});
}
willUnmount() {
this.env.store.off("update", this);
}
updateProps(nextProps, forceUpdate) {
nextProps = Object.assign(nextProps, this.__widget__.currentStoreProps);
return super.updateProps(nextProps, forceUpdate);
}
};
};
}
@@ -13,17 +44,28 @@ interface StoreConfig {
actions?: any;
mutations?: any;
}
interface StoreOption {
debug?: boolean;
}
export class Store extends EventBus {
_state: any;
actions: any;
mutations: any;
_isMutating: boolean = false;
history: any[] = [];
debug: boolean;
constructor(config: StoreConfig = {}) {
constructor(config: StoreConfig, options: StoreOption = {}) {
super();
this.debug = options.debug || false;
this._state = Object.assign({}, config.state);
this.actions = config.actions;
this.mutations = config.mutations;
if (this.debug) {
this.history.push({ state: this.state });
}
}
get state() {
@@ -50,10 +92,17 @@ export class Store extends EventBus {
this._isMutating = true;
this.mutations[type].call(null, this._state, payload);
if (this.debug) {
this.history.push({
state: this.state,
mutation: type,
payload: payload
});
}
await Promise.resolve();
if (this._isMutating) {
this._isMutating = false;
this.trigger("update");
this.trigger("update", this.state);
}
}
+64
View File
@@ -112,3 +112,67 @@ export function findInTree<T extends Tree<T>>(
}
return null;
}
export function shallowEqual(objA, objB) {
if (objA === objB) {
return true;
}
const keysA = Object.keys(objA);
for (let key of keysA) {
if (!(key in objB) || objA[key] !== objB[key]) {
return false;
}
}
return true;
}
export function patch(C: any, patchName: string, patch: any) {
const proto = C.prototype;
if (!proto.__patches) {
proto.__patches = {
origMethods: {},
patches: {},
current: []
};
}
if (proto.__patches.patches[patchName]) {
throw new Error(`Patch [${patchName}] already exists`);
}
proto.__patches.patches[patchName] = patch;
applyPatch(proto, patch);
proto.__patches.current.push(patchName);
function applyPatch(proto, patch) {
Object.keys(patch).forEach(function(methodName) {
const method = patch[methodName];
if (typeof method === "function") {
const original = proto[methodName];
if (!(methodName in proto.__patches.origMethods)) {
proto.__patches.origMethods[methodName] = original;
}
proto[methodName] = function(...args) {
this._super = original;
return method.call(this, ...args);
};
}
});
}
}
export function unpatch(C: any, patchName: string) {
const proto = C.prototype;
const patchInfo = proto.__patches;
delete proto.__patches;
// reset to original
for (let k in patchInfo.origMethods) {
proto[k] = patchInfo.origMethods[k];
}
// apply other patches
for (let name of patchInfo.current) {
if (name !== patchName) {
patch(C, name, patchInfo.patches[name]);
}
}
}
+4 -12
View File
@@ -8,8 +8,6 @@ exports[`composition sub widgets with some state rendered in a loop 1`] = `
let h = this.utils.h;
let c1 = [], p1 = {key:1};
let vn1 = h('div', p1, c1);
c1.push({text: \`
\`});
let _2 = context['state'].numbers;
if (!_2) { throw new Error('QWeb error: Invalid loop expression')}
if (typeof _2 === 'number') { _2 = Array.from(Array(_2).keys())}
@@ -22,8 +20,6 @@ exports[`composition sub widgets with some state rendered in a loop 1`] = `
context.number_index = i;
context.number = _3[i];
context.number_value = _4[i];
c1.push({text: \`
\`});
//WIDGET
let key8 = context['number'];
let _5_index = c1.length;
@@ -34,7 +30,7 @@ exports[`composition sub widgets with some state rendered in a loop 1`] = `
let isNew7 = !w7;
if (w7 && w7.__widget__.renderPromise) {
if (w7.__widget__.isStarted) {
def6 = w7.updateProps(props7);
def6 = w7.updateProps(props7, extra.forceUpdate);
} else {
isNew7 = true
if (props7 === w7.__widget__.renderProps) {
@@ -47,7 +43,7 @@ exports[`composition sub widgets with some state rendered in a loop 1`] = `
}
if (!def6) {
if (w7) {
def6 = w7.updateProps(props7);
def6 = w7.updateProps(props7, extra.forceUpdate);
} else {
w7 = new context.widgets['ChildWidget'](owner, props7);
context.__widget__.cmap[key8] = w7.__widget__.id;
@@ -60,11 +56,7 @@ exports[`composition sub widgets with some state rendered in a loop 1`] = `
def6 = def6.then(()=>{if (w7.__widget__.isDestroyed) {return};let vnode;if (!w7.__widget__.vnode){vnode=w7.__widget__.pvnode} else { vnode=h(w7.__widget__.vnode.sel, {key: key8});vnode.elm=w7.el;vnode.data.hook = {insert(a){a.elm.parentNode.replaceChild(w7.el,a.elm);a.elm=w7.el;w7.__mount();},remove(){w7.destroy()}}}c1[_5_index]=vnode;});
}
extra.promises.push(def6);
c1.push({text: \`
\`});
}
c1.push({text: \`
\`});
return vn1;
}"
`;
@@ -86,7 +78,7 @@ exports[`random stuff/miscellaneous snapshotting compiled code 1`] = `
let isNew4 = !w4;
if (w4 && w4.__widget__.renderPromise) {
if (w4.__widget__.isStarted) {
def3 = w4.updateProps(props4);
def3 = w4.updateProps(props4, extra.forceUpdate);
} else {
isNew4 = true
if (props4 === w4.__widget__.renderProps) {
@@ -99,7 +91,7 @@ exports[`random stuff/miscellaneous snapshotting compiled code 1`] = `
}
if (!def3) {
if (w4) {
def3 = w4.updateProps(props4);
def3 = w4.updateProps(props4, extra.forceUpdate);
} else {
w4 = new context.widgets['child'](owner, props4);
context.__widget__.cmap[key5] = w4.__widget__.id;
+109 -111
View File
@@ -99,6 +99,20 @@ exports[`attributes format value 1`] = `
}"
`;
exports[`attributes from object variables set previously 1`] = `
"function anonymous(context,extra
) {
let h = this.utils.h;
let c1 = [], p1 = {key:1};
let vn1 = h('div', p1, c1);
let _2 = {a:'b'}.a;
let c3 = [], p3 = {key:3,attrs:{class: _2}};
let vn3 = h('span', p3, c3);
c1.push(vn3);
return vn1;
}"
`;
exports[`attributes from variables set previously 1`] = `
"function anonymous(context,extra
) {
@@ -174,7 +188,20 @@ exports[`attributes t-att-class and class should combine together 1`] = `
let _1 = 'hello';
let _3 = context['value'];
let _2 = 'hello' + (_3 ? ' ' + _3 : '');
let c4 = [], p4 = {key:4,attrs:{class: _1,class: _2}};
let c4 = [], p4 = {key:4,attrs:{class: _2}};
let vn4 = h('div', p4, c4);
return vn4;
}"
`;
exports[`attributes t-att-class with object 1`] = `
"function anonymous(context,extra
) {
let h = this.utils.h;
let _1 = 'static';
let _3 = this.utils.objectToAttrString({a: context['b'],c: context['d'],e: context['f']});
let _2 = 'static' + (_3 ? ' ' + _3 : '');
let c4 = [], p4 = {key:4,attrs:{class: _2}};
let vn4 = h('div', p4, c4);
return vn4;
}"
@@ -223,8 +250,6 @@ exports[`foreach does not pollute the rendering context 1`] = `
let h = this.utils.h;
let c1 = [], p1 = {key:1};
let vn1 = h('div', p1, c1);
c1.push({text: \`
\`});
let _2 = [1];
if (!_2) { throw new Error('QWeb error: Invalid loop expression')}
if (typeof _2 === 'number') { _2 = Array.from(Array(_2).keys())}
@@ -242,8 +267,6 @@ exports[`foreach does not pollute the rendering context 1`] = `
c1.push({text: e5});
}
}
c1.push({text: \`
\`});
return vn1;
}"
`;
@@ -255,8 +278,6 @@ exports[`foreach iterate on items (on a element node) 1`] = `
let h = this.utils.h;
let c1 = [], p1 = {key:1};
let vn1 = h('div', p1, c1);
c1.push({text: \`
\`});
let _2 = [1, 2];
if (!_2) { throw new Error('QWeb error: Invalid loop expression')}
if (typeof _2 === 'number') { _2 = Array.from(Array(_2).keys())}
@@ -277,8 +298,6 @@ exports[`foreach iterate on items (on a element node) 1`] = `
c5.push({text: e6});
}
}
c1.push({text: \`
\`});
return vn1;
}"
`;
@@ -290,8 +309,6 @@ exports[`foreach iterate on items 1`] = `
let h = this.utils.h;
let c1 = [], p1 = {key:1};
let vn1 = h('div', p1, c1);
c1.push({text: \`
\`});
let _2 = [3, 2, 1];
if (!_2) { throw new Error('QWeb error: Invalid loop expression')}
if (typeof _2 === 'number') { _2 = Array.from(Array(_2).keys())}
@@ -304,8 +321,7 @@ exports[`foreach iterate on items 1`] = `
context.item_index = i;
context.item = _3[i];
context.item_value = _4[i];
c1.push({text: \`
[\`});
c1.push({text: \` [\`});
let e5 = context['item_index'];
if (e5 || e5 === 0) {
c1.push({text: e5});
@@ -320,11 +336,8 @@ exports[`foreach iterate on items 1`] = `
if (e7 || e7 === 0) {
c1.push({text: e7});
}
c1.push({text: \`]
\`});
c1.push({text: \`] \`});
}
c1.push({text: \`
\`});
return vn1;
}"
`;
@@ -336,8 +349,6 @@ exports[`foreach iterate, dict param 1`] = `
let h = this.utils.h;
let c1 = [], p1 = {key:1};
let vn1 = h('div', p1, c1);
c1.push({text: \`
\`});
let _2 = context['value'];
if (!_2) { throw new Error('QWeb error: Invalid loop expression')}
if (typeof _2 === 'number') { _2 = Array.from(Array(_2).keys())}
@@ -350,8 +361,7 @@ exports[`foreach iterate, dict param 1`] = `
context.item_index = i;
context.item = _3[i];
context.item_value = _4[i];
c1.push({text: \`
[\`});
c1.push({text: \` [\`});
let e5 = context['item_index'];
if (e5 || e5 === 0) {
c1.push({text: e5});
@@ -371,11 +381,8 @@ exports[`foreach iterate, dict param 1`] = `
if (e8 || e8 === 0) {
c1.push({text: e8});
}
c1.push({text: \`]
\`});
c1.push({text: \`] \`});
}
c1.push({text: \`
\`});
return vn1;
}"
`;
@@ -399,8 +406,7 @@ exports[`foreach iterate, integer param 1`] = `
context.item_index = i;
context.item = _3[i];
context.item_value = _4[i];
c1.push({text: \`
[\`});
c1.push({text: \` [\`});
let e5 = context['item_index'];
if (e5 || e5 === 0) {
c1.push({text: e5});
@@ -415,8 +421,7 @@ exports[`foreach iterate, integer param 1`] = `
if (e7 || e7 === 0) {
c1.push({text: e7});
}
c1.push({text: \`]
\`});
c1.push({text: \`] \`});
}
return vn1;
}"
@@ -429,8 +434,6 @@ exports[`foreach iterate, position 1`] = `
let h = this.utils.h;
let c1 = [], p1 = {key:1};
let vn1 = h('div', p1, c1);
c1.push({text: \`
\`});
let _2 = 5;
if (!_2) { throw new Error('QWeb error: Invalid loop expression')}
if (typeof _2 === 'number') { _2 = Array.from(Array(_2).keys())}
@@ -443,8 +446,7 @@ exports[`foreach iterate, position 1`] = `
context.elem_index = i;
context.elem = _3[i];
context.elem_value = _4[i];
c1.push({text: \`
-\`});
c1.push({text: \` -\`});
if (context['elem_first']) {
c1.push({text: \` first\`});
}
@@ -456,11 +458,8 @@ exports[`foreach iterate, position 1`] = `
if (e5 || e5 === 0) {
c1.push({text: e5});
}
c1.push({text: \`)
\`});
c1.push({text: \`) \`});
}
c1.push({text: \`
\`});
return vn1;
}"
`;
@@ -490,8 +489,6 @@ exports[`misc global 1`] = `
let h = this.utils.h;
let c1 = [], p1 = {key:1};
let vn1 = h('div', p1, c1);
c1.push({text: \`
\`});
let _2 = [4,5,6];
if (!_2) { throw new Error('QWeb error: Invalid loop expression')}
if (typeof _2 === 'number') { _2 = Array.from(Array(_2).keys())}
@@ -504,8 +501,6 @@ exports[`misc global 1`] = `
context.value_index = i;
context.value = _3[i];
context.value_value = _4[i];
c1.push({text: \`
\`});
let c5 = [], p5 = {key:5,on:{}};
let vn5 = h('span', p5, c5);
c1.push(vn5);
@@ -513,14 +508,10 @@ exports[`misc global 1`] = `
if (e6 || e6 === 0) {
c5.push({text: e6});
}
c1.push({text: \`
\`});
let _7 = 'agüero';
let c8 = [], p8 = {key:8,attrs:{\\"falló\\": _7},on:{}};
let vn8 = h('Año', p8, c8);
c1.push(vn8);
c8.push({text: \`
\`});
let c9 = [], p9 = {key:9,on:{}};
let vn9 = h('span', p9, c9);
c8.push(vn9);
@@ -530,8 +521,6 @@ exports[`misc global 1`] = `
} else {
c9.push({text: \`foo default\`});
}
c8.push({text: \`
\`});
let c11 = [], p11 = {key:11,on:{}};
let vn11 = h('span', p11, c11);
c8.push(vn11);
@@ -541,10 +530,6 @@ exports[`misc global 1`] = `
} else {
c11.push({text: \`foo default\`});
}
c8.push({text: \`
\`});
c8.push({text: \`
\`});
let c13 = [], p13 = {key:13,on:{}};
let vn13 = h('span', p13, c13);
c8.push(vn13);
@@ -554,13 +539,7 @@ exports[`misc global 1`] = `
} else {
c13.push({text: \`foo default\`});
}
c8.push({text: \`
\`});
c1.push({text: \`
\`});
}
c1.push({text: \`
\`});
let c15 = [], p15 = {key:15,on:{}};
let vn15 = h('div', p15, c15);
c1.push(vn15);
@@ -575,8 +554,6 @@ exports[`misc global 1`] = `
} else {
c15.push({text: \`toto default\`});
}
c1.push({text: \`
\`});
return vn1;
}"
`;
@@ -599,52 +576,36 @@ exports[`special cases for some boolean html attributes/properties various boole
let h = this.utils.h;
let c1 = [], p1 = {key:1};
let vn1 = h('div', p1, c1);
c1.push({text: \`
\`});
let _2 = 'checkbox';
let _3 = 'checked';
let c4 = [], p4 = {key:4,attrs:{type: _2,checked: _3},props:{checked: _3}};
let vn4 = h('input', p4, c4);
c1.push(vn4);
c1.push({text: \`
\`});
let _5 = 'checked';
let c6 = [], p6 = {key:6,attrs:{checked: _5}};
let vn6 = h('input', p6, c6);
c1.push(vn6);
c1.push({text: \`
\`});
let _7 = 'checked';
let c8 = [], p8 = {key:8,attrs:{checked: _7}};
let vn8 = h('div', p8, c8);
c1.push(vn8);
c1.push({text: \`
\`});
let _9 = 'selected';
let c10 = [], p10 = {key:10,attrs:{selected: _9}};
let vn10 = h('div', p10, c10);
c1.push(vn10);
c1.push({text: \`
\`});
let _11 = 'selected';
let _12 = '1';
let c13 = [], p13 = {key:13,attrs:{selected: _11,other: _12},props:{selected: _11}};
let vn13 = h('option', p13, c13);
c1.push(vn13);
c1.push({text: \`
\`});
let _14 = 'readonly';
let c15 = [], p15 = {key:15,attrs:{readonly: _14},props:{readonly: _14}};
let vn15 = h('input', p15, c15);
c1.push(vn15);
c1.push({text: \`
\`});
let _16 = 'disabled';
let c17 = [], p17 = {key:17,attrs:{disabled: _16},props:{disabled: _16}};
let vn17 = h('button', p17, c17);
c1.push(vn17);
c1.push({text: \`
\`});
return vn1;
}"
`;
@@ -724,17 +685,11 @@ exports[`t-call (template calling scoped parameters 1`] = `
let h = this.utils.h;
let c1 = [], p1 = {key:1};
let vn1 = h('div', p1, c1);
c1.push({text: \`
\`});
c1.push({text: \`ok\`});
c1.push({text: \`
\`});
let e2 = context['foo'];
if (e2 || e2 === 0) {
c1.push({text: e2});
}
c1.push({text: \`
\`});
return vn1;
}"
`;
@@ -888,28 +843,20 @@ exports[`t-if boolean value condition else 1`] = `
let h = this.utils.h;
let c1 = [], p1 = {key:1};
let vn1 = h('div', p1, c1);
c1.push({text: \`
\`});
let c2 = [], p2 = {key:2};
let vn2 = h('span', p2, c2);
c1.push(vn2);
c2.push({text: \`begin\`});
c1.push({text: \`
\`});
if (context['condition']) {
c1.push({text: \`ok\`});
}
else {
c1.push({text: \`ok-else\`});
}
c1.push({text: \`
\`});
let c3 = [], p3 = {key:3};
let vn3 = h('span', p3, c3);
c1.push(vn3);
c3.push({text: \`end\`});
c1.push({text: \`
\`});
return vn1;
}"
`;
@@ -983,48 +930,30 @@ exports[`t-if can use some boolean operators in expressions 1`] = `
let h = this.utils.h;
let c1 = [], p1 = {key:1};
let vn1 = h('div', p1, c1);
c1.push({text: \`
\`});
if (context['cond1'] && context['cond2']) {
c1.push({text: \`and\`});
}
c1.push({text: \`
\`});
if (context['cond1'] && context['cond3']) {
c1.push({text: \`nope\`});
}
c1.push({text: \`
\`});
if (context['cond1'] || context['cond3']) {
c1.push({text: \`or\`});
}
c1.push({text: \`
\`});
if (context['cond3'] || context['cond4']) {
c1.push({text: \`nope\`});
}
c1.push({text: \`
\`});
if (context['m'] > 3) {
c1.push({text: \`mgt\`});
}
c1.push({text: \`
\`});
if (context['n'] > 3) {
c1.push({text: \`ngt\`});
}
c1.push({text: \`
\`});
if (context['m'] < 3) {
c1.push({text: \`mlt\`});
}
c1.push({text: \`
\`});
if (context['n'] < 3) {
c1.push({text: \`nlt\`});
}
c1.push({text: \`
\`});
return vn1;
}"
`;
@@ -1090,8 +1019,6 @@ exports[`t-on can bind handlers with loop variable as argument 1`] = `
let h = this.utils.h;
let c1 = [], p1 = {key:1};
let vn1 = h('ul', p1, c1);
c1.push({text: \`
\`});
let _2 = ['someval'];
if (!_2) { throw new Error('QWeb error: Invalid loop expression')}
if (typeof _2 === 'number') { _2 = Array.from(Array(_2).keys())}
@@ -1113,8 +1040,6 @@ exports[`t-on can bind handlers with loop variable as argument 1`] = `
p6.on['click'] = context['activate'].bind(owner, context['action']);
c6.push({text: \`link\`});
}
c1.push({text: \`
\`});
return vn1;
}"
`;
@@ -1360,3 +1285,76 @@ exports[`t-set value priority 1`] = `
return vn1;
}"
`;
exports[`whitespace handling consecutives whitespaces are condensed into a single space 1`] = `
"function anonymous(context,extra
) {
let h = this.utils.h;
let c1 = [], p1 = {key:1};
let vn1 = h('div', p1, c1);
c1.push({text: \` abc \`});
return vn1;
}"
`;
exports[`whitespace handling nothing is done in pre tags 1`] = `
"function anonymous(context,extra
) {
let h = this.utils.h;
let c1 = [], p1 = {key:1};
let vn1 = h('pre', p1, c1);
c1.push({text: \` \`});
return vn1;
}"
`;
exports[`whitespace handling nothing is done in pre tags 2`] = `
"function anonymous(context,extra
) {
let h = this.utils.h;
let c1 = [], p1 = {key:1};
let vn1 = h('pre', p1, c1);
c1.push({text: \`
some text
\`});
return vn1;
}"
`;
exports[`whitespace handling nothing is done in pre tags 3`] = `
"function anonymous(context,extra
) {
let h = this.utils.h;
let c1 = [], p1 = {key:1};
let vn1 = h('pre', p1, c1);
c1.push({text: \`
\`});
return vn1;
}"
`;
exports[`whitespace handling white space only text nodes are condensed into a single space 1`] = `
"function anonymous(context,extra
) {
let h = this.utils.h;
let c1 = [], p1 = {key:1};
let vn1 = h('div', p1, c1);
c1.push({text: \` \`});
return vn1;
}"
`;
exports[`whitespace handling whitespace only text nodes with newlines are removed 1`] = `
"function anonymous(context,extra
) {
let h = this.utils.h;
let c1 = [], p1 = {key:1};
let vn1 = h('div', p1, c1);
let c2 = [], p2 = {key:2};
let vn2 = h('span', p2, c2);
c1.push(vn2);
c2.push({text: \`abc\`});
return vn1;
}"
`;
+5
View File
@@ -0,0 +1,5 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`connecting a component to store connecting a component works 1`] = `"<div></div>"`;
exports[`connecting a component to store connecting a component works 2`] = `"<div><span>hello</span></div>"`;
+46
View File
@@ -70,6 +70,12 @@ class WidgetB extends Widget {
//------------------------------------------------------------------------------
describe("basic widget properties", () => {
test("props and state are properly defined", async () => {
const widget = new Widget(env);
expect(widget.props).toEqual({});
expect(widget.state).toEqual({});
});
test("has no el after creation", async () => {
const widget = new Widget(env);
expect(widget.el).toBe(null);
@@ -1096,6 +1102,29 @@ describe("updating environment", () => {
expect((<any>widget).env.somekey).toBe(4);
});
test("updating widget env does not render widget (if not mounted)", async () => {
let n = 0;
class TestWidget extends Widget {
_render() {
n++;
return super._render();
}
}
const widget = new TestWidget(env);
expect(n).toBe(0);
await widget.updateEnv(<any>{ somekey: 4 });
expect(n).toBe(0);
await widget.mount(fixture);
expect(n).toBe(1);
await widget.updateEnv(<any>{ somekey: 5 });
expect(n).toBe(2);
widget.detach();
expect(n).toBe(2);
await widget.updateEnv(<any>{ somekey: 5 });
expect(n).toBe(2);
});
test("updating child env does not modify parent env", async () => {
class ParentWidget extends Widget {
inlineTemplate = `<div><t t-widget="child"/></div>`;
@@ -1148,4 +1177,21 @@ describe("updating environment", () => {
await widget.updateEnv(<any>{ someKey: "rerendered" });
expect(fixture.innerHTML).toBe("<div>rerendered</div>");
});
test("updating env force rerendering children", async () => {
class Parent extends Widget {
inlineTemplate = `<div><t t-widget="Child"/></div>`;
widgets = { Child };
}
class Child extends Widget {
inlineTemplate = `<div><t t-esc="env.someKey"/></div>`;
}
(<any>env).someKey = "hey";
const widget = new Parent(env);
await widget.mount(fixture);
expect(fixture.innerHTML).toBe("<div><div>hey</div></div>");
await widget.updateEnv(<any>{ someKey: "rerendered" });
await nextTick();
expect(fixture.innerHTML).toBe("<div><div>rerendered</div></div>");
});
});
+57 -6
View File
@@ -410,14 +410,11 @@ describe("attributes", () => {
expect(result).toBe('<div><span class="def"></span></div>');
});
test.skip("from object variables set previously", () => {
// qweb is stupid and does not support this.
// To do that properly, we need to extend the formatExpression method to
// be able to deal with objects. I think that it is not easy:
// it should properly deal with stuff like {a:expr, b: {c: otherexpr}}
test("from object variables set previously", () => {
// Note: standard qweb does not allow this...
qweb.addTemplate(
"test",
`<div t-debug="1"><t t-set="o" t-value="{a:'b'}"/><span t-att-class="o.a"/></div>`
`<div><t t-set="o" t-value="{a:'b'}"/><span t-att-class="o.a"/></div>`
);
const result = renderToString(qweb, "test");
expect(result).toBe('<div><span class="b"></span></div>');
@@ -467,6 +464,15 @@ describe("attributes", () => {
const result = renderToString(qweb, "test", { value: "world" });
expect(result).toBe(`<div class="hello world"></div>`);
});
test("t-att-class with object", () => {
qweb.addTemplate(
"test",
`<div class="static" t-att-class="{a: b, c: d, e: f}"/>`
);
const result = renderToString(qweb, "test", { b: true, d: false, f: true });
expect(result).toBe(`<div class="static a e"></div>`);
});
});
describe("t-call (template calling", () => {
@@ -915,3 +921,48 @@ describe("special cases for some boolean html attributes/properties", () => {
renderToString(qweb, "test", { flag: true });
});
});
describe("whitespace handling", () => {
test("white space only text nodes are condensed into a single space", () => {
qweb.addTemplate("test", `<div> </div>`);
const result = renderToString(qweb, "test");
expect(result).toBe(`<div> </div>`);
});
test("consecutives whitespaces are condensed into a single space", () => {
qweb.addTemplate("test", `<div> abc </div>`);
const result = renderToString(qweb, "test");
expect(result).toBe(`<div> abc </div>`);
});
test("whitespace only text nodes with newlines are removed", () => {
qweb.addTemplate(
"test",
`<div>
<span>abc</span>
</div>`
);
const result = renderToString(qweb, "test");
expect(result).toBe(`<div><span>abc</span></div>`);
});
test("nothing is done in pre tags", () => {
qweb.addTemplate("test", `<pre> </pre>`);
const result = renderToString(qweb, "test");
expect(result).toBe(`<pre> </pre>`);
const pretagtext = `<pre>
some text
</pre>`;
qweb.addTemplate("test2", pretagtext);
const result2 = renderToString(qweb, "test2");
expect(result2).toBe(pretagtext);
const pretagwithonlywhitespace = `<pre>
</pre>`;
qweb.addTemplate("test3", pretagwithonlywhitespace);
const result3 = renderToString(qweb, "test3");
expect(result3).toBe(pretagwithonlywhitespace);
});
});
+109
View File
@@ -0,0 +1,109 @@
import { Store, connect } from "../src/store";
import { Component, Env } from "../src/component";
import {
nextMicroTick,
makeTestWEnv,
makeTestFixture,
nextTick
} from "./helpers";
describe("basic use", () => {
test("commit a mutation", () => {
const state = { n: 1 };
const mutations = {
inc(state, delta) {
state.n += delta;
}
};
const store = new Store({ state, mutations });
expect(store.state.n).toBe(1);
store.commit("inc", 14);
expect(store.state.n).toBe(15);
});
test("dispatch an action", () => {
const state = { n: 1 };
const mutations = {
inc(state, delta) {
state.n += delta;
}
};
const actions = {
inc({ commit }, delta) {
commit("inc", delta);
}
};
const store = new Store({ state, mutations, actions });
expect(store.state.n).toBe(1);
store.dispatch("inc", 14);
expect(store.state.n).toBe(15);
});
test("multiple commits trigger one update", async () => {
let updateCounter = 0;
const state = { n: 1 };
const mutations = {
inc(state, delta) {
state.n += delta;
}
};
const store = new Store({ state, mutations });
store.on("update", null, () => updateCounter++);
store.commit("inc", 14);
expect(updateCounter).toBe(0);
store.commit("inc", 50);
expect(updateCounter).toBe(0);
await nextMicroTick();
expect(updateCounter).toBe(1);
});
});
describe("connecting a component to store", () => {
let fixture: HTMLElement;
let env: Env;
beforeEach(() => {
fixture = makeTestFixture();
env = makeTestWEnv();
});
afterEach(() => {
fixture.remove();
});
class App extends Component<any, any, any> {
inlineTemplate = `
<div>
<t t-foreach="props.todos" t-as="todo">
<t t-widget="Todo" t-props="todo"/>
</t>
</div>`;
widgets = { Todo };
}
class Todo extends Component<any, any, any> {
inlineTemplate = `<span><t t-esc="props.msg"/></span>`;
}
test("connecting a component works", async () => {
const state = { todos: [] };
const mutations = {
addTodo(state, msg) {
state.todos.push({ msg });
}
};
const TodoApp = connect(s => s)(App);
const store = new Store({ state, mutations });
(<any>env).store = store;
const app = new TodoApp(env);
await app.mount(fixture);
expect(fixture.innerHTML).toMatchSnapshot();
store.commit("addTodo", "hello");
await nextTick();
expect(fixture.innerHTML).toMatchSnapshot();
});
});
+68 -1
View File
@@ -4,7 +4,10 @@ import {
idGenerator,
memoize,
debounce,
findInTree
findInTree,
shallowEqual,
patch,
unpatch
} from "../src/utils";
describe("escape", () => {
@@ -91,3 +94,67 @@ describe("findInTree", () => {
expect(match2).toBe(null);
});
});
describe("shallowEqual", () => {
test("simple comparisons", () => {
const obj1 = {};
expect(shallowEqual(obj1, obj1)).toBe(true);
expect(shallowEqual({}, {})).toBe(true);
expect(shallowEqual({ a: 1 }, {})).toBe(false);
expect(shallowEqual({ a: 1 }, { a: 1 })).toBe(true);
expect(shallowEqual({ a: 1 }, ["a"])).toBe(false);
});
});
describe("patch/unpatch", () => {
test("can monkey patch a class", () => {
class Test {
n = 1;
doSomething(): string {
return "hey";
}
}
patch(Test, "some_custo", {
doSomething(): string {
this.n = this.n + 1;
return this._super();
}
});
const t = new Test();
expect(t.n).toBe(1);
expect(t.doSomething()).toBe("hey");
expect(t.n).toBe(2);
});
test("cannot patch a class twice with same patch name", () => {
class Test {}
patch(Test, "some_custo", {});
expect(() => {
patch(Test, "some_custo", {});
}).toThrow();
});
test("can unpatch a class", () => {
class Test {
doSomething(): number {
return 1;
}
}
patch(Test, "some_custo", {
doSomething(): number {
return this._super() + 2;
}
});
const t = new Test();
expect(t.doSomething()).toBe(3);
unpatch(Test, "some_custo");
expect(t.doSomething()).toBe(1);
});
});