diff --git a/doc/qweb.md b/doc/qweb.md index 942158fe..3e997093 100644 --- a/doc/qweb.md +++ b/doc/qweb.md @@ -497,10 +497,10 @@ directive is there to help. Whenever a node has a `t-transition` directive, with a `name` value, the following will happen: -At node creation: +At node insertion: -- the css classes `name-enter` and `name-enter-active` will be added before the - node is added to the DOM, +- the css classes `name-enter` and `name-enter-active` will be added directly + when the node is inserted into the DOM, - on the next animation frame: the css class `name-enter` will be removed and the class `name-enter-to` will be added (so they can be used to trigger css transition effects), @@ -535,6 +535,8 @@ For example, a simple fade in/out effect can be done with this: } ``` +The `t-transition` directive can be combined with `t-widget`. + Note: more information on animations are available [here](doc/animations.md). ### `t-mounted` directive diff --git a/src/qweb_core.ts b/src/qweb_core.ts index fc417d4c..5376db42 100644 --- a/src/qweb_core.ts +++ b/src/qweb_core.ts @@ -269,6 +269,9 @@ export class QWeb { // pollute the rendering context by adding some keys in it. ctx.code.unshift(" let owner = context;"); } + if (ctx.shouldDefineUtils) { + ctx.code.unshift(" let utils = this.utils;"); + } if (!ctx.rootNode) { throw new Error("A template should have one root node"); @@ -613,6 +616,7 @@ export class Context { rootContext: Context; caller: Element | undefined; shouldDefineOwner: boolean = false; + shouldDefineUtils: boolean = false; shouldProtectContext: boolean = false; inLoop: boolean = false; inPreTag: boolean = false; diff --git a/src/qweb_extensions.ts b/src/qweb_extensions.ts index 712612b4..8660504c 100644 --- a/src/qweb_extensions.ts +++ b/src/qweb_extensions.ts @@ -192,7 +192,10 @@ QWeb.addDirective({ * explanation of the code generated by the t-widget directive for the following * situation: * ```xml - * + * * ``` * * ```js @@ -252,6 +255,8 @@ QWeb.addDirective({ * } * w4 = new W4(owner, props4); * + * let utils = this.utils; + * * // Whenever we rerender the parent widget, we need to be sure that we * // are able to find the widget instance. To do that, we register it to * // the parent cmap (children map). Note that the 'template' key is @@ -281,15 +286,21 @@ QWeb.addDirective({ * // to synchronise the pvnode elm with the resulting elm * let nvn = w4._mount(vnode, vn.elm); * pvnode.elm = nvn.elm; + * // what follows is only present if there are animations on the widget + * utils.transitionInsert(vn.elm, "fade"); * }, * remove() { - * // apparently, in some cases, it is necessary to call the destroy - * // method here - * w4.destroy(); + * // override with empty function to prevent from removing the node + * // directly. It will be removed when destroy is called anyway, which + * // delays the removal if there are animations. * }, * destroy() { - * // and here... - * w4.destroy(); + * // if there are animations, we delay the call to destroy on the + * // widget, if not, we call it directly. + * let finalize = () => { + * w4.destroy(); + * }; + * utils.transitionRemove(vn.elm, "fade", finalize); * } * }; * // the pvnode is inserted at the correct position in the div's children @@ -332,16 +343,20 @@ QWeb.addDirective({ atNodeEncounter({ ctx, value, node }): boolean { ctx.addLine("//WIDGET"); ctx.rootContext.shouldDefineOwner = true; + ctx.rootContext.shouldDefineUtils = true; let props = node.getAttribute("t-props"); let keepAlive = node.getAttribute("t-keepalive") ? true : false; - // t-on- events... + // t-on- events and t-transition const events: [string, string][] = []; + let transition: string = ""; const attributes = (node).attributes; for (let i = 0; i < attributes.length; i++) { const name = attributes[i].name; if (name.startsWith("t-on-")) { events.push([name.slice(5), attributes[i].textContent!]); + } else if (name === "t-transition") { + transition = attributes[i].textContent!; } } @@ -378,13 +393,23 @@ QWeb.addDirective({ ctx.addLine(`const ${refKey} = ${ctx.formatExpression(ref)}`); refExpr = `context.refs[${refKey}] = w${widgetID};`; } - + let transitionsInsertCode = ""; + if (transition) { + transitionsInsertCode = `utils.transitionInsert(vn.elm, '${transition}');`; + } let finalizeWidgetCode = `w${widgetID}.${ keepAlive ? "unmount" : "destroy" - }()`; + }();`; if (ref) { - finalizeWidgetCode += `;delete context.refs[${refKey}]`; + finalizeWidgetCode += `delete context.refs[${refKey}];`; // FIXME: shouldn't we keep ref if keepAlive is true? } + if (transition) { + finalizeWidgetCode = `let finalize = () => { + ${finalizeWidgetCode} + }; + utils.transitionRemove(vn.elm, '${transition}', finalize);`; + } + let createHook = ""; let classAttr = node.getAttribute("class"); let tattClass = node.getAttribute("t-att-class"); @@ -447,8 +472,10 @@ QWeb.addDirective({ ctx.addLine(`w${widgetID}.on('${event}', owner, owner['${method}'])`); } 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 ctx.addLine( - `def${defID} = def${defID}.then(vnode=>{${createHook}let pvnode=h(vnode.sel, {key: ${templateID}, hook: {insert(vn){let nvn=w${widgetID}._mount(vnode, pvnode.elm);pvnode.elm=nvn.elm;${refExpr}},remove(){${finalizeWidgetCode}},destroy(){${finalizeWidgetCode}}}});c${ + `def${defID} = def${defID}.then(vnode=>{${createHook}let pvnode=h(vnode.sel, {key: ${templateID}, hook: {insert(vn) {let nvn=w${widgetID}._mount(vnode, pvnode.elm);pvnode.elm=nvn.elm;${refExpr}${transitionsInsertCode}},remove() {},destroy(vn) {${finalizeWidgetCode}}}});c${ ctx.parentNode }[_${dummyID}_index]=pvnode;w${widgetID}.__owl__.pvnode = pvnode;});` ); diff --git a/tests/__snapshots__/component.test.ts.snap b/tests/__snapshots__/component.test.ts.snap index 57841099..f07dba6b 100644 --- a/tests/__snapshots__/component.test.ts.snap +++ b/tests/__snapshots__/component.test.ts.snap @@ -1,8 +1,85 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`animations t-transition combined with t-widget 1`] = ` +"function anonymous(context,extra +) { + let utils = this.utils; + 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 && props4 !== w4.__owl__.renderProps) { + w4.destroy(); + w4 = false + } + if (!w4) { + let W4 = context.widgets['Child']; + if (!W4) {throw new Error(\`Cannot find the definition of widget \\"Child\\"\`)} + w4 = new W4(owner, props4); + context.__owl__.cmap[4] = w4.__owl__.id; + 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;utils.transitionInsert(vn.elm, 'chimay');},remove() {},destroy(vn) {let finalize = () => { + w4.destroy(); + }; + utils.transitionRemove(vn.elm, 'chimay', finalize);}}});c1[_2_index]=pvnode;w4.__owl__.pvnode = pvnode;}); + } else { + 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[`animations t-transition combined with t-widget and t-if 1`] = ` +"function anonymous(context,extra +) { + let utils = this.utils; + let owner = context; + var h = this.utils.h; + let c1 = [], p1 = {key:1}; + var vn1 = h('div', p1, c1); + if (context['state'].display) { + //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 && props4 !== w4.__owl__.renderProps) { + w4.destroy(); + w4 = false + } + if (!w4) { + let W4 = context.widgets['Child']; + if (!W4) {throw new Error(\`Cannot find the definition of widget \\"Child\\"\`)} + w4 = new W4(owner, props4); + context.__owl__.cmap[4] = w4.__owl__.id; + 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;utils.transitionInsert(vn.elm, 'chimay');},remove() {},destroy(vn) {let finalize = () => { + w4.destroy(); + }; + utils.transitionRemove(vn.elm, 'chimay', finalize);}}});c1[_2_index]=pvnode;w4.__owl__.pvnode = pvnode;}); + } else { + 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[`class and style attributes with t-widget dynamic t-att-style is properly added and updated on widget root el 1`] = ` "function anonymous(context,extra ) { + let utils = this.utils; let owner = context; var h = this.utils.h; let c1 = [], p1 = {key:1}; @@ -24,7 +101,7 @@ exports[`class and style attributes with t-widget dynamic t-att-style is properl w4 = new W4(owner, props4); context.__owl__.cmap[4] = w4.__owl__.id; def3 = w4._prepare(); - def3 = def3.then(vnode=>{vnode.data.hook = {create(_, vn){vn.elm.style = _5}};let pvnode=h(vnode.sel, {key: 4, hook: {insert(vn){let nvn=w4._mount(vnode, pvnode.elm);pvnode.elm=nvn.elm;},remove(){w4.destroy()},destroy(){w4.destroy()}}});c1[_2_index]=pvnode;w4.__owl__.pvnode = pvnode;}); + def3 = def3.then(vnode=>{vnode.data.hook = {create(_, vn){vn.elm.style = _5}};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 = w4._updateProps(props4, extra.forceUpdate, extra.patchQueue); def3 = def3.then(()=>{if (w4.__owl__.isDestroyed) {return};w4.el.style=_5;let pvnode=w4.__owl__.pvnode;c1[_2_index]=pvnode;}); @@ -37,6 +114,7 @@ exports[`class and style attributes with t-widget dynamic t-att-style is properl exports[`class and style attributes with t-widget t-att-class is properly added/removed on widget root el 1`] = ` "function anonymous(context,extra ) { + let utils = this.utils; let owner = context; var h = this.utils.h; let c1 = [], p1 = {key:1}; @@ -62,7 +140,7 @@ exports[`class and style attributes with t-widget t-att-class is properly added/ if (_5[k]) { vn.elm.classList.add(k); } - }}};let pvnode=h(vnode.sel, {key: 4, hook: {insert(vn){let nvn=w4._mount(vnode, pvnode.elm);pvnode.elm=nvn.elm;},remove(){w4.destroy()},destroy(){w4.destroy()}}});c1[_2_index]=pvnode;w4.__owl__.pvnode = pvnode;}); + }}};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 = w4._updateProps(props4, extra.forceUpdate, extra.patchQueue); def3 = def3.then(()=>{if (w4.__owl__.isDestroyed) {return};let cl=w4.el.classList;for (let k in _5) {if (_5[k]) {cl.add(k)} else {cl.remove(k)}}let pvnode=w4.__owl__.pvnode;c1[_2_index]=pvnode;}); @@ -75,6 +153,7 @@ exports[`class and style attributes with t-widget t-att-class is properly added/ exports[`composition sub widgets with some state rendered in a loop 1`] = ` "function anonymous(context,extra ) { + let utils = this.utils; let owner = context; context = Object.create(context); var h = this.utils.h; @@ -109,7 +188,7 @@ exports[`composition sub widgets with some state rendered in a loop 1`] = ` w7 = new W7(owner, props7); context.__owl__.cmap[key8] = w7.__owl__.id; def6 = w7._prepare(); - def6 = def6.then(vnode=>{let pvnode=h(vnode.sel, {key: key8, hook: {insert(vn){let nvn=w7._mount(vnode, pvnode.elm);pvnode.elm=nvn.elm;},remove(){w7.destroy()},destroy(){w7.destroy()}}});c1[_5_index]=pvnode;w7.__owl__.pvnode = pvnode;}); + def6 = def6.then(vnode=>{let pvnode=h(vnode.sel, {key: key8, hook: {insert(vn) {let nvn=w7._mount(vnode, pvnode.elm);pvnode.elm=nvn.elm;},remove() {},destroy(vn) {w7.destroy();}}});c1[_5_index]=pvnode;w7.__owl__.pvnode = pvnode;}); } else { def6 = w7._updateProps(props7, extra.forceUpdate, extra.patchQueue); def6 = def6.then(()=>{if (w7.__owl__.isDestroyed) {return};let pvnode=w7.__owl__.pvnode;c1[_5_index]=pvnode;}); @@ -123,6 +202,7 @@ exports[`composition sub widgets with some state rendered in a loop 1`] = ` exports[`random stuff/miscellaneous snapshotting compiled code 1`] = ` "function anonymous(context,extra ) { + let utils = this.utils; let owner = context; var h = this.utils.h; let c1 = [], p1 = {key:1}; @@ -144,7 +224,7 @@ exports[`random stuff/miscellaneous snapshotting compiled code 1`] = ` w4 = new W4(owner, props4); context.__owl__.cmap[key5] = w4.__owl__.id; def3 = w4._prepare(); - def3 = def3.then(vnode=>{let pvnode=h(vnode.sel, {key: key5, hook: {insert(vn){let nvn=w4._mount(vnode, pvnode.elm);pvnode.elm=nvn.elm;},remove(){w4.destroy()},destroy(){w4.destroy()}}});c1[_2_index]=pvnode;w4.__owl__.pvnode = pvnode;}); + def3 = def3.then(vnode=>{let pvnode=h(vnode.sel, {key: key5, 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 = w4._updateProps(props4, extra.forceUpdate, extra.patchQueue); def3 = def3.then(()=>{if (w4.__owl__.isDestroyed) {return};let pvnode=w4.__owl__.pvnode;c1[_2_index]=pvnode;}); @@ -157,6 +237,7 @@ exports[`random stuff/miscellaneous snapshotting compiled code 1`] = ` exports[`random stuff/miscellaneous t-props should not be undefined (snapshotting) 1`] = ` "function anonymous(context,extra ) { + let utils = this.utils; let owner = context; var h = this.utils.h; let c1 = [], p1 = {key:1}; @@ -177,7 +258,7 @@ exports[`random stuff/miscellaneous t-props should not be undefined (snapshottin w4 = new W4(owner, props4); context.__owl__.cmap[4] = w4.__owl__.id; 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(){w4.destroy()},destroy(){w4.destroy()}}});c1[_2_index]=pvnode;w4.__owl__.pvnode = pvnode;}); + 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 = w4._updateProps(props4, extra.forceUpdate, extra.patchQueue); def3 = def3.then(()=>{if (w4.__owl__.isDestroyed) {return};let pvnode=w4.__owl__.pvnode;c1[_2_index]=pvnode;}); diff --git a/tests/__snapshots__/untitled.js b/tests/__snapshots__/untitled.js new file mode 100644 index 00000000..b1409550 --- /dev/null +++ b/tests/__snapshots__/untitled.js @@ -0,0 +1,33 @@ +function anonymous(context, extra) { + var h = this.utils.h; + let c1 = [], p1 = {key:1}; + var vn1 = h('div', p1, c1); + if (context['state'].display) { + //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 && props4 !== w4.__owl__.renderProps) { + w4.destroy(); + w4 = false + } + if (!w4) { + let W4 = context.widgets['Child']; + if (!W4) {throw new Error(`Cannot find the definition of widget "Child"`)} + w4 = new W4(owner, props4); + context.__owl__.cmap[4] = w4.__owl__.id; + 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;this.utils.transitionInsert(vn.elm, 'chimay');},remove: () => {},destroy: (vn) => {let finalize = () => { + w4.destroy(); + }; + this.utils.transitionRemove(vn.elm, 'chimay', finalize);}}});c1[_2_index]=pvnode;w4.__owl__.pvnode = pvnode;}); + } else { + 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); + } + +} diff --git a/tests/component.test.ts b/tests/component.test.ts index cd7addef..d40f715a 100644 --- a/tests/component.test.ts +++ b/tests/component.test.ts @@ -1778,7 +1778,6 @@ describe("random stuff/miscellaneous", () => { "C:_patch", "E:willUnmount", "E:destroy", - "E:destroy", // maybe should look into this "F:_patch(from _mount)", "F:mounted", "D:_patch", @@ -2281,7 +2280,7 @@ describe("t-mounted directive", () => { widget.f = jest.fn(); await widget.mount(fixture); - patchNextFrame((cb) => cb()); + patchNextFrame(cb => cb()); expect(widget.f).toHaveBeenCalledTimes(0); widget.state.flag = true; @@ -2426,4 +2425,100 @@ describe("animations", () => { spanNode.dispatchEvent(new Event("transitionend")); // mock end of css transition expect(spanNode.className).toBe(""); }); + + test("t-transition combined with t-widget", async () => { + expect.assertions(5); + + env.qweb.addTemplate( + "Parent", + `
` + ); + env.qweb.addTemplate("Child", `blue`); + class Parent extends Widget { + widgets = { Child: Child }; + } + class Child extends Widget {} + const widget = new Parent(env); + + let def = makeDeferred(); + var spanNode; + patchNextFrame(cb => { + expect(fixture.innerHTML).toBe( + '
blue
' + ); + cb(performance.now()); + expect(fixture.innerHTML).toBe( + '
blue
' + ); + def.resolve(); + }); + await widget.mount(fixture); + spanNode = widget.el!.children[0]; + + expect(env.qweb.templates.Parent.fn.toString()).toMatchSnapshot(); + expect(fixture.innerHTML).toBe( + '
blue
' + ); + + await def; // wait for the mocked repaint to be done + spanNode.dispatchEvent(new Event("transitionend")); // mock end of css transition + expect(fixture.innerHTML).toBe('
blue
'); + }); + + test("t-transition combined with t-widget and t-if", async () => { + expect.assertions(8); + + env.qweb.addTemplate( + "Parent", + `
` + ); + env.qweb.addTemplate("Child", `blue`); + class Parent extends Widget { + widgets = { Child: Child }; + state = { display: true }; + } + class Child extends Widget {} + const widget = new Parent(env); + + let def = makeDeferred(); + var spanNode; + patchNextFrame(cb => { + expect(fixture.innerHTML).toBe( + '
blue
' + ); + cb(performance.now()); + expect(fixture.innerHTML).toBe( + '
blue
' + ); + def.resolve(); + }); + await widget.mount(fixture); + spanNode = widget.el!.children[0]; + + expect(env.qweb.templates.Parent.fn.toString()).toMatchSnapshot(); + expect(fixture.innerHTML).toBe( + '
blue
' + ); + + await def; // wait for the mocked repaint to be done + spanNode.dispatchEvent(new Event("transitionend")); // mock end of css transition + expect(fixture.innerHTML).toBe('
blue
'); + + // remove span from the DOM + def = makeDeferred(); + widget.state.display = false; + patchNextFrame(cb => { + expect(fixture.innerHTML).toBe( + '
blue
' + ); + cb(performance.now()); + expect(fixture.innerHTML).toBe( + '
blue
' + ); + def.resolve(); + }); + await def; // wait for the mocked repaint to be done + spanNode.dispatchEvent(new Event("transitionend")); // mock end of css transition + expect(fixture.innerHTML).toBe("
"); + }); });