From 7cdf04df67b45b89486d5586889e005c3868dccc Mon Sep 17 00:00:00 2001 From: Romain Derie Date: Tue, 8 Dec 2020 14:43:23 +0000 Subject: [PATCH] [IMP] website: add qweb view to see view hierarchy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The core website behavior is the COW (copy on write) which makes it possible to handle multiple websites on a single database. When you do a CRUD operation on an `ir.ui.view` in a website context, the ORM normal behavior is bypass to perform special stuff, such as: - Write: if the view is generic (no `website_id`), we bifurcate the write on the specific view (same `key` but with `website_id` set). If that view doesn't exist, we create it and fork its whole descending hierarchy. This write behavior is known as COW. - Create: we ensure that view is created only for the website and no other. Basically we just add a `website_id` to that view, but it might also be about setting the correct `inherit_id` by finding the specific view among multiple views with the same `key`. - Unlink: if the view is generic, we will unlink that view but also create a copy of that view for every other websites so they remain unmodified: this is known as COU (copy on unlink). All those behavior aims to satisfy to the multi website holy grail: -- Any modification in a website context should never impact another website -- Because of those uncommon behaviors, it is sometimes hard to understand or investigate a database, as views might be copied, up to the point where there is multiple whole view hierarchies. This commit attempts to create an user interface to easily figure what's going on in a database at an `ir.ui.view` level. The mains advantages of that new `ir.ui.view` Qweb view is to: - easily figure which views are COW'd on which website - easily detect which views have a real `arch` modification - easily see that `arch` change Closes #63040 Signed-off-by: Jérémy Kersten (jke) --- addons/website/models/ir_ui_view.py | 20 +- .../static/src/js/backend/view_hierarchy.js | 262 ++++++++++++++++++ .../static/src/scss/view_hierarchy.scss | 80 ++++++ addons/website/views/assets.xml | 2 + addons/website/views/website_views.xml | 93 ++++++- 5 files changed, 443 insertions(+), 14 deletions(-) create mode 100644 addons/website/static/src/js/backend/view_hierarchy.js create mode 100644 addons/website/static/src/scss/view_hierarchy.scss diff --git a/addons/website/models/ir_ui_view.py b/addons/website/models/ir_ui_view.py index 71c1cd63ffe..0f3acc7a7b0 100644 --- a/addons/website/models/ir_ui_view.py +++ b/addons/website/models/ir_ui_view.py @@ -72,14 +72,15 @@ class View(models.Model): return super().create(vals_list) def name_get(self): - if (not self._context.get('display_website') and not self.env.user.has_group('website.group_multi_website')) or \ - not self._context.get('display_website'): + if not (self._context.get('display_key') or self._context.get('display_website')): return super(View, self).name_get() res = [] for view in self: view_name = view.name - if view.website_id: + if self._context.get('display_key'): + view_name += ' <%s>' % view.key + if self._context.get('display_website') and view.website_id: view_name += ' [%s]' % view.website_id.name res.append((view.id, view_name)) return res @@ -233,6 +234,10 @@ 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): + self.ensure_one() + return self.inherit_id._get_top_level_view() if self.inherit_id else self + @api.model def get_related_views(self, key, bundles=False): '''Make this only return most specific views for website.''' @@ -358,15 +363,6 @@ class View(models.Model): return view.id return super(View, self.sudo()).get_view_id(xml_id) - def _get_original_view(self): - """Given a view, retrieve the original view it was COW'd from. - The given view might already be the original one. In that case it will - (and should) return itself. - """ - self.ensure_one() - domain = [('key', '=', self.key), ('model_data_id', '!=', None)] - return self.with_context(active_test=False).search(domain, limit=1) # Useless limit has multiple xmlid should not be possible - def _handle_visibility(self, do_raise=True): """ Check the visibility set on the main view and raise 403 if you should not have access. Order is: Public, Connected, Has group, Password diff --git a/addons/website/static/src/js/backend/view_hierarchy.js b/addons/website/static/src/js/backend/view_hierarchy.js new file mode 100644 index 00000000000..cda0c3d0c98 --- /dev/null +++ b/addons/website/static/src/js/backend/view_hierarchy.js @@ -0,0 +1,262 @@ +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.addClass('text-info'); + $('.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 ml-auto small font-italic pr-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/scss/view_hierarchy.scss b/addons/website/static/src/scss/view_hierarchy.scss new file mode 100644 index 00000000000..0922ac3160b --- /dev/null +++ b/addons/website/static/src/scss/view_hierarchy.scss @@ -0,0 +1,80 @@ +.o_tree_nav { + top: unset; + z-index: $zindex-dropdown - 1; + + .o_search .o_not_found { + box-shadow: 0 0 0 0.2rem red; + } +} + +.o_tree_container { + .o_text_orange { + color: $orange; + } + .o_text_pink { + color: $pink; + } + + .o_has_child .js_fold { + cursor: pointer; + } + + .o_search_found { + background-color: $gray-200; + } + + .o_tree_entry { + a { + display: none; + } + + &:hover { + a { + display: block; + } + } + } + + ul { + line-height: 2em; + list-style: none; + margin-left: 1em; + padding: 0; + position: relative; + + &:before { + border-left: 1px dashed; + bottom: 0; + content: ''; + display: block; + left: 0; + position: absolute; + top: 0; + width: 0; + } + + li { + padding: 0 1em; + position: relative; + + &:before { + border-top: 1px dashed; + content: ''; + display: block; + height: 0; + left: 0; + margin-top: -1px; + position: absolute; + top: 1em; + width: 10px; + } + + &.o_last_visible_child:before { + background: #fff; + bottom: 0; + height: auto; + top: 1em; + } + } + } +} diff --git a/addons/website/views/assets.xml b/addons/website/views/assets.xml index 1c65e715f62..a5dbcd863e9 100644 --- a/addons/website/views/assets.xml +++ b/addons/website/views/assets.xml @@ -36,6 +36,7 @@