From 710f42d4e46b525557d746f87217f71332054f87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A9ry=20Debongnie?= Date: Tue, 11 Jun 2019 15:02:10 +0200 Subject: [PATCH] [IMP] qweb: add t-slot directive Closes #67 --- doc/component.md | 61 ++++++++++ doc/qweb.md | 5 +- src/qweb_core.ts | 5 + src/qweb_extensions.ts | 36 +++++- tests/__snapshots__/component.test.ts.snap | 61 ++++++++++ tests/component.test.ts | 101 +++++++++++++++++ tools/playground/samples.js | 124 ++++++++++++++++++++- 7 files changed, 388 insertions(+), 5 deletions(-) diff --git a/doc/component.md b/doc/component.md index c0064408..553f8828 100644 --- a/doc/component.md +++ b/doc/component.md @@ -15,6 +15,7 @@ - [`t-mounted` directive](#t-mounted-directive) - [Props Validation](#props-validation) - [Keeping References](#keeping-references) + - [Slots](#slots) - [Asynchronous rendering](#asynchronous-rendering) ## Overview @@ -748,6 +749,66 @@ The `t-ref` directive also accepts dynamic values with string interpolation 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 +will need to render some content, some footer, but with the parent as the +rendering context. + +This is what *slots* are for. + +```xml + +``` + +Slots are defined by the caller, with the `t-set` directive: + +```xml +
+
some widget
+ + +
hey
+
+ + + +
+
+``` + +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 +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, + +```xml + +
A
+
B
+
+``` + +is not allowed. A workaround could be to wrap the content in a div: + +```xml +
+
A
+
B
+
+``` + ### Asynchronous rendering Working with asynchronous code always adds a lot of complexity to a system. Whenever diff --git a/doc/qweb.md b/doc/qweb.md index 9a68df1d..7f026de2 100644 --- a/doc/qweb.md +++ b/doc/qweb.md @@ -73,6 +73,7 @@ needs. Here is a list of all Owl specific directives: | `t-on-*` | [Event handling](component.md#event-handling) | | `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) | ## QWeb Engine @@ -177,10 +178,10 @@ root nodes. QWeb expressions are strings that will be processed at compile time. Each variable in the javascript expression will be replaced by a lookup in the context (so, the -widget). For example, `a + b.c(d)` will be converted into: +widget). For example, `a + b.c(d)` will be converted into: ```js -context['a'] + context['b'].c(context['d']) +context["a"] + context["b"].c(context["d"]); ``` It is useful to explain the various rules that applies on these expressions: diff --git a/src/qweb_core.ts b/src/qweb_core.ts index 17e14942..75e2615a 100644 --- a/src/qweb_core.ts +++ b/src/qweb_core.ts @@ -140,6 +140,11 @@ export class QWeb { // able to map a qweb instance to a template name. id = nextID++; + // slots contains sub templates defined with t-set inside t-widget nodes, and + // are meant to be used by the t-slot directive. + slots = {}; + nextSlotId = 1; + constructor(data?: string) { if (data) { this.addTemplates(data); diff --git a/src/qweb_extensions.ts b/src/qweb_extensions.ts index 81375ea0..1da7d45a 100644 --- a/src/qweb_extensions.ts +++ b/src/qweb_extensions.ts @@ -11,6 +11,7 @@ import { QWeb, UTILS } from "./qweb_core"; * - t-transition * - t-widget/t-keepalive * - t-mounted + * - t-slot */ //------------------------------------------------------------------------------ @@ -351,7 +352,7 @@ QWeb.addDirective({ name: "widget", extraNames: ["props", "keepalive"], priority: 100, - atNodeEncounter({ ctx, value, node }): boolean { + atNodeEncounter({ ctx, value, node, qweb }): boolean { ctx.addLine("//WIDGET"); ctx.rootContext.shouldDefineOwner = true; ctx.rootContext.shouldDefineQWeb = true; @@ -523,6 +524,21 @@ QWeb.addDirective({ ctx.addLine( `context.__owl__.cmap[${templateID}] = w${widgetID}.__owl__.id;` ); + + // SLOTS + const slotNodes = node.querySelectorAll("[t-set]"); + if (slotNodes.length) { + const slotId = qweb.nextSlotId++; + for (let i = 0, length = slotNodes.length; i < length; i++) { + const slotNode = slotNodes[i]; + const key = slotNode.getAttribute("t-set")!; + slotNode.removeAttribute("t-set"); + const slotFn = qweb._compile(`slot_${key}_template`, slotNode); + qweb.slots[`${slotId}_${key}`] = slotFn.bind(qweb); + } + ctx.addLine(`w${widgetID}.__owl__.slotId = ${slotId};`); + } + ctx.addLine(`def${defID} = w${widgetID}._prepare();`); // hack: specify empty remove hook to prevent the node from being removed from the DOM // FIXME: click to re-add widget during remove transition -> leak @@ -602,3 +618,21 @@ QWeb.addDirective({ ); } }); + +//------------------------------------------------------------------------------ +// t-slot +//------------------------------------------------------------------------------ +QWeb.addDirective({ + name: "slot", + priority: 80, + atNodeEncounter({ ctx, value }): boolean { + const slotKey = ctx.generateID(); + ctx.addLine( + `const slot${slotKey} = this.slots[context.__owl__.slotId + '_' + '${value}'];` + ); + ctx.addLine( + `c${ctx.parentNode}.push(slot${slotKey}(context.__owl__.parent, extra));` + ); + return true; + } +}); diff --git a/tests/__snapshots__/component.test.ts.snap b/tests/__snapshots__/component.test.ts.snap index 1e74a42f..53fd4178 100644 --- a/tests/__snapshots__/component.test.ts.snap +++ b/tests/__snapshots__/component.test.ts.snap @@ -583,3 +583,64 @@ exports[`random stuff/miscellaneous snapshotting compiled code 1`] = ` return vn1; }" `; + +exports[`t-slot directive can define and call slots 1`] = ` +"function anonymous(context,extra +) { + let utils = this.utils; + let QWeb = this.constructor; + let owner = context; + var h = this.utils.h; + let c1 = [], p1 = {key:1}; + var vn1 = h('div', p1, c1); + //WIDGET + let _2_index = c1.length; + c1.push(null); + let def3; + let w4 = 4 in context.__owl__.cmap ? context.__owl__.children[context.__owl__.cmap[4]] : false; + let props4 = {}; + if (w4 && w4.__owl__.renderPromise && !w4.__owl__.vnode) { + if (utils.shallowEqual(props4, w4.__owl__.renderProps)) { + def3 = w4.__owl__.renderPromise; + } else { + w4.destroy(); + w4 = false; + } + } + if (!w4) { + let widgetKey4 = \`Dialog\`; + let W4 = context.widgets && context.widgets[widgetKey4] || QWeb.widgets[widgetKey4]; + if (!W4) {throw new Error('Cannot find the definition of widget \\"' + widgetKey4 + '\\"')} + w4 = new W4(owner, props4); + context.__owl__.cmap[4] = w4.__owl__.id; + w4.__owl__.slotsId = 1; + def3 = w4._prepare(); + def3 = def3.then(vnode=>{let pvnode=h(vnode.sel, {key: 4, hook: {insert(vn) {let nvn=w4._mount(vnode, pvnode.elm);pvnode.elm=nvn.elm;},remove() {},destroy(vn) {w4.destroy();}}});c1[_2_index]=pvnode;w4.__owl__.pvnode = pvnode;}); + } else { + def3 = def3 || w4._updateProps(props4, extra.forceUpdate, extra.patchQueue); + def3 = def3.then(()=>{if (w4.__owl__.isDestroyed) {return};let pvnode=w4.__owl__.pvnode;c1[_2_index]=pvnode;}); + } + extra.promises.push(def3); + return vn1; +}" +`; + +exports[`t-slot directive can define and call slots 2`] = ` +"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}; + var vn2 = h('div', p2, c2); + c1.push(vn2); + const slot3 = this.slots[context.__owl__.slotsId + '_' + 'header']; + c2.push(slot3(context.__owl__.parent, extra)); + let c4 = [], p4 = {key:4}; + var vn4 = h('div', p4, c4); + c1.push(vn4); + const slot5 = this.slots[context.__owl__.slotsId + '_' + 'footer']; + c4.push(slot5(context.__owl__.parent, extra)); + return vn1; +}" +`; diff --git a/tests/component.test.ts b/tests/component.test.ts index 12a68bce..c4251729 100644 --- a/tests/component.test.ts +++ b/tests/component.test.ts @@ -2613,3 +2613,104 @@ describe("can deduce template from name", () => { expect(fixture.innerHTML).toBe("Rochefort 10"); }); }); + +describe("t-slot directive", () => { + test("can define and call slots", async () => { + env.qweb.addTemplates(` + +
+ + header + footer + +
+
+
+
+
+
+ `); + class Dialog extends Widget {} + class Parent extends Widget { + widgets = { Dialog }; + } + const parent = new Parent(env); + await parent.mount(fixture); + + expect(fixture.innerHTML).toBe( + "
header
footer
" + ); + expect(env.qweb.templates.Parent.fn.toString()).toMatchSnapshot(); + expect(env.qweb.templates.Dialog.fn.toString()).toMatchSnapshot(); + }); + + test("slots are rendered with proper context", async () => { + env.qweb.addTemplates(` + +
+ + + + +
+ +
+ `); + class Dialog extends Widget {} + class Parent extends Widget { + widgets = { Dialog }; + state = { val: 0 }; + doSomething() { + this.state.val++; + } + } + const parent = new Parent(env); + await parent.mount(fixture); + + expect(fixture.innerHTML).toBe( + '
0
' + ); + + fixture.querySelector("button")!.click(); + await nextTick(); + + expect(fixture.innerHTML).toBe( + '
1
' + ); + }); + + test("refs are properly bound in slots", async () => { + env.qweb.addTemplates(` + +
+ + + + +
+ +
+ `); + class Dialog extends Widget {} + class Parent extends Widget { + widgets = { Dialog }; + state = { val: 0 }; + doSomething() { + this.state.val++; + } + } + const parent = new Parent(env); + await parent.mount(fixture); + + expect(fixture.innerHTML).toBe( + '
0
' + ); + + (parent.refs.myButton).click(); + await nextTick(); + + expect(fixture.innerHTML).toBe( + '
1
' + ); + }); +}); diff --git a/tools/playground/samples.js b/tools/playground/samples.js index 4ebe6f3e..e1ad026a 100644 --- a/tools/playground/samples.js +++ b/tools/playground/samples.js @@ -233,7 +233,6 @@ const ANIMATION_CSS = `button { } `; - const LIFECYCLE_DEMO = `class HookWidget extends owl.Component { constructor() { super(...arguments); @@ -1082,6 +1081,121 @@ const RESPONSIVE_CSS = `body { } `; +const SLOTS = `// This example will not work if your browser does not support ESNext class fields + +// We show here how slots can be used to create generic components. In this +// example, the Card component is basically only a container, and is created +// by giving it slots, inside the t-widget directive. + +class Card extends owl.Component { + state = { fullDisplay: true }; + + toggleDisplay() { + this.state.fullDisplay = !this.state.fullDisplay; + } +} + +class Counter extends owl.Component { + state = {val: 1}; + + inc() { + this.state.val++; + } +} + +// Main root widget +class App extends owl.Component { + widgets = {Card, Counter}; + state = {a: 1, b: 3}; + + inc(key, delta) { + this.state[key] += delta; + } +} + +// Application setup +const qweb = new owl.QWeb(TEMPLATES); +const app = new App({ qweb }); +app.mount(document.body);`; + +const SLOTS_XML = ` +
+
+ +
+ +
+ +
+ +
+
+ +
+ +
+ +
+ + Content of card 1... [] + + + +
+
Card 2... []
+ +
+ +
+
+
`; + +const SLOTS_CSS = `.main { + display: flex; +} + +.card { + display: flex; + flex-direction: column; + background-color: #eeeeee; + width: 200px; + height: 100px; + margin: 10px; + border: 1px solid gray; +} + +.card.full { + height: 100px; +} + +.card.small { + height: 25px; +} + +.card-title { + flex: 0 0 25px; + font-weight: bold; + background-color: darkcyan; + color: white; + padding: 2px; +} + +.card-title button { + float: right; +} + +.card-content { + flex: 1 1 auto; + padding: 5px; + border-top: 1px solid white; +} + +.card-footer { + border-top: 1px solid white; +}`; + const EMPTY = `class App extends owl.Component { } @@ -1113,7 +1227,7 @@ export const SAMPLES = [ description: "Animations", code: ANIMATION, xml: ANIMATION_XML, - css: ANIMATION_CSS, + css: ANIMATION_CSS }, { description: "Lifecycle demo", @@ -1132,6 +1246,12 @@ export const SAMPLES = [ css: RESPONSIVE_CSS, xml: RESPONSIVE_XML }, + { + description: "Slots", + code: SLOTS, + xml: SLOTS_XML, + css: SLOTS_CSS + }, { description: "Empty", code: EMPTY