From 0096f6f32ebad9a7bfe49cd39b0fccfbeebb305a Mon Sep 17 00:00:00 2001 From: "Lucas Perais (lpe)" Date: Thu, 23 Jun 2022 07:39:41 +0000 Subject: [PATCH] [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 --- .../web/static/src/core/notebook/notebook.js | 160 ++++++++++ .../static/src/core/notebook/notebook.scss | 18 ++ .../web/static/src/core/notebook/notebook.xml | 22 ++ .../web/static/tests/core/notebook_tests.js | 296 ++++++++++++++++++ 4 files changed, 496 insertions(+) create mode 100644 addons/web/static/src/core/notebook/notebook.js create mode 100644 addons/web/static/src/core/notebook/notebook.scss create mode 100644 addons/web/static/src/core/notebook/notebook.xml create mode 100644 addons/web/static/tests/core/notebook_tests.js diff --git a/addons/web/static/src/core/notebook/notebook.js b/addons/web/static/src/core/notebook/notebook.js new file mode 100644 index 00000000000..e6f6bc59942 --- /dev/null +++ b/addons/web/static/src/core/notebook/notebook.js @@ -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` +

+

`; + + * `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", + * }, + * }, + * ] + * + * + * + *

Page Content 1
+ * + * + *
Page Content 2
+ *
+ * + * + * @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 }, +}; diff --git a/addons/web/static/src/core/notebook/notebook.scss b/addons/web/static/src/core/notebook/notebook.scss new file mode 100644 index 00000000000..77553ab1d36 --- /dev/null +++ b/addons/web/static/src/core/notebook/notebook.scss @@ -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; + } + } +} diff --git a/addons/web/static/src/core/notebook/notebook.xml b/addons/web/static/src/core/notebook/notebook.xml new file mode 100644 index 00000000000..2604f6a501a --- /dev/null +++ b/addons/web/static/src/core/notebook/notebook.xml @@ -0,0 +1,22 @@ + + + + +
+ +
+
+ + +
+
+
+
+ +
diff --git a/addons/web/static/tests/core/notebook_tests.js b/addons/web/static/tests/core/notebook_tests.js new file mode 100644 index 00000000000..53be41c4b4f --- /dev/null +++ b/addons/web/static/tests/core/notebook_tests.js @@ -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` + +

About the bird

+

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, ...

+
+ +

Their favorite activity: hunting

+

Owls are called raptors, or birds of prey, which means they use sharp talons and curved bills to hunt, kill, and eat other animals.

+
+ +

TODO find a great secret about OWLs.

+
+
`; + 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` + +

About the bird

+

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, ...

+
+ +

Their favorite activity: hunting

+

Owls are called raptors, or birds of prey, which means they use sharp talons and curved bills to hunt, kill, and eat other animals.

+
+ +

TODO find a great secret about OWLs.

+
+
`; + 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` + +

About the bird

+

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, ...

+
+ +

Their favorite activity: hunting

+

Owls are called raptors, or birds of prey, which means they use sharp talons and curved bills to hunt, kill, and eat other animals.

+
+ +

Oooops

+

TODO find a great secret to reveal about OWLs.

+
+
`; + 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` + +

About the bird

+

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, ...

+
+ +

Their favorite activity: hunting

+

Owls are called raptors, or birds of prey, which means they use sharp talons and curved bills to hunt, kill, and eat other animals.

+
+
`; + 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` +

+

+ `; + + 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` + +

Page 1

+

First page set directly as a slot

+ + +

Page 4

+
+ `; + 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`

Coucou

`; + + 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``; + 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); + }); +});