[ADD] web: server-side-rendered qweb templates as views
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,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();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user