[ADD] component: implement t-model directive

closes #170
This commit is contained in:
Géry Debongnie
2019-06-13 16:27:12 +02:00
parent e64e415b3b
commit c481a73a76
8 changed files with 602 additions and 9 deletions
+98 -5
View File
@@ -18,6 +18,7 @@
- [Props Validation](#props-validation) - [Props Validation](#props-validation)
- [Keeping References](#keeping-references) - [Keeping References](#keeping-references)
- [Slots](#slots) - [Slots](#slots)
- [Form input bindings](#form-input-bindings)
- [Asynchronous rendering](#asynchronous-rendering) - [Asynchronous rendering](#asynchronous-rendering)
## Overview ## Overview
@@ -753,12 +754,12 @@ this.refs.widget_44;
### Slots ### Slots
To make generic components, it is useful to be able for a parent widget to *inject* To make generic components, it is useful to be able for a parent widget to _inject_
some sub template, but still be the owner. For example, a generic dialog widget some sub template, but still be the owner. For example, a generic dialog widget
will need to render some content, some footer, but with the parent as the will need to render some content, some footer, but with the parent as the
rendering context. rendering context.
This is what *slots* are for. This is what _slots_ are for.
```xml ```xml
<div t-name="Dialog" class="modal"> <div t-name="Dialog" class="modal">
@@ -789,11 +790,11 @@ Slots are defined by the caller, with the `t-set` directive:
``` ```
In this example, the widget `Dialog` will render the slots `content` and `footer` In this example, the widget `Dialog` will render the slots `content` and `footer`
with its parent as rendering context. This means that clicking on the button with its parent as rendering context. This means that clicking on the button
will execute the `doSomething` method on the parent, not on the dialog. will execute the `doSomething` method on the parent, not on the dialog.
Warning! Slots have a technical constraint: the result of the slot rendering Warning! Slots have a technical constraint: the result of the slot rendering
should have exactly one root node. So, should have exactly one root node. So,
```xml ```xml
<t t-set="content"> <t t-set="content">
@@ -811,6 +812,98 @@ is not allowed. A workaround could be to wrap the content in a div:
</div> </div>
``` ```
### Form Input Bindings
It is very common to need to be able to read the value out of an html `input` (or
`textarea`, or `select`) in order to use it (note: it does not need to be in a
form!). A possible way to do this is to do it by hand:
```js
class Form extends owl.Component {
state = { text: "" };
_updateInputValue(event) {
this.state.text = event.target.value;
}
}
```
```xml
<div>
<input t-on-input="_updateInputValue"/>
<span t-esc="state.text"/>
</div>
```
This works. However, this requires a little bit of _plumbing_ code. Also, the
plumbing code is slightly different if you need to interact with a checkbox,
or with radio buttons, or with select tags.
To help with this situation, Owl has a builtin directive `t-model`: its value
is the (top-level) name in the state object. With the `t-model` directive, we
can write a shorter code, equivalent to the previous example:
```js
class Form extends owl.Component {
state = { text: "" };
}
```
```xml
<div>
<input t-model="text"/>
<span t-esc="state.text"/>
</div>
```
The `t-model` directive works with `<input>`, `<input type="checkbox">`,
`<input type="radio">`, `<textarea>` and `<select>`:
```xml
<div>
<div>Text in an input: <input t-model="someVal"/></div>
<div>Textarea: <textarea t-model="otherVal"/></div>
<div>Boolean value: <input type="checkbox" t-model="someFlag"/></div>
<div>Selection:
<select t-model="color">
<option value="">Select a color</option>
<option value="red">Red</option>
<option value="blue">Blue</option>
</select>
</div>
<div>
Selection with radio buttons:
<span>
<input type="radio" name="color" id="red" value="red" t-model="color"/>
<label for="red">Red</label>
</span>
<span>
<input type="radio" name="color" id="blue" value="blue" t-model="color"/>
<label for="blue">Blue</label>
</span>
</div>
</div>
```
Like event handling, the `t-model` directive accepts some modifiers:
| Modifier | Description |
| --------- | -------------------------------------------------------------------- |
| `.lazy` | update the value on the `change` event (default is on `input` event) |
| `.number` | tries to parse the value to a number (using `parseFloat`) |
| `.trim` | trim the resulting value |
For example:
```xml
<input t-model.lazy="someVal"/>
```
These modifiers can be combined. For instance, `t-model.lazy.number` will only
update a number whenever the change is done.
Note: the online playground has an example to show how it works.
### Asynchronous rendering ### Asynchronous rendering
Working with asynchronous code always adds a lot of complexity to a system. Whenever Working with asynchronous code always adds a lot of complexity to a system. Whenever
+1
View File
@@ -74,6 +74,7 @@ needs. Here is a list of all Owl specific directives:
| `t-transition` | [Defining an animation](animations.md#css-transitions) | | `t-transition` | [Defining an animation](animations.md#css-transitions) |
| `t-mounted` | [Callback when a node or component is mounted](component.md#t-mounted-directive) | | `t-mounted` | [Callback when a node or component is mounted](component.md#t-mounted-directive) |
| `t-slot` | [Rendering a slot](component.md#slots) | | `t-slot` | [Rendering a slot](component.md#slots) |
| `t-model` | [Form input bindings](component.md#form-input-bindings) |
## QWeb Engine ## QWeb Engine
+4 -3
View File
@@ -362,7 +362,7 @@ export class QWeb {
for (let i = 0; i < attributes.length; i++) { for (let i = 0; i < attributes.length; i++) {
let attrName = attributes[i].name; let attrName = attributes[i].name;
if (attrName.startsWith("t-")) { if (attrName.startsWith("t-")) {
let dName = attrName.slice(2).split("-")[0]; let dName = attrName.slice(2).split(/-|\./)[0];
if (!(dName in DIRECTIVE_NAMES)) { if (!(dName in DIRECTIVE_NAMES)) {
throw new Error(`Unknown QWeb directive: '${attrName}'`); throw new Error(`Unknown QWeb directive: '${attrName}'`);
} }
@@ -379,12 +379,13 @@ export class QWeb {
const name = attributes[j].name; const name = attributes[j].name;
if ( if (
name === "t-" + directive.name || name === "t-" + directive.name ||
name.startsWith("t-" + directive.name + "-") name.startsWith("t-" + directive.name + "-") ||
name.startsWith("t-" + directive.name + ".")
) { ) {
fullName = name; fullName = name;
value = attributes[j].textContent; value = attributes[j].textContent;
validDirectives.push({ directive, value, fullName }); validDirectives.push({ directive, value, fullName });
if (directive.name === "on") { if (directive.name === "on" || directive.name === 'model') {
withHandlers = true; withHandlers = true;
} }
} }
+49
View File
@@ -13,6 +13,7 @@ import { VNode } from "./vdom";
* - t-widget/t-keepalive * - t-widget/t-keepalive
* - t-mounted * - t-mounted
* - t-slot * - t-slot
* - t-model
*/ */
//------------------------------------------------------------------------------ //------------------------------------------------------------------------------
@@ -658,3 +659,51 @@ QWeb.addDirective({
return true; return true;
} }
}); });
//------------------------------------------------------------------------------
// t-model
//------------------------------------------------------------------------------
UTILS.toNumber = function(val: string): number | string {
const n = parseFloat(val);
return isNaN(n) ? val : n;
};
QWeb.addDirective({
name: "model",
priority: 42,
atNodeCreation({ ctx, nodeID, value, node, fullName }) {
const type = node.getAttribute("type");
let handler;
let event = fullName.includes(".lazy") ? "change" : "input";
if (node.tagName === "select") {
ctx.addLine(`p${nodeID}.props = {value: context.state['${value}']};`);
event = "change";
handler = `(ev) => {context.state['${value}'] = ev.target.value}`;
} else if (type === "checkbox") {
ctx.addLine(`p${nodeID}.props = {checked: context.state['${value}']};`);
handler = `(ev) => {context.state['${value}'] = ev.target.checked}`;
} else if (type === "radio") {
const nodeValue = node.getAttribute("value")!;
ctx.addLine(
`p${nodeID}.props = {checked:context.state['${value}'] === '${nodeValue}'};`
);
handler = `(ev) => {context.state['${value}'] = ev.target.value}`;
event = "click";
} else {
ctx.addLine(`p${nodeID}.props = {value: context.state['${value}']};`);
const trimCode = fullName.includes(".trim") ? ".trim()" : "";
let valueCode = `ev.target.value${trimCode}`;
if (fullName.includes(".number")) {
ctx.rootContext.shouldDefineUtils = true;
valueCode = `utils.toNumber(${valueCode})`;
}
handler = `(ev) => {context.state['${value}'] = ${valueCode}}`;
}
ctx.addLine(
`extra.handlers['${event}' + ${nodeID}] = extra.handlers['${event}' + ${nodeID}] || (${handler});`
);
ctx.addLine(
`p${nodeID}.on['${event}'] = extra.handlers['${event}' + ${nodeID}];`
);
}
});
+147
View File
@@ -827,6 +827,153 @@ exports[`random stuff/miscellaneous snapshotting compiled code 1`] = `
}" }"
`; `;
exports[`t-model directive .lazy modifier 1`] = `
"function anonymous(context,extra
) {
var h = this.utils.h;
let c1 = [], p1 = {key:1};
var vn1 = h('div', p1, c1);
let c2 = [], p2 = {key:2,on:{}};
var vn2 = h('input', p2, c2);
c1.push(vn2);
p2.props = {value: context.state['text']};
extra.handlers['change' + 2] = extra.handlers['change' + 2] || ((ev) => {context.state['text'] = ev.target.value});
p2.on['change'] = extra.handlers['change' + 2];
let c3 = [], p3 = {key:3};
var vn3 = h('span', p3, c3);
c1.push(vn3);
var _4 = context['state'].text;
if (_4 || _4 === 0) {
c3.push({text: _4});
}
return vn1;
}"
`;
exports[`t-model directive basic use, on an input 1`] = `
"function anonymous(context,extra
) {
var h = this.utils.h;
let c1 = [], p1 = {key:1};
var vn1 = h('div', p1, c1);
let c2 = [], p2 = {key:2,on:{}};
var vn2 = h('input', p2, c2);
c1.push(vn2);
p2.props = {value: context.state['text']};
extra.handlers['input' + 2] = extra.handlers['input' + 2] || ((ev) => {context.state['text'] = ev.target.value});
p2.on['input'] = extra.handlers['input' + 2];
let c3 = [], p3 = {key:3};
var vn3 = h('span', p3, c3);
c1.push(vn3);
var _4 = context['state'].text;
if (_4 || _4 === 0) {
c3.push({text: _4});
}
return vn1;
}"
`;
exports[`t-model directive on a select 1`] = `
"function anonymous(context,extra
) {
var h = this.utils.h;
let c1 = [], p1 = {key:1};
var vn1 = h('div', p1, c1);
let c2 = [], p2 = {key:2,on:{}};
var vn2 = h('select', p2, c2);
c1.push(vn2);
p2.props = {value: context.state['color']};
extra.handlers['change' + 2] = extra.handlers['change' + 2] || ((ev) => {context.state['color'] = ev.target.value});
p2.on['change'] = extra.handlers['change' + 2];
var _3 = '';
let c4 = [], p4 = {key:4,attrs:{value: _3}};
var vn4 = h('option', p4, c4);
c2.push(vn4);
c4.push({text: \`Please select one\`});
var _5 = 'red';
let c6 = [], p6 = {key:6,attrs:{value: _5}};
var vn6 = h('option', p6, c6);
c2.push(vn6);
c6.push({text: \`Red\`});
var _7 = 'blue';
let c8 = [], p8 = {key:8,attrs:{value: _7}};
var vn8 = h('option', p8, c8);
c2.push(vn8);
c8.push({text: \`Blue\`});
let c9 = [], p9 = {key:9};
var vn9 = h('span', p9, c9);
c1.push(vn9);
c9.push({text: \`Choice: \`});
var _10 = context['state'].color;
if (_10 || _10 === 0) {
c9.push({text: _10});
}
return vn1;
}"
`;
exports[`t-model directive on an input type=radio 1`] = `
"function anonymous(context,extra
) {
var h = this.utils.h;
let c1 = [], p1 = {key:1};
var vn1 = h('div', p1, c1);
var _2 = 'radio';
var _3 = 'one';
var _4 = 'One';
let c5 = [], p5 = {key:5,attrs:{type: _2,id: _3,value: _4},on:{}};
var vn5 = h('input', p5, c5);
c1.push(vn5);
p5.props = {checked:context.state['choice'] === 'One'};
extra.handlers['click' + 5] = extra.handlers['click' + 5] || ((ev) => {context.state['choice'] = ev.target.value});
p5.on['click'] = extra.handlers['click' + 5];
var _6 = 'radio';
var _7 = 'two';
var _8 = 'Two';
let c9 = [], p9 = {key:9,attrs:{type: _6,id: _7,value: _8},on:{}};
var vn9 = h('input', p9, c9);
c1.push(vn9);
p9.props = {checked:context.state['choice'] === 'Two'};
extra.handlers['click' + 9] = extra.handlers['click' + 9] || ((ev) => {context.state['choice'] = ev.target.value});
p9.on['click'] = extra.handlers['click' + 9];
let c10 = [], p10 = {key:10};
var vn10 = h('span', p10, c10);
c1.push(vn10);
c10.push({text: \`Choice: \`});
var _11 = context['state'].choice;
if (_11 || _11 === 0) {
c10.push({text: _11});
}
return vn1;
}"
`;
exports[`t-model directive on an input, type=checkbox 1`] = `
"function anonymous(context,extra
) {
var h = this.utils.h;
let c1 = [], p1 = {key:1};
var vn1 = h('div', p1, c1);
var _2 = 'checkbox';
let c3 = [], p3 = {key:3,attrs:{type: _2},on:{}};
var vn3 = h('input', p3, c3);
c1.push(vn3);
p3.props = {checked: context.state['flag']};
extra.handlers['input' + 3] = extra.handlers['input' + 3] || ((ev) => {context.state['flag'] = ev.target.checked});
p3.on['input'] = extra.handlers['input' + 3];
let c4 = [], p4 = {key:4};
var vn4 = h('span', p4, c4);
c1.push(vn4);
if (context['state'].flag) {
c4.push({text: \`yes\`});
}
else {
c4.push({text: \`no\`});
}
return vn1;
}"
`;
exports[`t-slot directive can define and call slots 1`] = ` exports[`t-slot directive can define and call slots 1`] = `
"function anonymous(context,extra "function anonymous(context,extra
) { ) {
+234 -1
View File
@@ -6,7 +6,8 @@ import {
makeTestEnv, makeTestEnv,
nextMicroTick, nextMicroTick,
nextTick, nextTick,
normalize normalize,
editInput
} from "./helpers"; } from "./helpers";
//------------------------------------------------------------------------------ //------------------------------------------------------------------------------
@@ -2902,3 +2903,235 @@ describe("t-slot directive", () => {
); );
}); });
}); });
describe("t-model directive", () => {
test("basic use, on an input", async () => {
env.qweb.addTemplates(`
<templates>
<div t-name="SomeComponent">
<input t-model="text"/>
<span><t t-esc="state.text"/></span>
</div>
</templates>`);
class SomeComponent extends Widget {
state = { text: "" };
}
const comp = new SomeComponent(env);
await comp.mount(fixture);
expect(fixture.innerHTML).toBe("<div><input><span></span></div>");
const input = fixture.querySelector("input")!;
await editInput(input, "test");
expect(comp.state.text).toBe("test");
expect(fixture.innerHTML).toBe("<div><input><span>test</span></div>");
expect(env.qweb.templates.SomeComponent.fn.toString()).toMatchSnapshot();
});
test("on an input, type=checkbox", async () => {
env.qweb.addTemplates(`
<templates>
<div t-name="SomeComponent">
<input type="checkbox" t-model="flag"/>
<span>
<t t-if="state.flag">yes</t>
<t t-else="1">no</t>
</span>
</div>
</templates>`);
class SomeComponent extends Widget {
state = { flag: false };
}
const comp = new SomeComponent(env);
await comp.mount(fixture);
expect(fixture.innerHTML).toBe(
'<div><input type="checkbox"><span>no</span></div>'
);
let input = fixture.querySelector("input")!;
input.click();
await nextTick();
expect(fixture.innerHTML).toBe(
'<div><input type="checkbox"><span>yes</span></div>'
);
expect(comp.state.flag).toBe(true);
expect(env.qweb.templates.SomeComponent.fn.toString()).toMatchSnapshot();
input.click();
await nextTick();
expect(comp.state.flag).toBe(false);
});
test("on an textarea", async () => {
env.qweb.addTemplates(`
<templates>
<div t-name="SomeComponent">
<textarea t-model="text"/>
<span><t t-esc="state.text"/></span>
</div>
</templates>`);
class SomeComponent extends Widget {
state = { text: "" };
}
const comp = new SomeComponent(env);
await comp.mount(fixture);
expect(fixture.innerHTML).toBe(
"<div><textarea></textarea><span></span></div>"
);
const textarea = fixture.querySelector("textarea")!;
await editInput(textarea, "test");
expect(comp.state.text).toBe("test");
expect(fixture.innerHTML).toBe(
"<div><textarea></textarea><span>test</span></div>"
);
});
test("on an input type=radio", async () => {
env.qweb.addTemplates(`
<templates>
<div t-name="SomeComponent">
<input type="radio" id="one" value="One" t-model="choice"/>
<input type="radio" id="two" value="Two" t-model="choice"/>
<span>Choice: <t t-esc="state.choice"/></span>
</div>
</templates>`);
class SomeComponent extends Widget {
state = { choice: "" };
}
const comp = new SomeComponent(env);
await comp.mount(fixture);
expect(fixture.innerHTML).toBe(
'<div><input type="radio" id="one" value="One"><input type="radio" id="two" value="Two"><span>Choice: </span></div>'
);
const firstInput = fixture.querySelector("input")!;
firstInput.click();
await nextTick();
expect(comp.state.choice).toBe("One");
expect(fixture.innerHTML).toBe(
'<div><input type="radio" id="one" value="One"><input type="radio" id="two" value="Two"><span>Choice: One</span></div>'
);
const secondInput = fixture.querySelectorAll("input")[1];
secondInput.click();
await nextTick();
expect(comp.state.choice).toBe("Two");
expect(fixture.innerHTML).toBe(
'<div><input type="radio" id="one" value="One"><input type="radio" id="two" value="Two"><span>Choice: Two</span></div>'
);
expect(env.qweb.templates.SomeComponent.fn.toString()).toMatchSnapshot();
});
test("on a select", async () => {
env.qweb.addTemplates(`
<templates>
<div t-name="SomeComponent">
<select t-model="color">
<option value="">Please select one</option>
<option value="red">Red</option>
<option value="blue">Blue</option>
</select>
<span>Choice: <t t-esc="state.color"/></span>
</div>
</templates>`);
class SomeComponent extends Widget {
state = { color: "" };
}
const comp = new SomeComponent(env);
await comp.mount(fixture);
expect(fixture.innerHTML).toBe(
'<div><select><option value="">Please select one</option><option value="red">Red</option><option value="blue">Blue</option></select><span>Choice: </span></div>'
);
const select = fixture.querySelector("select")!;
select.value = "red";
select.dispatchEvent(new Event("change"));
await nextTick();
expect(comp.state.color).toBe("red");
expect(fixture.innerHTML).toBe(
'<div><select><option value="">Please select one</option><option value="red">Red</option><option value="blue">Blue</option></select><span>Choice: red</span></div>'
);
expect(env.qweb.templates.SomeComponent.fn.toString()).toMatchSnapshot();
});
test(".lazy modifier", async () => {
env.qweb.addTemplates(`
<templates>
<div t-name="SomeComponent">
<input t-model.lazy="text"/>
<span><t t-esc="state.text"/></span>
</div>
</templates>`);
class SomeComponent extends Widget {
state = { text: "" };
}
const comp = new SomeComponent(env);
await comp.mount(fixture);
expect(fixture.innerHTML).toBe("<div><input><span></span></div>");
const input = fixture.querySelector("input")!;
input.value = "test";
input.dispatchEvent(new Event("input"));
await nextTick();
expect(comp.state.text).toBe("");
expect(fixture.innerHTML).toBe("<div><input><span></span></div>");
input.dispatchEvent(new Event("change"));
await nextTick();
expect(comp.state.text).toBe("test");
expect(fixture.innerHTML).toBe("<div><input><span>test</span></div>");
expect(env.qweb.templates.SomeComponent.fn.toString()).toMatchSnapshot();
});
test(".trim modifier", async () => {
env.qweb.addTemplates(`
<templates>
<div t-name="SomeComponent">
<input t-model.trim="text"/>
<span><t t-esc="state.text"/></span>
</div>
</templates>`);
class SomeComponent extends Widget {
state = { text: "" };
}
const comp = new SomeComponent(env);
await comp.mount(fixture);
const input = fixture.querySelector("input")!;
await editInput(input, " test ");
expect(comp.state.text).toBe("test");
expect(fixture.innerHTML).toBe("<div><input><span>test</span></div>");
});
test(".number modifier", async () => {
env.qweb.addTemplates(`
<templates>
<div t-name="SomeComponent">
<input t-model.number="number"/>
<span><t t-esc="state.number"/></span>
</div>
</templates>`);
class SomeComponent extends Widget {
state = { number: 0 };
}
const comp = new SomeComponent(env);
await comp.mount(fixture);
expect(fixture.innerHTML).toBe("<div><input><span>0</span></div>");
const input = fixture.querySelector("input")!;
await editInput(input, "13");
expect(comp.state.number).toBe(13);
expect(fixture.innerHTML).toBe("<div><input><span>13</span></div>");
await editInput(input, "invalid");
expect(comp.state.number).toBe("invalid");
expect(fixture.innerHTML).toBe("<div><input><span>invalid</span></div>");
});
});
+10
View File
@@ -91,3 +91,13 @@ export function patchNextFrame(f: Function) {
export function unpatchNextFrame() { export function unpatchNextFrame() {
UTILS.nextFrame = nextFrame; UTILS.nextFrame = nextFrame;
} }
export async function editInput(
input: HTMLInputElement | HTMLTextAreaElement,
value: string
) {
input.value = value;
input.dispatchEvent(new Event("input"));
input.dispatchEvent(new Event("change"));
return nextTick();
}
+59
View File
@@ -1292,6 +1292,60 @@ const app = new App({qweb});
app.mount(document.body); app.mount(document.body);
`; `;
const FORM = `class Form extends owl.Component {
state = {
text: "",
othertext: "",
number: 11,
color: "",
bool: false
};
}
const qweb = new owl.QWeb(TEMPLATES);
const form = new Form({ qweb });
form.mount(document.body);
`;
const FORM_XML = `<templates>
<div t-name="Form">
<h1>Form</h1>
<div>
Text (immediate): <input t-model="text"/>
</div>
<div>
Other text (lazy): <input t-model.lazy="othertext"/>
</div>
<div>
Number: <input t-model.number="number"/>
</div>
<div>
Boolean: <input type="checkbox" t-model="bool"/>
</div>
<div>
Color, with a select: <select t-model="color">
<option value="">Select a color</option>
<option value="red">Red</option>
<option value="blue">Blue</option>
</select>
</div>
<div>
Color, with radio buttons:
<span><input type="radio" name="color" id="red" value="red" t-model="color"/><label for="red">Red</label></span>
<span><input type="radio" name="color" id="blue" value="blue" t-model="color"/><label for="blue">Blue</label></span>
</div>
<hr/>
<h1>State</h1>
<div>Text: <t t-esc="state.text"/></div>
<div>Other Text: <t t-esc="state.othertext"/></div>
<div>Number: <t t-esc="state.number"/></div>
<div>Boolean: <t t-if="state.bool">True</t><t t-else="1">False</t></div>
<div>Color: <t t-esc="state.color"/></div>
</div>
</templates>
`;
export const SAMPLES = [ export const SAMPLES = [
{ {
description: "Click Counter", description: "Click Counter",
@@ -1305,6 +1359,11 @@ export const SAMPLES = [
xml: CLICK_COUNTER_XML, xml: CLICK_COUNTER_XML,
css: CLICK_COUNTER_CSS css: CLICK_COUNTER_CSS
}, },
{
description: "Form input bindings",
code: FORM,
xml: FORM_XML,
},
{ {
description: "Widget Composition", description: "Widget Composition",
code: WIDGET_COMPOSITION, code: WIDGET_COMPOSITION,