[IMP] qweb: add t-slot directive

Closes #67
This commit is contained in:
Géry Debongnie
2019-06-11 15:02:10 +02:00
parent 45a2b0122d
commit 710f42d4e4
7 changed files with 388 additions and 5 deletions
+61
View File
@@ -15,6 +15,7 @@
- [`t-mounted` directive](#t-mounted-directive) - [`t-mounted` directive](#t-mounted-directive)
- [Props Validation](#props-validation) - [Props Validation](#props-validation)
- [Keeping References](#keeping-references) - [Keeping References](#keeping-references)
- [Slots](#slots)
- [Asynchronous rendering](#asynchronous-rendering) - [Asynchronous rendering](#asynchronous-rendering)
## Overview ## Overview
@@ -748,6 +749,66 @@ The `t-ref` directive also accepts dynamic values with string interpolation
this.refs.widget_44; 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
<div t-name="Dialog" class="modal">
<div class="modal-title"><t t-esc="props.title"/></div>
<div class="modal-content">
<t t-slot="content"/>
</div>
<div class="modal-footer">
<t t-slot="footer"/>
</div>
</div>
```
Slots are defined by the caller, with the `t-set` directive:
```xml
<div t-name="SomeWidget">
<div>some widget</div>
<t t-widget="Dialog" title="Some Dialog">
<t t-set="content">
<div>hey</div>
</t>
<t t-set="footer">
<button t-on-click="doSomething">ok</button>
</t>
</t>
</div>
```
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
<t t-set="content">
<div>A</div>
<div>B</div>
</t>
```
is not allowed. A workaround could be to wrap the content in a div:
```xml
<div t-set="content">
<div>A</div>
<div>B</div>
</div>
```
### 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
+3 -2
View File
@@ -73,6 +73,7 @@ needs. Here is a list of all Owl specific directives:
| `t-on-*` | [Event handling](component.md#event-handling) | | `t-on-*` | [Event handling](component.md#event-handling) |
| `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) |
## QWeb Engine ## QWeb Engine
@@ -177,10 +178,10 @@ root nodes.
QWeb expressions are strings that will be processed at compile time. Each variable in 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 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 ```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: It is useful to explain the various rules that applies on these expressions:
+5
View File
@@ -140,6 +140,11 @@ export class QWeb {
// able to map a qweb instance to a template name. // able to map a qweb instance to a template name.
id = nextID++; 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) { constructor(data?: string) {
if (data) { if (data) {
this.addTemplates(data); this.addTemplates(data);
+35 -1
View File
@@ -11,6 +11,7 @@ import { QWeb, UTILS } from "./qweb_core";
* - t-transition * - t-transition
* - t-widget/t-keepalive * - t-widget/t-keepalive
* - t-mounted * - t-mounted
* - t-slot
*/ */
//------------------------------------------------------------------------------ //------------------------------------------------------------------------------
@@ -351,7 +352,7 @@ QWeb.addDirective({
name: "widget", name: "widget",
extraNames: ["props", "keepalive"], extraNames: ["props", "keepalive"],
priority: 100, priority: 100,
atNodeEncounter({ ctx, value, node }): boolean { atNodeEncounter({ ctx, value, node, qweb }): boolean {
ctx.addLine("//WIDGET"); ctx.addLine("//WIDGET");
ctx.rootContext.shouldDefineOwner = true; ctx.rootContext.shouldDefineOwner = true;
ctx.rootContext.shouldDefineQWeb = true; ctx.rootContext.shouldDefineQWeb = true;
@@ -523,6 +524,21 @@ QWeb.addDirective({
ctx.addLine( ctx.addLine(
`context.__owl__.cmap[${templateID}] = w${widgetID}.__owl__.id;` `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();`); ctx.addLine(`def${defID} = w${widgetID}._prepare();`);
// hack: specify empty remove hook to prevent the node from being removed from the DOM // 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 // 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;
}
});
@@ -583,3 +583,64 @@ exports[`random stuff/miscellaneous snapshotting compiled code 1`] = `
return vn1; 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;
}"
`;
+101
View File
@@ -2613,3 +2613,104 @@ describe("can deduce template from name", () => {
expect(fixture.innerHTML).toBe("<span>Rochefort 10</span>"); expect(fixture.innerHTML).toBe("<span>Rochefort 10</span>");
}); });
}); });
describe("t-slot directive", () => {
test("can define and call slots", async () => {
env.qweb.addTemplates(`
<templates>
<div t-name="Parent">
<t t-widget="Dialog">
<t t-set="header"><span>header</span></t>
<t t-set="footer"><span>footer</span></t>
</t>
</div>
<div t-name="Dialog">
<div><t t-slot="header"/></div>
<div><t t-slot="footer"/></div>
</div>
</templates>
`);
class Dialog extends Widget {}
class Parent extends Widget {
widgets = { Dialog };
}
const parent = new Parent(env);
await parent.mount(fixture);
expect(fixture.innerHTML).toBe(
"<div><div><div><span>header</span></div><div><span>footer</span></div></div></div>"
);
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(`
<templates>
<div t-name="Parent">
<span class="counter"><t t-esc="state.val"/></span>
<t t-widget="Dialog">
<t t-set="footer"><button t-on-click="doSomething">do something</button></t>
</t>
</div>
<span t-name="Dialog"><t t-slot="footer"/></span>
</templates>
`);
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(
'<div><span class="counter">0</span><span><button>do something</button></span></div>'
);
fixture.querySelector("button")!.click();
await nextTick();
expect(fixture.innerHTML).toBe(
'<div><span class="counter">1</span><span><button>do something</button></span></div>'
);
});
test("refs are properly bound in slots", async () => {
env.qweb.addTemplates(`
<templates>
<div t-name="Parent">
<span class="counter"><t t-esc="state.val"/></span>
<t t-widget="Dialog">
<t t-set="footer"><button t-ref="myButton" t-on-click="doSomething">do something</button></t>
</t>
</div>
<span t-name="Dialog"><t t-slot="footer"/></span>
</templates>
`);
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(
'<div><span class="counter">0</span><span><button>do something</button></span></div>'
);
(<any>parent.refs.myButton).click();
await nextTick();
expect(fixture.innerHTML).toBe(
'<div><span class="counter">1</span><span><button>do something</button></span></div>'
);
});
});
+122 -2
View File
@@ -233,7 +233,6 @@ const ANIMATION_CSS = `button {
} }
`; `;
const LIFECYCLE_DEMO = `class HookWidget extends owl.Component { const LIFECYCLE_DEMO = `class HookWidget extends owl.Component {
constructor() { constructor() {
super(...arguments); 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 = `<templates>
<div t-name="Card" class="card" t-att-class="state.fullDisplay ? 'full' : 'small'">
<div class="card-title">
<t t-esc="props.title"/><button t-on-click="toggleDisplay">Toggle</button>
</div>
<t t-if="state.fullDisplay">
<div class="card-content" >
<t t-slot="content"/>
</div>
<div class="card-footer">
<t t-slot="footer"/>
</div>
</t>
</div>
<div t-name="Counter">
<t t-esc="state.val"/><button t-on-click="inc">Inc</button>
</div>
<div t-name="App" class="main">
<t t-widget="Card" title="'Title card A'">
<t t-set="content">Content of card 1... [<t t-esc="state.a"/>]</t>
<t t-set="footer"><button t-on-click="inc('a', 1)">Increment A</button></t>
</t>
<t t-widget="Card" title="'Title card B'">
<div t-set="content">
<div>Card 2... [<t t-esc="state.b"/>]</div>
<t t-widget="Counter"/>
</div>
<t t-set="footer"><button t-on-click="inc('b', -1)">Decrement B</button></t>
</t>
</div>
</templates>`;
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 { const EMPTY = `class App extends owl.Component {
} }
@@ -1113,7 +1227,7 @@ export const SAMPLES = [
description: "Animations", description: "Animations",
code: ANIMATION, code: ANIMATION,
xml: ANIMATION_XML, xml: ANIMATION_XML,
css: ANIMATION_CSS, css: ANIMATION_CSS
}, },
{ {
description: "Lifecycle demo", description: "Lifecycle demo",
@@ -1132,6 +1246,12 @@ export const SAMPLES = [
css: RESPONSIVE_CSS, css: RESPONSIVE_CSS,
xml: RESPONSIVE_XML xml: RESPONSIVE_XML
}, },
{
description: "Slots",
code: SLOTS,
xml: SLOTS_XML,
css: SLOTS_CSS
},
{ {
description: "Empty", description: "Empty",
code: EMPTY code: EMPTY