[IMP] website: add qweb view to see view hierarchy

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) <jke@openerp.com>
This commit is contained in:
Romain Derie
2021-03-26 15:29:42 +00:00
committed by Jeremy Kersten
parent 905c9b52a7
commit 7cdf04df67
5 changed files with 443 additions and 14 deletions
+8 -12
View File
@@ -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
@@ -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($('<a/>', {
'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) {
// <Tab> or <Enter>
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($('<span/>', {
class: 'o_tab_hint text-info ml-auto small font-italic pr-2',
text: _.str.sprintf(_t("Press %s for next %s"), "<Tab>", `[${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);
});
@@ -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;
}
}
}
}
+2
View File
@@ -36,6 +36,7 @@
<template id="assets_backend" inherit_id="web.assets_backend" name="Website Backend Assets (used in backend interface)">
<xpath expr="//link[last()]" position="after">
<link rel="stylesheet" type="text/scss" href="/website/static/src/scss/view_hierarchy.scss"/>
<link rel="stylesheet" type="text/scss" href="/website/static/src/scss/website.backend.scss"/>
<link rel="stylesheet" type="text/scss" href="/website/static/src/scss/website_visitor_views.scss"/>
<link rel="stylesheet" type="text/scss" href="/website/static/src/scss/website.theme_install.scss"/>
@@ -44,6 +45,7 @@
<script type="text/javascript" src="/website/static/src/js/backend/button.js"/>
<script type="text/javascript" src="/website/static/src/js/backend/dashboard.js"/>
<script type="text/javascript" src="/website/static/src/js/backend/res_config_settings.js"/>
<script type="text/javascript" src="/website/static/src/js/backend/view_hierarchy.js"/>
<script type="text/javascript" src="/website/static/src/js/widget_iframe.js"/>
<script type="text/javascript" src="/website/static/src/js/theme_preview_kanban.js"/>
<script type="text/javascript" src="/website/static/src/js/theme_preview_form.js"/>
+91 -2
View File
@@ -12,6 +12,12 @@
<field name="context" eval="{'search_default_category_id': ref('base.module_category_website_website'), 'searchpanel_default_category_id': ref('base.module_category_website')}"/>
</record>
<record id="action_show_viewhierarchy" model="ir.actions.act_window">
<field name="name">Show View Hierarchy</field>
<field name="res_model">ir.ui.view</field>
<field name="view_mode">qweb</field>
</record>
<!-- ====== website views ==============================================
==================================================================== -->
<record id="view_website_form" model="ir.ui.view">
@@ -254,9 +260,13 @@
<field name="inherit_id" position="attributes">
<attribute name="context">{'display_website': True}</attribute>
</field>
<field name="type" position="after">
<field name="model" position="before">
<field name="website_id" options="{'no_create': True}" groups="website.group_multi_website"/>
<field name="key"/>
<label for="key"/>
<div class='o_row'>
<field name="key"/>
<button string="" attrs="{'invisible': [('type', '!=', 'qweb')]}" name="website.action_show_viewhierarchy" icon="fa-sitemap" type="action" class="btn btn-link"/>
</div>
<field name="page_ids" invisible="1" />
<field name="first_page_id" attrs="{'invisible': [('page_ids', '=', [])]}" />
<field name="visibility" attrs="{'invisible': [('type', '!=', 'qweb')]}" />
@@ -275,7 +285,11 @@
<field name="model">ir.ui.view</field>
<field name="inherit_id" ref="base.view_view_tree"/>
<field name="arch" type="xml">
<tree position="attributes">
<attribute name="decoration-muted">not active</attribute>
</tree>
<field name="name" position="after">
<field name="active" invisible="1"/>
<field name="website_id" groups="website.group_multi_website"/>
</field>
<field name="xml_id" position="before">
@@ -284,6 +298,81 @@
</field>
</record>
<record id="view_view_qweb" model="ir.ui.view">
<field name="name">View Hierarchy</field>
<field name="type">qweb</field>
<field name="model">ir.ui.view</field>
<field name="arch" type="xml">
<qweb js_class="view_hierarchy">
<nav class="o_tree_nav navbar justify-content-start w-100 fixed-top bg-white shadow">
<div class="dropdown ml-2 border border-info rounded">
<a href="#" role="button" class="btn dropdown-toggle text-info" data-toggle="dropdown">
All Websites
</a>
<div class="dropdown-menu o_website_filter">
<a href="#" class="dropdown-item active" data-website_name="*">All Websites</a>
</div>
</div>
<div class="ml-2 custom-control custom-switch">
<input id="o_show_inactive" class="custom-control-input" type="checkbox"/>
<label class="custom-control-label" for="o_show_inactive">Show inactive views</label>
</div>
<div class="o_search input-group ml-auto col-8 col-sm-6 col-md-4 col-xl-3" role="search">
<input type="search" name="search" class="form-control border-info" placeholder="Name, id or key"/>
<div class="input-group-append">
<button type="submit" class="btn btn-info" aria-label="Search" title="Search">
<i class="fa fa-search"/>
</button>
</div>
</div>
</nav>
<t t-set="view" t-value="env['ir.ui.view'].browse(context['active_id']).with_context(active_test=False)"/>
<t t-set="requested_view" t-value="view"/>
<t t-set="view" t-value="view._get_top_level_view()"/>
<div class="o_tree_container ml-2" t-att-data-requested-view-id="requested_view.id">
<t t-set="sibling_views" t-value="view.search([('key', '=', view.key)]) - view"/>
<div t-if="sibling_views" class="alert alert-info m-1 p-1">
Multiple tree exists for this view
<a t-foreach="sibling_views" t-as="sibling_view" href="#" class="o_load_hierarchy" t-att-data-view_id="sibling_view.id">
<i class="fa fa-arrow-right mr-1"/>
<t t-esc="sibling_view.with_context(display_website=True, display_key=True).display_name"/>
</a>
</div>
<t t-call="website.report_viewhierarchy_children"/>
</div>
</qweb>
</field>
</record>
<template id="report_viewhierarchy_children">
<t t-set="classes_for_search" t-value="'d-flex align-items-center'"/>
<p t-attf-class="o_tree_entry mb-0 #{'text-muted font-weight-normal' if not view.active else ''} #{view.inherit_children_ids and 'o_has_child' or ''} #{classes_for_search}">
<i t-if="view.inherit_children_ids" class="js_fold o_fold_icon fa fa-minus-square-o mr-1"/>
<i t-if="view.arch_updated" class="fa fa-pencil-square mr-1 o_text_pink" title="This view arch has been modified"/>
<span class="js_fold">
<t t-esc="view.name"/> (<span class="font-weight-bold" t-esc="view.key"/>)
<span class="o_text_orange" t-if="view.website_id" t-esc="' [%s]' % view.website_id.name"/>
</span>
<a type="action" data-model="ir.ui.view" t-att-data-res-id="view.id">
<i class="fa fa-eye ml-2 text-muted" title="Go to View"/>
</a>
<a href="#" class="o_show_diff" t-att-data-view_id="view.id">
<i class="fa fa-files-o ml-2 text-muted" title="Show Arch Diff"/>
</a>
</p>
<ul t-if="view.inherit_children_ids">
<t t-foreach="view.inherit_children_ids" t-as="child">
<li t-att-class="not child.active and 'o_is_inactive d-none'"
t-att-data-website_name="child.website_id.name" t-att-data-key="child.key"
t-att-data-id="child.id" t-att-data-name="child.name">
<t t-call="website.report_viewhierarchy_children">
<t t-set="view" t-value="child"/>
</t>
</li>
</t>
</ul>
</template>
<record id="reset_view_arch_wizard_view" model="ir.ui.view">
<field name="model">reset.view.arch.wizard</field>
<field name="inherit_id" ref="base.reset_view_arch_wizard_view"/>