[ADD] web: server-side-rendered qweb templates as views

This commit is contained in:
Xavier Morel
2019-01-29 10:22:18 +00:00
parent 1d1d5ed7ad
commit 58e0c88ece
9 changed files with 372 additions and 12 deletions
+37 -2
View File
@@ -1,11 +1,14 @@
# -*- coding: utf-8 -*-
from datetime import datetime
import babel.dates
import pytz
from odoo.tools.misc import DEFAULT_SERVER_DATETIME_FORMAT, DEFAULT_SERVER_DATE_FORMAT
from odoo import _, api, fields, models
from odoo.tools import lazy
class IrActionsActWindowView(models.Model):
_inherit = 'ir.actions.act_window.view'
view_mode = fields.Selection(selection_add=[('qweb', 'QWeb')])
class Base(models.AbstractModel):
_inherit = 'base'
@@ -78,3 +81,35 @@ class Base(models.AbstractModel):
data[group_by_value][field_value] += 1
return data
##### qweb view hooks #####
@api.model
def qweb_render_view(self, view_id, domain):
assert view_id
return self.env['ir.qweb'].render(
view_id, {
**self.env['ir.ui.view']._prepare_qcontext(),
**self._qweb_prepare_qcontext(view_id, domain),
})
def _qweb_prepare_qcontext(self, view_id, domain):
"""
Base qcontext for rendering qweb views bound to this model
"""
return {
'model': self,
'domain': domain,
# not necessarily necessary as env is already part of the
# non-minimal qcontext
'context': self.env.context,
'records': lazy(self.search, domain),
}
@api.model
def fields_view_get(self, view_id=None, view_type='form', toolbar=False, submenu=False):
r = super().fields_view_get(view_id, view_type, toolbar, submenu)
# avoid leaking the raw (un-rendered) template, also avoids bloating
# the response payload for no reason
if r['type'] == 'qweb':
r['arch'] = '<qweb/>'
return r
@@ -456,7 +456,10 @@ var AbstractController = mvc.Controller.extend(ActionMixin, {
}
});
} else if ($target.attr('name')) {
this.do_action($target.attr('name'));
this.do_action(
$target.attr('name'),
data.context && {additional_context: data.context}
);
} else {
this.do_action({
name: $target.attr('title') || _.str.strip($target.text()),
@@ -0,0 +1,202 @@
/**
* Client-side implementation of a qweb view.
*/
odoo.define('web.qweb', function (require) {
"use strict";
var core = require('web.core');
var AbstractView = require('web.AbstractView');
var AbstractModel = require('web.AbstractModel');
var AbstractRenderer = require('web.AbstractRenderer');
var AbstractController = require('web.AbstractController');
var registry = require('web.view_registry');
var _lt = core._lt;
/**
* model
*/
var Model = AbstractModel.extend({
/**
* init
*/
init: function () {
this._super.apply(this, arguments);
this._state = {
viewId: false,
modelName: false,
body: '',
context: {},
domain: [],
};
},
/**
* fetches the rendered qweb view
*/
_fetch: function () {
var state = this._state;
return this._rpc({
model: state.modelName,
method: 'qweb_render_view',
kwargs: {
view_id: state.viewId,
domain: state.domain,
context: state.context
}
}).then(function (r) {
state.body = r;
return state.viewId;
});
},
/**
* get
*/
get: function () {
return this._state;
},
/**
* load
*/
load: function (params) {
_.extend(this._state, _.pick(params, ['viewId', 'modelName', 'domain', 'context']));
return this._fetch();
},
/**
* reload
*/
reload: function (_id, _params) {
// FIXME: update this._state or something?
return this._fetch();
}
});
/**
* renderer
*/
var Renderer = AbstractRenderer.extend({
/**
* render
*/
_render: function () {
var self = this;
return this._super.apply(this, arguments).then(function () {
self.$el.html(self.state.body);
});
}
});
/**
* controller
*/
var Controller = AbstractController.extend({
events: _.extend({}, AbstractController.prototype.events, {
'click [type="toggle"]': '_onLazyToggle',
}),
init: function () {
this._super.apply(this, arguments);
this._$buttons = $('<nav>').on('click', '[type="action"]', this.proxy('_onActionClicked'));
},
renderButtons: function ($node) {
this._super($node);
$node.append(this._$buttons);
},
_update: function () {
var self = this;
return this._super.apply(this, arguments).then(function () {
// move control panel buttons from the view to the control panel
// area
var $cp_buttons = self.renderer.$('nav.o_qweb_cp_buttons');
$cp_buttons.children().appendTo(self._$buttons.empty());
$cp_buttons.remove();
});
},
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* Lazy toggle. Content is not remembered between unfolds.
*/
_onLazyToggle: function (e) {
// TODO: add support for view (possibly action as well?)
var $target = $(e.target);
var $t = $target.closest('[data-model]');
if (!($target.hasClass('fa-caret-down') || $target.hasClass('fa-caret-right'))) {
$target = $t.find('.fa-caret-down, .fa-caret-right');
}
var data = $t.data();
if (this._fold($t)) {
$target.removeClass('fa-caret-down').addClass('fa-caret-right');
return;
}
// NB: $.data() automatically parses json attributes, but does not
// automatically parse lone float literals in data-*, so a
// data-args (as a json object) is very convenient
var args = data.args || _.omit(data, 'model', 'method', 'id');
return this._rpc({
model: data.model,
method: data.method,
args: data.id ? [data.id] : undefined,
kwargs: args // FIXME: context?
}).then(function (s) {
return $(s);
}).then(function ($newcontent) {
$t.data('children', $newcontent).after($newcontent);
$target.removeClass('fa-caret-right').addClass('fa-caret-down');
});
},
/**
* Attempts to fold the parameter, returns whether that happened.
*/
_fold: function ($el) {
var $children = $el.data('children');
if (!$children) {
return false;
}
var self = this;
$children.each(function (_i, e) {
self._fold($(e));
}).remove();
$el.removeData('children');
return true;
}
});
/**
* view
*/
var QWebView = AbstractView.extend({
display_name: _lt('Freedom View'),
icon: 'fa-file-picture-o',
viewType: 'qweb',
// groupable?
enableTimeRangeMenu: true,
config: {
Model: Model,
Renderer: Renderer,
Controller: Controller,
},
/**
* init method
*/
init: function (viewInfo, params) {
this._super.apply(this, arguments);
this.loadParams.viewId = viewInfo.view_id;
}
});
registry.add('qweb', QWebView);
return {
View: QWebView,
Controller: Controller,
Renderer: Renderer,
Model: Model
};
});
@@ -182,3 +182,10 @@ div.o_boolean_toggle.custom-control.custom-checkbox {
overflow-x: initial;
}
}
//== Action manager
// ensure special links are styled as pointers even when they don't
// have an href
[type="action"], [type="toggle"] {
cursor: pointer !important;
}
+11 -1
View File
@@ -70,6 +70,7 @@ var MockServer = Class.extend({
*
* @param {Object} params
* @param {string|Object} params.arch a string OR a parsed xml document
* @param {Number} [params.view_id] the id of the arch's view
* @param {string} params.model a model name (that should be in this.data)
* @param {Object} params.toolbar the actions possible in the toolbar
* @param {Object} [params.viewOptions] the view options set in the test (optional)
@@ -78,6 +79,7 @@ var MockServer = Class.extend({
fieldsViewGet: function (params) {
var model = params.model;
var toolbar = params.toolbar;
var viewId = params.view_id;
var viewOptions = params.viewOptions || {};
if (!(model in this.data)) {
throw new Error('Model ' + model + ' was not defined in mock server data');
@@ -87,6 +89,9 @@ var MockServer = Class.extend({
if (toolbar) {
fvg.toolbar = toolbar;
}
if (viewId) {
fvg.view_id = viewId;
}
return fvg;
},
/**
@@ -506,12 +511,17 @@ var MockServer = Class.extend({
}
}
var key = [model, viewID, viewType].join(',');
var arch = self.archs[key];
var arch = self.archs[key] || _.find(self.archs, function (_v, k) {
var ka = k.split(',');
viewID = parseInt(ka[1], 10);
return ka[0] === model && ka[2] === viewType;
});
if (!arch) {
throw new Error('No arch found for key ' + key);
}
views[viewType] = {
arch: arch,
view_id: viewID,
model: model,
viewOptions: {
context: kwargs.context,
+1 -1
View File
@@ -1,4 +1,4 @@
odoo.define('web.qweb', function (require) {
odoo.define('web.qweb_tests', function (require) {
"use strict";
var qwebPath = '/web/static/lib/qweb/';
@@ -0,0 +1,70 @@
odoo.define('web.qweb_view_tests', function (require) {
"use strict";
var qweb = require('web.qweb');
var utils = require('web.test_utils');
QUnit.module("Views", {
}, function () {
QUnit.module("QWeb");
QUnit.test("basic", function (assert) {
assert.expect(13);
var am = utils.createActionManager({
data: {
test: {
fields: {},
records: [],
}
},
archs: {
'test,5,qweb': '<div id="xxx"><t t-esc="ok"/></div>',
'test,false,search': '<search/>'
},
mockRPC: function (route, args) {
if (/^\/web\/dataset\/call_kw/.test(route)) {
switch (_.str.sprintf('%(model)s.%(method)s', args)) {
case 'test.qweb_render_view':
assert.step('fetch');
assert.equal(args.kwargs.view_id, 5);
return $.when(
'<div>foo' +
'<div data-model="test" data-method="wheee" data-id="42" data-other="5">' +
'<a type="toggle" class="fa fa-caret-right">Unfold</a>' +
'</div>' +
'</div>'
);
case 'test.wheee':
assert.step('unfold');
assert.deepEqual(args.args, [42]);
assert.deepEqual(args.kwargs, {other: 5});
return $.when('<div id="sub">ok</div>');
}
}
return this._super.apply(this, arguments);
}
});
try {
var resolved = false;
am.doAction({
type: 'ir.actions.act_window',
views: [[false, 'qweb']],
res_model: 'test',
}).then(function () { resolved = true; });
assert.ok(resolved, "Action should have resolved synchronously");
var $content = am.$('.o_content');
assert.ok(/^\s*foo/.test($content.text()));
utils.dom.click($content.find('[type=toggle]'));
assert.equal($content.find('div#sub').text(), 'ok', 'should have unfolded the sub-item');
utils.dom.click($content.find('[type=toggle]'));
assert.equal($content.find('div#sub').length, 0, "should have removed the sub-item");
utils.dom.click($content.find('[type=toggle]'));
assert.verifySteps(['fetch', 'unfold', 'unfold']);
} finally {
am.destroy();
}
});
});
});
+2
View File
@@ -318,6 +318,7 @@
<script type="text/javascript" src="/web/static/src/js/views/calendar/calendar_quick_create.js"></script>
<script type="text/javascript" src="/web/static/src/js/views/calendar/calendar_renderer.js"></script>
<script type="text/javascript" src="/web/static/src/js/views/calendar/calendar_view.js"></script>
<script type="text/javascript" src="/web/static/src/js/views/qweb/qweb_view.js"></script>
<script type="text/javascript" src="/web/static/src/js/widgets/attach_document.js"></script>
<script type="text/javascript" src="/web/static/src/js/report/utils.js"/>
@@ -550,6 +551,7 @@
<script type="text/javascript" src="/web/static/tests/views/pivot_tests.js"></script>
<script type="text/javascript" src="/web/static/tests/views/kanban_tests.js"></script>
<script type="text/javascript" src="/web/static/tests/views/calendar_tests.js"></script>
<script type="text/javascript" src="/web/static/tests/views/qweb_tests.js"></script>
<script type="text/javascript" src="/web/static/tests/views/basic_model_tests.js"></script>
<script type="text/javascript" src="/web/static/tests/views/abstract_view_banner_tests.js"></script>
<script type="text/javascript" src="/web/static/tests/views/kanban_model_tests.js"></script>
+38 -7
View File
@@ -19,7 +19,7 @@ otherwise.
only useful as a mnemonic/description of the view when looking for one in
a list of some sort
``model``
the model linked to the view, if applicable (it doesn't for QWeb views)
the model linked to the view, if applicable
``priority``
client programs can request views by ``id``, or by ``(model, type)``. For
the latter, all the views for the right type and model will be searched,
@@ -1817,14 +1817,45 @@ QWeb
====
QWeb views are standard :ref:`reference/qweb` templates inside a view's
``arch``. They don't have a specific root element.
``arch``. They don't have a specific root element. Because QWeb views don't
have a specific root element, their type must be specified explicitly (it can
not be inferred from the root element of the ``arch`` field).
A QWeb view can only contain a single template\ [#template_inherit]_, and the
template's name *must* match the view's complete (including module name)
:term:`external id`.
QWeb views have two use cases:
:ref:`reference/data/template` should be used as a shortcut to define QWeb
views.
* they can be used as frontend templates, in which case
:ref:`reference/data/template` should be used as a shortcut.
* they can be used as actual qweb views (opened inside an action), in which
case they should be defined as regular view with an explicit ``type`` (it
can not be inferred) and a model.
The main additions of qweb-as-view to the basic qweb-as-template are:
* qweb-as-view has a special case for a ``<nav>`` element bearing the CSS
class ``o_qweb_cp_buttons``: its contents should be buttons and will be
extracted and moved to the control panel's button area, the ``<nav>`` itself
will be removed, this is a work-around to control panel views not existing
yet
* qweb-as-view rendering adds several items to the standard qweb rendering
context:
``model``
the model to which the qweb view is bound
``domain``
the domain provided by the search view
``context``
the context provided by the search view
``records``
a lazy proxy to ``model.search(domain)``, this can be used if you just
want to iterate the records and not perform more complex operations
(e.g. grouping)
* qweb-as-view also provides additional rendering hooks:
- ``_qweb_prepare_context(view_id, domain)`` prepares the rendering context
specific to qweb-as-view
- ``qweb_render_view(view_id, domain)`` is the method called by the client
and will call the context-preparation methods and ultimately
``env['ir.qweb'].render()``.
.. [#backwards-compatibility] for backwards compatibility reasons
.. [#hasclass] an extension function is added for simpler matching in QWeb