mirror of
https://github.com/odoo/owl.git
synced 2025-10-06 19:59:41 +07:00
0b4b7899f6
Before this commit, Portal overrode the _render function for its component node, which means it bypassed the error handling mechanism that was implemented in that method. It could have been fixed by duplicating the error handling code as well, but a better solution in my opinion is to simply override the renderFn function. This is closer to the actual intent of the portal implementation: wrap the result of the rendering in a VPortal vnode.
634 lines
18 KiB
TypeScript
634 lines
18 KiB
TypeScript
import {
|
|
Component,
|
|
mount,
|
|
onError,
|
|
onMounted,
|
|
onPatched,
|
|
onWillPatch,
|
|
onWillUnmount,
|
|
useState,
|
|
} from "../../src";
|
|
import { Portal, xml } from "../../src/";
|
|
import { elem, makeTestFixture, nextTick, snapshotEverything } from "../helpers";
|
|
import { DEV_MSG } from "../../src/app/app";
|
|
|
|
let fixture: HTMLElement;
|
|
let originalconsoleWarn = console.warn;
|
|
let mockConsoleWarn: any;
|
|
const info = console.info;
|
|
|
|
function addOutsideDiv(fixture: HTMLElement): HTMLElement {
|
|
let outside = document.createElement("div");
|
|
outside.setAttribute("id", "outside");
|
|
fixture.appendChild(outside);
|
|
return outside;
|
|
}
|
|
|
|
snapshotEverything();
|
|
|
|
beforeAll(() => {
|
|
console.info = (message: any) => {
|
|
if (message === DEV_MSG) {
|
|
return;
|
|
}
|
|
info(message);
|
|
};
|
|
});
|
|
|
|
afterAll(() => {
|
|
console.info = info;
|
|
});
|
|
|
|
beforeEach(() => {
|
|
fixture = makeTestFixture();
|
|
mockConsoleWarn = jest.fn(() => {});
|
|
console.warn = mockConsoleWarn;
|
|
});
|
|
|
|
afterEach(() => {
|
|
console.warn = originalconsoleWarn;
|
|
});
|
|
|
|
describe("Portal", () => {
|
|
test("basic use of portal", async () => {
|
|
class Parent extends Component {
|
|
static components = { Portal };
|
|
static template = xml`
|
|
<div>
|
|
<span>1</span>
|
|
<Portal target="'#outside'">
|
|
<p>2</p>
|
|
</Portal>
|
|
</div>`;
|
|
}
|
|
|
|
addOutsideDiv(fixture);
|
|
await mount(Parent, fixture);
|
|
|
|
expect(fixture.innerHTML).toBe('<div id="outside"><p>2</p></div><div><span>1</span></div>');
|
|
});
|
|
|
|
test("simple catchError with portal", async () => {
|
|
class Boom extends Component {
|
|
static components = { Portal };
|
|
static template = xml`
|
|
<div>
|
|
<span>1</span>
|
|
<Portal target="'#outside'">
|
|
<p><t t-esc="a.b.c"/></p>
|
|
</Portal>
|
|
</div>`;
|
|
}
|
|
|
|
class Parent extends Component {
|
|
static template = xml`
|
|
<div>
|
|
<t t-if="error">Error</t>
|
|
<t t-else="">
|
|
<Boom />
|
|
</t>
|
|
</div>`;
|
|
static components = { Boom };
|
|
|
|
error: any = false;
|
|
|
|
setup() {
|
|
onError((err) => {
|
|
this.error = err;
|
|
this.render();
|
|
});
|
|
}
|
|
}
|
|
addOutsideDiv(fixture);
|
|
|
|
await mount(Parent, fixture);
|
|
expect(fixture.innerHTML).toBe('<div id="outside"></div><div>Error</div>');
|
|
});
|
|
|
|
test("basic use of portal in dev mode", async () => {
|
|
class Parent extends Component {
|
|
static components = { Portal };
|
|
static template = xml`
|
|
<div>
|
|
<span>1</span>
|
|
<Portal target="'#outside'">
|
|
<p>2</p>
|
|
</Portal>
|
|
</div>`;
|
|
}
|
|
|
|
addOutsideDiv(fixture);
|
|
await mount(Parent, fixture, { dev: true });
|
|
|
|
expect(fixture.innerHTML).toBe('<div id="outside"><p>2</p></div><div><span>1</span></div>');
|
|
});
|
|
|
|
test("conditional use of Portal", async () => {
|
|
class Parent extends Component {
|
|
static components = { Portal };
|
|
static template = xml`
|
|
<span>1</span>
|
|
<Portal target="'#outside'" t-if="state.hasPortal">
|
|
<p>2</p>
|
|
</Portal>`;
|
|
|
|
state = useState({ hasPortal: false });
|
|
}
|
|
|
|
addOutsideDiv(fixture);
|
|
const parent = await mount(Parent, fixture);
|
|
expect(fixture.innerHTML).toBe('<div id="outside"></div><span>1</span>');
|
|
|
|
parent.state.hasPortal = true;
|
|
await nextTick();
|
|
expect(fixture.innerHTML).toBe('<div id="outside"><p>2</p></div><span>1</span>');
|
|
|
|
parent.state.hasPortal = false;
|
|
await nextTick();
|
|
expect(fixture.innerHTML).toBe('<div id="outside"></div><span>1</span>');
|
|
|
|
parent.state.hasPortal = true;
|
|
await nextTick();
|
|
expect(fixture.innerHTML).toBe('<div id="outside"><p>2</p></div><span>1</span>');
|
|
});
|
|
|
|
test("conditional use of Portal (with sub Component)", async () => {
|
|
class Child extends Component {
|
|
static template = xml`<p><t t-esc="props.val"/></p>`;
|
|
}
|
|
class Parent extends Component {
|
|
static components = { Portal, Child };
|
|
static template = xml`
|
|
<span>1</span>
|
|
<Portal t-if="state.hasPortal" target="'#outside'">
|
|
<Child val="state.val"/>
|
|
</Portal>`;
|
|
state = useState({ hasPortal: false, val: 1 });
|
|
}
|
|
|
|
addOutsideDiv(fixture);
|
|
const parent = await mount(Parent, fixture);
|
|
|
|
expect(fixture.innerHTML).toBe('<div id="outside"></div><span>1</span>');
|
|
|
|
parent.state.hasPortal = true;
|
|
await nextTick();
|
|
expect(fixture.innerHTML).toBe('<div id="outside"><p>1</p></div><span>1</span>');
|
|
|
|
parent.state.hasPortal = false;
|
|
await nextTick();
|
|
expect(fixture.innerHTML).toBe('<div id="outside"></div><span>1</span>');
|
|
|
|
parent.state.val = 2;
|
|
await nextTick();
|
|
expect(fixture.innerHTML).toBe('<div id="outside"></div><span>1</span>');
|
|
|
|
parent.state.hasPortal = true;
|
|
await nextTick();
|
|
expect(fixture.innerHTML).toBe('<div id="outside"><p>2</p></div><span>1</span>');
|
|
});
|
|
|
|
test("with target in template (before portal)", async () => {
|
|
class Parent extends Component {
|
|
static components = { Portal };
|
|
static template = xml`
|
|
<div>
|
|
<div id="local-target"></div>
|
|
<span>1</span>
|
|
<Portal target="'#local-target'">
|
|
<p>2</p>
|
|
</Portal>
|
|
</div>`;
|
|
}
|
|
|
|
await mount(Parent, fixture);
|
|
expect(fixture.innerHTML).toBe(
|
|
'<div><div id="local-target"><p>2</p></div><span>1</span></div>'
|
|
);
|
|
});
|
|
|
|
test("with target in template (after portal)", async () => {
|
|
class Parent extends Component {
|
|
static components = { Portal };
|
|
static template = xml`
|
|
<div>
|
|
<span>1</span>
|
|
<Portal target="'#local-target'">
|
|
<p>2</p>
|
|
</Portal>
|
|
<div id="local-target"></div>
|
|
</div>`;
|
|
}
|
|
|
|
await mount(Parent, fixture);
|
|
expect(fixture.innerHTML).toBe(
|
|
'<div><span>1</span><div id="local-target"><p>2</p></div></div>'
|
|
);
|
|
});
|
|
|
|
test("portal with target not in dom", async () => {
|
|
class Parent extends Component {
|
|
static components = { Portal };
|
|
static template = xml`
|
|
<div>
|
|
<Portal target="'#does-not-exist'">
|
|
<div>2</div>
|
|
</Portal>
|
|
</div>`;
|
|
}
|
|
|
|
let error: Error;
|
|
try {
|
|
await mount(Parent, fixture);
|
|
} catch (e) {
|
|
error = e as Error;
|
|
}
|
|
|
|
expect(error!).toBeDefined();
|
|
expect(error!.message).toBe("invalid portal target");
|
|
expect(fixture.innerHTML).toBe(`<div></div>`);
|
|
expect(mockConsoleWarn).toBeCalledTimes(1);
|
|
});
|
|
|
|
test("portal with child and props", async () => {
|
|
const steps: string[] = [];
|
|
const outside = addOutsideDiv(fixture);
|
|
|
|
class Child extends Component {
|
|
static template = xml`<span><t t-esc="props.val"/></span>`;
|
|
|
|
setup() {
|
|
onMounted(() => {
|
|
steps.push("mounted");
|
|
expect(outside.innerHTML).toBe("<span>1</span>");
|
|
});
|
|
onPatched(() => {
|
|
steps.push("patched");
|
|
expect(outside.innerHTML).toBe("<span>2</span>");
|
|
});
|
|
}
|
|
}
|
|
class Parent extends Component {
|
|
static components = { Portal, Child };
|
|
static template = xml`
|
|
<div>
|
|
<Portal target="'#outside'">
|
|
<Child val="state.val"/>
|
|
</Portal>
|
|
</div>`;
|
|
state = useState({ val: 1 });
|
|
}
|
|
|
|
const parent = await mount(Parent, fixture);
|
|
expect(outside.innerHTML).toBe("<span>1</span>");
|
|
expect(fixture.innerHTML).toBe('<div id="outside"><span>1</span></div><div></div>');
|
|
|
|
parent.state.val = 2;
|
|
await nextTick();
|
|
expect(outside.innerHTML).toBe("<span>2</span>");
|
|
expect(fixture.innerHTML).toBe('<div id="outside"><span>2</span></div><div></div>');
|
|
expect(steps).toEqual(["mounted", "patched"]);
|
|
});
|
|
|
|
test("portal with only text as content", async () => {
|
|
class Parent extends Component {
|
|
static components = { Portal };
|
|
static template = xml`
|
|
<div>
|
|
<Portal target="'#outside'">
|
|
<t t-esc="'only text'"/>
|
|
</Portal>
|
|
</div>`;
|
|
}
|
|
|
|
addOutsideDiv(fixture);
|
|
await mount(Parent, fixture);
|
|
expect(fixture.innerHTML).toBe('<div id="outside">only text</div><div></div>');
|
|
});
|
|
|
|
test("portal with no content", async () => {
|
|
class Parent extends Component {
|
|
static components = { Portal };
|
|
static template = xml`
|
|
<div>
|
|
<Portal target="'#outside'">
|
|
<t t-if="false" t-esc="'ABC'"/>
|
|
</Portal>
|
|
</div>`;
|
|
}
|
|
|
|
addOutsideDiv(fixture);
|
|
await mount(Parent, fixture);
|
|
expect(fixture.innerHTML).toBe(`<div id="outside"></div><div></div>`);
|
|
});
|
|
|
|
test("portal with many children", async () => {
|
|
class Parent extends Component {
|
|
static components = { Portal };
|
|
static template = xml`
|
|
<div>
|
|
<Portal target="'#outside'">
|
|
<div>1</div>
|
|
<p>2</p>
|
|
</Portal>
|
|
</div>`;
|
|
}
|
|
addOutsideDiv(fixture);
|
|
await mount(Parent, fixture);
|
|
expect(fixture.innerHTML).toBe('<div id="outside"><div>1</div><p>2</p></div><div></div>');
|
|
});
|
|
|
|
test("portal with dynamic body", async () => {
|
|
class Parent extends Component {
|
|
static components = { Portal };
|
|
static template = xml`
|
|
<div>
|
|
<Portal target="'#outside'">
|
|
<span t-if="state.val" t-esc="state.val"/>
|
|
<div t-else=""/>
|
|
</Portal>
|
|
</div>`;
|
|
state = useState({ val: "ab" });
|
|
}
|
|
|
|
const outside = addOutsideDiv(fixture);
|
|
const parent = await mount(Parent, fixture);
|
|
|
|
expect(outside.innerHTML).toBe(`<span>ab</span>`);
|
|
|
|
parent.state.val = "";
|
|
await nextTick();
|
|
expect(outside.innerHTML).toBe(`<div></div>`);
|
|
});
|
|
|
|
test("portal could have dynamically no content", async () => {
|
|
class Parent extends Component {
|
|
static components = { Portal };
|
|
static template = xml`
|
|
<div>
|
|
<Portal target="'#outside'">
|
|
<span t-if="state.val" t-esc="state.val"/>
|
|
</Portal>
|
|
</div>`;
|
|
state = useState({ val: "ab" });
|
|
}
|
|
const outside = addOutsideDiv(fixture);
|
|
const parent = await mount(Parent, fixture);
|
|
|
|
expect(outside.innerHTML).toBe(`<span>ab</span>`);
|
|
|
|
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`<span t-esc="props.val"/>`;
|
|
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`
|
|
<div>
|
|
<Portal t-if="state.hasChild" target="'#outside'">
|
|
<Child val="state.val"/>
|
|
</Portal>
|
|
</div>`;
|
|
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("portal destroys on crash", async () => {
|
|
class Child extends Component {
|
|
static template = xml`<span t-esc="props.error and this.will.crash" />`;
|
|
state = {};
|
|
}
|
|
class Parent extends Component {
|
|
static components = { Portal, Child };
|
|
static template = xml`
|
|
<div>
|
|
<Portal target="'#outside'" >
|
|
<Child error="state.error"/>
|
|
</Portal>
|
|
</div>`;
|
|
state = { error: false };
|
|
setup() {
|
|
onError((e) => (error = e));
|
|
}
|
|
}
|
|
addOutsideDiv(fixture);
|
|
const parent = await mount(Parent, fixture);
|
|
|
|
let error: Error;
|
|
parent.state.error = true;
|
|
parent.render();
|
|
await nextTick();
|
|
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's parent's env is not polluted", async () => {
|
|
class Child extends Component {
|
|
static template = xml`
|
|
<button>child</button>`;
|
|
}
|
|
class Parent extends Component {
|
|
static components = { Portal, Child };
|
|
static template = xml`
|
|
<div>
|
|
<Portal target="'#outside'">
|
|
<Child />
|
|
</Portal>
|
|
</div>`;
|
|
}
|
|
const env = {};
|
|
addOutsideDiv(fixture);
|
|
const parent = await mount(Parent, fixture, { env });
|
|
expect(parent.env).toStrictEqual({});
|
|
});
|
|
|
|
test("Portal composed with t-slot", async () => {
|
|
const steps: Array<string> = [];
|
|
let childInst: Component | null = null;
|
|
class Child2 extends Component {
|
|
static template = xml`<div t-on-custom="onCustom"><span id="childSpan">child2</span></div>`;
|
|
setup() {
|
|
childInst = this;
|
|
}
|
|
onCustom(ev: Event) {
|
|
this.props.customHandler(ev);
|
|
}
|
|
}
|
|
class Child extends Component {
|
|
static components = { Portal, Child2 };
|
|
static template = xml`
|
|
<Portal target="'#outside'">
|
|
<t t-slot="default"/>
|
|
</Portal>`;
|
|
}
|
|
class Parent extends Component {
|
|
static components = { Child, Child2 };
|
|
static template = xml`
|
|
<div>
|
|
<Child>
|
|
<Child2 customHandler="_handled"/>
|
|
</Child>
|
|
</div>`;
|
|
|
|
_handled(ev: Event) {
|
|
steps.push(ev.type as string);
|
|
}
|
|
}
|
|
|
|
addOutsideDiv(fixture);
|
|
await mount(Parent, fixture);
|
|
|
|
elem(childInst!).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`
|
|
<input id="target-me" t-att-placeholder="props.val"/>`;
|
|
}
|
|
class Parent extends Component {
|
|
static components = { Portal, Child };
|
|
static template = xml`
|
|
<div>
|
|
<Portal target="'#outside'">
|
|
<Child val="state.val"/>
|
|
</Portal>
|
|
</div>`;
|
|
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();
|
|
});
|
|
});
|
|
|
|
describe("Portal: Props validation", () => {
|
|
test("target is mandatory", async () => {
|
|
const consoleInfo = console.info;
|
|
console.info = jest.fn();
|
|
|
|
class Parent extends Component {
|
|
static components = { Portal };
|
|
static template = xml`
|
|
<div>
|
|
<Portal>
|
|
<div>2</div>
|
|
</Portal>
|
|
</div>`;
|
|
}
|
|
let error: Error;
|
|
try {
|
|
await mount(Parent, fixture, { dev: true });
|
|
} catch (e) {
|
|
error = e as Error;
|
|
}
|
|
expect(error!).toBeDefined();
|
|
expect(error!.message).toBe(`Missing props 'target' (component 'Portal')`);
|
|
console.info = consoleInfo;
|
|
expect(mockConsoleWarn).toBeCalledTimes(1);
|
|
});
|
|
|
|
test("target is not list", async () => {
|
|
const consoleInfo = console.info;
|
|
console.info = jest.fn();
|
|
class Parent extends Component {
|
|
static components = { Portal };
|
|
static template = xml`
|
|
<div>
|
|
<Portal target="['body']">
|
|
<div>2</div>
|
|
</Portal>
|
|
</div>`;
|
|
}
|
|
let error: Error;
|
|
try {
|
|
await mount(Parent, fixture, { dev: true });
|
|
} catch (e) {
|
|
error = e as Error;
|
|
}
|
|
expect(error!).toBeDefined();
|
|
expect(error!.message).toBe(`Invalid Prop 'target' in component 'Portal'`);
|
|
console.info = consoleInfo;
|
|
expect(mockConsoleWarn).toBeCalledTimes(1);
|
|
});
|
|
});
|