[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:
committed by
Aaron Bohy
parent
a8b13c2481
commit
0096f6f32e
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user