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`
`;
}
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`
`;
}
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`
`;
}
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("");
QWeb.dev = dev;
});
test("conditional use of Portal", async () => {
class Parent extends Component {
static components = { Portal };
static template = xml`
`;
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("");
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("");
});
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`
`;
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("");
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("");
});
test("with target in template (before portal)", async () => {
class Parent extends Component {
static components = { Portal };
static template = xml`
`;
}
const parent = new Parent();
await parent.mount(fixture);
expect(parent.el!.innerHTML).toBe(
'1 '
);
});
test("with target in template (after portal)", async () => {
class Parent extends Component {
static components = { Portal };
static template = xml`
`;
}
const parent = new Parent();
await parent.mount(fixture);
expect(parent.el!.innerHTML).toBe(
'1 '
);
});
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`
`;
}
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`
`;
}
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`
`;
}
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`
child `;
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`
child `;
}
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();
});
});