diff --git a/addons/website/models/ir_ui_view.py b/addons/website/models/ir_ui_view.py index 4088e1a99b4..54b26d2cd57 100644 --- a/addons/website/models/ir_ui_view.py +++ b/addons/website/models/ir_ui_view.py @@ -236,9 +236,31 @@ class View(models.Model): }) page.menu_ids.filtered(lambda m: m.website_id.id == website.id).page_id = new_page.id - def _get_top_level_view(self): + def get_view_hierarchy(self): self.ensure_one() - return self.inherit_id._get_top_level_view() if self.inherit_id else self + top_level_view = self + while top_level_view.inherit_id: + top_level_view = top_level_view.inherit_id + top_level_view = top_level_view.with_context(active_test=False) + sibling_views = top_level_view.search_read([('key', '=', top_level_view.key), ('id', '!=', top_level_view.id)]) + return { + 'sibling_views': sibling_views, + 'hierarchy': top_level_view._build_hierarchy_datastructure() + } + + def _build_hierarchy_datastructure(self): + inherit_children = [] + for child in self.inherit_children_ids: + inherit_children.append(child._build_hierarchy_datastructure()) + return { + 'id': self.id, + 'name': self.name, + 'inherit_children': inherit_children, + 'arch_updated': self.arch_updated, + 'website_name': self.website_id.name if self.website_id else False, + 'active': self.active, + 'key': self.key, + } @api.model def get_related_views(self, key, bundles=False): diff --git a/addons/website/static/src/js/backend/view_hierarchy.js b/addons/website/static/src/js/backend/view_hierarchy.js deleted file mode 100644 index 84fe168415c..00000000000 --- a/addons/website/static/src/js/backend/view_hierarchy.js +++ /dev/null @@ -1,262 +0,0 @@ -odoo.define('website.view_hierarchy', function (require) { -"use strict"; - -const core = require('web.core'); -const qweb = require('web.qweb'); -const viewRegistry = require('web.view_registry'); - -const _t = core._t; - -const Renderer = qweb.Renderer.extend({ - events: _.extend({}, qweb.Renderer.prototype.events, { - 'click .js_fold': '_onCollapseClick', - 'click .o_website_filter a': '_onWebsiteFilterClick', - 'click .o_search button': '_onSearchButtonClick', - 'click .o_show_diff': '_onShowDiffClick', - 'click .o_load_hierarchy': '_onLoadHierarchyClick', - 'keydown .o_search input': '_onSearchInputKeyDown', - 'input .o_search input': '_onSearchInputKeyInput', - 'change #o_show_inactive': '_onShowActiveClick', - }), - /** - * @override - */ - init: function () { - this._super(...arguments); - - // Search - this.cptFound = 0; - this.prevSearch = ''; - }, - /** - * @override - */ - on_attach_callback: function () { - this._super(...arguments); - - const self = this; - this._handleLastVisibleChild(); - // Fixed Navbar - this.$('.o_tree_container').css({ - 'padding-top': this.$('.o_tree_nav').outerHeight() + 10, - }); - // Website Filters - this.$wNodes = this.$("li[data-website_name]"); - this.$notwNodes = this.$("li:not([data-website_name])"); - const websiteNames = _.uniq($.map(self.$wNodes, el => el.getAttribute('data-website_name'))); - for (const websiteName of websiteNames) { - this.$('.o_website_filter').append($('', { - 'class': 'dropdown-item', - 'data-website_name': websiteName, - 'text': websiteName, - })); - } - this.$(`.o_website_filter a[data-website_name="${websiteNames[0] || '*'}"]`).click(); - // Highlight requested view as google does - const reqViewId = this.$('.o_tree_container').data('requested-view-id'); - const $reqView = $(`[data-id="${reqViewId}"] span.js_fold`).first(); - $reqView.css({'background-color': 'yellow'}); - $('.o_content').scrollTo($reqView[0], 300, {offset: -200}); - }, - - //-------------------------------------------------------------------------- - // Handlers - //-------------------------------------------------------------------------- - - /** - * @private - * @param {Event} ev - */ - _onCollapseClick: function (ev) { - const $parent = $(ev.currentTarget).parent(); - const folded = $parent.find('.o_fold_icon').hasClass('fa-plus-square-o'); - let $ul, $oFoldIcon; - if (folded) { // Unfold only self - $ul = $parent.siblings('ul'); - $oFoldIcon = $parent.find('.o_fold_icon'); - } else { // Fold all - $ul = $parent.parent().find('ul'); - $oFoldIcon = $parent.parent().find('.o_fold_icon'); - } - $ul.toggleClass('d-none', !folded); - $oFoldIcon.toggleClass('fa-minus-square-o', folded).toggleClass('fa-plus-square-o', !folded); - this._handleLastVisibleChild(); - }, - /** - * @private - * @param {Event} ev - */ - _onShowActiveClick: function (ev) { - this.$('.o_is_inactive').toggleClass('d-none', !ev.currentTarget.checked); - this._handleLastVisibleChild(); - }, - /** - * @private - * @param {Event} ev - */ - _onWebsiteFilterClick: function (ev) { - ev.preventDefault(); - // Update Dropdown Filter - const $el = $(ev.currentTarget); - $el.addClass('active').siblings().removeClass('active'); - $el.parent().siblings('.dropdown-toggle').text($el.text()); - // Show all views - const websiteName = $el.data('website_name'); - this.$wNodes.add(this.$notwNodes).removeClass('d-none'); - if (websiteName !== '*') { - // Hide all website views - this.$wNodes.addClass('d-none'); - // Show selected website views - const $selectedWebsiteNodes = this.$(`li[data-website_name="${websiteName}"]`); - $selectedWebsiteNodes.removeClass('d-none'); - // Hide generic siblings - $selectedWebsiteNodes.each(function () { - $(this).siblings(`li[data-key="${$(this).data('key')}"]:not([data-website_name])`).addClass('d-none'); - }); - } - // Preserve current inactive toggle state - this.$('.o_is_inactive').toggleClass('d-none', !$('#o_show_inactive').prop('checked')); - this._handleLastVisibleChild(); - }, - /** - * @private - * @param {Event} ev - */ - _onSearchInputKeyDown: function (ev) { - // or - if (ev.which === 13 || ev.which === 9) { - this._searchScrollTo($(ev.currentTarget).val(), !ev.shiftKey); - ev.preventDefault(); - } - }, - /** - * @private - * @param {Event} ev - */ - _onSearchInputKeyInput: function (ev) { - // Useful for input empty either with ms-clear or by typing - if (ev.currentTarget.value === "") { - this._searchScrollTo(""); - } - }, - /** - * @private - * @param {Event} ev - */ - _onSearchButtonClick: function (ev) { - this._searchScrollTo(this.$('.o_search input').val()); - }, - /** - * @private - * @param {Event} ev - */ - _onShowDiffClick: function (ev) { - ev.preventDefault(); - this.do_action('base.reset_view_arch_wizard_action', { - additional_context: { - 'active_model': 'ir.ui.view', - 'active_ids': [parseInt(ev.currentTarget.dataset['view_id'])], - } - }); - }, - /** - * @private - * @param {Event} ev - */ - _onLoadHierarchyClick: function (ev) { - ev.preventDefault(); - this.do_action('website.action_show_viewhierarchy', { - additional_context: { - 'active_model': 'ir.ui.view', - 'active_id': parseInt(ev.currentTarget.dataset['view_id']), - } - }); - }, - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - - /** - * Adds a class to the last visible element of every lists. - * This is purely cosmetic to add a right angle dashed `:before` style in - * css. This can't be done in css as there is no way to target a last - * element by class. - * - * @private - */ - _handleLastVisibleChild: function () { - this.$('.o_last_visible_child').removeClass('o_last_visible_child'); - const lastElements = _.filter(_.map( - this.$('ul'), el => $(el).find('> li:visible').last()[0] - )); - $(lastElements).addClass('o_last_visible_child'); - - const selector = $('#o_show_inactive').prop('checked') ? '> li' : '> li:not(.o_is_inactive)'; - this.$('.o_fold_icon').map(function () { - let $ico = $(this); - let childs = $ico.parent().parent().first().find('ul').find(selector); - $ico.toggleClass('d-none', !childs.length); - }); - }, - /** - * Searches and scrolls to view entries matching the given text. Exact - * matches will be returned first. Search is done on `key`, `id` and `name` - * for exact matches, and `key`, `name` for simple matches. - * - * @private - * @param {string} search text to search and scroll to - * @param {boolean} [forward] set to false to go to previous find - */ - _searchScrollTo: function (search, forward = true) { - const foundClasses = 'o_search_found border border-info rounded px-2'; - this.$('.o_search_found').removeClass(foundClasses); - this.$('.o_not_found').removeClass('o_not_found'); - this.$('.o_tab_hint').remove(); - if (search !== this.prevSearch) { - this.prevSearch = search; - this.cptFound = -1; - } - - if (search) { - // Exact match first - const exactMatches = $(`[data-key="${search}" i], [data-id="${search}" i], [data-name="${search}" i]`).not(':hidden').get(); - let matches = $(`[data-key*="${search}" i], [data-name*="${search}" i]`).not(':hidden').not(exactMatches).get(); - matches = exactMatches.concat(matches); - if (!matches.length) { - this.$('.o_search input').addClass('o_not_found'); - } else { - if (forward) { - this.cptFound++; - if (this.cptFound > matches.length - 1) { - this.cptFound = 0; - } - } else { - this.cptFound--; - if (this.cptFound < 0) { - this.cptFound = matches.length - 1; - } - } - const el = matches[this.cptFound]; - $(el).children('p').addClass(foundClasses).append($('', { - class: 'o_tab_hint text-info ms-auto small fst-italic pe-2', - text: _.str.sprintf(_t("Press %s for next %s"), "", `[${this.cptFound + 1}/${matches.length}]`), - })); - $('.o_content').scrollTo(el, 0, {offset: -200}); - - this.prevSearch = search; - this.$('.o_search input').focus(); - } - } - }, -}); - -const ViewHierarchy = qweb.View.extend({ - withSearchBar: false, - config: _.extend({}, qweb.View.prototype.config, { - Renderer: Renderer, - }), -}); - -viewRegistry.add('view_hierarchy', ViewHierarchy); -}); diff --git a/addons/website/static/src/js/backend/view_hierarchy/hierarchy_navbar.js b/addons/website/static/src/js/backend/view_hierarchy/hierarchy_navbar.js new file mode 100644 index 00000000000..05ff049678b --- /dev/null +++ b/addons/website/static/src/js/backend/view_hierarchy/hierarchy_navbar.js @@ -0,0 +1,41 @@ +/** @odoo-module **/ + +import { Dropdown } from "@web/core/dropdown/dropdown"; +import { DropdownItem } from "@web/core/dropdown/dropdown_item"; +import { Component, useRef, useState } from "@odoo/owl"; + +export class HierarchyNavbar extends Component { + setup() { + this.searchInput = useRef("search"); + this.websiteNames = useState(Array.from(this.props.websites.names)); + } + + /** + * @param {Event} event + */ + onInputKeydown(event) { + if (event.key === "Enter" || event.key === "Tab") { + event.preventDefault(); + this.props.searchView(event.target.value, !event.shiftKey); + } + } + + /** + * @param {Event} event + */ + onInputClick(event) { + this.props.searchView(this.searchInput.el.value, !event.shiftKey); + } +} + +HierarchyNavbar.components = { + Dropdown, + DropdownItem, +}; +HierarchyNavbar.template = "website.hierarchy_navbar"; +HierarchyNavbar.props = { + toggleInactive: Function, + websites: Object, + selectWebsite: Function, + searchView: Function, +}; diff --git a/addons/website/static/src/js/backend/view_hierarchy/hierarchy_navbar.xml b/addons/website/static/src/js/backend/view_hierarchy/hierarchy_navbar.xml new file mode 100644 index 00000000000..aa9a209456c --- /dev/null +++ b/addons/website/static/src/js/backend/view_hierarchy/hierarchy_navbar.xml @@ -0,0 +1,29 @@ + + + + + + + + diff --git a/addons/website/static/src/js/backend/view_hierarchy/view_hierarchy.js b/addons/website/static/src/js/backend/view_hierarchy/view_hierarchy.js new file mode 100644 index 00000000000..6e58cae6e7f --- /dev/null +++ b/addons/website/static/src/js/backend/view_hierarchy/view_hierarchy.js @@ -0,0 +1,251 @@ +/** @odoo-module **/ + +import { HierarchyNavbar } from "./hierarchy_navbar"; +import { Layout } from "@web/search/layout"; +import { registry } from "@web/core/registry"; +import { useService } from "@web/core/utils/hooks"; +import { Component, onWillStart, useEffect, useState } from "@odoo/owl"; + +export class ViewHierarchy extends Component { + setup() { + this.action = useService("action"); + this.orm = useService("orm"); + this.router = useService("router"); + this.state = useState({ showInactive: false, searchedView: {}, viewTree: {} }); + this.websites = useState({ names: new Set(["All Websites"]), selected: "All Websites" }); + this.viewId = this.props.action.context.active_id || this.router.current.hash.active_id; + this.hideGenericViewByWebsite = {}; + + onWillStart(async () => { + ({ + sibling_views: this.siblingViews, + hierarchy: this.state.viewTree, + } = await this.orm.call("ir.ui.view", "get_view_hierarchy", [this.viewId], {})); + + this.setupWebsiteNames(); + this.setupHideGenericViewByWebsite(); + this.linkViewsToParent(); + }); + + useEffect( + (searchFoundElem) => { + if (searchFoundElem) { + searchFoundElem.scrollIntoView({ behavior: "smooth", block: "center" }); + } + }, + () => [document.querySelector(".o_search_found")] + ); + } + + /** + * Filter the treeView by website + * @param {String} websiteName + */ + selectWebsite(websiteName) { + this.websites.selected = websiteName; + } + + /** + * Show/hide inactive views + * @param {Boolean} checked + */ + toggleInactive(checked) { + this.state.showInactive = checked; + } + + /** + * @param {String} keyword + * @returns {Array} a list of visible views that match the keyword + * insensitive case. + * The comparison is done on the name, the key and the id of each views. + * Priority is given to the exact matches and then to the order + */ + getSearchResults(keyword) { + const exactMatches = []; + const matches = []; + const lowercaseKeyword = keyword.toLowerCase(); + this.viewTraversal( + this.state.viewTree, + (currentView) => { + if ( + this.isViewDisplayed(currentView) && + (currentView.name.toLowerCase() === lowercaseKeyword || + currentView.key.toLowerCase() === lowercaseKeyword || + currentView.id === parseInt(lowercaseKeyword)) + ) { + exactMatches.push(currentView); + } else if ( + this.isViewDisplayed(currentView) && + (currentView.name.toLowerCase().includes(lowercaseKeyword) || + currentView.key.toLowerCase().includes(lowercaseKeyword)) + ) { + matches.push(currentView); + } + }, + (currentView) => this.isViewDisplayed(currentView) + ); + return exactMatches.concat(matches); + } + + /** + * Search the next visibile view that matches the keyword to the name, the + * key and the id of the view + * @param {String} keyword + * @param {Boolean} forward + */ + searchView(keyword, forward = true) { + const matches = this.getSearchResults(keyword); + let index = 0; + if (this.state.searchedView.keyword === keyword) { + index = matches.findIndex((view) => this.state.searchedView.id === view.id); + index = forward ? index + 1 : index - 1; + index = index - matches.length * Math.floor(index / matches.length); + } + + const view = matches[index]; + if (view) { + this.state.searchedView = { + id: view.id, + keyword: keyword, + total: matches.length, + index, + }; + } + } + + /** + * Makes an inorder traversal of the view tree and apply a function at each + * node + * @param {Object} currentView represent the current view tree + * @param {Function} fn function applied at each node with currentView as + * parameter + * @param {Function} continueRec take the view as argument and decide if + * the recursion continue + */ + viewTraversal(currentView, fn, continueRec = (view) => true) { + fn(currentView); + if (continueRec(currentView)) { + currentView.inherit_children.forEach((childView) => { + this.viewTraversal(childView, fn, continueRec); + }); + } + } + + /** + * Setup website names from the viewTree into this.websites.names + */ + setupWebsiteNames() { + this.viewTraversal(this.state.viewTree, (currentView) => { + if (currentView.website_name) { + this.websites.names.add(currentView.website_name); + } + }); + } + + /** + * States for each website filter if a generic view should be hided or not + */ + setupHideGenericViewByWebsite() { + this.viewTraversal(this.state.viewTree, (currentView) => { + if (currentView.website_name) { + if (!this.hideGenericViewByWebsite[currentView.website_name]) { + this.hideGenericViewByWebsite[currentView.website_name] = {}; + } + this.hideGenericViewByWebsite[currentView.website_name][currentView.name] = true; + } + }); + } + + /** + * Link views in the viewTree to their parent + */ + linkViewsToParent() { + this.viewTraversal(this.state.viewTree, (currentView) => { + currentView.inherit_children.forEach((child) => (child.parent = currentView)); + }); + } + + /** + * Collapse the view to show/hide the children + * @param {Object} view + */ + onCollapseClick(view) { + view.collapsed = !view.collapsed; + if (view.collapsed) { + // When folding a parent, children should also fold + this.viewTraversal(view, (child) => { + child.collapsed = view.collapsed; + }); + } + } + + /** + * @param {Object} view + * @param {Boolean} isCollapsedDisplayed + * @returns true if the view is displayed in the view tree, false otherwise + */ + isViewDisplayed(view, isCollapsedDisplayed = false) { + let isCollapsed = view.parent ? view.parent.collapsed : false; + if (isCollapsedDisplayed) { + isCollapsed = false; + } + const isActive = this.state.showInactive || view.active; + const isWebsiteDisplayed = + this.websites.selected === "All Websites" || + view.website_name === this.websites.selected || + (!view.website_name && + !this.hideGenericViewByWebsite[this.websites.selected][view.name]); + return !isCollapsed && isActive && isWebsiteDisplayed; + } + + /** + * @param {Object} view + * @returns true if view has a child to unfold, false otherwise + */ + hasChildToUnfold(view) { + return view.inherit_children.some((child) => this.isViewDisplayed(child, true)); + } + + /** + * @param {Number} viewId + */ + onShowDiffClick(viewId) { + this.action.doAction("base.reset_view_arch_wizard_action", { + additionalContext: { + active_model: "ir.ui.view", + active_ids: [viewId], + }, + }); + } + + /** + * @param {Number} viewId + */ + openFormView(viewId) { + this.action.doAction({ + type: "ir.actions.act_window", + res_model: "ir.ui.view", + res_id: viewId, + views: [[false, "form"]], + }); + } + + /** + * @param {Number} viewId + */ + onShowHierarchy(viewId) { + this.action.doAction({ + type: "ir.actions.client", + tag: "website_view_hierarchy", + name: "View Hierarchy", + context: { + active_id: viewId, + }, + }); + } +} + +ViewHierarchy.components = { Layout, HierarchyNavbar }; +ViewHierarchy.template = "website.view_hierarchy"; + +registry.category("actions").add("website_view_hierarchy", ViewHierarchy); diff --git a/addons/website/static/src/js/backend/view_hierarchy/view_hierarchy.xml b/addons/website/static/src/js/backend/view_hierarchy/view_hierarchy.xml new file mode 100644 index 00000000000..4f6835383f3 --- /dev/null +++ b/addons/website/static/src/js/backend/view_hierarchy/view_hierarchy.xml @@ -0,0 +1,62 @@ + + + + + + + + + + + +

+ + + + + () + + + + + + + + + + Press <Tab> for next [/] + +

+ +
    + +
  • + + + +
  • +
    +
+
+ diff --git a/addons/website/static/src/scss/view_hierarchy.scss b/addons/website/static/src/scss/view_hierarchy.scss index 0922ac3160b..680f891c2cc 100644 --- a/addons/website/static/src/scss/view_hierarchy.scss +++ b/addons/website/static/src/scss/view_hierarchy.scss @@ -69,8 +69,8 @@ width: 10px; } - &.o_last_visible_child:before { - background: #fff; + &:last-child:before { + background: $o-webclient-background-color; bottom: 0; height: auto; top: 1em; diff --git a/addons/website/views/website_pages_views.xml b/addons/website/views/website_pages_views.xml index bf77bf8f472..397d1997ce5 100644 --- a/addons/website/views/website_pages_views.xml +++ b/addons/website/views/website_pages_views.xml @@ -179,7 +179,7 @@
+
+ Multiple tree exists for this view + + + + <> + + [] + + +
+ + +