From c481a73a76b29db0acbf8483a2722c0e880f153d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A9ry=20Debongnie?= Date: Thu, 13 Jun 2019 16:27:12 +0200 Subject: [PATCH] [ADD] component: implement t-model directive closes #170 --- doc/component.md | 103 ++++++++- doc/qweb.md | 1 + src/qweb_core.ts | 7 +- src/qweb_extensions.ts | 49 +++++ tests/__snapshots__/component.test.ts.snap | 147 +++++++++++++ tests/component.test.ts | 235 ++++++++++++++++++++- tests/helpers.ts | 10 + tools/playground/samples.js | 59 ++++++ 8 files changed, 602 insertions(+), 9 deletions(-) diff --git a/doc/component.md b/doc/component.md index b14fe2e2..94c31cd3 100644 --- a/doc/component.md +++ b/doc/component.md @@ -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 ``` +### 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 +
+ + +
+``` + +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 +
+ + +
+``` + +The `t-model` directive works with ``, ``, +``, `" + ); + + const textarea = fixture.querySelector("textarea")!; + await editInput(textarea, "test"); + expect(comp.state.text).toBe("test"); + expect(fixture.innerHTML).toBe( + "
test
" + ); + }); + + test("on an input type=radio", async () => { + env.qweb.addTemplates(` + +
+ + + Choice: +
+
`); + class SomeComponent extends Widget { + state = { choice: "" }; + } + const comp = new SomeComponent(env); + await comp.mount(fixture); + + expect(fixture.innerHTML).toBe( + '
Choice:
' + ); + + const firstInput = fixture.querySelector("input")!; + firstInput.click(); + await nextTick(); + expect(comp.state.choice).toBe("One"); + expect(fixture.innerHTML).toBe( + '
Choice: One
' + ); + + const secondInput = fixture.querySelectorAll("input")[1]; + secondInput.click(); + await nextTick(); + expect(comp.state.choice).toBe("Two"); + expect(fixture.innerHTML).toBe( + '
Choice: Two
' + ); + expect(env.qweb.templates.SomeComponent.fn.toString()).toMatchSnapshot(); + }); + + test("on a select", async () => { + env.qweb.addTemplates(` + +
+ + Choice: +
+
`); + class SomeComponent extends Widget { + state = { color: "" }; + } + const comp = new SomeComponent(env); + await comp.mount(fixture); + + expect(fixture.innerHTML).toBe( + '
Choice:
' + ); + + 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( + '
Choice: red
' + ); + + expect(env.qweb.templates.SomeComponent.fn.toString()).toMatchSnapshot(); + }); + + test(".lazy modifier", async () => { + env.qweb.addTemplates(` + +
+ + +
+
`); + class SomeComponent extends Widget { + state = { text: "" }; + } + const comp = new SomeComponent(env); + await comp.mount(fixture); + + expect(fixture.innerHTML).toBe("
"); + + const input = fixture.querySelector("input")!; + input.value = "test"; + input.dispatchEvent(new Event("input")); + await nextTick(); + expect(comp.state.text).toBe(""); + expect(fixture.innerHTML).toBe("
"); + input.dispatchEvent(new Event("change")); + await nextTick(); + expect(comp.state.text).toBe("test"); + expect(fixture.innerHTML).toBe("
test
"); + expect(env.qweb.templates.SomeComponent.fn.toString()).toMatchSnapshot(); + }); + + test(".trim modifier", async () => { + env.qweb.addTemplates(` + +
+ + +
+
`); + 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("
test
"); + }); + + test(".number modifier", async () => { + env.qweb.addTemplates(` + +
+ + +
+
`); + class SomeComponent extends Widget { + state = { number: 0 }; + } + const comp = new SomeComponent(env); + await comp.mount(fixture); + expect(fixture.innerHTML).toBe("
0
"); + + const input = fixture.querySelector("input")!; + await editInput(input, "13"); + expect(comp.state.number).toBe(13); + expect(fixture.innerHTML).toBe("
13
"); + + await editInput(input, "invalid"); + expect(comp.state.number).toBe("invalid"); + expect(fixture.innerHTML).toBe("
invalid
"); + }); +}); diff --git a/tests/helpers.ts b/tests/helpers.ts index 2c0e23fe..43dbbf89 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -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(); +} diff --git a/tools/playground/samples.js b/tools/playground/samples.js index 99af2c12..9e73bdd5 100644 --- a/tools/playground/samples.js +++ b/tools/playground/samples.js @@ -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 = ` +
+

Form

+
+ Text (immediate): +
+
+ Other text (lazy): +
+
+ Number: +
+
+ Boolean: +
+
+ Color, with a select: +
+
+ Color, with radio buttons: + + +
+ +
+

State

+
Text:
+
Other Text:
+
Number:
+
Boolean: TrueFalse
+
Color:
+
+
+`; + 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,