[IMP] web: add Notebook component

This commit introduces a Notebook component, which displays tabs
and an active page. The component is configurable to display the
notebook vertically (e.g. form views) or horizontally (e.g. kanban
examples dialog).

Part-of: odoo/odoo#92475
This commit is contained in:
Lucas Perais (lpe)
2022-06-30 09:29:42 +02:00
committed by Aaron Bohy
parent a8b13c2481
commit 0096f6f32e
4 changed files with 496 additions and 0 deletions
@@ -0,0 +1,160 @@
/** @odoo-module **/
import { scrollTo } from "@web/core/utils/scrolling";
const { Component, onWillDestroy, onWillUpdateProps, useEffect, useRef, useState } = owl;
/**
* A notebook component that will render only the current page and allow
* switching between its pages.
*
* You can also set pages using a template component. Use an array with
* the `pages` props to do such rendering.
*
* Pages can also specify their index in the notebook.
*
* e.g.:
* PageTemplate.template = xml`
<h1 t-esc="props.title" />
<p t-esc="props.text" />`;
* `pages` could be:
* [
* {
* Component: PageTemplate,
* props: {
* title: "Some Title 1",
* isVisible: bool,
* text: "Text Content 1",
* },
* name: 'some_name' // optional
* },
* {
* Component: PageTemplate,
* props: {
* title: "Some Title 2",
* isVisible: bool,
* text: "Text Content 2",
* },
* },
* ]
*
* <Notebook pages="pages">
* <t t-set-slot="Page Name 1" title="Some Title" isVisible="bool">
* <div>Page Content 1</div>
* </t>
* <t t-set-slot="Page Name 2" title="Some Title" isVisible="bool">
* <div>Page Content 2</div>
* </t>
* </Notebook>
*
* @extends Component
*/
export class Notebook extends Component {
setup() {
this.activePane = useRef("activePane");
this.anchorTarget = null;
this.pages = this.computePages(this.props);
this.state = useState({ currentPage: null });
this.state.currentPage = this.props.defaultPage || this.computeActivePage();
const onAnchorClicked = this.onAnchorClicked.bind(this);
this.env.bus.addEventListener("SCROLLER:ANCHOR_LINK_CLICKED", onAnchorClicked);
useEffect(
() => {
this.props.onPageUpdate(this.state.currentPage);
if (this.anchorTarget) {
const matchingEl = this.activePane.el.querySelector(`#${this.anchorTarget}`);
scrollTo(matchingEl, { isAnchor: true });
this.anchorTarget = null;
}
},
() => [this.state.currentPage]
);
onWillUpdateProps((nextProps) => {
this.pages = this.computePages(nextProps);
this.state.currentPage = this.computeActivePage();
});
onWillDestroy(() => {
this.env.bus.removeEventListener("SCROLLER:ANCHOR_LINK_CLICKED", onAnchorClicked);
});
}
get navItems() {
return this.pages.filter((e) => e[1].isVisible);
}
get page() {
const page = this.pages.find((e) => e[0] === this.state.currentPage)[1];
return page.Component && page;
}
onAnchorClicked(ev) {
if (!this.props.anchors) {
return;
}
const id = ev.detail.detail.id.substring(1);
if (this.props.anchors[id]) {
if (this.state.currentPage !== this.props.anchors[id].target) {
ev.preventDefault();
ev.detail.detail.originalEv.preventDefault();
this.anchorTarget = id;
this.state.currentPage = this.props.anchors[id].target;
}
}
}
computePages(props) {
if (!props.slots && !props.pages) {
return [];
}
const pages = [];
const pagesWithIndex = [];
for (const [k, v] of Object.entries({ ...props.slots, ...props.pages })) {
if (v.props) {
v.isVisible = v.props.isVisible;
v.title = v.props.title;
v.index = v.props.index;
}
const id = v.id || k;
if (v.index) {
pagesWithIndex.push([id, v]);
} else {
pages.push([id, v]);
}
}
for (const page of pagesWithIndex) {
pages.splice(page[1].index, 0, page);
}
return pages;
}
computeActivePage() {
if (!this.pages.length) {
return null;
}
const current = this.state.currentPage;
if (!current || (current && !this.pages.find((e) => e[0] === current)[1].isVisible)) {
const candidate = this.pages.find((v) => v[1].isVisible);
return candidate ? candidate[0] : null;
}
return current;
}
}
Notebook.template = "web.Notebook";
Notebook.defaultProps = {
className: "",
orientation: "horizontal",
onPageUpdate: () => {},
};
Notebook.props = {
slots: { type: Object, optional: true },
pages: { type: Object, optional: true },
class: { optional: true },
className: { type: String, optional: true },
anchors: { type: Object, optional: true },
defaultPage: { type: String, optional: true },
orientation: { type: String, optional: true },
onPageUpdate: { type: Function, optional: true },
};
@@ -0,0 +1,18 @@
.o_notebook {
&.horizontal {
flex-direction: column;
.nav.nav-tabs {
flex-flow: row nowrap;
}
}
&.vertical {
flex-direction: row;
.nav.nav-pills {
width: max-content;
}
}
}
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="web.Notebook" owl="1">
<div t-attf-class="o_notebook d-flex w-100 {{ props.orientation }} {{ props.className }}" t-if="state.currentPage">
<div class="o_notebook_headers">
<ul t-attf-class="nav {{ props.orientation === 'horizontal' ? 'nav-tabs' : 'nav-pills flex-column' }}">
<li t-foreach="navItems" t-as="navItem" t-key="navItem[0]" class="nav-item" t-if="navItem[1].isVisible">
<a class="nav-link" t-att-name="navItem[1].name" t-on-click.prevent="() => state.currentPage = navItem[0]" href="#" role="tab" tabindex="-1" t-att-class="{ active: navItem[0] === state.currentPage}" t-esc="navItem[1].title"/>
</li>
</ul>
</div>
<div class="o_notebook_content tab-content">
<div class="tab-pane active" t-ref="activePane">
<t t-if="page" t-component="page.Component" t-key="state.currentPage" t-props="page.props" />
<t t-else="" t-slot="{{ state.currentPage }}" />
</div>
</div>
</div>
</t>
</templates>
@@ -0,0 +1,296 @@
/** @odoo-module **/
import { Notebook } from "@web/core/notebook/notebook";
import { makeTestEnv } from "@web/../tests/helpers/mock_env";
import { click, getFixture, mount } from "@web/../tests/helpers/utils";
const { Component, xml } = owl;
let target;
QUnit.module("Components", (hooks) => {
hooks.beforeEach(async () => {
target = getFixture();
});
QUnit.module("Notebook");
QUnit.test("not rendered if empty slots", async (assert) => {
const env = await makeTestEnv();
await mount(Notebook, target, { env, props: {} });
assert.containsNone(target, "div.o_notebook");
});
QUnit.test("notebook with multiple pages given as slots", async (assert) => {
const env = await makeTestEnv();
class Parent extends Component {}
Parent.template = xml`<Notebook>
<t t-set-slot="page_about" title="'About'" isVisible="true">
<h3>About the bird</h3>
<p>Owls are birds from the order Strigiformes which includes over
200 species of mostly solitary and nocturnal birds of prey typified by an upright stance, ...</p>
</t>
<t t-set-slot="page_hunting" title="'Owl Activities'" isVisible="true">
<h3>Their favorite activity: hunting</h3>
<p>Owls are called raptors, or birds of prey, which means they use sharp talons and curved bills to hunt, kill, and eat other animals.</p>
</t>
<t t-set-slot="page_secret" title="'Secret about OWLs'" isVisible="false">
<p>TODO find a great secret about OWLs.</p>
</t>
</Notebook>`;
Parent.components = { Notebook };
await mount(Parent, target, { env });
assert.containsOnce(target, "div.o_notebook");
assert.hasClass(
target.querySelector(".o_notebook"),
"horizontal",
"default orientation is set as horizontal"
);
assert.hasClass(
target.querySelector(".nav"),
"nav-tabs",
"navigation container uses the right class to display as horizontal tabs"
);
assert.containsN(
target,
".o_notebook_headers a.nav-link",
2,
"navigation link is present for each visible page"
);
assert.hasClass(
target.querySelector(".o_notebook_headers .nav-item:first-child a"),
"active",
"first page is selected by default"
);
assert.strictEqual(
target.querySelector(".tab-pane.active").firstElementChild.textContent,
"About the bird",
"first page content is displayed by the notebook"
);
await click(target, ".o_notebook_headers .nav-item:nth-child(2) a");
assert.hasClass(
target.querySelector(".o_notebook_headers .nav-item:nth-child(2) a"),
"active",
"second page is now selected"
);
assert.strictEqual(
target.querySelector(".tab-pane.active").firstElementChild.textContent,
"Their favorite activity: hunting",
"second page content is displayed by the notebook"
);
});
QUnit.test("notebook with defaultPage props", async (assert) => {
const env = await makeTestEnv();
class Parent extends Component {}
Parent.template = xml`<Notebook defaultPage="'page_hunting'">
<t t-set-slot="page_about" title="'About'" isVisible="true">
<h3>About the bird</h3>
<p>Owls are birds from the order Strigiformes which includes over
200 species of mostly solitary and nocturnal birds of prey typified by an upright stance, ...</p>
</t>
<t t-set-slot="page_hunting" title="'Owl Activities'" isVisible="true">
<h3>Their favorite activity: hunting</h3>
<p>Owls are called raptors, or birds of prey, which means they use sharp talons and curved bills to hunt, kill, and eat other animals.</p>
</t>
<t t-set-slot="page_secret" title="'Secret about OWLs'" isVisible="false">
<p>TODO find a great secret about OWLs.</p>
</t>
</Notebook>`;
Parent.components = { Notebook };
await mount(Parent, target, { env });
assert.containsOnce(target, "div.o_notebook");
assert.hasClass(
target.querySelector(".o_notebook_headers .nav-item:nth-child(2) a"),
"active",
"second page is selected by default"
);
assert.strictEqual(
target.querySelector(".tab-pane.active").firstElementChild.textContent,
"Their favorite activity: hunting",
"second page content is displayed by the notebook"
);
});
QUnit.test("notebook with defaultPage set on invisible page", async (assert) => {
const env = await makeTestEnv();
class Parent extends Component {}
Parent.template = xml`<Notebook defaultPage="'page_secret'">
<t t-set-slot="page_about" title="'About'" isVisible="true">
<h3>About the bird</h3>
<p>Owls are birds from the order Strigiformes which includes over
200 species of mostly solitary and nocturnal birds of prey typified by an upright stance, ...</p>
</t>
<t t-set-slot="page_hunting" title="'Owl Activities'" isVisible="true">
<h3>Their favorite activity: hunting</h3>
<p>Owls are called raptors, or birds of prey, which means they use sharp talons and curved bills to hunt, kill, and eat other animals.</p>
</t>
<t t-set-slot="page_secret" title="'Secret about OWLs'" isVisible="false">
<h3>Oooops</h3>
<p>TODO find a great secret to reveal about OWLs.</p>
</t>
</Notebook>`;
Parent.components = { Notebook };
await mount(Parent, target, { env });
assert.containsOnce(target, "div.o_notebook");
assert.containsNone(
target,
".o_notebook_headers .nav-item a.active",
"no tab is selected as the default page is not visible"
);
assert.containsN(
target,
".o_notebook_headers a.nav-link",
2,
"navigation link is only present for visible pages"
);
assert.strictEqual(
target.querySelector(".tab-pane.active").firstElementChild.textContent,
"Oooops",
"third page content is displayed by the notebook"
);
});
QUnit.test("notebook set vertically", async (assert) => {
const env = await makeTestEnv();
class Parent extends Component {}
Parent.template = xml`<Notebook orientation="'vertical'">
<t t-set-slot="page_about" title="'About'" isVisible="true">
<h3>About the bird</h3>
<p>Owls are birds from the order Strigiformes which includes over
200 species of mostly solitary and nocturnal birds of prey typified by an upright stance, ...</p>
</t>
<t t-set-slot="page_hunting" title="'Owl Activities'" isVisible="true">
<h3>Their favorite activity: hunting</h3>
<p>Owls are called raptors, or birds of prey, which means they use sharp talons and curved bills to hunt, kill, and eat other animals.</p>
</t>
</Notebook>`;
Parent.components = { Notebook };
await mount(Parent, target, { env });
assert.containsOnce(target, "div.o_notebook");
assert.hasClass(
target.querySelector(".o_notebook"),
"vertical",
"orientation is set as vertical"
);
assert.hasClass(
target.querySelector(".nav"),
"nav-pills",
"navigation container uses the right class to display as vertical buttons"
);
});
QUnit.test("notebook pages rendered by a template component", async (assert) => {
const env = await makeTestEnv();
class NotebookPageRenderer extends Component {}
NotebookPageRenderer.template = xml`
<h3 t-esc="props.title"></h3>
<p t-esc="props.text" />
`;
class Parent extends Component {
setup() {
this.pages = [
{
Component: NotebookPageRenderer,
props: {
index: 1,
isVisible: true,
title: "Page 2",
text: "Second page rendered by a template component",
},
},
{
Component: NotebookPageRenderer,
props: {
index: 2,
isVisible: true,
title: "Page 3",
text: "Third page rendered by a template component",
},
id: "page_three", // required to be set as default page
},
];
}
}
Parent.template = xml`<Notebook defaultPage="'page_three'" pages="pages">
<t t-set-slot="page_one" title="'Page 1'" isVisible="true">
<h3>Page 1</h3>
<p>First page set directly as a slot</p>
</t>
<t t-set-slot="page_four" title="'Page 4'" isVisible="true">
<h3>Page 4</h3>
</t>
</Notebook>`;
Parent.components = { Notebook };
await mount(Parent, target, { env });
assert.containsOnce(target, "div.o_notebook");
assert.hasClass(
target.querySelector(".o_notebook_headers .nav-item:nth-child(3) a"),
"active",
"third page is selected by default"
);
await click(target.querySelector(".o_notebook_headers .nav-item:nth-child(2) a"));
assert.strictEqual(
target.querySelector(".o_notebook_content p").textContent,
"Second page rendered by a template component",
"displayed content corresponds to the current page"
);
});
QUnit.test("each page is different", async (assert) => {
const env = await makeTestEnv();
class Page extends Component {}
Page.template = xml`<h3>Coucou</h3>`;
class Parent extends Component {
setup() {
this.pages = [
{
Component: Page,
props: {
index: 1,
isVisible: true,
title: "Page 1",
},
},
{
Component: Page,
props: {
index: 2,
isVisible: true,
title: "Page 2",
},
},
];
}
}
Parent.template = xml`<Notebook pages="pages"/>`;
Parent.components = { Notebook };
await mount(Parent, target, { env });
const firstPage = target.querySelector("h3");
assert.ok(firstPage instanceof HTMLElement);
await click(target.querySelector(".o_notebook_headers .nav-item:nth-child(2) a"));
const secondPage = target.querySelector("h3");
assert.ok(firstPage instanceof HTMLElement);
assert.notEqual(firstPage, secondPage);
});
});