import { App, Component, mount, onMounted, onPatched, onWillPatch, onWillUnmount, useState, } from "../../src"; import { Portal } from "../../src/"; import { xml } from "../../src/tags"; import { makeTestFixture, nextTick, snapshotEverything } from "../helpers"; let fixture: HTMLElement; function addOutsideDiv(fixture: HTMLElement): HTMLElement { let outside = document.createElement("div"); outside.setAttribute("id", "outside"); fixture.appendChild(outside); return outside; } snapshotEverything(); beforeEach(() => { fixture = makeTestFixture(); }); describe("Portal", () => { test("basic use of portal", async () => { class Parent extends Component { static components = { Portal }; static template = xml`
1

2

`; } addOutsideDiv(fixture); await mount(Parent, fixture); expect(fixture.innerHTML).toBe('

2

1
'); }); test("conditional use of Portal", async () => { class Parent extends Component { static components = { Portal }; static template = xml` 1

2

`; state = useState({ hasPortal: false }); } addOutsideDiv(fixture); const parent = await mount(Parent, fixture); expect(fixture.innerHTML).toBe('
1'); parent.state.hasPortal = true; await nextTick(); expect(fixture.innerHTML).toBe('

2

1'); parent.state.hasPortal = false; await nextTick(); expect(fixture.innerHTML).toBe('
1'); parent.state.hasPortal = true; await nextTick(); expect(fixture.innerHTML).toBe('

2

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 }); } addOutsideDiv(fixture); const parent = await mount(Parent, fixture); expect(fixture.innerHTML).toBe('
1'); parent.state.hasPortal = true; await nextTick(); expect(fixture.innerHTML).toBe('

1

1'); parent.state.hasPortal = false; await nextTick(); expect(fixture.innerHTML).toBe('
1'); parent.state.val = 2; await nextTick(); expect(fixture.innerHTML).toBe('
1'); parent.state.hasPortal = true; await nextTick(); expect(fixture.innerHTML).toBe('

2

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

2

`; } const parent = await mount(Parent, fixture); expect((parent.el as any)!.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 = await mount(Parent, fixture); expect((parent.el as any)!.innerHTML).toBe( '1

2

' ); }); test.skip("portal with target not in dom", async () => { // need error handling to unskip this one class Parent extends Component { static components = { Portal }; static template = xml`
2
`; } let error; try { await mount(Parent, fixture); } catch (e) { error = e; } expect(error).toBeDefined(); expect(error.message).toBe('Could not find any match for "#does-not-exist"'); expect(fixture.innerHTML).toBe(`
`); }); test("portal with child and props", async () => { const steps: string[] = []; const outside = addOutsideDiv(fixture); class Child extends Component { static template = xml``; setup() { onMounted(() => { steps.push("mounted"); expect(outside.innerHTML).toBe("1"); }); onPatched(() => { 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 = await mount(Parent, fixture); expect(outside.innerHTML).toBe("1"); expect((parent.el as any).innerHTML).toBe(""); parent.state.val = 2; await nextTick(); expect(outside.innerHTML).toBe("2"); expect((parent.el as any).innerHTML).toBe(""); expect(steps).toEqual(["mounted", "patched"]); }); test("portal with only text as content", async () => { class Parent extends Component { static components = { Portal }; static template = xml`
`; } addOutsideDiv(fixture); await mount(Parent, fixture); expect(fixture.innerHTML).toBe('
only text
'); }); test("portal with no content", async () => { class Parent extends Component { static components = { Portal }; static template = xml`
`; } addOutsideDiv(fixture); await mount(Parent, fixture); expect(fixture.innerHTML).toBe(`
`); }); test("portal with many children", async () => { class Parent extends Component { static components = { Portal }; static template = xml`
1

2

`; } addOutsideDiv(fixture); await mount(Parent, fixture); expect(fixture.innerHTML).toBe('
1

2

'); }); test("portal with dynamic body", async () => { class Parent extends Component { static components = { Portal }; static template = xml`
`; state = useState({ val: "ab" }); } const outside = addOutsideDiv(fixture); const parent = await mount(Parent, fixture); expect(outside.innerHTML).toBe(`ab`); parent.state.val = ""; await nextTick(); expect(outside.innerHTML).toBe(`
`); }); test("portal could have dynamically no content", async () => { class Parent extends Component { static components = { Portal }; static template = xml`
`; state = useState({ val: "ab" }); } const outside = addOutsideDiv(fixture); const parent = await mount(Parent, fixture); expect(outside.innerHTML).toBe(`ab`); parent.state.val = ""; await nextTick(); expect(outside.innerHTML).toBe(``); }); test("lifecycle hooks of portal sub component are properly called", async () => { const steps: any[] = []; class Child extends Component { static template = xml``; setup() { onMounted(() => steps.push("child:mounted")); onWillPatch(() => steps.push("child:willPatch")); onPatched(() => steps.push("child:patched")); onWillUnmount(() => steps.push("child:willUnmount")); } } class Parent extends Component { static components = { Portal, Child }; static template = xml`
`; state = useState({ hasChild: false, val: 1 }); setup() { onMounted(() => steps.push("parent:mounted")); onWillPatch(() => steps.push("parent:willPatch")); onPatched(() => steps.push("parent:patched")); onWillUnmount(() => steps.push("parent:willUnmount")); } } addOutsideDiv(fixture); const parent = await mount(Parent, 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.skip("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.skip("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.skip("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.skip("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.skip("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.skip("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.skip("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.skip("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 env = {}; const app = new App(Parent); app.configure({ env }); addOutsideDiv(fixture); const parent = await mount(Parent, fixture); expect(parent.env).toStrictEqual({}); }); test("Portal composed with t-slot", async () => { const steps: Array = []; let childInst: Component | null = null; class Child2 extends Component { static template = xml`
child2
`; setup() { 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: Event) { steps.push(ev.type as string); } } addOutsideDiv(fixture); await mount(Parent, fixture); childInst!.el!.dispatchEvent(new CustomEvent("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" }); } addOutsideDiv(fixture); const parent = await mount(Parent, 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(); }); });