[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)
- [Keeping References](#keeping-references)
- [Slots](#slots)
- [Form input bindings](#form-input-bindings)
- [Asynchronous rendering](#asynchronous-rendering)
## Overview
@@ -753,12 +754,12 @@ this.refs.widget_44;
### Slots
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
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
will need to render some content, some footer, but with the parent as the
rendering context.
This is what *slots* are for.
This is what _slots_ are for.
```xml
<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`
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.
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
<t t-set="content">
@@ -811,6 +812,98 @@ is not allowed. A workaround could be to wrap the content in a 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
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-mounted` | [Callback when a node or component is mounted](component.md#t-mounted-directive) |
| `t-slot` | [Rendering a slot](component.md#slots) |
| `t-model` | [Form input bindings](component.md#form-input-bindings) |
## QWeb Engine
+4 -3
View File
@@ -362,7 +362,7 @@ export class QWeb {
for (let i = 0; i < attributes.length; i++) {
let attrName = attributes[i].name;
if (attrName.startsWith("t-")) {
let dName = attrName.slice(2).split("-")[0];
let dName = attrName.slice(2).split(/-|\./)[0];
if (!(dName in DIRECTIVE_NAMES)) {
throw new Error(`Unknown QWeb directive: '${attrName}'`);
}
@@ -379,12 +379,13 @@ export class QWeb {
const name = attributes[j].name;
if (
name === "t-" + directive.name ||
name.startsWith("t-" + directive.name + "-")
name.startsWith("t-" + directive.name + "-") ||
name.startsWith("t-" + directive.name + ".")
) {
fullName = name;
value = attributes[j].textContent;
validDirectives.push({ directive, value, fullName });
if (directive.name === "on") {
if (directive.name === "on" || directive.name === 'model') {
withHandlers = true;
}
}
+49
View File
@@ -13,6 +13,7 @@ import { VNode } from "./vdom";
* - t-widget/t-keepalive
* - t-mounted
* - t-slot
* - t-model
*/
//------------------------------------------------------------------------------
@@ -658,3 +659,51 @@ QWeb.addDirective({
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`] = `
"function anonymous(context,extra
) {
+234 -1
View File
@@ -6,7 +6,8 @@ import {
makeTestEnv,
nextMicroTick,
nextTick,
normalize
normalize,
editInput
} 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() {
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);
`;
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 = [
{
description: "Click Counter",
@@ -1305,6 +1359,11 @@ export const SAMPLES = [
xml: CLICK_COUNTER_XML,
css: CLICK_COUNTER_CSS
},
{
description: "Form input bindings",
code: FORM,
xml: FORM_XML,
},
{
description: "Widget Composition",
code: WIDGET_COMPOSITION,