Compare commits

...

24 Commits

Author SHA1 Message Date
Géry Debongnie 1020b35fe5 bump to v0.3.0 2019-03-21 09:55:33 +01:00
Géry Debongnie da8dd58490 add date and hash in build 2019-03-21 09:55:04 +01:00
Géry Debongnie 8e82a8612a small cleanup of the store 2019-03-21 09:11:44 +01:00
Géry Debongnie c32f1d1e38 complete base implementation of todoapp 2019-03-20 15:17:53 +01:00
Géry Debongnie 9baf3ad4a7 add support for boolean properties, such as 'checked'
fix #5
2019-03-20 13:19:08 +01:00
Géry Debongnie 5da7df8bf0 add todoapp 2019-03-20 11:49:45 +01:00
Géry Debongnie c9796e6bdc add store class to core 2019-03-20 11:49:41 +01:00
Géry Debongnie 6a2e4dbf2f rename demo example into benchmarks 2019-03-19 13:55:27 +01:00
Géry Debongnie 91e7e00f97 clean component file, add some documentation 2019-03-19 13:46:18 +01:00
Géry Debongnie 38f307ef57 implement updateEnv feature 2019-03-19 13:26:05 +01:00
Géry Debongnie bd7a2bfc67 fix crash in template compilation with objects in props
fix #1
2019-03-19 10:44:01 +01:00
Géry Debongnie 695636e60e cleanup, rename setState into updateState 2019-03-19 10:35:41 +01:00
Géry Debongnie 7d10e57cc0 remove a leftover d.ts file 2019-03-18 16:15:08 +01:00
Géry Debongnie 1f602512a5 improve demo app 2019-03-18 16:15:08 +01:00
Géry Debongnie 65a65bd241 remove memory leak... 2019-03-18 16:15:08 +01:00
Géry Debongnie cf289323c8 add demo application (benchmark) 2019-03-18 14:26:33 +01:00
Géry Debongnie 050025a922 update documentation 2019-03-17 15:44:02 +01:00
Géry Debongnie 9e608dae49 update documentation 2019-03-17 08:57:18 +01:00
Géry Debongnie 46aedf78af move demo code to examples/ 2019-03-15 12:21:55 +01:00
Géry Debongnie d99ea4c9f4 rename updateState into setState 2019-03-15 11:54:59 +01:00
Alexandre Kühn 9be0b65cd3 make t-set works with t-props
test by aku, fix by ged
2019-03-15 11:06:05 +01:00
Géry Debongnie 733d5f81e6 slightly improve build script 2019-03-14 16:22:50 +01:00
Géry Debongnie 7b3938e56e add tests/ to typescript config file 2019-03-14 15:54:40 +01:00
Géry Debongnie 46c8d5a4ee add support for ES6-like syntax for props 2019-03-14 15:53:31 +01:00
1665 changed files with 1484 additions and 119 deletions
+3
View File
@@ -0,0 +1,3 @@
{
"esversion": 9
}
+8 -17
View File
@@ -31,14 +31,6 @@ ideas:
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).
## Web client demo
To test and play with the component class, there is a demo application located
in the _demo/_ 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.
## Main scripts
To install every dependency needed to play with this code:
@@ -60,18 +52,17 @@ npm run test
npm run test:watch
```
To play with the demo application:
```
npm run demo:build # make a build in dist/app/
npm run demo:dev # make a build in dist/app/, and make a live server to access it
```
The _demo: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.
Note that the test scripts also run the example tests suites.
## Documentation
- [Quick Start](doc/quick_start.md)
- [Component](doc/component.md)
- [QWeb](doc/qweb.md)
# Examples
There are two examples of how to work with this web framework:
- [Demo Application](examples/readme.md#demo)
- [Web Client](examples/readme.md#web-client-example)
+45
View File
@@ -0,0 +1,45 @@
.main {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
display: grid;
grid-template-columns: 200px 1fr;
}
.left-thing {
background-color: gray;
padding: 20px;
}
.left-thing button {
width: 100%;
}
.left-thing .counter span {
color: white;
}
.left-thing .counter button {
width: 40px;
}
.right-thing {
padding: 20px;
overflow: auto;
}
/* Message widget */
.message .author {
font-weight: bold;
}
.message {
width: 400px;
background-color: lightblue;
margin: 10px 5px;
border-radius: 5px;
padding: 5px;
}
+55
View File
@@ -0,0 +1,55 @@
import { Message } from "./message.js";
import { messages } from "./data.js";
const template = `
<div class="main">
<div class="left-thing">
<div class="counter">
<button t-on-click="increment(-1)">-</button>
<span style="font-weight:bold">Value: <t t-esc="state.messages.length"/></span>
<button t-on-click="increment(1)">+</button>
</div>
<button t-on-click="setMessageCount(10)">10 messages</button>
<button t-on-click="setMessageCount(20)">20 messages</button>
<button t-on-click="setMessageCount(500)">500 messages</button>
<button t-on-click="setMessageCount(1000)">1000 messages</button>
<button t-on-click="setMessageCount(5000)">5000 messages</button>
<button t-on-click="setMessageCount(15000)">15000 messages</button>
</div>
<div class="right-thing">
<div class="content">
<t t-foreach="state.messages" t-as="message">
<t t-widget="Message" t-att-key="message.id" t-props="message" t-on-remove_message="removeMessage"/>
</t>
</div>
</div>
</div>`;
export class App extends odoo.core.Component {
constructor(parent, props) {
super(parent, props);
this.inlineTemplate = template;
this.widgets = { Message };
this.state = {
messages: messages.slice(0, 10)
};
}
setMessageCount(n) {
this.updateState({
messages: messages.slice(0, n)
});
}
removeMessage(data) {
const index = messages.findIndex(m => m.id === data.id);
const n = this.state.messages.length;
messages.splice(index, 1);
this.updateState({ messages: messages.slice(0, n - 1) });
}
increment(delta) {
const n = this.state.messages.length + delta;
this.setMessageCount(n);
}
}
+18
View File
@@ -0,0 +1,18 @@
export class Counter extends odoo.core.Component {
inlineTemplate = `
<div>
<button t-on-click="increment(-1)">-</button>
<span style="font-weight:bold">Value: <t t-esc="state.counter"/></span>
<button t-on-click="increment(1)">+</button>
</div>`;
constructor(parent, props) {
super(parent, props);
this.state = {
counter: props.initialState || 0
};
}
increment(delta) {
this.updateState({ counter: this.state.counter + delta });
}
}
+22
View File
@@ -0,0 +1,22 @@
export const messages = [];
const authors = ["Aaron", "David", "Vincent"];
const content = [
"Lorem ipsum dolor sit amet",
"Sed ut perspiciatis unde omnis iste natus error sit voluptatem",
"Excepteur sint occaecat cupidatat non proident"
];
function chooseRandomly(array) {
const index = Math.floor(Math.random() * array.length);
return array[index];
}
for (let i = 1; i < 16000; i++) {
messages.push({
id: i,
author: chooseRandomly(authors),
msg: `${i}: ${chooseRandomly(content)}`,
likes: 0
});
}
+16
View File
@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Odoo WebCore Benchmarks App</title>
<link rel="icon" href="data:,">
<!-- Application JS/CSS -->
<script src="/core.js"></script>
<link rel="stylesheet" href="/app.css">
<script type="module" src="/main.js"></script>
</head>
<body>
</body>
</html>
+13
View File
@@ -0,0 +1,13 @@
import { App } from "./app.js";
function createApp(el) {
const env = {
qweb: new odoo.core.QWeb()
};
const app = new App(env, { initialState: 13 });
app.mount(el);
}
document.addEventListener("DOMContentLoaded", function() {
createApp(document.body);
});
+22
View File
@@ -0,0 +1,22 @@
import { Counter } from "./counter.js";
export class Message extends odoo.core.Component {
inlineTemplate = `
<div class="message">
<span class="author"><t t-esc="props.author"/></span>
<span class="msg"><t t-esc="props.msg"/></span>
<button class="remove" t-on-click="removeMessage">Remove</button>
<t t-widget="Counter" t-props="{initialState: props.id}"/>
</div>`;
constructor(parent, props) {
super(parent, props);
this.widgets = { Counter };
}
removeMessage() {
this.trigger("remove_message", {
id: this.props.id
});
}
}
+24
View File
@@ -0,0 +1,24 @@
# Examples
To play with the examples:
```
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
```
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.
## 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.
## Demo
This example is just a playground to experiment/showcase some features of
the framework.
+376
View File
@@ -0,0 +1,376 @@
html,
body {
margin: 0;
padding: 0;
}
button {
margin: 0;
padding: 0;
border: 0;
background: none;
font-size: 100%;
vertical-align: baseline;
font-family: inherit;
font-weight: inherit;
color: inherit;
-webkit-appearance: none;
appearance: none;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
font: 14px "Helvetica Neue", Helvetica, Arial, sans-serif;
line-height: 1.4em;
background: #f5f5f5;
color: #4d4d4d;
min-width: 230px;
max-width: 550px;
margin: 0 auto;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
font-weight: 300;
}
:focus {
outline: 0;
}
.hidden {
display: none;
}
.todoapp {
background: #fff;
margin: 130px 0 40px 0;
position: relative;
box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.2), 0 25px 50px 0 rgba(0, 0, 0, 0.1);
}
.todoapp input::-webkit-input-placeholder {
font-style: italic;
font-weight: 300;
color: #e6e6e6;
}
.todoapp input::-moz-placeholder {
font-style: italic;
font-weight: 300;
color: #e6e6e6;
}
.todoapp input::input-placeholder {
font-style: italic;
font-weight: 300;
color: #e6e6e6;
}
.todoapp h1 {
position: absolute;
top: -155px;
width: 100%;
font-size: 100px;
font-weight: 100;
text-align: center;
color: rgba(175, 47, 47, 0.15);
-webkit-text-rendering: optimizeLegibility;
-moz-text-rendering: optimizeLegibility;
text-rendering: optimizeLegibility;
}
.new-todo,
.edit {
position: relative;
margin: 0;
width: 100%;
font-size: 24px;
font-family: inherit;
font-weight: inherit;
line-height: 1.4em;
border: 0;
color: inherit;
padding: 6px;
border: 1px solid #999;
box-shadow: inset 0 -1px 5px 0 rgba(0, 0, 0, 0.2);
box-sizing: border-box;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.new-todo {
padding: 16px 16px 16px 60px;
border: none;
background: rgba(0, 0, 0, 0.003);
box-shadow: inset 0 -2px 1px rgba(0, 0, 0, 0.03);
}
.main {
position: relative;
z-index: 2;
border-top: 1px solid #e6e6e6;
}
.toggle-all {
width: 1px;
height: 1px;
border: none; /* Mobile Safari */
opacity: 0;
position: absolute;
right: 100%;
bottom: 100%;
}
.toggle-all + label {
width: 60px;
height: 34px;
font-size: 0;
position: absolute;
top: -52px;
left: -13px;
-webkit-transform: rotate(90deg);
transform: rotate(90deg);
}
.toggle-all + label:before {
content: "❯";
font-size: 22px;
color: #e6e6e6;
padding: 10px 27px 10px 27px;
}
.toggle-all:checked + label:before {
color: #737373;
}
.todo-list {
margin: 0;
padding: 0;
list-style: none;
}
.todo-list li {
position: relative;
font-size: 24px;
border-bottom: 1px solid #ededed;
}
.todo-list li:last-child {
border-bottom: none;
}
.todo-list li.editing {
border-bottom: none;
padding: 0;
}
.todo-list li.editing .edit {
display: block;
width: calc(100% - 43px);
padding: 12px 16px;
margin: 0 0 0 43px;
}
.todo-list li.editing .view {
display: none;
}
.todo-list li .toggle {
text-align: center;
width: 40px;
/* auto, since non-WebKit browsers doesn't support input styling */
height: auto;
position: absolute;
top: 0;
bottom: 0;
margin: auto 0;
border: none; /* Mobile Safari */
-webkit-appearance: none;
appearance: none;
}
.todo-list li .toggle {
opacity: 0;
}
.todo-list li .toggle + label {
/*
Firefox requires `#` to be escaped - https://bugzilla.mozilla.org/show_bug.cgi?id=922433
IE and Edge requires *everything* to be escaped to render, so we do that instead of just the `#` - https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/7157459/
*/
background-image: url("data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%23ededed%22%20stroke-width%3D%223%22/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: center left;
}
.todo-list li .toggle:checked + label {
background-image: url("data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%23bddad5%22%20stroke-width%3D%223%22/%3E%3Cpath%20fill%3D%22%235dc2af%22%20d%3D%22M72%2025L42%2071%2027%2056l-4%204%2020%2020%2034-52z%22/%3E%3C/svg%3E");
}
.todo-list li label {
word-break: break-all;
padding: 15px 15px 15px 60px;
display: block;
line-height: 1.2;
transition: color 0.4s;
}
.todo-list li.completed label {
color: #d9d9d9;
text-decoration: line-through;
}
.todo-list li .destroy {
display: none;
position: absolute;
top: 0;
right: 10px;
bottom: 0;
width: 40px;
height: 40px;
margin: auto 0;
font-size: 30px;
color: #cc9a9a;
margin-bottom: 11px;
transition: color 0.2s ease-out;
}
.todo-list li .destroy:hover {
color: #af5b5e;
}
.todo-list li .destroy:after {
content: "×";
}
.todo-list li:hover .destroy {
display: block;
}
.todo-list li .edit {
display: none;
}
.todo-list li.editing:last-child {
margin-bottom: -1px;
}
.footer {
color: #777;
padding: 10px 15px;
height: 20px;
text-align: center;
border-top: 1px solid #e6e6e6;
}
.footer:before {
content: "";
position: absolute;
right: 0;
bottom: 0;
left: 0;
height: 50px;
overflow: hidden;
box-shadow: 0 1px 1px rgba(0, 0, 0, 0.2), 0 8px 0 -3px #f6f6f6,
0 9px 1px -3px rgba(0, 0, 0, 0.2), 0 16px 0 -6px #f6f6f6,
0 17px 2px -6px rgba(0, 0, 0, 0.2);
}
.todo-count {
float: left;
text-align: left;
}
.todo-count strong {
font-weight: 300;
}
.filters {
margin: 0;
padding: 0;
list-style: none;
position: absolute;
right: 0;
left: 0;
}
.filters li {
display: inline;
}
.filters li a {
color: inherit;
margin: 3px;
padding: 3px 7px;
text-decoration: none;
border: 1px solid transparent;
border-radius: 3px;
}
.filters li a:hover {
border-color: rgba(175, 47, 47, 0.1);
}
.filters li a.selected {
border-color: rgba(175, 47, 47, 0.2);
}
.clear-completed,
html .clear-completed:active {
float: right;
position: relative;
line-height: 20px;
text-decoration: none;
cursor: pointer;
}
.clear-completed:hover {
text-decoration: underline;
}
.info {
margin: 65px auto 0;
color: #bfbfbf;
font-size: 10px;
text-shadow: 0 1px 0 rgba(255, 255, 255, 0.5);
text-align: center;
}
.info p {
line-height: 1;
}
.info a {
color: inherit;
text-decoration: none;
font-weight: 400;
}
.info a:hover {
text-decoration: underline;
}
/*
Hack to remove background from Mobile Safari.
Can't use it globally since it destroys checkboxes in Firefox
*/
@media screen and (-webkit-min-device-pixel-ratio: 0) {
.toggle-all,
.todo-list li .toggle {
background: none;
}
.todo-list li .toggle {
height: 40px;
}
}
@media (max-width: 430px) {
.footer {
height: 50px;
}
.filters {
bottom: 10px;
}
}
+56
View File
@@ -0,0 +1,56 @@
import { TodoItem } from "./TodoItem.js";
const { StoreMixin, Component } = odoo.core;
const ENTER_KEY = 13;
export class TodoApp extends StoreMixin(Component) {
template = "todoapp";
widgets = { TodoItem };
state = { filter: "all" };
get todos() {
return this.env.store.state.todos;
}
get visibleTodos() {
let todos = this.todos;
if (this.state.filter === "active") {
todos = todos.filter(t => !t.completed);
}
if (this.state.filter === "completed") {
todos = todos.filter(t => t.completed);
}
return todos;
}
get allChecked() {
return this.todos.every(todo => todo.completed);
}
get remaining() {
return this.todos.filter(todo => !todo.completed).length;
}
get remainingText() {
return (this.remaining < 2 ? "item" : "items") + " left";
}
addTodo(ev) {
if (ev.keyCode === ENTER_KEY) {
const title = ev.target.value;
if (title.trim()) {
this.env.store.dispatch("addTodo", title);
}
ev.target.value = "";
}
}
clearCompleted() {
this.env.store.dispatch("clearCompleted");
}
toggleAll() {
this.env.store.dispatch("toggleAll", !this.allChecked);
}
}
+49
View File
@@ -0,0 +1,49 @@
const ENTER_KEY = 13;
const ESC_KEY = 27;
export class TodoItem extends odoo.core.Component {
template = "todoitem";
state = { isEditing: false };
removeTodo() {
this.env.store.dispatch("removeTodo", this.props.id);
}
toggleTodo() {
this.env.store.dispatch("toggleTodo", this.props.id);
}
async editTodo() {
await this.updateState({ isEditing: true });
this.refs.input.value = "";
this.refs.input.focus();
this.refs.input.value = this.props.title;
}
handleKeyup(ev) {
if (ev.keyCode === ENTER_KEY) {
this.updateTitle(ev.target.value);
}
if (ev.keyCode === ESC_KEY) {
ev.target.value = this.props.title;
this.updateState({ isEditing: false });
}
}
handleBlur(ev) {
this.updateTitle(ev.target.value);
}
updateTitle(title) {
const value = title.trim();
if (!value) {
this.removeTodo(this.props.id);
} else {
this.env.store.dispatch("editTodo", {
id: this.props.id,
title: value
});
this.updateState({ isEditing: false });
}
}
}
+16
View File
@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Odoo Demo App</title>
<link rel="icon" href="data:,">
<!-- Application JS/CSS -->
<script src="/core.js"></script>
<link rel="stylesheet" href="/app.css">
<script type="module" src="/main.js"></script>
</head>
<body>
</body>
</html>
+26
View File
@@ -0,0 +1,26 @@
import { TodoApp } from "./components/TodoApp.js";
import { makeStore } from "./store.js";
async function makeEnv() {
const result = await fetch("templates.xml");
if (!result.ok) {
throw new Error("Error while fetching xml templates");
}
let templates = await result.text();
templates = templates.replace(/<!--[\s\S]*?-->/g, "");
const qweb = new odoo.core.QWeb();
qweb.loadTemplates(templates);
return {
qweb,
store: makeStore()
};
}
document.addEventListener("DOMContentLoaded", async function() {
const env = await makeEnv();
const app = new TodoApp(env);
// for debugging purpose
window.app = app;
await app.mount(document.body);
});
+75
View File
@@ -0,0 +1,75 @@
//------------------------------------------------------------------------------
// ACTIONS
//------------------------------------------------------------------------------
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 });
}
};
//------------------------------------------------------------------------------
// MUTATIONS
//------------------------------------------------------------------------------
const mutations = {
addTodo(state, title) {
const id = state.nextId++;
const todo = { id, title, completed: false };
state.todos.push(todo);
},
removeTodo(state, id) {
const index = state.todos.findIndex(t => t.id === id);
state.todos.splice(index, 1);
},
editTodo(state, { id, title, completed }) {
const todo = state.todos.find(t => t.id === id);
if (title !== undefined) {
todo.title = title;
}
if (completed !== undefined) {
todo.completed = completed;
}
}
};
//------------------------------------------------------------------------------
// STORE
//------------------------------------------------------------------------------
const LOCALSTORAGE_KEY = "todos-odoo";
export 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 odoo.core.Store({ state, actions, mutations });
store.on("update", null, () => {
const state = JSON.stringify(store.state.todos);
window.localStorage.setItem(LOCALSTORAGE_KEY, state);
});
return store;
}
+56
View File
@@ -0,0 +1,56 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<section t-name="todoapp" class="todoapp">
<!-- header -->
<header class="header">
<h1>todos</h1>
<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">
<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">
<t t-foreach="visibleTodos" t-as="todo">
<t t-widget="TodoItem" t-props="todo"/>
</t>
</ul>
</section>
<!-- footer -->
<footer class="footer" t-if="todos.length">
<span class="todo-count">
<strong>
<t t-esc="remaining"/>
</strong>
<t t-esc="remainingText"/>
</span>
<ul class="filters">
<li>
<a href="#/all" t-on-click="updateState({filter:'all'})" t-att-class="state.filter === 'all' ? 'selected' : ''">All</a>
</li>
<li>
<a href="#/active" t-on-click="updateState({filter:'active'})" t-att-class="state.filter === 'active' ? 'selected' : ''">Active</a>
</li>
<li>
<a href="#/completed" t-on-click="updateState({filter:'completed'})" t-att-class="state.filter === 'completed' ? 'selected' : ''">Completed</a>
</li>
</ul>
<button class="clear-completed" t-if="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' : '')">
<div class="view">
<input class="toggle" type="checkbox" t-on-change="toggleTodo" t-att-checked="props.completed"/>
<label t-on-dblclick="editTodo">
<t t-esc="props.title"/>
</label>
<button class="destroy" t-on-click="removeTodo"></button>
</div>
<input class="edit" t-ref="input" t-if="state.isEditing" t-att-value="props.title" t-on-keyup="handleKeyup" t-on-blur="handleBlur"/>
</li>
</templates>

Before

Width:  |  Height:  |  Size: 440 KiB

After

Width:  |  Height:  |  Size: 440 KiB

Before

Width:  |  Height:  |  Size: 115 KiB

After

Width:  |  Height:  |  Size: 115 KiB

Before

Width:  |  Height:  |  Size: 642 KiB

After

Width:  |  Height:  |  Size: 642 KiB

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Before

Width:  |  Height:  |  Size: 775 B

After

Width:  |  Height:  |  Size: 775 B

Before

Width:  |  Height:  |  Size: 852 B

After

Width:  |  Height:  |  Size: 852 B

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Before

Width:  |  Height:  |  Size: 267 B

After

Width:  |  Height:  |  Size: 267 B

Before

Width:  |  Height:  |  Size: 195 B

After

Width:  |  Height:  |  Size: 195 B

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