From e0796020ee0c3188e1e9d9fa077de73a2211c6f7 Mon Sep 17 00:00:00 2001 From: Benoit Socias Date: Wed, 28 Jun 2023 16:22:18 +0200 Subject: [PATCH] [IMP] website: make it possible to create new pages from templates This commit modifies the "New Page" dialog so that it displays a list of page templates to pick from in addition to the possibility to create a blank page. task-3381714 Part-of: odoo/odoo#126719 Co-authored-by: stefanorigano (SRI) --- addons/website/controllers/main.py | 48 +- addons/website/models/website.py | 12 +- .../src/components/dialog/add_page_dialog.js | 436 ++++++++++++++++++ .../components/dialog/add_page_dialog.scss | 95 ++++ .../src/components/dialog/add_page_dialog.xml | 114 +++++ .../static/src/components/dialog/dialog.js | 113 +---- .../static/src/components/dialog/dialog.xml | 4 +- .../src/components/views/page_views_mixin.js | 12 +- .../static/src/systray_items/new_content.js | 10 +- addons/website/static/tests/tours/rte.js | 6 + .../views/new_page_template_templates.xml | 147 ++++++ 11 files changed, 879 insertions(+), 118 deletions(-) create mode 100644 addons/website/static/src/components/dialog/add_page_dialog.js create mode 100644 addons/website/static/src/components/dialog/add_page_dialog.scss create mode 100644 addons/website/static/src/components/dialog/add_page_dialog.xml diff --git a/addons/website/controllers/main.py b/addons/website/controllers/main.py index 46baaeed00b..47eec868503 100644 --- a/addons/website/controllers/main.py +++ b/addons/website/controllers/main.py @@ -12,7 +12,7 @@ import werkzeug.utils import werkzeug.wrappers from itertools import islice -from lxml import etree +from lxml import etree, html from textwrap import shorten from werkzeug.exceptions import NotFound from xml.etree import ElementTree as ET @@ -24,6 +24,7 @@ from odoo.exceptions import AccessError from odoo.http import request, SessionExpiredException from odoo.osv import expression from odoo.tools import OrderedSet, escape_psql, html_escape as escape +from odoo.addons.base.models.ir_qweb import QWebException from odoo.addons.http_routing.models.ir_http import slug, slugify, _guess_mimetype from odoo.addons.portal.controllers.portal import pager as portal_pager from odoo.addons.portal.controllers.web import Home @@ -629,7 +630,7 @@ class Website(Home): if website_id: website = request.env['website'].browse(int(website_id)) website._force() - page = request.env['website'].new_page(path, add_menu=add_menu, **template) + page = request.env['website'].new_page(path, add_menu=add_menu, sections_arch=kwargs.get('sections_arch'), **template) url = page['url'] if redirect: @@ -641,6 +642,49 @@ class Website(Home): return json.dumps({'view_id': page.get('view_id')}) return json.dumps({'url': url}) + @http.route('/website/get_new_page_templates', type='json', auth='user', website=True) + def get_new_page_templates(self, **kw): + View = request.env['ir.ui.view'] + result = [] + groups_html = View._render_template("website.new_page_template_groups") + groups_el = etree.fromstring(f'{groups_html}') + for group_el in groups_el.getchildren(): + group = { + 'id': group_el.attrib['id'], + 'title': group_el.text, + 'templates': [], + } + for template in View.search([ + ('mode', '=', 'primary'), + ('key', 'like', escape_psql(f'new_page_template_sections_{group["id"]}_')), + ], order='key'): + try: + html_tree = html.fromstring(View.with_context(inherit_branding=False)._render_template( + template.key, + )) + for section_el in html_tree.xpath("//section[@data-snippet]"): + # data-snippet must be the short general name + snippet = section_el.attrib['data-snippet'] + # Because the templates are generated from specific + # t-snippet-calls such as: + # "website.new_page_template_about_0_s_text_block", + # the generated data-snippet looks like: + # "new_page_template_about_0_s_text_block" + # while it should be "s_text_block" only. + if '_s_' in snippet: + section_el.attrib['data-snippet'] = f's_{snippet.split("_s_")[-1]}' + + group['templates'].append({ + 'key': template.key, + 'template': html.tostring(html_tree), + }) + except QWebException as qe: + # Do not fail if theme is not compatible. + logger.warning("Theme not compatible with template %r: %s", template.key, qe) + if group['templates']: + result.append(group) + return result + @http.route("/website/get_switchable_related_views", type="json", auth="user", website=True) def get_switchable_related_views(self, key): views = request.env["ir.ui.view"].get_related_views(key, bundles=False).filtered(lambda v: v.customize_show) diff --git a/addons/website/models/website.py b/addons/website/models/website.py index f64bd14803b..fcd58c69432 100644 --- a/addons/website/models/website.py +++ b/addons/website/models/website.py @@ -765,7 +765,7 @@ class Website(models.Model): copy_menu(submenu, new_top_menu) @api.model - def new_page(self, name=False, add_menu=False, template='website.default_page', ispage=True, namespace=None, page_values=None, menu_values=None): + def new_page(self, name=False, add_menu=False, template='website.default_page', ispage=True, namespace=None, page_values=None, menu_values=None, sections_arch=None): """ Create a new website page, and assign it a xmlid based on the given one :param name: the name of the page :param add_menu: if True, add a menu for that page @@ -773,6 +773,7 @@ class Website(models.Model): :param namespace: module part of the xml_id if none, the template module name is used :param page_values: default values for the page to be created :param menu_values: default values for the menu to be created + :param sections_arch: HTML content of sections """ if namespace: template_module = namespace @@ -788,12 +789,19 @@ class Website(models.Model): page_key = 'home' template_record = self.env.ref(template) + arch = template_record.arch + if sections_arch: + tree = html.fromstring(arch) + wrap = tree.xpath('//div[@id="wrap"]')[0] + for section in html.fromstring(f'{sections_arch}'): + wrap.append(section) + arch = etree.tostring(tree, encoding="unicode") website_id = self._context.get('website_id') key = self.get_unique_key(page_key, template_module) view = template_record.copy({'website_id': website_id, 'key': key}) view.with_context(lang=None).write({ - 'arch': template_record.arch.replace(template, key), + 'arch': arch.replace(template, key), 'name': name, }) result['view_id'] = view.id diff --git a/addons/website/static/src/components/dialog/add_page_dialog.js b/addons/website/static/src/components/dialog/add_page_dialog.js new file mode 100644 index 00000000000..783344168e1 --- /dev/null +++ b/addons/website/static/src/components/dialog/add_page_dialog.js @@ -0,0 +1,436 @@ +/** @odoo-module **/ + +import { isBrowserFirefox } from "@web/core/browser/feature_detection"; +import { useAutofocus, useService } from '@web/core/utils/hooks'; +import { _t } from "@web/core/l10n/translation"; +import { WebsiteDialog } from '@website/components/dialog/dialog'; +import { Switch } from '@website/components/switch/switch'; +import { useRef, useState, useSubEnv, Component, onWillStart, onMounted } from "@odoo/owl"; +import wUtils from '@website/js/utils'; + +const NO_OP = () => {}; + +export class AddPageConfirmDialog extends Component { + setup() { + super.setup(); + useAutofocus(); + + this.title = _t("New Page"); + this.primaryTitle = _t("Create"); + this.switchLabel = _t("Add to menu"); + this.website = useService('website'); + this.http = useService('http'); + this.action = useService('action'); + + this.state = useState({ + addMenu: true, + name: this.props.name, + }); + } + + onChangeAddMenu(value) { + this.state.addMenu = value; + } + + async addPage() { + const params = {'add_menu': this.state.addMenu || '', csrf_token: odoo.csrf_token}; + if (this.props.sectionsArch) { + params.sections_arch = this.props.sectionsArch; + } + // Remove any leading slash. + const pageName = this.state.name.replace(/^\/*/, "") || _t("New Page"); + const url = `/website/add/${encodeURIComponent(pageName)}`; + params['website_id'] = this.props.websiteId; + const data = await this.http.post(url, params); + if (data.view_id) { + this.action.doAction({ + 'res_model': 'ir.ui.view', + 'res_id': data.view_id, + 'views': [[false, 'form']], + 'type': 'ir.actions.act_window', + 'view_mode': 'form', + }); + } else { + this.website.goToWebsite({path: data.url, edition: true, websiteId: this.props.websiteId}); + } + this.props.onAddPage(this.state); + } +} +AddPageConfirmDialog.props = { + close: Function, + onAddPage: { + type: Function, + optional: true, + }, + websiteId: Number, + sectionsArch: { + type: String, + optional: true, + }, + name: String, +}; +AddPageConfirmDialog.defaultProps = { + onAddPage: NO_OP, +}; +AddPageConfirmDialog.components = { + Switch, + WebsiteDialog, +}; +AddPageConfirmDialog.template = "website.AddPageConfirmDialog"; + +export class AddPageTemplateBlank extends Component { + setup() { + super.setup(); + this.holderRef = useRef("holder"); + + onMounted(async () => { + this.holderRef.el.classList.add("o_ready"); + }); + } + + select() { + this.env.addPage(); + } +} +AddPageTemplateBlank.props = { + firstRow: { + type: Boolean, + optional: true, + }, +}; +AddPageTemplateBlank.template = "website.AddPageTemplateBlank"; + +export class AddPageTemplatePreview extends Component { + setup() { + super.setup(); + this.iframeRef = useRef("iframe"); + this.previewRef = useRef("preview"); + this.holderRef = useRef("holder"); + + onMounted(async () => { + const holderEl = this.holderRef.el; + holderEl.classList.add("o_loading"); + if (!this.props.template.key) { + return; + } + const previewEl = this.previewRef.el; + const iframeEl = this.iframeRef.el; + // Firefox replaces the built content with about:blank. + const isFirefox = isBrowserFirefox(); + if (isFirefox) { + // Make sure empty preview iframe is loaded. + // This event is never triggered on Chrome. + await new Promise(resolve => { + iframeEl.contentDocument.body.onload = resolve; + }); + } + // Apply styles. + for (const cssLinkEl of await this.env.getCssLinkEls()) { + const preloadLinkEl = document.createElement("link"); + preloadLinkEl.setAttribute("rel", "preload"); + preloadLinkEl.setAttribute("href", cssLinkEl.getAttribute("href")); + preloadLinkEl.setAttribute("as", "style"); + iframeEl.contentDocument.head.appendChild(preloadLinkEl); + iframeEl.contentDocument.head.appendChild(cssLinkEl.cloneNode(true)); + } + // Adjust styles. + const styleEl = document.createElement("style"); + // Does not work with fit-content in Firefox. + const carouselHeight = isFirefox ? '450px' : 'fit-content'; + // Prevent successive resizes. + const fullHeight = getComputedStyle(document.querySelector(".o_action_manager")).height; + const halfHeight = `${Math.round(parseInt(fullHeight) / 2)}px`; + const css = ` + #wrapwrap { + overflow: hidden; + padding-right: 0px; + padding-left: 0px; + } + section { + /* Avoid the zoom's missing pixel. */ + transform: scale(101%); + } + section[data-snippet="s_carousel"], + section[data-snippet="s_quotes_carousel"] { + height: ${carouselHeight} !important; + } + section.o_half_screen_height { + min-height: ${halfHeight} !important; + } + section.o_full_screen_height { + min-height: ${fullHeight} !important; + } + section[data-snippet="s_three_columns"] .figure-img[style*="height:50vh"] { + /* In Travel theme. */ + height: 170px !important; + } + .o_we_shape { + /* Avoid the zoom's missing pixel. */ + transform: scale(101%); + } + `; + const cssText = document.createTextNode(css); + styleEl.appendChild(cssText); + iframeEl.contentDocument.head.appendChild(styleEl); + // Put blocks. + // To preserve styles, the whole #wrapwrap > main > #wrap + // nesting must be reproduced. + const mainEl = document.createElement("main"); + const wrapwrapEl = document.createElement("div"); + wrapwrapEl.id = "wrapwrap"; + wrapwrapEl.appendChild(mainEl); + iframeEl.contentDocument.body.appendChild(wrapwrapEl); + const templateDocument = new DOMParser().parseFromString(this.props.template.template, "text/html"); + const wrapEl = templateDocument.getElementById("wrap"); + mainEl.appendChild(wrapEl); + // Make image loading eager. + const lazyLoadedImgEls = wrapEl.querySelectorAll("img[loading=lazy]"); + for (const imgEl of lazyLoadedImgEls) { + imgEl.setAttribute("loading", "eager"); + } + mainEl.appendChild(wrapEl); + await wUtils.onceAllImagesLoaded($(wrapEl)); + // Restore image lazy loading. + for (const imgEl of lazyLoadedImgEls) { + imgEl.setAttribute("loading", "lazy"); + } + // Wait for fonts. + await iframeEl.contentDocument.fonts.ready; + holderEl.classList.remove("o_loading"); + const adjustHeight = () => { + if (!this.previewRef.el) { + // Stop ajusting height when preview is removed. + return; + } + const outerWidth = parseInt(window.getComputedStyle(previewEl).width); + const innerHeight = wrapEl.getBoundingClientRect().height; + const innerWidth = wrapEl.getBoundingClientRect().width; + const ratio = outerWidth / innerWidth; + iframeEl.height = Math.round(innerHeight); + previewEl.style.setProperty("height", `${Math.round(innerHeight * ratio)}px`); + // Sometimes the final height is not ready yet. + setTimeout(adjustHeight, 50); + holderEl.classList.add("o_ready"); + }; + adjustHeight(); + }); + } + + select() { + if (this.holderRef.el.classList.contains("o_loading")) { + return; + } + const wrapEl = this.iframeRef.el.contentDocument.getElementById("wrap").cloneNode(true); + for (const previewEl of wrapEl.querySelectorAll(".o_new_page_snippet_preview")) { + previewEl.remove(); + } + this.env.addPage(wrapEl.innerHTML); + } +} +AddPageTemplatePreview.props = { + template: Object, + animationDelay: Number, + firstRow: { + type: Boolean, + optional: true, + }, +}; +AddPageTemplatePreview.template = "website.AddPageTemplatePreview"; + +export class AddPageTemplatePreviews extends Component { + setup() { + super.setup(); + } + + get columns() { + const result = [[], [], []]; + let currentColumnIndex = 0; + for (const template of this.props.templates) { + result[currentColumnIndex].push(template); + currentColumnIndex = (currentColumnIndex + 1) % result.length; + } + return result; + } +} +AddPageTemplatePreviews.props = { + templates: { + type: Array, + element: Object, + }, +}; +AddPageTemplatePreviews.components = { + AddPageTemplateBlank, + AddPageTemplatePreview, +}; +AddPageTemplatePreviews.template = "website.AddPageTemplatePreviews"; + +export class AddPageTemplates extends Component { + setup() { + super.setup(); + this.tabsRef = useRef("tabs"); + this.panesRef = useRef("panes"); + this.rpc = useService('rpc'); + + this.state = useState({ + pages: [{ + Component: AddPageTemplatePreviews, + title: _t("Loading..."), + isPreloading: true, + props: { + id: "basic", + title: _t("Basic"), + // Blank and 5 preloading boxes. + templates: [{ isBlank: true }, {}, {}, {}, {}, {}], + }, + }], + }); + this.pages = undefined; + + onWillStart(() => { + this.preparePages().then(pages => { + this.state.pages = pages; + }); + }); + } + + async preparePages() { + // Forces the correct website if needed before fetching the templates. + // Displaying the correct images in the previews also relies on the + // website id having been forced. + await this.env.getCssLinkEls(); + + if (this.pages) { + return this.pages; + } + + const newPageTemplates = await this.rpc("/website/get_new_page_templates"); + newPageTemplates[0].templates.unshift({ + isBlank: true, + }); + const pages = []; + for (const template of newPageTemplates) { + pages.push({ + Component: AddPageTemplatePreviews, + title: template.title, + props: template, + id: `${template.id}`, + }); + } + this.pages = pages; + return pages; + } + + onTabClick(id) { + for (const page of this.state.pages) { + if (page.id === id) { + page.isAccessed = true; + } + } + const activeTabEl = this.tabsRef.el.querySelector(".active"); + const activePaneEl = this.panesRef.el.querySelector(".active"); + activeTabEl?.classList?.remove("active"); + activePaneEl?.classList?.remove("active"); + const tabEl = this.tabsRef.el.querySelector(`[data-id=${id}]`); + const paneEl = this.panesRef.el.querySelector(`[data-id=${id}]`); + tabEl.classList.add("active"); + paneEl.classList.add("active"); + this.props.onTemplatePageChanged(tabEl.dataset.id === "basic" ? "" : tabEl.textContent); + } +} +AddPageTemplates.props = { + onTemplatePageChanged: Function, +}; +AddPageTemplates.components = { + AddPageTemplatePreviews, +}; +AddPageTemplates.template = "website.AddPageTemplates"; + +export class AddPageDialog extends Component { + setup() { + super.setup(); + useAutofocus(); + + this.title = _t("New Page"); + this.primaryTitle = _t("Create"); + this.switchLabel = _t("Add to menu"); + this.website = useService('website'); + this.dialogs = useService("dialog"); + this.orm = useService('orm'); + this.rpc = useService('rpc'); + this.http = useService('http'); + this.action = useService('action'); + this.userService = useService('user'); + + this.cssLinkEls = undefined; + if (this.props.ready) { + onMounted(() => this.props.ready()); + } + this.lastTabName = ""; + + useSubEnv({ + addPage: sectionsArch => this.addPage(sectionsArch), + getCssLinkEls: () => this.getCssLinkEls(), + }); + } + + onTemplatePageChanged(name) { + this.lastTabName = name; + } + + async addPage(sectionsArch) { + const props = this.props; + this.dialogs.add(AddPageConfirmDialog, { + onAddPage: () => { + props.onAddPage(); + props.close(); + }, + websiteId: this.props.websiteId, + sectionsArch: sectionsArch, + name: this.lastTabName, + }); + } + + getCssLinkEls() { + if (!this.cssLinkEls) { + this.cssLinkEls = new Promise(async resolve => { + let contentDocument; + // Already in DOM ? + const pageIframeEl = document.querySelector("iframe.o_iframe"); + if (pageIframeEl?.getAttribute("is-ready") === "true") { + // If there is a fully loaded website preview, use it. + contentDocument = pageIframeEl.contentDocument; + } + if (!contentDocument) { + // If there is no website preview or it was not ready yet, fetch page. + const html = await this.http.get(`/website/force/${this.props.websiteId}?path=/`, "text"); + contentDocument = new DOMParser().parseFromString(html, "text/html"); + } + resolve(contentDocument.head.querySelectorAll("link[type='text/css']")); + }); + } + return this.cssLinkEls; + } +} +AddPageDialog.props = { + close: Function, + onAddPage: { + type: Function, + optional: true, + }, + ready: { + type: Function, + optional: true, + }, + websiteId: { + type: Number, + }, +}; +AddPageDialog.defaultProps = { + onAddPage: NO_OP, +}; +AddPageDialog.components = { + WebsiteDialog, + AddPageTemplates, + AddPageTemplatePreviews, +}; +AddPageDialog.template = "website.AddPageDialog"; diff --git a/addons/website/static/src/components/dialog/add_page_dialog.scss b/addons/website/static/src/components/dialog/add_page_dialog.scss new file mode 100644 index 00000000000..43d39c7304e --- /dev/null +++ b/addons/website/static/src/components/dialog/add_page_dialog.scss @@ -0,0 +1,95 @@ +.o_website_page_templates_dialog { + max-height: $modal-lg !important; + + .modal-body { + display: flex; + padding: 0; + + .nav { + width: 200px; + } + } + + .o_website_page_templates_pane.active { + // Avoid using "display: [none/block]" to trigger the tabs behavior + // because doing so would recalculate iframes' height. + z-index: 1; + } + + .o_page_template { + $-iframe-width: 1024; + $-preview-width: 275; // Empirically obtained + $-preview-width-xl: 220; // Empirically obtained + $-preview-width-lg: 408; // Empirically obtained + width: #{$-preview-width}px; + @include media-breakpoint-down(xl) { + width: #{$-preview-width-xl}px; + } + @include media-breakpoint-down(lg) { + width: #{$-preview-width-lg}px; + } + + .o_page_template_preview { + // Resize iframe: + height: 300px; + padding: 0; + overflow: hidden; + + iframe { + width: #{$-iframe-width}px; + transform: scale($-preview-width / $-iframe-width); + @include media-breakpoint-down(xl) { + transform: scale($-preview-width-xl / $-iframe-width); + } + @include media-breakpoint-down(lg) { + transform: scale($-preview-width-lg / $-iframe-width); + } + transform-origin: 0 0; + } + + &, iframe { + opacity: 0; + transition: height 300ms ease-in, opacity 300ms ease 300ms; + } + } + + .placeholder { + transition: opacity 1s ease; + } + + .o_button_area { + opacity: 0; + } + + &:empty { + opacity: 0; // Hide empty column filler + } + + &.o_loading { + cursor: wait; + } + + &.o_ready { + .placeholder { + animation: none; + opacity: 0; + } + + .o_page_template_preview, iframe { + opacity: 1; + } + + &:hover { + transform: translateY(-0.15rem); + + iframe { + opacity: .1; + } + + .o_button_area { + opacity: 1; + } + } + } + } +} diff --git a/addons/website/static/src/components/dialog/add_page_dialog.xml b/addons/website/static/src/components/dialog/add_page_dialog.xml new file mode 100644 index 00000000000..b9ef5466bbe --- /dev/null +++ b/addons/website/static/src/components/dialog/add_page_dialog.xml @@ -0,0 +1,114 @@ + + + + +
+ +
+ +
+ +
+
+
+ + +
+
+ +
+ +
+
+ + + + +
Blank Page
+
+
+ + + + + + +