mirror of
https://github.com/odoo/owl.git
synced 2025-10-06 19:59:41 +07:00
[ADD] misc: component Portal
The component Portal is used to teleport the content in its slot as a child of an element somewhere else in the DOM, usually 'body' It is designed to be transparent and is just here to do the teleportation task OwlEvents (with method `Component.trigger`) are redirected from any Component instanciated within the Portal (and therefore teleported somewhere else) to the place it would have been without teleportation i.e. they are re-triggered onto the Portal root node Co-authored-by: Aaron Bohy <aab@odoo.com> closes #184 closes #466
This commit is contained in:
committed by
Géry Debongnie
parent
821bd0b4b8
commit
556a4644f0
+14
-14
@@ -50,20 +50,20 @@ and `EventBus` is exported as `owl.core.EventBus`):
|
|||||||
```
|
```
|
||||||
Component misc
|
Component misc
|
||||||
Context AsyncRoot
|
Context AsyncRoot
|
||||||
QWeb router
|
QWeb Portal
|
||||||
Store Link
|
Store router
|
||||||
useState RouteComponent
|
useState Link
|
||||||
config Router
|
config RouteComponent
|
||||||
mode tags
|
mode Router
|
||||||
core xml
|
core tags
|
||||||
EventBus utils
|
EventBus xml
|
||||||
Observer debounce
|
Observer utils
|
||||||
hooks escape
|
hooks debounce
|
||||||
onWillStart loadJS
|
onWillStart escape
|
||||||
onMounted loadFile
|
onMounted loadJS
|
||||||
onWillUpdateProps shallowEqual
|
onWillUpdateProps loadFile
|
||||||
onWillPatch whenReady
|
onWillPatch shallowEqual
|
||||||
onPatched
|
onPatched whenReady
|
||||||
onWillUnmount
|
onWillUnmount
|
||||||
useContext
|
useContext
|
||||||
useState
|
useState
|
||||||
|
|||||||
@@ -1,5 +1,116 @@
|
|||||||
# 🦉 Miscellaneous 🦉
|
# 🦉 Miscellaneous 🦉
|
||||||
|
|
||||||
|
## Content
|
||||||
|
|
||||||
|
- [Portal](#portal)
|
||||||
|
- [AsyncRoot](#asyncroot)
|
||||||
|
|
||||||
|
## `Portal`
|
||||||
|
|
||||||
|
### Overview
|
||||||
|
|
||||||
|
The component `Portal` is meant to be used as a transparent way to 'teleport' a piece
|
||||||
|
of DOM to the node represented by its sole `target` props.
|
||||||
|
|
||||||
|
This component aims at helping the implementation of the needed infrastructure
|
||||||
|
for modals (as in `bootstrap-modal`).
|
||||||
|
|
||||||
|
### Usage
|
||||||
|
|
||||||
|
The content it will teleport is defined within the `<Portal>` node and
|
||||||
|
internally uses the `default` [Slot](component.md#slots).
|
||||||
|
|
||||||
|
This slot must contain only **one** node, which in turn can have as many children as necessary.
|
||||||
|
|
||||||
|
The element under which the content will be teleported is represented as a selector
|
||||||
|
by the `target` props which only accepts a string as value.
|
||||||
|
|
||||||
|
The `target` props only supports static selector, and is not meant to be passed to `Portal`
|
||||||
|
as a variable. Namely, `<Portal target="'body'" />` is the intended use.
|
||||||
|
By contrast, `<Portal target="state.target" />` is not supported.
|
||||||
|
|
||||||
|
The component `Portal` has no particular state, rather it is meant to be a slave to its parent,
|
||||||
|
and ultimately just a way for the parent to teleport a piece of its own DOM elsewhere.
|
||||||
|
|
||||||
|
The `Portal`'s root node is always `<portal/>` and is placed where the teleported content
|
||||||
|
*would have* been. It is this element that the [teleported events](#expected-behaviors) are re-directed on.
|
||||||
|
|
||||||
|
### Example
|
||||||
|
|
||||||
|
The canonic use-case is to implement a Dialog, where a Component may choose to break the natural
|
||||||
|
workflow to help the user put in some data, which it could use later on.
|
||||||
|
|
||||||
|
JavaScript:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const { Component } = owl;
|
||||||
|
const { Portal } = owl.misc;
|
||||||
|
|
||||||
|
class TeleportedComponent extends Component {}
|
||||||
|
class App extends Component {
|
||||||
|
static components = { Portal, TeleportedComponent };
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = new App();
|
||||||
|
app.mount(document.body);
|
||||||
|
```
|
||||||
|
|
||||||
|
XML:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<templates>
|
||||||
|
<div t-name="TeleportedComponent">
|
||||||
|
<span>I will move soon enough</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div t-name="App">
|
||||||
|
<span>I am like the rest of us</span>
|
||||||
|
<Portal target="'body'">
|
||||||
|
<TeleportedComponent />
|
||||||
|
</Portal>
|
||||||
|
</div>
|
||||||
|
</templates>
|
||||||
|
```
|
||||||
|
|
||||||
|
In this example, the `Portal` component will teleport the `TeleportedComponent`'s `div` as a child of the `body`.
|
||||||
|
`TeleportedComponent` is acting as a Dialog here.
|
||||||
|
|
||||||
|
The resulting DOM will look like:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<body>
|
||||||
|
<div>
|
||||||
|
<span>I am like the rest of us</span>
|
||||||
|
<portal></portal>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>I will move soon enough</span>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Expected Behaviors
|
||||||
|
|
||||||
|
The teleported piece is updated as any other `Component`'s DOM and in the same sequence.
|
||||||
|
Namely the teleported piece will be updated in function of its parents components, and patched as
|
||||||
|
a normal child.
|
||||||
|
|
||||||
|
The [_business_ events](component.md#event-handling) triggered by a child component will be stopped
|
||||||
|
to not bubble outside of the `target`. They will, on the other hand, be re-directed onto the
|
||||||
|
`Portal`'s root node and bubble up the DOM as if it were triggered by a regular child component.
|
||||||
|
|
||||||
|
Beware that those re-directed events are copies of the original event.
|
||||||
|
They have:
|
||||||
|
|
||||||
|
- The same payload.
|
||||||
|
- The same `originalComponent` than their original counterpart,
|
||||||
|
that is the actual Component that triggered it.
|
||||||
|
- A **different** `target` property than their original counterpart.
|
||||||
|
The `target` of a re-directed event is necessarily the `Portal`'s root node.
|
||||||
|
|
||||||
|
Pure DOM events do not follow this pattern and are free to bubble their natural, unaltered way
|
||||||
|
up to the `body`.
|
||||||
|
|
||||||
## `AsyncRoot`
|
## `AsyncRoot`
|
||||||
|
|
||||||
When this component is used, a new rendering sub tree is created, such that the
|
When this component is used, a new rendering sub tree is created, such that the
|
||||||
|
|||||||
@@ -84,6 +84,8 @@ interface Internal<T extends Env, Props> {
|
|||||||
refs: { [key: string]: Component<T, any> | HTMLElement | undefined } | null;
|
refs: { [key: string]: Component<T, any> | HTMLElement | undefined } | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const portalSymbol = Symbol("portal"); // FIXME
|
||||||
|
|
||||||
//------------------------------------------------------------------------------
|
//------------------------------------------------------------------------------
|
||||||
// Component
|
// Component
|
||||||
//------------------------------------------------------------------------------
|
//------------------------------------------------------------------------------
|
||||||
@@ -397,14 +399,7 @@ export class Component<T extends Env, Props extends {}> {
|
|||||||
* willUnmount().
|
* willUnmount().
|
||||||
*/
|
*/
|
||||||
trigger(eventType: string, payload?: any) {
|
trigger(eventType: string, payload?: any) {
|
||||||
if (this.el) {
|
this.__trigger(this, eventType, payload);
|
||||||
const ev = new OwlEvent(this, eventType, {
|
|
||||||
bubbles: true,
|
|
||||||
cancelable: true,
|
|
||||||
detail: payload
|
|
||||||
});
|
|
||||||
this.el.dispatchEvent(ev);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
//--------------------------------------------------------------------------
|
//--------------------------------------------------------------------------
|
||||||
@@ -478,7 +473,24 @@ export class Component<T extends Env, Props extends {}> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* Private trigger method, allows to choose the component which triggered
|
||||||
|
* the event in the first place
|
||||||
|
*/
|
||||||
|
__trigger(component: Component<any, any>, eventType: string, payload?: any) {
|
||||||
|
if (this.el) {
|
||||||
|
const ev = new OwlEvent(component, eventType, {
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
detail: payload
|
||||||
|
});
|
||||||
|
const triggerHook = this.env[portalSymbol as any];
|
||||||
|
if (triggerHook) {
|
||||||
|
triggerHook(ev);
|
||||||
|
}
|
||||||
|
this.el.dispatchEvent(ev);
|
||||||
|
}
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* The __updateProps method is called by the t-component directive whenever
|
* The __updateProps method is called by the t-component directive whenever
|
||||||
* it updates a component (so, when the parent template is rerendered).
|
* it updates a component (so, when the parent template is rerendered).
|
||||||
|
|||||||
@@ -222,6 +222,7 @@ export class Fiber {
|
|||||||
if (this.target) {
|
if (this.target) {
|
||||||
this.target.appendChild(this.component.el!);
|
this.target.appendChild(this.component.el!);
|
||||||
inDOM = document.body.contains(this.target);
|
inDOM = document.body.contains(this.target);
|
||||||
|
this.component.env.qweb.trigger("dom-appended");
|
||||||
}
|
}
|
||||||
|
|
||||||
// call patched/mounted hook on each fiber of (reversed) patchQueue
|
// call patched/mounted hook on each fiber of (reversed) patchQueue
|
||||||
|
|||||||
+2
-1
@@ -12,6 +12,7 @@ import * as _store from "./store";
|
|||||||
import * as _utils from "./utils";
|
import * as _utils from "./utils";
|
||||||
import * as _tags from "./tags";
|
import * as _tags from "./tags";
|
||||||
import { AsyncRoot } from "./misc/async_root";
|
import { AsyncRoot } from "./misc/async_root";
|
||||||
|
import { Portal } from "./misc/portal";
|
||||||
import * as _hooks from "./hooks";
|
import * as _hooks from "./hooks";
|
||||||
import * as _context from "./context";
|
import * as _context from "./context";
|
||||||
import { Link } from "./router/link";
|
import { Link } from "./router/link";
|
||||||
@@ -29,7 +30,7 @@ export const router = { Router, RouteComponent, Link };
|
|||||||
export const Store = _store.Store;
|
export const Store = _store.Store;
|
||||||
export const utils = _utils;
|
export const utils = _utils;
|
||||||
export const tags = _tags;
|
export const tags = _tags;
|
||||||
export const misc = { AsyncRoot };
|
export const misc = { AsyncRoot, Portal };
|
||||||
export const hooks = Object.assign({}, _hooks, {
|
export const hooks = Object.assign({}, _hooks, {
|
||||||
useContext: _context.useContext,
|
useContext: _context.useContext,
|
||||||
useDispatch: _store.useDispatch,
|
useDispatch: _store.useDispatch,
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import { Component, portalSymbol } from "../component/component";
|
||||||
|
import { VNode, patch } from "../vdom/index";
|
||||||
|
import { xml } from "../tags";
|
||||||
|
import { OwlEvent } from "../core/owl_event";
|
||||||
|
import { useSubEnv } from "../hooks";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Portal
|
||||||
|
*
|
||||||
|
* The Portal component allows to render a part of a component outside it's DOM.
|
||||||
|
* It is for example useful for dialogs: for css reasons, dialogs are in general
|
||||||
|
* placed in a specific spot of the DOM (e.g. directly in the body). With the
|
||||||
|
* Portal, a component can conditionally specify in its tempate that it contains
|
||||||
|
* a dialog, and where this dialog should be inserted in the DOM.
|
||||||
|
*
|
||||||
|
* The Portal component ensures that the communication between the content of
|
||||||
|
* the Portal and its parent properly works: business events reaching the Portal
|
||||||
|
* are re-triggered on an empty <portal> node located in the parent's DOM.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class Portal extends Component<any, any> {
|
||||||
|
static template = xml`<portal><t t-slot="default"/></portal>`;
|
||||||
|
static props = {
|
||||||
|
target: {
|
||||||
|
type: String
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// boolean to indicate whether or not we must listen to 'dom-appended' event
|
||||||
|
// to hook on the moment when the target is inserted into the DOM (because it
|
||||||
|
// is not when the portal is rendered)
|
||||||
|
doTargetLookUp: boolean = true;
|
||||||
|
// set of encountered events that need to be redirected
|
||||||
|
_handledEvents: Set<string> = new Set();
|
||||||
|
// function that will be the event's tunnel (needs to be an arrow function to
|
||||||
|
// avoid having to rebind `this`)
|
||||||
|
_handlerTunnel: (f: OwlEvent<any>) => void = (ev: OwlEvent<any>) => {
|
||||||
|
ev.stopPropagation();
|
||||||
|
this.__trigger(ev.originalComponent, ev.type, ev.detail);
|
||||||
|
};
|
||||||
|
// Storing the parent's env
|
||||||
|
parentEnv: any = null;
|
||||||
|
// represents the element that is moved somewhere else
|
||||||
|
portal: VNode | null = null;
|
||||||
|
// the target where we will move `portal`
|
||||||
|
target: HTMLElement | null = null;
|
||||||
|
|
||||||
|
constructor(parent, props) {
|
||||||
|
super(parent, props);
|
||||||
|
this.parentEnv = parent ? parent.env : {};
|
||||||
|
// put a callback in the env that is propagated to children s.t. portal can
|
||||||
|
// register an handler to those events just before children will trigger them
|
||||||
|
useSubEnv({
|
||||||
|
[portalSymbol]: ev => {
|
||||||
|
if (!this._handledEvents.has(ev.type)) {
|
||||||
|
this.portal!.elm!.addEventListener(ev.type, this._handlerTunnel);
|
||||||
|
this._handledEvents.add(ev.type);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* At each DOM change, we must ensure that the portal contains exactly one
|
||||||
|
* child
|
||||||
|
*/
|
||||||
|
__checkVNodeStructure(vnode: VNode) {
|
||||||
|
const children = vnode.children!;
|
||||||
|
let countRealNodes = 0;
|
||||||
|
for (let child of children) {
|
||||||
|
if ((child as VNode).sel) {
|
||||||
|
countRealNodes++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (countRealNodes !== 1) {
|
||||||
|
throw new Error(`Portal must have exactly one non-text child (has ${countRealNodes})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Ensure the target is still there at whichever time we render
|
||||||
|
*/
|
||||||
|
__checkTargetPresence() {
|
||||||
|
if (!this.target || !document.contains(this.target)) {
|
||||||
|
throw new Error(`Could not find any match for "${this.props.target}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Move the portal's element to the target
|
||||||
|
*/
|
||||||
|
__deployPortal() {
|
||||||
|
this.__checkTargetPresence();
|
||||||
|
this.target!.appendChild(this.portal!.elm!);
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Override to remove from the DOM the element we have teleported
|
||||||
|
*
|
||||||
|
* @override
|
||||||
|
*/
|
||||||
|
__destroy(parent) {
|
||||||
|
if (this.portal && this.portal.elm) {
|
||||||
|
const displacedElm = this.portal.elm!;
|
||||||
|
const parent = displacedElm.parentNode;
|
||||||
|
if (parent) {
|
||||||
|
parent.removeChild(displacedElm);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
super.__destroy(parent);
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Override to patch the element that has been teleported
|
||||||
|
*
|
||||||
|
* @override
|
||||||
|
*/
|
||||||
|
__patch(vnode) {
|
||||||
|
if (this.doTargetLookUp) {
|
||||||
|
const target = document.querySelector(this.props.target);
|
||||||
|
if (!target) {
|
||||||
|
this.env.qweb.on("dom-appended", this, () => {
|
||||||
|
this.doTargetLookUp = false;
|
||||||
|
this.env.qweb.off("dom-appended", this);
|
||||||
|
this.target = document.querySelector(this.props.target);
|
||||||
|
this.__deployPortal();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.doTargetLookUp = false;
|
||||||
|
this.target = target;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.__checkVNodeStructure(vnode);
|
||||||
|
const shouldDeploy = !this.portal && !this.doTargetLookUp;
|
||||||
|
|
||||||
|
if (!this.doTargetLookUp && !shouldDeploy) {
|
||||||
|
// Only on pure patching, provided the
|
||||||
|
// this.target's parent has not been unmounted
|
||||||
|
this.__checkTargetPresence();
|
||||||
|
}
|
||||||
|
|
||||||
|
const portalPatch = this.portal ? this.portal : document.createElement(vnode.children[0].sel);
|
||||||
|
this.portal = patch(portalPatch, vnode.children![0] as VNode);
|
||||||
|
vnode.children = [];
|
||||||
|
|
||||||
|
super.__patch(vnode);
|
||||||
|
|
||||||
|
if (shouldDeploy) {
|
||||||
|
this.__deployPortal();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Override to set the env
|
||||||
|
*/
|
||||||
|
__trigger(component: Component<any, any>, eventType: string, payload?: any) {
|
||||||
|
const env = this.env;
|
||||||
|
this.env = this.parentEnv;
|
||||||
|
super.__trigger(component, eventType, payload);
|
||||||
|
this.env = env;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,828 @@
|
|||||||
|
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<any, any> {
|
||||||
|
static components = { Portal };
|
||||||
|
static template = xml`
|
||||||
|
<div>
|
||||||
|
<Portal>
|
||||||
|
<div>2</div>
|
||||||
|
</Portal>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
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<any, any> {
|
||||||
|
static components = { Portal };
|
||||||
|
static template = xml`
|
||||||
|
<div>
|
||||||
|
<Portal target="['body']">
|
||||||
|
<div>2</div>
|
||||||
|
</Portal>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
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<any, any> {
|
||||||
|
static components = { Portal };
|
||||||
|
static template = xml`
|
||||||
|
<div>
|
||||||
|
<span>1</span>
|
||||||
|
<Portal target="'#outside'">
|
||||||
|
<div>2</div>
|
||||||
|
</Portal>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
let error;
|
||||||
|
let parent;
|
||||||
|
try {
|
||||||
|
parent = new Parent();
|
||||||
|
} catch (e) {
|
||||||
|
error = e;
|
||||||
|
}
|
||||||
|
expect(error).toBeUndefined();
|
||||||
|
await parent.mount(fixture);
|
||||||
|
expect(outside.innerHTML).toBe("<div>2</div>");
|
||||||
|
expect(parent.el!.outerHTML).toBe("<div><span>1</span><portal></portal></div>");
|
||||||
|
QWeb.dev = dev;
|
||||||
|
});
|
||||||
|
|
||||||
|
test("conditional use of Portal", async () => {
|
||||||
|
class Parent extends Component<any, any> {
|
||||||
|
static components = { Portal };
|
||||||
|
static template = xml`
|
||||||
|
<div>
|
||||||
|
<span>1</span>
|
||||||
|
<Portal target="'#outside'" t-if="state.hasPortal">
|
||||||
|
<div>2</div>
|
||||||
|
</Portal>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
state = useState({ hasPortal: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
const parent = new Parent();
|
||||||
|
await parent.mount(fixture);
|
||||||
|
expect(outside.innerHTML).toBe("");
|
||||||
|
expect(parent.el!.outerHTML).toBe("<div><span>1</span></div>");
|
||||||
|
|
||||||
|
parent.state.hasPortal = true;
|
||||||
|
await nextTick();
|
||||||
|
expect(outside.innerHTML).toBe("<div>2</div>");
|
||||||
|
expect(parent.el!.outerHTML).toBe("<div><span>1</span><portal></portal></div>");
|
||||||
|
|
||||||
|
parent.state.hasPortal = false;
|
||||||
|
await nextTick();
|
||||||
|
expect(outside.innerHTML).toBe("");
|
||||||
|
expect(parent.el!.outerHTML).toBe("<div><span>1</span></div>");
|
||||||
|
|
||||||
|
parent.state.hasPortal = true;
|
||||||
|
await nextTick();
|
||||||
|
expect(outside.innerHTML).toBe("<div>2</div>");
|
||||||
|
expect(parent.el!.outerHTML).toBe("<div><span>1</span><portal></portal></div>");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("conditional use of Portal (with sub Component)", async () => {
|
||||||
|
class Child extends Component<any, any> {
|
||||||
|
static template = xml`<div><t t-esc="props.val"/></div>`;
|
||||||
|
}
|
||||||
|
class Parent extends Component<any, any> {
|
||||||
|
static components = { Portal, Child };
|
||||||
|
static template = xml`
|
||||||
|
<div>
|
||||||
|
<span>1</span>
|
||||||
|
<Portal t-if="state.hasPortal" target="'#outside'">
|
||||||
|
<Child val="state.val"/>
|
||||||
|
</Portal>
|
||||||
|
</div>`;
|
||||||
|
state = useState({ hasPortal: false, val: 1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const parent = new Parent();
|
||||||
|
await parent.mount(fixture);
|
||||||
|
expect(outside.innerHTML).toBe("");
|
||||||
|
expect(parent.el!.outerHTML).toBe("<div><span>1</span></div>");
|
||||||
|
|
||||||
|
parent.state.hasPortal = true;
|
||||||
|
await nextTick();
|
||||||
|
expect(outside.innerHTML).toBe("<div>1</div>");
|
||||||
|
expect(parent.el!.outerHTML).toBe("<div><span>1</span><portal></portal></div>");
|
||||||
|
|
||||||
|
parent.state.hasPortal = false;
|
||||||
|
await nextTick();
|
||||||
|
expect(outside.innerHTML).toBe("");
|
||||||
|
expect(parent.el!.outerHTML).toBe("<div><span>1</span></div>");
|
||||||
|
|
||||||
|
parent.state.val = 2;
|
||||||
|
await nextTick();
|
||||||
|
expect(outside.innerHTML).toBe("");
|
||||||
|
expect(parent.el!.outerHTML).toBe("<div><span>1</span></div>");
|
||||||
|
|
||||||
|
parent.state.hasPortal = true;
|
||||||
|
await nextTick();
|
||||||
|
expect(outside.innerHTML).toBe("<div>2</div>");
|
||||||
|
expect(parent.el!.outerHTML).toBe("<div><span>1</span><portal></portal></div>");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("with target in template (before portal)", async () => {
|
||||||
|
class Parent extends Component<any, any> {
|
||||||
|
static components = { Portal };
|
||||||
|
static template = xml`
|
||||||
|
<div>
|
||||||
|
<div id="local-target"></div>
|
||||||
|
<span>1</span>
|
||||||
|
<Portal target="'#local-target'">
|
||||||
|
<p>2</p>
|
||||||
|
</Portal>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parent = new Parent();
|
||||||
|
await parent.mount(fixture);
|
||||||
|
expect(parent.el!.innerHTML).toBe(
|
||||||
|
'<div id="local-target"><p>2</p></div><span>1</span><portal></portal>'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("with target in template (after portal)", async () => {
|
||||||
|
class Parent extends Component<any, any> {
|
||||||
|
static components = { Portal };
|
||||||
|
static template = xml`
|
||||||
|
<div>
|
||||||
|
<span>1</span>
|
||||||
|
<Portal target="'#local-target'">
|
||||||
|
<p>2</p>
|
||||||
|
</Portal>
|
||||||
|
<div id="local-target"></div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parent = new Parent();
|
||||||
|
await parent.mount(fixture);
|
||||||
|
expect(parent.el!.innerHTML).toBe(
|
||||||
|
'<span>1</span><portal></portal><div id="local-target"><p>2</p></div>'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("portal with target not in dom", async () => {
|
||||||
|
const consoleError = console.error;
|
||||||
|
console.error = jest.fn(() => {});
|
||||||
|
|
||||||
|
class Parent extends Component<any, any> {
|
||||||
|
static components = { Portal };
|
||||||
|
static template = xml`
|
||||||
|
<div>
|
||||||
|
<Portal target="'#does-not-exist'">
|
||||||
|
<div>2</div>
|
||||||
|
</Portal>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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(`<div id="outside"></div>`);
|
||||||
|
console.error = consoleError;
|
||||||
|
});
|
||||||
|
|
||||||
|
test("portal with child and props", async () => {
|
||||||
|
const steps: string[] = [];
|
||||||
|
class Child extends Component<any, any> {
|
||||||
|
static template = xml`<span><t t-esc="props.val"/></span>`;
|
||||||
|
mounted() {
|
||||||
|
steps.push("mounted");
|
||||||
|
expect(outside.innerHTML).toBe("<span>1</span>");
|
||||||
|
}
|
||||||
|
patched() {
|
||||||
|
steps.push("patched");
|
||||||
|
expect(outside.innerHTML).toBe("<span>2</span>");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
class Parent extends Component<any, any> {
|
||||||
|
static components = { Portal, Child };
|
||||||
|
static template = xml`
|
||||||
|
<div>
|
||||||
|
<Portal target="'#outside'">
|
||||||
|
<Child val="state.val"/>
|
||||||
|
</Portal>
|
||||||
|
</div>`;
|
||||||
|
state = useState({ val: 1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const parent = new Parent();
|
||||||
|
await parent.mount(fixture);
|
||||||
|
expect(outside.innerHTML).toBe("<span>1</span>");
|
||||||
|
expect(parent.el!.innerHTML).toBe("<portal></portal>");
|
||||||
|
|
||||||
|
parent.state.val = 2;
|
||||||
|
await nextTick();
|
||||||
|
expect(outside.innerHTML).toBe("<span>2</span>");
|
||||||
|
expect(parent.el!.innerHTML).toBe("<portal></portal>");
|
||||||
|
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<any, any> {
|
||||||
|
static components = { Portal };
|
||||||
|
static template = xml`
|
||||||
|
<div>
|
||||||
|
<Portal target="'#outside'">
|
||||||
|
<t t-esc="'only text'"/>
|
||||||
|
</Portal>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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(`<div id="outside"></div>`);
|
||||||
|
console.error = consoleError;
|
||||||
|
});
|
||||||
|
|
||||||
|
test("portal with no content", async () => {
|
||||||
|
const consoleError = console.error;
|
||||||
|
console.error = jest.fn(() => {});
|
||||||
|
|
||||||
|
class Parent extends Component<any, any> {
|
||||||
|
static components = { Portal };
|
||||||
|
static template = xml`
|
||||||
|
<div>
|
||||||
|
<Portal target="'#outside'">
|
||||||
|
<t t-if="false" t-esc="'ABC'"/>
|
||||||
|
</Portal>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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(`<div id="outside"></div>`);
|
||||||
|
console.error = consoleError;
|
||||||
|
});
|
||||||
|
|
||||||
|
test("portal with many children", async () => {
|
||||||
|
const consoleError = console.error;
|
||||||
|
console.error = jest.fn(() => {});
|
||||||
|
|
||||||
|
class Parent extends Component<any, any> {
|
||||||
|
static components = { Portal };
|
||||||
|
static template = xml`
|
||||||
|
<div>
|
||||||
|
<Portal target="'#outside'">
|
||||||
|
<div>1</div>
|
||||||
|
<p>2</p>
|
||||||
|
</Portal>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
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(`<div id="outside"></div>`);
|
||||||
|
console.error = consoleError;
|
||||||
|
});
|
||||||
|
|
||||||
|
test("portal with dynamic body", async () => {
|
||||||
|
class Parent extends Component<any, any> {
|
||||||
|
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 parent = new Parent();
|
||||||
|
await parent.mount(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 () => {
|
||||||
|
const consoleError = console.error;
|
||||||
|
console.error = jest.fn(() => {});
|
||||||
|
|
||||||
|
class Parent extends Component<any, any> {
|
||||||
|
static components = { Portal };
|
||||||
|
static template = xml`
|
||||||
|
<div>
|
||||||
|
<Portal target="'#outside'">
|
||||||
|
<span t-if="state.val" t-esc="state.val"/>
|
||||||
|
</Portal>
|
||||||
|
</div>`;
|
||||||
|
state = { val: "ab" };
|
||||||
|
}
|
||||||
|
const parent = new Parent();
|
||||||
|
await parent.mount(fixture);
|
||||||
|
|
||||||
|
expect(outside.innerHTML).toBe(`<span>ab</span>`);
|
||||||
|
|
||||||
|
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<any, any> {
|
||||||
|
static template = xml`<span t-esc="props.val"/>`;
|
||||||
|
mounted() {
|
||||||
|
steps.push("child:mounted");
|
||||||
|
}
|
||||||
|
willPatch() {
|
||||||
|
steps.push("child:willPatch");
|
||||||
|
}
|
||||||
|
patched() {
|
||||||
|
steps.push("child:patched");
|
||||||
|
}
|
||||||
|
willUnmount() {
|
||||||
|
steps.push("child:willUnmount");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
class Parent extends Component<any, any> {
|
||||||
|
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 });
|
||||||
|
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<any, any> {
|
||||||
|
static template = xml`<span t-esc="props.error and this.will.crash" />`;
|
||||||
|
state = {};
|
||||||
|
}
|
||||||
|
class Parent extends Component<any, any> {
|
||||||
|
static components = { Portal, Child };
|
||||||
|
static template = xml`
|
||||||
|
<div>
|
||||||
|
<Portal target="'#outside'" >
|
||||||
|
<Child error="state.error"/>
|
||||||
|
</Portal>
|
||||||
|
</div>`;
|
||||||
|
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();
|
||||||
|
expect(error.message).toBe("Cannot read property 'crash' of undefined");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Portal: Events handling", () => {
|
||||||
|
test("events triggered on movable pure node are handled", async () => {
|
||||||
|
class Parent extends Component<any, any> {
|
||||||
|
static components = { Portal };
|
||||||
|
static template = xml`
|
||||||
|
<div>
|
||||||
|
<Portal target="'#outside'">
|
||||||
|
<span id="trigger-me" t-on-custom="_onCustom" t-esc="state.val"/>
|
||||||
|
</Portal>
|
||||||
|
</div>`;
|
||||||
|
state = useState({ val: "ab" });
|
||||||
|
|
||||||
|
_onCustom() {
|
||||||
|
this.state.val = "triggered";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const parent = new Parent();
|
||||||
|
await parent.mount(fixture);
|
||||||
|
|
||||||
|
expect(outside.innerHTML).toBe(`<span id="trigger-me">ab</span>`);
|
||||||
|
outside.querySelector("#trigger-me")!.dispatchEvent(new Event("custom"));
|
||||||
|
await nextTick();
|
||||||
|
expect(outside.innerHTML).toBe(`<span id="trigger-me">triggered</span>`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("events triggered on movable owl components are redirected", async () => {
|
||||||
|
let childInst: Component<any, any> | null = null;
|
||||||
|
class Child extends Component<any, any> {
|
||||||
|
static template = xml`
|
||||||
|
<span t-on-custom="_onCustom" t-esc="props.val"/>`;
|
||||||
|
|
||||||
|
constructor(parent, props) {
|
||||||
|
super(parent, props);
|
||||||
|
childInst = this;
|
||||||
|
}
|
||||||
|
|
||||||
|
_onCustom() {
|
||||||
|
this.trigger("custom-portal");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
class Parent extends Component<any, any> {
|
||||||
|
static components = { Portal, Child };
|
||||||
|
static template = xml`
|
||||||
|
<div t-on-custom-portal="_onCustomPortal">
|
||||||
|
<Portal target="'#outside'">
|
||||||
|
<Child val="state.val"/>
|
||||||
|
</Portal>
|
||||||
|
</div>`;
|
||||||
|
state = useState({ val: "ab" });
|
||||||
|
|
||||||
|
_onCustomPortal() {
|
||||||
|
this.state.val = "triggered";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const parent = new Parent();
|
||||||
|
await parent.mount(fixture);
|
||||||
|
|
||||||
|
expect(outside.innerHTML).toBe(`<span>ab</span>`);
|
||||||
|
childInst!.trigger("custom");
|
||||||
|
await nextTick();
|
||||||
|
expect(outside.innerHTML).toBe(`<span>triggered</span>`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("events triggered on contained movable owl components are redirected", async () => {
|
||||||
|
const steps: string[] = [];
|
||||||
|
let childInst: Component<any, any> | null = null;
|
||||||
|
class Child extends Component<any, any> {
|
||||||
|
static template = xml`
|
||||||
|
<span t-on-custom="_onCustom"/>`;
|
||||||
|
|
||||||
|
constructor(parent, props) {
|
||||||
|
super(parent, props);
|
||||||
|
childInst = this;
|
||||||
|
}
|
||||||
|
|
||||||
|
_onCustom() {
|
||||||
|
this.trigger("custom-portal");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
class Parent extends Component<any, any> {
|
||||||
|
static components = { Portal, Child };
|
||||||
|
static template = xml`
|
||||||
|
<div t-on-custom="_handled" t-on-custom-portal="_handled">
|
||||||
|
<Portal target="'#outside'">
|
||||||
|
<div>
|
||||||
|
<Child/>
|
||||||
|
</div>
|
||||||
|
</Portal>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
_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<any, any> | null = null;
|
||||||
|
const steps: string[] = [];
|
||||||
|
class Child extends Component<any, any> {
|
||||||
|
static template = xml`
|
||||||
|
<button>child</button>`;
|
||||||
|
|
||||||
|
constructor(parent, props) {
|
||||||
|
super(parent, props);
|
||||||
|
childInst = this;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
class Parent extends Component<any, any> {
|
||||||
|
static components = { Portal, Child };
|
||||||
|
static template = xml`
|
||||||
|
<div t-on-click="_handled">
|
||||||
|
<Portal target="'#outside'">
|
||||||
|
<Child />
|
||||||
|
</Portal>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
_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<string> = [];
|
||||||
|
let childInst: Component<any, any> | null = null;
|
||||||
|
class Child2 extends Component<any, any> {
|
||||||
|
static template = xml`<div>child2</div>`;
|
||||||
|
constructor(parent, props) {
|
||||||
|
super(parent, props);
|
||||||
|
childInst = this;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
class Child extends Component<any, any> {
|
||||||
|
static components = { Portal, Child2 };
|
||||||
|
static template = xml`
|
||||||
|
<Portal target="'#outside2'">
|
||||||
|
<Child2 />
|
||||||
|
</Portal>`;
|
||||||
|
}
|
||||||
|
class Parent extends Component<any, any> {
|
||||||
|
static components = { Portal, Child };
|
||||||
|
static template = xml`
|
||||||
|
<div t-on-custom='_handled'>
|
||||||
|
<Portal target="'#outside'">
|
||||||
|
<Child/>
|
||||||
|
</Portal>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
_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<any, any> {
|
||||||
|
static template = xml`
|
||||||
|
<button>child</button>`;
|
||||||
|
}
|
||||||
|
class Parent extends Component<any, any> {
|
||||||
|
static components = { Portal, Child, };
|
||||||
|
static template = xml`
|
||||||
|
<div>
|
||||||
|
<Portal target="'#outside'">
|
||||||
|
<Child />
|
||||||
|
</Portal>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
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<string> = [];
|
||||||
|
let childInst: Component<any, any> | null = null;
|
||||||
|
class Child2 extends Component<any, any> {
|
||||||
|
static template = xml`<div>child2</div>`;
|
||||||
|
constructor(parent, props) {
|
||||||
|
super(parent, props);
|
||||||
|
childInst = this;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
class Child extends Component<any, any> {
|
||||||
|
static components = { Portal, Child2 };
|
||||||
|
static template = xml`
|
||||||
|
<Portal target="'#outside'">
|
||||||
|
<t t-slot="default"/>
|
||||||
|
</Portal>`;
|
||||||
|
}
|
||||||
|
class Parent extends Component<any, any> {
|
||||||
|
static components = { Child, Child2 };
|
||||||
|
static template = xml`
|
||||||
|
<div t-on-custom='_handled'>
|
||||||
|
<Child>
|
||||||
|
<Child2/>
|
||||||
|
</Child>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
_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<any, any> {
|
||||||
|
static template = xml`
|
||||||
|
<input id="target-me" t-att-placeholder="props.val"/>`;
|
||||||
|
}
|
||||||
|
class Parent extends Component<any, any> {
|
||||||
|
static components = { Portal, Child };
|
||||||
|
static template = xml`
|
||||||
|
<div>
|
||||||
|
<Portal target="'#outside'">
|
||||||
|
<Child val="state.val"/>
|
||||||
|
</Portal>
|
||||||
|
</div>`;
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
+112
-1
@@ -1444,6 +1444,111 @@ const FORM_XML = `<templates>
|
|||||||
</templates>
|
</templates>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const PORTAL_COMPONENTS = `
|
||||||
|
// This shows the expected use case of Portal
|
||||||
|
// which is to implement something similar
|
||||||
|
// to bootstrap modal
|
||||||
|
const { Component, useState } = owl;
|
||||||
|
const { Portal } = owl.misc;
|
||||||
|
|
||||||
|
class Modal extends Component {}
|
||||||
|
Modal.components = { Portal };
|
||||||
|
|
||||||
|
class Dialog extends Component {}
|
||||||
|
Dialog.components = { Modal };
|
||||||
|
|
||||||
|
class Interstellar extends Component {}
|
||||||
|
|
||||||
|
// Main root component
|
||||||
|
class App extends Component {
|
||||||
|
state = useState({
|
||||||
|
name: 'Portal used for Dialog (Modal)',
|
||||||
|
dialog: false,
|
||||||
|
text: 'Hello !',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
App.components = { Dialog , Interstellar };
|
||||||
|
|
||||||
|
// Application setup
|
||||||
|
const app = new App();
|
||||||
|
app.mount(document.body);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const PORTAL_XML = `
|
||||||
|
<templates>
|
||||||
|
<t t-name="Modal">
|
||||||
|
<Portal target="'body'">
|
||||||
|
<div class="owl-modal-supercontainer">
|
||||||
|
<div class="owl-modal-backdrop"></div>
|
||||||
|
<div class="owl-modal-container">
|
||||||
|
<t t-slot="default" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Portal>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
<t t-name="Dialog">
|
||||||
|
<Modal>
|
||||||
|
<div class="owl-dialog-body">
|
||||||
|
<t t-slot="default" />
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
<div t-name="Interstellar" class="owl-interstellar">
|
||||||
|
<h4>This is a subComponent</h4>
|
||||||
|
<p>The events it triggers will go through the Portal and be teleported
|
||||||
|
on the other side of the wormhole it has created</p>
|
||||||
|
<button t-on-click="trigger('collapse-all')">Close the wormhole</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div t-name="App" t-on-collapse-all="state.dialog=false">
|
||||||
|
<div t-esc="state.name"/>
|
||||||
|
<button t-on-click="state.dialog = true">Open Dialog</button>
|
||||||
|
<Dialog t-if="state.dialog">
|
||||||
|
<div t-esc="state.text"/>
|
||||||
|
<Interstellar />
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
</templates>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const PORTAL_CSS = `
|
||||||
|
.owl-modal-supercontainer {
|
||||||
|
position: static;
|
||||||
|
}
|
||||||
|
.owl-modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left:0;
|
||||||
|
background-color: #000000;
|
||||||
|
opacity: 0.5;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
.owl-modal-container {
|
||||||
|
opacity:1;
|
||||||
|
z-index: 1050;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left:0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.owl-dialog-body {
|
||||||
|
max-width: 500px;
|
||||||
|
margin: 0 auto;
|
||||||
|
position: relative;
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
background-color: #FFFFFF;
|
||||||
|
max-height: 100%;
|
||||||
|
}
|
||||||
|
.owl-interstellar {
|
||||||
|
border: groove;
|
||||||
|
}`
|
||||||
|
|
||||||
const WMS = `// This example is slightly more complex than usual. We demonstrate
|
const WMS = `// This example is slightly more complex than usual. We demonstrate
|
||||||
// here a way to manage sub windows in Owl, declaratively. This is still just a
|
// here a way to manage sub windows in Owl, declaratively. This is still just a
|
||||||
// demonstration. Managing windows can be as complex as we want. For example,
|
// demonstration. Managing windows can be as complex as we want. For example,
|
||||||
@@ -1770,5 +1875,11 @@ export const SAMPLES = [
|
|||||||
code: ASYNC_COMPONENTS,
|
code: ASYNC_COMPONENTS,
|
||||||
xml: ASYNC_COMPONENTS_XML,
|
xml: ASYNC_COMPONENTS_XML,
|
||||||
css: ASYNC_COMPONENTS_CSS
|
css: ASYNC_COMPONENTS_CSS
|
||||||
}
|
},
|
||||||
|
{
|
||||||
|
description: "Portal (Dialog)",
|
||||||
|
code: PORTAL_COMPONENTS,
|
||||||
|
xml: PORTAL_XML,
|
||||||
|
css: PORTAL_CSS,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
Reference in New Issue
Block a user