import { Portal } from "../../src/misc/portal"; import { xml } from "../../src/tags"; import { makeTestFixture, makeTestEnv, nextTick } from "../helpers"; import { Component } from "../../src/component/component"; import { useState } from "../../src/hooks"; import { QWeb } from "../../src/qweb"; //------------------------------------------------------------------------------ // Setup and helpers //------------------------------------------------------------------------------ // We create before each test: // - fixture: a div, appended to the DOM, intended to be the target of dom // manipulations. Note that it is removed after each test. // - outside: a div with id #outside appended into fixture, meant to be used as // target by Portal component // - a test env, necessary to create components, that is set on Component let fixture: HTMLElement; let outside: HTMLElement; beforeEach(() => { fixture = makeTestFixture(); outside = document.createElement("div"); outside.setAttribute("id", "outside"); fixture.appendChild(outside); Component.env = makeTestEnv(); }); afterEach(() => { fixture.remove(); }); describe("Portal: Props validation", () => { test("target is mandatory", async () => { const dev = QWeb.dev; QWeb.dev = true; class Parent extends Component { static components = { Portal }; static template = xml`
2
`; } let error; try { const parent = new Parent(); await parent.mount(fixture); } catch (e) { error = e; } expect(error).toBeDefined(); expect(error.message).toBe(`Missing props 'target' (component 'Portal')`); QWeb.dev = dev; }); test("target is not list", async () => { const dev = QWeb.dev; QWeb.dev = true; class Parent extends Component { static components = { Portal }; static template = xml`
2
`; } let error; try { const parent = new Parent(); await parent.mount(fixture); } catch (e) { error = e; } expect(error).toBeDefined(); expect(error.message).toBe(`Invalid Prop 'target' in component 'Portal'`); QWeb.dev = dev; }); }); describe("Portal: Basic use and DOM placement", () => { test("basic use of portal", async () => { const dev = QWeb.dev; QWeb.dev = true; class Parent extends Component { static components = { Portal }; static template = xml`
1
2
`; } let error; let parent; try { parent = new Parent(); } catch (e) { error = e; } expect(error).toBeUndefined(); await parent.mount(fixture); expect(outside.innerHTML).toBe("
2
"); expect(parent.el!.outerHTML).toBe("
1
"); QWeb.dev = dev; }); test("conditional use of Portal", async () => { class Parent extends Component { static components = { Portal }; static template = xml`
1
2
`; state = useState({ hasPortal: false }); } const parent = new Parent(); await parent.mount(fixture); expect(outside.innerHTML).toBe(""); expect(parent.el!.outerHTML).toBe("
1
"); parent.state.hasPortal = true; await nextTick(); expect(outside.innerHTML).toBe("
2
"); expect(parent.el!.outerHTML).toBe("
1
"); parent.state.hasPortal = false; await nextTick(); expect(outside.innerHTML).toBe(""); expect(parent.el!.outerHTML).toBe("
1
"); parent.state.hasPortal = true; await nextTick(); expect(outside.innerHTML).toBe("
2
"); expect(parent.el!.outerHTML).toBe("
1
"); }); test("conditional use of Portal (with sub Component)", async () => { class Child extends Component { static template = xml`
`; } class Parent extends Component { static components = { Portal, Child }; static template = xml`
1
`; state = useState({ hasPortal: false, val: 1 }); } const parent = new Parent(); await parent.mount(fixture); expect(outside.innerHTML).toBe(""); expect(parent.el!.outerHTML).toBe("
1
"); parent.state.hasPortal = true; await nextTick(); expect(outside.innerHTML).toBe("
1
"); expect(parent.el!.outerHTML).toBe("
1
"); parent.state.hasPortal = false; await nextTick(); expect(outside.innerHTML).toBe(""); expect(parent.el!.outerHTML).toBe("
1
"); parent.state.val = 2; await nextTick(); expect(outside.innerHTML).toBe(""); expect(parent.el!.outerHTML).toBe("
1
"); parent.state.hasPortal = true; await nextTick(); expect(outside.innerHTML).toBe("
2
"); expect(parent.el!.outerHTML).toBe("
1
"); }); test("with target in template (before portal)", async () => { class Parent extends Component { static components = { Portal }; static template = xml`
1

2

`; } const parent = new Parent(); await parent.mount(fixture); expect(parent.el!.innerHTML).toBe( '

2

1' ); }); test("with target in template (after portal)", async () => { class Parent extends Component { static components = { Portal }; static template = xml`
1

2

`; } const parent = new Parent(); await parent.mount(fixture); expect(parent.el!.innerHTML).toBe( '1

2

' ); }); test("portal with target not in dom", async () => { const consoleError = console.error; console.error = jest.fn(() => {}); class Parent extends Component { static components = { Portal }; static template = xml`
2
`; } const parent = new Parent(); let error; try { await parent.mount(fixture); } catch (e) { error = e; } expect(error).toBeDefined(); expect(error.message).toBe('Could not find any match for "#does-not-exist"'); expect(console.error).toBeCalledTimes(0); expect(fixture.innerHTML).toBe(`
`); console.error = consoleError; }); test("portal with child and props", async () => { const steps: string[] = []; class Child extends Component { static template = xml``; mounted() { steps.push("mounted"); expect(outside.innerHTML).toBe("1"); } patched() { steps.push("patched"); expect(outside.innerHTML).toBe("2"); } } class Parent extends Component { static components = { Portal, Child }; static template = xml`
`; state = useState({ val: 1 }); } const parent = new Parent(); await parent.mount(fixture); expect(outside.innerHTML).toBe("1"); expect(parent.el!.innerHTML).toBe(""); parent.state.val = 2; await nextTick(); expect(outside.innerHTML).toBe("2"); expect(parent.el!.innerHTML).toBe(""); expect(steps).toEqual(["mounted", "patched"]); }); test("portal with only text as content", async () => { const consoleError = console.error; console.error = jest.fn(() => {}); class Parent extends Component { static components = { Portal }; static template = xml`
`; } const parent = new Parent(); let error; try { await parent.mount(fixture); } catch (e) { error = e; } expect(error).toBeDefined(); expect(error.message).toBe("Portal must have exactly one non-text child (has 0)"); expect(console.error).toBeCalledTimes(0); expect(fixture.innerHTML).toBe(`
`); console.error = consoleError; }); test("portal with no content", async () => { const consoleError = console.error; console.error = jest.fn(() => {}); class Parent extends Component { static components = { Portal }; static template = xml`
`; } const parent = new Parent(); let error; try { await parent.mount(fixture); } catch (e) { error = e; } expect(error).toBeDefined(); expect(error.message).toBe("Portal must have exactly one non-text child (has 0)"); expect(console.error).toBeCalledTimes(0); expect(fixture.innerHTML).toBe(`
`); console.error = consoleError; }); test("portal with many children", async () => { const consoleError = console.error; console.error = jest.fn(() => {}); class Parent extends Component { static components = { Portal }; static template = xml`
1

2

`; } const parent = new Parent(); let error; try { await parent.mount(fixture); } catch (e) { error = e; } expect(error).toBeDefined(); expect(error.message).toBe("Portal must have exactly one non-text child (has 2)"); expect(console.error).toBeCalledTimes(0); expect(fixture.innerHTML).toBe(`
`); console.error = consoleError; }); test("portal with dynamic body", async () => { class Parent extends Component { static components = { Portal }; static template = xml`
`; state = useState({ val: "ab" }); } const parent = new Parent(); await parent.mount(fixture); expect(outside.innerHTML).toBe(`ab`); parent.state.val = ""; await nextTick(); expect(outside.innerHTML).toBe(`
`); }); test("portal could have dynamically no content", async () => { const consoleError = console.error; console.error = jest.fn(() => {}); class Parent extends Component { static components = { Portal }; static template = xml`
`; state = { val: "ab" }; } const parent = new Parent(); await parent.mount(fixture); expect(outside.innerHTML).toBe(`ab`); let error; try { parent.state.val = ""; await parent.render(); } catch (e) { error = e; } expect(outside.innerHTML).toBe(``); expect(error).toBeDefined(); expect(error.message).toBe("Portal must have exactly one non-text child (has 0)"); expect(console.error).toBeCalledTimes(0); console.error = consoleError; }); test("lifecycle hooks of portal sub component are properly called", async () => { const steps: any[] = []; class Child extends Component { static template = xml``; mounted() { steps.push("child:mounted"); } willPatch() { steps.push("child:willPatch"); } patched() { steps.push("child:patched"); } willUnmount() { steps.push("child:willUnmount"); } } class Parent extends Component { static components = { Portal, Child }; static template = xml`
`; state = useState({ hasChild: false, val: 1 }); mounted() { steps.push("parent:mounted"); } willPatch() { steps.push("parent:willPatch"); } patched() { steps.push("parent:patched"); } willUnmount() { steps.push("parent:willUnmount"); } } const parent = new Parent(); await parent.mount(fixture); expect(steps).toEqual(["parent:mounted"]); parent.state.hasChild = true; await nextTick(); expect(steps).toEqual([ "parent:mounted", "parent:willPatch", "child:mounted", "parent:patched", ]); parent.state.val = 2; await nextTick(); expect(steps).toEqual([ "parent:mounted", "parent:willPatch", "child:mounted", "parent:patched", "parent:willPatch", "child:willPatch", "child:patched", "parent:patched", ]); parent.state.hasChild = false; await nextTick(); expect(steps).toEqual([ "parent:mounted", "parent:willPatch", "child:mounted", "parent:patched", "parent:willPatch", "child:willPatch", "child:patched", "parent:patched", "parent:willPatch", "child:willUnmount", "parent:patched", ]); }); test("portal destroys on crash", async () => { class Child extends Component { static template = xml``; state = {}; } class Parent extends Component { static components = { Portal, Child }; static template = xml`
`; state = { error: false }; } const parent = new Parent(); await parent.mount(fixture); parent.state.error = true; let error; try { await parent.render(); } catch (e) { error = e; } expect(error).toBeDefined(); const regexp = /Cannot read properties of undefined \(reading 'crash'\)|Cannot read property 'crash' of undefined/g; expect(error.message).toMatch(regexp); }); test("portal manual unmount", async () => { class Parent extends Component { static components = { Portal }; static template = xml`
gloria
`; } const parent = new Parent(); await parent.mount(fixture); expect(outside.innerHTML).toBe("gloria"); expect(parent.el!.innerHTML).toBe(""); parent.unmount(); expect(outside.innerHTML).toBe(""); expect(parent.el!.innerHTML).toBe("gloria"); await parent.mount(fixture); expect(outside.innerHTML).toBe("gloria"); expect(parent.el!.innerHTML).toBe(""); }); test("portal manual unmount with subcomponent", async () => { expect.assertions(9); class Child extends Component { static template = xml`gloria`; mounted() { expect(outside.contains(this.el)).toBeTruthy(); } willUnmount() { expect(outside.contains(this.el)).toBeTruthy(); } } class Parent extends Component { static components = { Portal, Child }; static template = xml`
`; } const parent = new Parent(); await parent.mount(fixture); expect(outside.innerHTML).toBe("gloria"); expect(parent.el!.innerHTML).toBe(""); parent.unmount(); expect(outside.innerHTML).toBe(""); expect(parent.el!.innerHTML).toBe("gloria"); await parent.mount(fixture); expect(outside.innerHTML).toBe("gloria"); expect(parent.el!.innerHTML).toBe(""); }); }); describe("Portal: Events handling", () => { test("events triggered on movable pure node are handled", async () => { class Parent extends Component { static components = { Portal }; static template = xml`
`; state = useState({ val: "ab" }); _onCustom() { this.state.val = "triggered"; } } const parent = new Parent(); await parent.mount(fixture); expect(outside.innerHTML).toBe(`ab`); outside.querySelector("#trigger-me")!.dispatchEvent(new Event("custom")); await nextTick(); expect(outside.innerHTML).toBe(`triggered`); }); test("events triggered on movable owl components are redirected", async () => { let childInst: Component | null = null; class Child extends Component { static template = xml` `; constructor(parent, props) { super(parent, props); childInst = this; } _onCustom() { this.trigger("custom-portal"); } } class Parent extends Component { static components = { Portal, Child }; static template = xml`
`; state = useState({ val: "ab" }); _onCustomPortal() { this.state.val = "triggered"; } } const parent = new Parent(); await parent.mount(fixture); expect(outside.innerHTML).toBe(`ab`); childInst!.trigger("custom"); await nextTick(); expect(outside.innerHTML).toBe(`triggered`); }); test("events triggered on contained movable owl components are redirected", async () => { const steps: string[] = []; let childInst: Component | null = null; class Child extends Component { static template = xml` `; constructor(parent, props) { super(parent, props); childInst = this; } _onCustom() { this.trigger("custom-portal"); } } class Parent extends Component { static components = { Portal, Child }; static template = xml`
`; _handled(ev) { steps.push(ev.type); } } const parent = new Parent(); await parent.mount(fixture); childInst!.trigger("custom"); await nextTick(); // This is expected because trigger is synchronous expect(steps).toMatchObject(["custom-portal", "custom"]); }); test("Dom events are not mapped", async () => { let childInst: Component | null = null; const steps: string[] = []; class Child extends Component { static template = xml` `; constructor(parent, props) { super(parent, props); childInst = this; } } class Parent extends Component { static components = { Portal, Child }; static template = xml`
`; _handled(ev) { steps.push(ev.type as string); } } const bodyListener = (ev) => { steps.push(`body: ${ev.type}`); }; document.body.addEventListener("click", bodyListener); const parent = new Parent(); await parent.mount(fixture); childInst!.el!.click(); expect(steps).toEqual(["body: click"]); document.body.removeEventListener("click", bodyListener); }); test("Nested portals event propagation", async () => { const outside2 = document.createElement("div"); outside2.setAttribute("id", "outside2"); fixture.appendChild(outside2); const steps: Array = []; let childInst: Component | null = null; class Child2 extends Component { static template = xml`
child2
`; constructor(parent, props) { super(parent, props); childInst = this; } } class Child extends Component { static components = { Portal, Child2 }; static template = xml` `; } class Parent extends Component { static components = { Portal, Child }; static template = xml`
`; _handled(ev) { steps.push(`${ev.type} from ${ev.originalComponent.constructor.name}`); } } const parent = new Parent(); await parent.mount(fixture); childInst!.trigger("custom"); expect(steps).toEqual(["custom from Child2"]); }); test("portal's parent's env is not polluted", async () => { class Child extends Component { static template = xml` `; } class Parent extends Component { static components = { Portal, Child }; static template = xml`
`; } const parent = new Parent(); const parentEnv = Object.assign({}, parent.env); await parent.mount(fixture); expect(parentEnv).toStrictEqual(parent.env); }); test("Portal composed with t-slot", async () => { const steps: Array = []; let childInst: Component | null = null; class Child2 extends Component { static template = xml`
child2
`; constructor(parent, props) { super(parent, props); childInst = this; } } class Child extends Component { static components = { Portal, Child2 }; static template = xml` `; } class Parent extends Component { static components = { Child, Child2 }; static template = xml`
`; _handled(ev) { steps.push(ev.type as string); } } const parent = new Parent(); await parent.mount(fixture); childInst!.trigger("custom"); expect(steps).toEqual(["custom"]); }); }); describe("Portal: UI/UX", () => { test("focus is kept across re-renders", async () => { class Child extends Component { static template = xml` `; } class Parent extends Component { static components = { Portal, Child }; static template = xml`
`; state = useState({ val: "ab" }); } const parent = new Parent(); await parent.mount(fixture); const input = document.querySelector("#target-me"); expect(input!.nodeName).toBe("INPUT"); expect((input as HTMLInputElement).placeholder).toBe("ab"); (input as HTMLInputElement).focus(); expect(document.activeElement === input).toBeTruthy(); parent.state.val = "bc"; await nextTick(); const inputReRendered = document.querySelector("#target-me"); expect(inputReRendered!.nodeName).toBe("INPUT"); expect((inputReRendered as HTMLInputElement).placeholder).toBe("bc"); expect(document.activeElement === inputReRendered).toBeTruthy(); }); });