[REF] web: add module system to the web client

This commit introduces a new module system for the javascript code.
Instead of using global (namespaced) variables everywhere (like
openerp.web.whatever, ...), each file declare one (or more) module
with its dependencies.  Each module will be loaded after its
dependencies, and will be given a reference to the return values.

This change is a massive departure from the way the client worked
previously.  It will sadly break some/most history, but I am sure that
the future git ecosystem will allow you to easily browse the history
past this revision (one can dream).

This commit feature:
* main namespace is now odoo instead of openerp
* a way to define modules and dependencies (inspired by requirejs)
* many files have been splitted in simpler subfiles
* basic support for debugging in odoo.__DEBUG__
* no global variable (i.e. nothing in window.openerp)
* many bug fixes, many new bugs

Future vision: this modularization should simplify future code
development, simplify debugging and will allow a serious testing effort
This commit is contained in:
Géry Debongnie
2015-03-18 09:01:49 +01:00
parent 9036084f06
commit e8a00bc50d
64 changed files with 18153 additions and 18228 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 270 KiB

+534
View File
@@ -0,0 +1,534 @@
odoo.define('web.ActionManager', ['web.core', 'web.crash_manager', 'web.data', 'web.Dialog', 'web.framework', 'web.pyeval', 'web.session', 'web.ViewManager', 'web.Widget'], function (require) {
"use strict";
var core = require('web.core');
var crash_manager = require('web.crash_manager');
var data = require('web.data');
var Dialog = require('web.Dialog');
var framework = require('web.framework');
var pyeval = require('web.pyeval');
var session = require('web.session');
var ViewManager = require('web.ViewManager');
var Widget = require('web.Widget');
var CompoundContext = data.CompoundContext;
var ActionManager = Widget.extend({
template: "ActionManager",
init: function(parent) {
this._super(parent);
this.inner_action = null;
this.inner_widget = null;
this.webclient = parent;
this.dialog = null;
this.dialog_widget = null;
this.widgets = [];
this.on('history_back', this, this.proxy('history_back'));
},
dialog_stop: function (reason) {
if (this.dialog) {
this.dialog.destroy(reason);
}
this.dialog = null;
},
/**
* Add a new widget to the action manager
*
* widget: typically, widgets added are instance.web.ViewManager. The action manager
* uses this list of widget to handle the breadcrumbs.
* action: new action
* options.on_reverse_breadcrumb: will be called when breadcrumb is selected
* options.clear_breadcrumbs: boolean, if true, current widgets are destroyed
* options.replace_breadcrumb: boolean, if true, replace current breadcrumb
*/
push_widget: function(widget, action, options) {
var self = this,
to_destroy,
old_widget = this.inner_widget;
options = options || {};
if (options.clear_breadcrumbs) {
to_destroy = this.widgets;
this.widgets = [];
} else if (options.replace_breadcrumb) {
to_destroy = _.last(this.widgets);
this.widgets = _.initial(this.widgets);
}
if (widget instanceof Widget) {
var title = widget.get('title') || action.display_name || action.name;
widget.set('title', title);
this.widgets.push(widget);
} else {
this.widgets.push({
view_stack: [{
controller: {get: function () {return action.display_name || action.name; }},
}],
destroy: function () {},
});
}
_.last(this.widgets).__on_reverse_breadcrumb = options.on_reverse_breadcrumb;
this.inner_action = action;
this.inner_widget = widget;
return $.when(this.inner_widget.appendTo(this.$el)).done(function () {
if ((action.target !== 'inline') && (!action.flags.headless) && widget.$header) {
widget.$header.show();
}
if (old_widget) {
old_widget.$el.hide();
}
if (options.clear_breadcrumbs) {
self.clear_widgets(to_destroy);
}
});
},
get_breadcrumbs: function () {
return _.flatten(_.map(this.widgets, function (widget) {
if (widget instanceof ViewManager) {
return widget.view_stack.map(function (view, index) {
return {
title: view.controller.get('title') || widget.title,
index: index,
widget: widget,
};
});
} else {
return {title: widget.get('title'), widget: widget };
}
}), true);
},
get_title: function () {
if (this.widgets.length === 1) {
// horrible hack to display the action title instead of "New" for the actions
// that use a form view to edit something that do not correspond to a real model
// for example, point of sale "Your Session" or most settings form,
var widget = this.widgets[0];
if (widget instanceof ViewManager && widget.view_stack.length === 1) {
return widget.title;
}
}
return _.pluck(this.get_breadcrumbs(), 'title').join(' / ');
},
get_widgets: function () {
return this.widgets.slice(0);
},
history_back: function() {
var widget = _.last(this.widgets);
if (widget instanceof ViewManager) {
var nbr_views = widget.view_stack.length;
if (nbr_views > 1) {
return this.select_widget(widget, nbr_views - 2);
}
}
if (this.widgets.length > 1) {
widget = this.widgets[this.widgets.length - 2];
var index = widget.view_stack && widget.view_stack.length - 1;
return this.select_widget(widget, index);
}
return $.Deferred().reject();
},
select_widget: function(widget, index) {
var self = this;
if (this.webclient.has_uncommitted_changes()) {
return $.Deferred().reject();
}
var widget_index = this.widgets.indexOf(widget),
def = $.when(widget.select_view && widget.select_view(index));
return def.done(function () {
if (widget.__on_reverse_breadcrumb) {
widget.__on_reverse_breadcrumb();
}
_.each(self.widgets.splice(widget_index + 1), function (w) {
w.destroy();
});
self.inner_widget = _.last(self.widgets);
if (self.inner_widget.display_breadcrumbs) {
self.inner_widget.display_breadcrumbs();
}
if (self.inner_widget.do_show) {
self.inner_widget.do_show();
}
});
},
clear_widgets: function(widgets) {
_.invoke(widgets || this.widgets, 'destroy');
if (!widgets) {
this.widgets = [];
this.inner_widget = null;
}
},
do_push_state: function(state) {
if (!this.webclient || !this.webclient.do_push_state || this.dialog) {
return;
}
state = state || {};
if (this.inner_action) {
if (this.inner_action._push_me === false) {
// this action has been explicitly marked as not pushable
return;
}
state.title = this.get_title();
if(this.inner_action.type == 'ir.actions.act_window') {
state.model = this.inner_action.res_model;
}
if (this.inner_action.menu_id) {
state.menu_id = this.inner_action.menu_id;
}
if (this.inner_action.id) {
state.action = this.inner_action.id;
} else if (this.inner_action.type == 'ir.actions.client') {
state.action = this.inner_action.tag;
var params = {};
_.each(this.inner_action.params, function(v, k) {
if(_.isString(v) || _.isNumber(v)) {
params[k] = v;
}
});
state = _.extend(params || {}, state);
}
if (this.inner_action.context) {
var active_id = this.inner_action.context.active_id;
if (active_id) {
state.active_id = active_id;
}
var active_ids = this.inner_action.context.active_ids;
if (active_ids && !(active_ids.length === 1 && active_ids[0] === active_id)) {
// We don't push active_ids if it's a single element array containing the active_id
// This makes the url shorter in most cases.
state.active_ids = this.inner_action.context.active_ids.join(',');
}
}
}
this.webclient.do_push_state(state);
},
do_load_state: function(state, warm) {
var self = this,
action_loaded;
if (state.action) {
if (_.isString(state.action) && core.action_registry.contains(state.action)) {
var action_client = {
type: "ir.actions.client",
tag: state.action,
params: state,
_push_me: state._push_me,
};
if (warm) {
this.null_action();
}
action_loaded = this.do_action(action_client);
} else {
var run_action = (!this.inner_widget || !this.inner_widget.action) || this.inner_widget.action.id !== state.action;
if (run_action) {
var add_context = {};
if (state.active_id) {
add_context.active_id = state.active_id;
}
if (state.active_ids) {
// The jQuery BBQ plugin does some parsing on values that are valid integers.
// It means that if there's only one item, it will do parseInt() on it,
// otherwise it will keep the comma seperated list as string.
add_context.active_ids = state.active_ids.toString().split(',').map(function(id) {
return parseInt(id, 10) || id;
});
} else if (state.active_id) {
add_context.active_ids = [state.active_id];
}
add_context.params = state;
if (warm) {
this.null_action();
}
action_loaded = this.do_action(state.action, { additional_context: add_context });
$.when(action_loaded || null).done(function() {
self.webclient.menu.is_bound.done(function() {
if (self.inner_action && self.inner_action.id) {
self.webclient.menu.open_action(self.inner_action.id);
}
});
});
}
}
} else if (state.model && state.id) {
// TODO handle context & domain ?
if (warm) {
this.null_action();
}
var action = {
res_model: state.model,
res_id: state.id,
type: 'ir.actions.act_window',
views: [[false, 'form']]
};
action_loaded = this.do_action(action);
} else if (state.sa) {
// load session action
if (warm) {
this.null_action();
}
action_loaded = this.rpc('/web/session/get_session_action', {key: state.sa}).then(function(action) {
if (action) {
return self.do_action(action);
}
});
}
$.when(action_loaded || null).done(function() {
if (self.inner_widget && self.inner_widget.do_load_state) {
self.inner_widget.do_load_state(state, warm);
}
});
},
/**
* Execute an OpenERP action
*
* @param {Number|String|Object} Can be either an action id, a client action or an action descriptor.
* @param {Object} [options]
* @param {Boolean} [options.clear_breadcrumbs=false] Clear the breadcrumbs history list
* @param {Boolean} [options.replace_breadcrumb=false] Replace the current breadcrumb with the action
* @param {Function} [options.on_reverse_breadcrumb] Callback to be executed whenever an anterior breadcrumb item is clicked on.
* @param {Function} [options.hide_breadcrumb] Do not display this widget's title in the breadcrumb
* @param {Function} [options.on_close] Callback to be executed when the dialog is closed (only relevant for target=new actions)
* @param {Function} [options.action_menu_id] Manually set the menu id on the fly.
* @param {Object} [options.additional_context] Additional context to be merged with the action's context.
* @return {jQuery.Deferred} Action loaded
*/
do_action: function(action, options) {
options = _.defaults(options || {}, {
clear_breadcrumbs: false,
on_reverse_breadcrumb: function() {},
hide_breadcrumb: false,
on_close: function() {},
action_menu_id: null,
additional_context: {},
});
if (action === false) {
action = { type: 'ir.actions.act_window_close' };
} else if (_.isString(action) && core.action_registry.contains(action)) {
var action_client = { type: "ir.actions.client", tag: action, params: {} };
return this.do_action(action_client, options);
} else if (_.isNumber(action) || _.isString(action)) {
var self = this;
var additional_context = {
active_id : options.additional_context.active_id,
active_ids : options.additional_context.active_ids,
active_model : options.additional_context.active_model
};
return self.rpc("/web/action/load", { action_id: action, additional_context : additional_context }).then(function(result) {
return self.do_action(result, options);
});
}
core.bus.trigger('action', action);
// Ensure context & domain are evaluated and can be manipulated/used
var ncontext = new CompoundContext(options.additional_context, action.context || {});
action.context = pyeval.eval('context', ncontext);
if (action.context.active_id || action.context.active_ids) {
// Here we assume that when an `active_id` or `active_ids` is used
// in the context, we are in a `related` action, so we disable the
// searchview's default custom filters.
action.context.search_disable_custom_filters = true;
}
if (action.domain) {
action.domain = pyeval.eval(
'domain', action.domain, action.context || {});
}
if (!action.type) {
console.error("No type for action", action);
return $.Deferred().reject();
}
var type = action.type.replace(/\./g,'_');
var popup = action.target === 'new';
var inline = action.target === 'inline' || action.target === 'inlineview';
var form = _.str.startsWith(action.view_mode, 'form');
action.flags = _.defaults(action.flags || {}, {
views_switcher : !popup && !inline,
search_view : !popup && !inline,
action_buttons : !popup && !inline,
sidebar : !popup && !inline,
pager : (!popup || !form) && !inline,
display_title : !popup,
headless: (popup || inline) && form,
search_disable_custom_filters: action.context && action.context.search_disable_custom_filters
});
action.menu_id = options.action_menu_id;
action.context.params = _.extend({ 'action' : action.id }, action.context.params);
if (!(type in this)) {
console.error("Action manager can't handle action of type " + action.type, action);
return $.Deferred().reject();
}
return this[type](action, options);
},
null_action: function() {
this.dialog_stop();
this.clear_widgets();
},
/**
*
* @param {Object} executor
* @param {Object} executor.action original action
* @param {Function<instance.web.Widget>} executor.widget function used to fetch the widget instance
* @param {String} executor.klass CSS class to add on the dialog root, if action.target=new
* @param {Function<instance.web.Widget, undefined>} executor.post_process cleanup called after a widget has been added as inner_widget
* @param {Object} options
* @return {*}
*/
ir_actions_common: function(executor, options) {
var widget;
if (executor.action.target === 'new') {
var pre_dialog = (this.dialog && !this.dialog.isDestroyed()) ? this.dialog : null;
if (pre_dialog){
// prevent previous dialog to consider itself closed,
// right now, as we're opening a new one (prevents
// reload of original form view)
pre_dialog.off('closing', null, pre_dialog.on_close);
}
if (this.dialog_widget && !this.dialog_widget.isDestroyed()) {
this.dialog_widget.destroy();
}
// explicitly passing a closing action to dialog_stop() prevents
// it from reloading the original form view
this.dialog_stop(executor.action);
this.dialog = new Dialog(this, {
title: executor.action.name,
dialogClass: executor.klass,
});
// chain on_close triggers with previous dialog, if any
this.dialog.on_close = function(){
options.on_close.apply(null, arguments);
if (pre_dialog && pre_dialog.on_close){
// no parameter passed to on_close as this will
// only be called when the last dialog is truly
// closing, and *should* trigger a reload of the
// underlying form view (see comments above)
pre_dialog.on_close();
}
};
this.dialog.on("closing", null, this.dialog.on_close);
widget = executor.widget();
if (widget instanceof ViewManager) {
_.extend(widget.flags, {
$buttons: this.dialog.$buttons,
footer_to_buttons: true,
});
if (widget.action.view_mode === 'form') {
widget.flags.headless = true;
}
}
this.dialog_widget = widget;
this.dialog_widget.setParent(this.dialog);
var initialized = this.dialog_widget.appendTo(this.dialog.$el);
this.dialog.open();
return $.when(initialized);
}
if (this.inner_widget && this.webclient.has_uncommitted_changes()) {
return $.Deferred().reject();
}
widget = executor.widget();
this.dialog_stop(executor.action);
return this.push_widget(widget, executor.action, options);
},
ir_actions_act_window: function (action, options) {
var self = this;
return this.ir_actions_common({
widget: function () {
return new ViewManager(self, null, null, null, action);
},
action: action,
klass: 'oe_act_window',
}, options);
},
ir_actions_client: function (action, options) {
var self = this;
var ClientWidget = core.action_registry.get(action.tag);
if (!ClientWidget) {
return self.do_warn("Action Error", "Could not find client action '" + action.tag + "'.");
}
if (!(ClientWidget.prototype instanceof Widget)) {
var next;
if ((next = ClientWidget(this, action))) {
return this.do_action(next, options);
}
return $.when();
}
return this.ir_actions_common({
widget: function () { return new ClientWidget(self, action); },
action: action,
klass: 'oe_act_client',
}, options).then(function () {
if (action.tag !== 'reload') {self.do_push_state({});}
});
},
ir_actions_act_window_close: function (action, options) {
if (!this.dialog) {
options.on_close();
}
this.dialog_stop();
return $.when();
},
ir_actions_server: function (action, options) {
var self = this;
this.rpc('/web/action/run', {
action_id: action.id,
context: action.context || {}
}).done(function (action) {
self.do_action(action, options);
});
},
ir_actions_report_xml: function(action, options) {
var self = this;
framework.blockUI();
action = _.clone(action);
var eval_contexts = ([session.user_context] || []).concat([action.context]);
action.context = pyeval.eval('contexts',eval_contexts);
// iOS devices doesn't allow iframe use the way we do it,
// opening a new window seems the best way to workaround
if (navigator.userAgent.match(/(iPod|iPhone|iPad)/)) {
var params = {
action: JSON.stringify(action),
token: new Date().getTime()
};
var url = self.session.url('/web/report', params);
framework.unblockUI();
$('<a href="'+url+'" target="_blank"></a>')[0].click();
return;
}
var c = crash_manager;
return $.Deferred(function (d) {
self.session.get_file({
url: '/web/report',
data: {action: JSON.stringify(action)},
complete: framework.unblockUI,
success: function(){
if (!self.dialog) {
options.on_close();
}
self.dialog_stop();
d.resolve();
},
error: function () {
c.rpc_error.apply(c, arguments);
d.reject();
}
});
});
},
ir_actions_act_url: function (action) {
if (action.target === 'self') {
framework.redirect(action.url);
} else {
window.open(action.url, '_blank');
}
return $.when();
},
});
return ActionManager;
});
+120 -41
View File
@@ -6,50 +6,129 @@
* @name openerp
* @namespace openerp
*/
(function() {
// copy everything in the openerp namespace to openerp.web
openerp.web = _.clone(openerp);
"use strict";
var inited = false;
var jobs = [];
var factories = Object.create(null);
var job_names = [];
var job_deps = [];
_.extend(openerp, {
// Per session namespace
// openerp.<module> will map to
// openerp.instances.sessionname.<module> using a closure
instances: {instance0: openerp},
// links to the global openerp
_openerp: openerp,
// this unique id will be replaced by hostname_databasename by
// openerp.web.Session on the first connection
_session_id: "instance0",
_modules: openerp._modules || ['web'],
web_mobile: {},
/**
* OpenERP instance constructor
*
* @param {Array|String} modules list of modules to initialize
*/
init: function(modules) {
if (modules === undefined) {
modules = openerp._modules;
}
modules = _.without(modules, "web");
if (inited)
throw new Error("OpenERP was already inited");
inited = true;
for(var i=0; i < modules.length; i++) {
var fct = openerp[modules[i]];
if (typeof(fct) === "function") {
openerp[modules[i]] = {};
for (var k in fct) {
openerp[modules[i]][k] = fct[k];
}
fct(openerp, openerp[modules[i]]);
var services = Object.create({
qweb: new QWeb2.Engine(),
$: $,
_: _,
});
var debug = ($.deparam($.param.querystring()).debug !== undefined);
var odoo = window.odoo = {
testing: typeof QUnit === "object",
debug: debug,
__DEBUG__: {
get_dependencies: function (name, transitive) {
var deps = name instanceof Array ? name: [name],
changed;
do {
changed = false;
_.each(job_deps, function (dep) {
if (_.contains(deps, dep.to) && (!_.contains(deps, dep.from))) {
deps.push(dep.from);
changed = true;
}
});
} while (changed && transitive)
return deps;
},
get_dependents: function (name) {
return _.pluck(_.where(job_deps, {from: name}), 'to');
},
factories: factories,
services: services,
},
define: function () {
var args = Array.prototype.slice.call(arguments),
name = typeof args[0] === 'string' ? args.shift() : _.uniqueId('__job'),
deps = args[0] instanceof Array ? args.shift() : [],
factory = args[0];
if (odoo.debug) {
if (!(deps instanceof Array)) {
throw new Error ('Dependencies should be defined by an array', deps);
}
if (typeof factory !== 'function') {
throw new Error ('Factory should be defined by a function', factory);
}
if (typeof name !== 'string') {
throw new Error("Invalid name definition (should be a string", name);
}
if (name in factories) {
throw new Error("Service " + name + " already defined");
}
}
openerp._modules = ['web'].concat(modules);
return openerp;
}
});
})();
factory.deps = deps;
factories[name] = factory;
jobs.push({
name: name,
factory: factory,
deps: deps,
});
job_names.push(name);
_.each(deps, function (dep) {
job_deps.push({from:dep, to:name});
});
this.process_jobs(jobs, services);
},
init: function () {
odoo.__DEBUG__.remaining_jobs = jobs;
odoo.__DEBUG__.web_client = services['web.web_client'];
if (jobs.length) {
console.warn('Warning: not all jobs could be started.', jobs);
}
// _.each(factories, function (value, key) {
// delete factories[key];
// });
},
process_jobs: function (jobs, services) {
var job, require;
while (jobs.length && (job = _.find(jobs, is_ready))) {
require = make_require(job);
services[job.name] = job.factory.call(null, require);
if (require.__require_calls !== job.deps.length) {
console.warn('Job ' + job.name + ' did not require all its dependencies');
}
jobs.splice(jobs.indexOf(job), 1);
}
return services;
function is_ready (job) {
return _.every(job.factory.deps, function (name) { return name in services; });
}
function make_require (job) {
var deps = _.pick(services, job.deps);
function require (name) {
if (!(name in deps)) {
console.error('Undefined dependency: ', name);
} else {
require.__require_calls++;
}
return deps[name];
}
require.__require_calls = 0;
return require;
}
}
};
})();
File diff suppressed because it is too large Load Diff
-787
View File
@@ -1,787 +0,0 @@
(function() {
if (typeof(console) === "undefined") {
// Even IE9 only exposes console object if debug window opened
window.console = {};
('log error debug info warn assert clear dir dirxml trace group'
+ ' groupCollapsed groupEnd time timeEnd profile profileEnd count'
+ ' exception').split(/\s+/).forEach(function(property) {
console[property] = _.identity;
});
}
var instance = openerp;
openerp.web.core = {};
var ControllerMixin = {
/**
* Informs the action manager to do an action. This supposes that
* the action manager can be found amongst the ancestors of the current widget.
* If that's not the case this method will simply return `false`.
*/
do_action: function() {
var parent = this.getParent();
if (parent) {
return parent.do_action.apply(parent, arguments);
}
return false;
},
do_notify: function() {
if (this.getParent()) {
return this.getParent().do_notify.apply(this,arguments);
}
return false;
},
do_warn: function() {
if (this.getParent()) {
return this.getParent().do_warn.apply(this,arguments);
}
return false;
},
rpc: function(url, data, options) {
return this.alive(openerp.session.rpc(url, data, options));
}
};
/**
A class containing common utility methods useful when working with OpenERP as well as the PropertiesMixin.
*/
openerp.web.Controller = openerp.web.Class.extend(openerp.web.PropertiesMixin, ControllerMixin, {
/**
* Constructs the object and sets its parent if a parent is given.
*
* @param {openerp.web.Controller} parent Binds the current instance to the given Controller instance.
* When that controller is destroyed by calling destroy(), the current instance will be
* destroyed too. Can be null.
*/
init: function(parent) {
openerp.web.PropertiesMixin.init.call(this);
this.setParent(parent);
this.session = openerp.session;
},
});
openerp.web.Widget.include(_.extend({}, ControllerMixin, {
init: function() {
this._super.apply(this, arguments);
this.session = openerp.session;
},
}));
instance.web.Registry = instance.web.Class.extend({
/**
* Stores a mapping of arbitrary key (strings) to object paths (as strings
* as well).
*
* Resolves those paths at query time in order to always fetch the correct
* object, even if those objects have been overloaded/replaced after the
* registry was created.
*
* An object path is simply a dotted name from the instance root to the
* object pointed to (e.g. ``"instance.web.Session"`` for an OpenERP
* session object).
*
* @constructs instance.web.Registry
* @param {Object} mapping a mapping of keys to object-paths
*/
init: function (mapping) {
this.parent = null;
this.map = mapping || {};
},
/**
* Retrieves the object matching the provided key string.
*
* @param {String} key the key to fetch the object for
* @param {Boolean} [silent_error=false] returns undefined if the key or object is not found, rather than throwing an exception
* @returns {Class} the stored class, to initialize or null if not found
*/
get_object: function (key, silent_error) {
var path_string = this.map[key];
if (path_string === undefined) {
if (this.parent) {
return this.parent.get_object(key, silent_error);
}
if (silent_error) { return void 'nooo'; }
return null;
}
var object_match = instance;
var path = path_string.split('.');
// ignore first section
for(var i=1; i<path.length; ++i) {
object_match = object_match[path[i]];
if (object_match === undefined) {
if (silent_error) { return void 'noooooo'; }
return null;
}
}
return object_match;
},
/**
* Checks if the registry contains an object mapping for this key.
*
* @param {String} key key to look for
*/
contains: function (key) {
if (key === undefined) { return false; }
if (key in this.map) {
return true;
}
if (this.parent) {
return this.parent.contains(key);
}
return false;
},
/**
* Tries a number of keys, and returns the first object matching one of
* the keys.
*
* @param {Array} keys a sequence of keys to fetch the object for
* @returns {Class} the first class found matching an object
*/
get_any: function (keys) {
for (var i=0; i<keys.length; ++i) {
var key = keys[i];
if (!this.contains(key)) {
continue;
}
return this.get_object(key);
}
return null;
},
/**
* Adds a new key and value to the registry.
*
* This method can be chained.
*
* @param {String} key
* @param {String} object_path fully qualified dotted object path
* @returns {instance.web.Registry} itself
*/
add: function (key, object_path) {
this.map[key] = object_path;
return this;
},
/**
* Creates and returns a copy of the current mapping, with the provided
* mapping argument added in (replacing existing keys if needed)
*
* Parent and child remain linked, a new key in the parent (which is not
* overwritten by the child) will appear in the child.
*
* @param {Object} [mapping={}] a mapping of keys to object-paths
*/
extend: function (mapping) {
var child = new instance.web.Registry(mapping);
child.parent = this;
return child;
},
/**
* @deprecated use Registry#extend
*/
clone: function (mapping) {
console.warn('Registry#clone is deprecated, use Registry#extend');
return this.extend(mapping);
}
});
instance.web.py_eval = function(expr, context) {
return py.eval(expr, _.extend({}, context || {}, {"true": true, "false": false, "null": null}));
};
/*
Some retro-compatibility.
*/
instance.web.JsonRPC = instance.web.Session;
/** Session openerp specific RPC class */
instance.web.Session.include( /** @lends instance.web.Session# */{
init: function() {
this._super.apply(this, arguments);
this.debug = ($.deparam($.param.querystring()).debug !== undefined);
// TODO: session store in cookie should be optional
this.name = instance._session_id;
this.qweb_mutex = new $.Mutex();
},
/**
* Setup a sessionm
*/
session_bind: function(origin) {
var self = this;
this.setup(origin);
instance.web.qweb.default_dict['_s'] = this.origin;
this.uid = null;
this.username = null;
this.user_context= {};
this.db = null;
this.module_list = instance._modules.slice();
this.module_loaded = {};
_(this.module_list).each(function (mod) {
self.module_loaded[mod] = true;
});
this.active_id = null;
return this.session_init();
},
/**
* Init a session, reloads from cookie, if it exists
*/
session_init: function () {
var self = this;
return this.session_reload().then(function(result) {
var modules = instance._modules.join(',');
var deferred = self.load_qweb(modules);
if(self.session_is_valid()) {
return deferred.then(function() { return self.load_modules(); });
}
return $.when(
deferred,
self.rpc('/web/webclient/bootstrap_translations', {mods: instance._modules}).then(function(trans) {
instance.web._t.database.set_bundle(trans);
})
);
});
},
session_is_valid: function() {
var db = $.deparam.querystring().db;
if (db && this.db !== db) {
return false;
}
return !!this.uid;
},
/**
* The session is validated by restoration of a previous session
*/
session_authenticate: function() {
var self = this;
return $.when(this._super.apply(this, arguments)).then(function() {
return self.load_modules();
});
},
session_logout: function() {
$.bbq.removeState();
return this.rpc("/web/session/destroy", {});
},
get_cookie: function (name) {
if (!this.name) { return null; }
var nameEQ = this.name + '|' + name + '=';
var cookies = document.cookie.split(';');
for(var i=0; i<cookies.length; ++i) {
var cookie = cookies[i].replace(/^\s*/, '');
if(cookie.indexOf(nameEQ) === 0) {
try {
return JSON.parse(decodeURIComponent(cookie.substring(nameEQ.length)));
} catch(err) {
// wrong cookie, delete it
this.set_cookie(name, '', -1);
}
}
}
return null;
},
/**
* Create a new cookie with the provided name and value
*
* @private
* @param name the cookie's name
* @param value the cookie's value
* @param ttl the cookie's time to live, 1 year by default, set to -1 to delete
*/
set_cookie: function (name, value, ttl) {
if (!this.name) { return; }
ttl = ttl || 24*60*60*365;
document.cookie = [
this.name + '|' + name + '=' + encodeURIComponent(JSON.stringify(value)),
'path=/',
'max-age=' + ttl,
'expires=' + new Date(new Date().getTime() + ttl*1000).toGMTString()
].join(';');
},
/**
* Load additional web addons of that instance and init them
*
*/
load_modules: function() {
var self = this;
return this.rpc('/web/session/modules', {}).then(function(result) {
var all_modules = _.uniq(self.module_list.concat(result));
var to_load = _.difference(result, self.module_list).join(',');
self.module_list = all_modules;
var loaded = self.load_translations();
var locale = "/web/webclient/locale/" + self.user_context.lang || 'en_US';
var file_list = [ locale ];
if(to_load.length) {
loaded = $.when(
loaded,
self.rpc('/web/webclient/csslist', {mods: to_load}).done(self.load_css.bind(self)),
self.load_qweb(to_load),
self.rpc('/web/webclient/jslist', {mods: to_load}).done(function(files) {
file_list = file_list.concat(files);
})
);
}
return loaded.then(function () {
return self.load_js(file_list);
}).done(function() {
self.on_modules_loaded();
self.trigger('module_loaded');
});
});
},
load_translations: function() {
return instance.web._t.database.load_translations(this, this.module_list, this.user_context.lang);
},
load_css: function (files) {
var self = this;
_.each(files, function (file) {
openerp.loadCSS(self.url(file, null));
});
},
load_js: function(files) {
var self = this;
var d = $.Deferred();
if (files.length !== 0) {
var file = files.shift();
var url = self.url(file, null);
openerp.loadJS(url).done(d.resolve);
} else {
d.resolve();
}
return d;
},
load_qweb: function(mods) {
var self = this;
self.qweb_mutex.exec(function() {
return self.rpc('/web/proxy/load', {path: '/web/webclient/qweb?mods=' + mods}).then(function(xml) {
if (!xml) { return; }
instance.web.qweb.add_template(_.str.trim(xml));
});
});
return self.qweb_mutex.def;
},
on_modules_loaded: function() {
for(var j=0; j<this.module_list.length; j++) {
var mod = this.module_list[j];
if(this.module_loaded[mod])
continue;
instance[mod] = {};
// init module mod
var fct = instance._openerp[mod];
if(typeof(fct) === "function") {
instance._openerp[mod] = {};
for (var k in fct) {
instance._openerp[mod][k] = fct[k];
}
fct(instance, instance._openerp[mod]);
}
this.module_loaded[mod] = true;
}
},
/**
* Cooperative file download implementation, for ajaxy APIs.
*
* Requires that the server side implements an httprequest correctly
* setting the `fileToken` cookie to the value provided as the `token`
* parameter. The cookie *must* be set on the `/` path and *must not* be
* `httpOnly`.
*
* It would probably also be a good idea for the response to use a
* `Content-Disposition: attachment` header, especially if the MIME is a
* "known" type (e.g. text/plain, or for some browsers application/json
*
* @param {Object} options
* @param {String} [options.url] used to dynamically create a form
* @param {Object} [options.data] data to add to the form submission. If can be used without a form, in which case a form is created from scratch. Otherwise, added to form data
* @param {HTMLFormElement} [options.form] the form to submit in order to fetch the file
* @param {Function} [options.success] callback in case of download success
* @param {Function} [options.error] callback in case of request error, provided with the error body
* @param {Function} [options.complete] called after both ``success`` and ``error` callbacks have executed
*/
get_file: function (options) {
// need to detect when the file is done downloading (not used
// yet, but we'll need it to fix the UI e.g. with a throbber
// while dump is being generated), iframe load event only fires
// when the iframe content loads, so we need to go smarter:
// http://geekswithblogs.net/GruffCode/archive/2010/10/28/detecting-the-file-download-dialog-in-the-browser.aspx
var timer, token = new Date().getTime(),
cookie_name = 'fileToken', cookie_length = cookie_name.length,
CHECK_INTERVAL = 1000, id = _.uniqueId('get_file_frame'),
remove_form = false;
// iOS devices doesn't allow iframe use the way we do it,
// opening a new window seems the best way to workaround
if (navigator.userAgent.match(/(iPod|iPhone|iPad)/)) {
var params = _.extend({}, options.data || {}, {token: token});
var url = this.url(options.url, params);
instance.web.unblockUI();
return window.open(url);
}
var $form, $form_data = $('<div>');
var complete = function () {
if (options.complete) { options.complete(); }
clearTimeout(timer);
$form_data.remove();
$target.remove();
if (remove_form && $form) { $form.remove(); }
};
var $target = $('<iframe style="display: none;">')
.attr({id: id, name: id})
.appendTo(document.body)
.load(function () {
try {
if (options.error) {
var body = this.contentDocument.body;
var nodes = body.children.length === 0 ? body.childNodes : body.children;
var node = nodes[1] || nodes[0];
options.error(JSON.parse(node.textContent));
}
} finally {
complete();
}
});
if (options.form) {
$form = $(options.form);
} else {
remove_form = true;
$form = $('<form>', {
action: options.url,
method: 'POST'
}).appendTo(document.body);
}
var hparams = _.extend({}, options.data || {}, {token: token});
if (this.override_session)
hparams.session_id = this.session_id;
_.each(hparams, function (value, key) {
var $input = $form.find('[name=' + key +']');
if (!$input.length) {
$input = $('<input type="hidden" name="' + key + '">')
.appendTo($form_data);
}
$input.val(value);
});
$form
.append($form_data)
.attr('target', id)
.get(0).submit();
var waitLoop = function () {
var cookies = document.cookie.split(';');
// setup next check
timer = setTimeout(waitLoop, CHECK_INTERVAL);
for (var i=0; i<cookies.length; ++i) {
var cookie = cookies[i].replace(/^\s*/, '');
if (!cookie.indexOf(cookie_name === 0)) { continue; }
var cookie_val = cookie.substring(cookie_length + 1);
if (parseInt(cookie_val, 10) !== token) { continue; }
// clear cookie
document.cookie = _.str.sprintf("%s=;expires=%s;path=/",
cookie_name, new Date().toGMTString());
if (options.success) { options.success(); }
complete();
return;
}
};
timer = setTimeout(waitLoop, CHECK_INTERVAL);
},
synchronized_mode: function(to_execute) {
var synch = this.synch;
this.synch = true;
try {
return to_execute();
} finally {
this.synch = synch;
}
}
});
/**
* Event Bus used to bind events scoped in the current instance
*/
instance.web.Bus = instance.web.Class.extend(instance.web.EventDispatcherMixin, {
init: function() {
instance.web.EventDispatcherMixin.init.call(this, parent);
var self = this;
// TODO fme: allow user to bind keys for some global actions.
// check gtk bindings
// http://unixpapa.com/js/key.html
_.each('click,dblclick,keydown,keypress,keyup'.split(','), function(evtype) {
$('html').on(evtype, function(ev) {
self.trigger(evtype, ev);
});
});
_.each('resize,scroll'.split(','), function(evtype) {
$(window).on(evtype, function(ev) {
self.trigger(evtype, ev);
});
});
}
});
instance.web.bus = new instance.web.Bus();
instance.web.TranslationDataBase.include({
set_bundle: function(translation_bundle) {
this._super(translation_bundle);
if (translation_bundle.lang_parameters) {
this.parameters.grouping = py.eval(this.parameters.grouping);
}
},
});
/** Custom jQuery plugins */
$.fn.getAttributes = function() {
var o = {};
if (this.length) {
for (var attr, i = 0, attrs = this[0].attributes, l = attrs.length; i < l; i++) {
attr = attrs.item(i);
o[attr.nodeName] = attr.value;
}
}
return o;
};
$.fn.openerpClass = function(additionalClass) {
// This plugin should be applied on top level elements
additionalClass = additionalClass || '';
if (!!$.browser.msie) {
additionalClass += ' openerp_ie';
}
return this.each(function() {
$(this).addClass('openerp ' + additionalClass);
});
};
$.fn.openerpBounce = function() {
return this.each(function() {
$(this).css('box-sizing', 'content-box').effect('bounce', {distance: 18, times: 5}, 250);
});
};
/** Jquery extentions */
$.Mutex = openerp.Mutex;
$.async_when = function() {
var async = false;
var def = $.Deferred();
$.when.apply($, arguments).done(function() {
var args = arguments;
var action = function() {
def.resolve.apply(def, args);
};
if (async)
action();
else
setTimeout(action, 0);
}).fail(function() {
var args = arguments;
var action = function() {
def.reject.apply(def, args);
};
if (async)
action();
else
setTimeout(action, 0);
});
async = true;
return def;
};
// special tweak for the web client
var old_async_when = $.async_when;
$.async_when = function() {
if (instance.session.synch)
return $.when.apply(this, arguments);
else
return old_async_when.apply(this, arguments);
};
/** Setup default session */
instance.session = new instance.web.Session();
/**
* Lazy translation function, only performs the translation when actually
* printed (e.g. inserted into a template)
*
* Useful when defining translatable strings in code evaluated before the
* translation database is loaded, as class attributes or at the top-level of
* an OpenERP Web module
*
* @param {String} s string to translate
* @returns {Object} lazy translation object
*/
instance.web._lt = function (s) {
return {toString: function () { return instance.web._t(s); }};
};
instance.web.qweb.debug = instance.session.debug;
_.extend(instance.web.qweb.default_dict, {
'__debug__': instance.session.debug,
'moment': function(date) { return new moment(date); },
});
instance.web.qweb.preprocess_node = function() {
// Note that 'this' is the Qweb Node
switch (this.node.nodeType) {
case Node.TEXT_NODE:
case Node.CDATA_SECTION_NODE:
// Text and CDATAs
var translation = this.node.parentNode.attributes['t-translation'];
if (translation && translation.value === 'off') {
return;
}
var match = /^(\s*)([\s\S]+?)(\s*)$/.exec(this.node.data);
if (match) {
this.node.data = match[1] + instance.web._t(match[2]) + match[3];
}
break;
case Node.ELEMENT_NODE:
// Element
var attr, attrs = ['label', 'title', 'alt', 'placeholder'];
while ((attr = attrs.pop())) {
if (this.attributes[attr]) {
this.attributes[attr] = instance.web._t(this.attributes[attr]);
}
}
}
};
/** Setup jQuery timeago */
var _t = instance.web._t;
/*
* Strings in timeago are "composed" with prefixes, words and suffixes. This
* makes their detection by our translating system impossible. Use all literal
* strings we're using with a translation mark here so the extractor can do its
* job.
*/
{
_t('less than a minute ago');
_t('about a minute ago');
_t('%d minutes ago');
_t('about an hour ago');
_t('%d hours ago');
_t('a day ago');
_t('%d days ago');
_t('about a month ago');
_t('%d months ago');
_t('about a year ago');
_t('%d years ago');
}
instance.session.on('module_loaded', this, function () {
// provide timeago.js with our own translator method
$.timeago.settings.translator = instance.web._t;
});
/** Setup blockui */
if ($.blockUI) {
$.blockUI.defaults.baseZ = 1100;
$.blockUI.defaults.message = '<div class="openerp oe_blockui_spin_container" style="background-color: transparent;">';
$.blockUI.defaults.css.border = '0';
$.blockUI.defaults.css["background-color"] = '';
}
var messages_by_seconds = function() {
return [
[0, _t("Loading...")],
[20, _t("Still loading...")],
[60, _t("Still loading...<br />Please be patient.")],
[120, _t("Don't leave yet,<br />it's still loading...")],
[300, _t("You may not believe it,<br />but the application is actually loading...")],
[420, _t("Take a minute to get a coffee,<br />because it's loading...")],
[3600, _t("Maybe you should consider reloading the application by pressing F5...")]
];
};
instance.web.Throbber = instance.web.Widget.extend({
template: "Throbber",
start: function() {
var opts = {
lines: 13, // The number of lines to draw
length: 7, // The length of each line
width: 4, // The line thickness
radius: 10, // The radius of the inner circle
rotate: 0, // The rotation offset
color: '#FFF', // #rgb or #rrggbb
speed: 1, // Rounds per second
trail: 60, // Afterglow percentage
shadow: false, // Whether to render a shadow
hwaccel: false, // Whether to use hardware acceleration
className: 'spinner', // The CSS class to assign to the spinner
zIndex: 2e9, // The z-index (defaults to 2000000000)
top: 'auto', // Top position relative to parent in px
left: 'auto' // Left position relative to parent in px
};
this.spin = new Spinner(opts).spin(this.$el[0]);
this.start_time = new Date().getTime();
this.act_message();
},
act_message: function() {
var self = this;
setTimeout(function() {
if (self.isDestroyed())
return;
var seconds = (new Date().getTime() - self.start_time) / 1000;
var mes;
_.each(messages_by_seconds(), function(el) {
if (seconds >= el[0])
mes = el[1];
});
self.$(".oe_throbber_message").html(mes);
self.act_message();
}, 1000);
},
destroy: function() {
if (this.spin)
this.spin.stop();
this._super();
},
});
instance.web.Throbber.throbbers = [];
instance.web.blockUI = function() {
var tmp = $.blockUI.apply($, arguments);
var throbber = new instance.web.Throbber();
instance.web.Throbber.throbbers.push(throbber);
throbber.appendTo($(".oe_blockui_spin_container"));
return tmp;
};
instance.web.unblockUI = function() {
_.each(instance.web.Throbber.throbbers, function(el) {
el.destroy();
});
return $.unblockUI.apply($, arguments);
};
/* Bootstrap defaults overwrite */
$.fn.tooltip.Constructor.DEFAULTS.placement = 'auto top';
$.fn.tooltip.Constructor.DEFAULTS.html = true;
$.fn.tooltip.Constructor.DEFAULTS.trigger = 'hover focus click';
$.fn.tooltip.Constructor.DEFAULTS.container = 'body';
//overwrite bootstrap tooltip method to prevent showing 2 tooltip at the same time
var bootstrap_show_function = $.fn.tooltip.Constructor.prototype.show;
$.fn.modal.Constructor.prototype.enforceFocus = function () { };
$.fn.tooltip.Constructor.prototype.show = function () {
$('.tooltip').remove();
//the following fix the bug when using placement
//auto and the parent element does not exist anymore resulting in
//an error. This should be remove once we updade bootstrap to a version that fix the bug
//edit: bug has been fixed here : https://github.com/twbs/bootstrap/pull/13752
var e = $.Event('show.bs.' + this.type);
var inDom = $.contains(document.documentElement, this.$element[0]);
if (e.isDefaultPrevented() || !inDom) return;
return bootstrap_show_function.call(this);
};
/**
* Registry for all the client actions key: tag value: widget
*/
instance.web.client_actions = new instance.web.Registry();
})();
-398
View File
@@ -1,398 +0,0 @@
(function() {
var instance = openerp;
openerp.web.formats = {};
var _t = instance.web._t;
/**
* Intersperses ``separator`` in ``str`` at the positions indicated by
* ``indices``.
*
* ``indices`` is an array of relative offsets (from the previous insertion
* position, starting from the end of the string) at which to insert
* ``separator``.
*
* There are two special values:
*
* ``-1``
* indicates the insertion should end now
* ``0``
* indicates that the previous section pattern should be repeated (until all
* of ``str`` is consumed)
*
* @param {String} str
* @param {Array<Number>} indices
* @param {String} separator
* @returns {String}
*/
instance.web.intersperse = function (str, indices, separator) {
separator = separator || '';
var result = [], last = str.length;
for(var i=0; i<indices.length; ++i) {
var section = indices[i];
if (section === -1 || last <= 0) {
// Done with string, or -1 (stops formatting string)
break;
} else if(section === 0 && i === 0) {
// repeats previous section, which there is none => stop
break;
} else if (section === 0) {
// repeat previous section forever
//noinspection AssignmentToForLoopParameterJS
section = indices[--i];
}
result.push(str.substring(last-section, last));
last -= section;
}
var s = str.substring(0, last);
if (s) { result.push(s); }
return result.reverse().join(separator);
};
/**
* Insert "thousands" separators in the provided number (which is actually
* a string)
*
* @param {String} num
* @returns {String}
*/
instance.web.insert_thousand_seps = function (num) {
var negative = num[0] === '-';
num = (negative ? num.slice(1) : num);
return (negative ? '-' : '') + instance.web.intersperse(
num, _t.database.parameters.grouping, _t.database.parameters.thousands_sep);
};
/**
* Check with a scary heuristic if the value is a bin_size or not.
* If not, compute an approximate size out of the base64 encoded string.
*
* @param {String} value original format
*/
instance.web.binary_to_binsize = function (value) {
if (!value) {
return instance.web.human_size(0);
}
if (value.substr(0, 10).indexOf(' ') == -1) {
// Computing approximate size out of base64 encoded string
// http://en.wikipedia.org/wiki/Base64#MIME
return instance.web.human_size(value.length / 1.37);
} else {
// already bin_size
return value;
}
};
/**
* Returns a human readable size
*
* @param {Number} numner of bytes
*/
instance.web.human_size = function(size) {
var units = _t("Bytes,Kb,Mb,Gb,Tb,Pb,Eb,Zb,Yb").split(',');
var i = 0;
while (size >= 1024) {
size /= 1024;
++i;
}
return size.toFixed(2) + ' ' + units[i];
};
/**
* Formats a single atomic value based on a field descriptor
*
* @param {Object} value read from OpenERP
* @param {Object} descriptor union of orm field and view field
* @param {Object} [descriptor.widget] widget to use to display the value
* @param {Object} descriptor.type fallback if no widget is provided, or if the provided widget is unknown
* @param {Object} [descriptor.digits] used for the formatting of floats
* @param {String} [value_if_empty=''] returned if the ``value`` argument is considered empty
*/
instance.web.format_value = function (value, descriptor, value_if_empty) {
var l10n = _t.database.parameters;
var date_format = instance.web.normalize_format(l10n.date_format);
var time_format = instance.web.normalize_format(l10n.time_format);
// If NaN value, display as with a `false` (empty cell)
if (typeof value === 'number' && isNaN(value)) {
value = false;
}
//noinspection FallthroughInSwitchStatementJS
switch (value) {
case '':
if (descriptor.type === 'char' || descriptor.type === 'text') {
return '';
}
console.warn('Field', descriptor, 'had an empty string as value, treating as false...');
return value_if_empty === undefined ? '' : value_if_empty;
case false:
case undefined:
case Infinity:
case -Infinity:
return value_if_empty === undefined ? '' : value_if_empty;
}
switch (descriptor.widget || descriptor.type || (descriptor.field && descriptor.field.type)) {
case 'id':
return value.toString();
case 'integer':
return instance.web.insert_thousand_seps(
_.str.sprintf('%d', value));
case 'float':
var digits = descriptor.digits ? descriptor.digits : [69,2];
digits = typeof digits === "string" ? py.eval(digits) : digits;
var precision = digits[1];
var formatted = _.str.sprintf('%.' + precision + 'f', value).split('.');
formatted[0] = instance.web.insert_thousand_seps(formatted[0]);
return formatted.join(l10n.decimal_point);
case 'float_time':
var pattern = '%02d:%02d';
if (value < 0) {
value = Math.abs(value);
pattern = '-' + pattern;
}
var hour = Math.floor(value);
var min = Math.round((value % 1) * 60);
if (min == 60){
min = 0;
hour = hour + 1;
}
return _.str.sprintf(pattern, hour, min);
case 'many2one':
// name_get value format
return value[1] ? value[1].split("\n")[0] : value[1];
case 'one2many':
case 'many2many':
if (typeof value === 'string') {
return value;
}
return _.str.sprintf(_t("(%d records)"), value.length);
case 'datetime':
if (typeof(value) == "string")
value = moment(instance.web.auto_str_to_date(value));
else {
value = moment(value);
}
return value.format(date_format + ' ' + time_format);
case 'date':
if (typeof(value) == "string")
value = moment(instance.web.auto_str_to_date(value));
else {
value = moment(value);
}
return value.format(date_format);
case 'time':
if (typeof(value) == "string")
value = moment(instance.web.auto_str_to_date(value));
else {
value = moment(value);
}
return value.format(time_format);
case 'selection': case 'statusbar':
// Each choice is [value, label]
if(_.isArray(value)) {
return value[1];
}
var result = _(descriptor.selection).detect(function (choice) {
return choice[0] === value;
});
if (result) { return result[1]; }
return;
default:
return value;
}
};
instance.web.parse_value = function (value, descriptor, value_if_empty) {
var date_pattern = instance.web.normalize_format(_t.database.parameters.date_format),
time_pattern = instance.web.normalize_format(_t.database.parameters.time_format);
var date_pattern_wo_zero = date_pattern.replace('MM','M').replace('DD','D'),
time_pattern_wo_zero = time_pattern.replace('HH','H').replace('mm','m').replace('ss','s');
switch (value) {
case false:
case "":
return value_if_empty === undefined ? false : value_if_empty;
}
var tmp;
switch (descriptor.widget || descriptor.type || (descriptor.field && descriptor.field.type)) {
case 'integer':
do {
tmp = value;
value = value.replace(instance.web._t.database.parameters.thousands_sep, "");
} while(tmp !== value);
tmp = Number(value);
// do not accept not numbers or float values
if (isNaN(tmp) || tmp % 1)
throw new Error(_.str.sprintf(_t("'%s' is not a correct integer"), value));
return tmp;
case 'float':
tmp = Number(value);
if (!isNaN(tmp))
return tmp;
var tmp2 = value;
do {
tmp = tmp2;
tmp2 = tmp.replace(instance.web._t.database.parameters.thousands_sep, "");
} while(tmp !== tmp2);
var reformatted_value = tmp.replace(instance.web._t.database.parameters.decimal_point, ".");
var parsed = Number(reformatted_value);
if (isNaN(parsed))
throw new Error(_.str.sprintf(_t("'%s' is not a correct float"), value));
return parsed;
case 'float_time':
var factor = 1;
if (value[0] === '-') {
value = value.slice(1);
factor = -1;
}
var float_time_pair = value.split(":");
if (float_time_pair.length != 2)
return factor * instance.web.parse_value(value, {type: "float"});
var hours = instance.web.parse_value(float_time_pair[0], {type: "integer"});
var minutes = instance.web.parse_value(float_time_pair[1], {type: "integer"});
return factor * (hours + (minutes / 60));
case 'progressbar':
return instance.web.parse_value(value, {type: "float"});
case 'datetime':
var datetime = moment(value, [date_pattern + ' ' + time_pattern, date_pattern_wo_zero + ' ' + time_pattern_wo_zero], true)
if (datetime.isValid())
return instance.web.datetime_to_str(datetime.toDate());
throw new Error(_.str.sprintf(_t("'%s' is not a correct datetime"), value));
case 'date':
var date = moment(value, [date_pattern, date_pattern_wo_zero], true)
if (date.isValid())
return instance.web.date_to_str(date.toDate());
throw new Error(_.str.sprintf(_t("'%s' is not a correct date"), value));
case 'time':
var time = moment(value, [time_pattern, time_pattern_wo_zero], true);
if (time.isValid())
return instance.web.time_to_str(time.toDate());
throw new Error(_.str.sprintf(_t("'%s' is not a correct time"), value));
}
return value;
};
instance.web.auto_str_to_date = function(value, type) {
try {
return instance.web.str_to_datetime(value);
} catch(e) {}
try {
return instance.web.str_to_date(value);
} catch(e) {}
try {
return instance.web.str_to_time(value);
} catch(e) {}
throw new Error(_.str.sprintf(_t("'%s' is not a correct date, datetime nor time"), value));
};
instance.web.auto_date_to_str = function(value, type) {
switch(type) {
case 'datetime':
return instance.web.datetime_to_str(value);
case 'date':
return instance.web.date_to_str(value);
case 'time':
return instance.web.time_to_str(value);
default:
throw new Error(_.str.sprintf(_t("'%s' is not convertible to date, datetime nor time"), type));
}
};
/**
* performs a half up rounding with arbitrary precision, correcting for float loss of precision
* See the corresponding float_round() in server/tools/float_utils.py for more info
* @param {Number} the value to be rounded
* @param {Number} a precision parameter. eg: 0.01 rounds to two digits.
*/
instance.web.round_precision = function(value, precision){
if (!value) {
return 0;
} else if (!precision || precision < 0) {
precision = 1;
}
var normalized_value = value / precision;
var epsilon_magnitude = Math.log(Math.abs(normalized_value))/Math.log(2);
var epsilon = Math.pow(2, epsilon_magnitude - 53);
normalized_value += normalized_value >= 0 ? epsilon : -epsilon;
var rounded_value = Math.round(normalized_value);
return rounded_value * precision;
};
/**
* performs a half up rounding with a fixed amount of decimals, correcting for float loss of precision
* See the corresponding float_round() in server/tools/float_utils.py for more info
* @param {Number} the value to be rounded
* @param {Number} the number of decimals. eg: round_decimals(3.141592,2) -> 3.14
*/
instance.web.round_decimals = function(value, decimals){
return instance.web.round_precision(value, Math.pow(10,-decimals));
};
/**
* Convert Python strftime to escaped moment.js format.
*
* @param {String} value original format
*/
instance.web.normalize_format = function (value) {
if (_normalize_format_cache[value] === undefined) {
var isletter = /[a-zA-Z]/,
output = [],
inToken = false,
table = instance.web.normalize_format_table;
for (var index=0; index < value.length; ++index) {
var character = value[index];
if (character === '%' && !inToken) {
inToken = true;
continue;
}
if (isletter.test(character)) {
if (inToken && table[character] !== undefined) {
character = table[character];
} else {
character = '[' + character + ']'; // moment.js escape
}
}
output.push(character);
inToken = false;
}
_normalize_format_cache[value] = output.join('');
}
return _normalize_format_cache[value];
};
instance.web.normalize_format_table = {
// Python strftime to moment.js conversion table
// See openerp/addons/base/res/res_lang_view.xml
// for details about supported directives
'a': 'ddd',
'A': 'dddd',
'b': 'MMM',
'B': 'MMMM',
'd': 'DD',
'H': 'HH',
'I': 'hh',
'j': 'DDDD',
'm': 'MM',
'M': 'mm',
'p': 'A',
'S': 'ss',
'U': 'ww',
'W': 'WW',
'w': 'd',
'y': 'YY',
'Y': 'YYYY',
// unsupported directives
'c': 'ddd MMM D HH:mm:ss YYYY',
'x': 'MM/DD/YY',
'X': 'HH:mm:ss'
};
var _normalize_format_cache = {};
instance.web.float_is_zero = function(value, decimals){
epsilon = Math.pow(10, -decimals);
return Math.abs(instance.web.round_precision(value, epsilon)) < epsilon;
};
})();
+286
View File
@@ -0,0 +1,286 @@
odoo.define('web.ajax', ['web.time'], function (require) {
"use strict";
var time = require('web.time');
function genericJsonRpc (fct_name, params, fct) {
var data = {
jsonrpc: "2.0",
method: fct_name,
params: params,
id: Math.floor(Math.random() * 1000 * 1000 * 1000)
};
var xhr = fct(data);
var result = xhr.pipe(function(result) {
if (result.error !== undefined) {
console.error("Server application error", result.error);
return $.Deferred().reject("server", result.error);
} else {
return result.result;
}
}, function() {
//console.error("JsonRPC communication error", _.toArray(arguments));
var def = $.Deferred();
return def.reject.apply(def, ["communication"].concat(_.toArray(arguments)));
});
// FIXME: jsonp?
result.abort = function () { if (xhr.abort) xhr.abort(); };
return result;
}
function jsonRpc(url, fct_name, params, settings) {
return genericJsonRpc(fct_name, params, function(data) {
return $.ajax(url, _.extend({}, settings, {
url: url,
dataType: 'json',
type: 'POST',
data: JSON.stringify(data, time.date_to_utc),
contentType: 'application/json'
}));
});
};
function jsonpRpc(url, fct_name, params, settings) {
settings = settings || {};
return genericJsonRpc(fct_name, params, function(data) {
var payload_str = JSON.stringify(data, time.date_to_utc);
var payload_url = $.param({r:payload_str});
var force2step = settings.force2step || false;
delete settings.force2step;
var session_id = settings.session_id || null;
delete settings.session_id;
if (payload_url.length < 2000 && ! force2step) {
return $.ajax(url, _.extend({}, settings, {
url: url,
dataType: 'jsonp',
jsonp: 'jsonp',
type: 'GET',
cache: false,
data: {r: payload_str, session_id: session_id}
}));
} else {
var args = {session_id: session_id, id: data.id};
var ifid = _.uniqueId('oe_rpc_iframe');
var html = "<iframe src='javascript:false;' name='" + ifid + "' id='" + ifid + "' style='display:none'></iframe>";
var $iframe = $(html);
var nurl = 'jsonp=1&' + $.param(args);
nurl = url.indexOf("?") !== -1 ? url + "&" + nurl : url + "?" + nurl;
var $form = $('<form>')
.attr('method', 'POST')
.attr('target', ifid)
.attr('enctype', "multipart/form-data")
.attr('action', nurl)
.append($('<input type="hidden" name="r" />').attr('value', payload_str))
.hide()
.appendTo($('body'));
var cleanUp = function() {
if ($iframe) {
$iframe.unbind("load").remove();
}
$form.remove();
};
var deferred = $.Deferred();
// the first bind is fired up when the iframe is added to the DOM
$iframe.bind('load', function() {
// the second bind is fired up when the result of the form submission is received
$iframe.unbind('load').bind('load', function() {
$.ajax({
url: url,
dataType: 'jsonp',
jsonp: 'jsonp',
type: 'GET',
cache: false,
data: {session_id: session_id, id: data.id}
}).always(function() {
cleanUp();
}).done(function() {
deferred.resolve.apply(deferred, arguments);
}).fail(function() {
deferred.reject.apply(deferred, arguments);
});
});
// now that the iframe can receive data, we fill and submit the form
$form.submit();
});
// append the iframe to the DOM (will trigger the first load)
$form.after($iframe);
if (settings.timeout) {
realSetTimeout(function() {
deferred.reject({});
}, settings.timeout);
}
return deferred;
}
});
};
// helper
function realSetTimeout (fct, millis) {
var finished = new Date().getTime() + millis;
var wait = function() {
var current = new Date().getTime();
if (current < finished) {
setTimeout(wait, finished - current);
} else {
fct();
}
};
setTimeout(wait, millis);
}
function loadCSS(url) {
if (!$('link[href="' + url + '"]').length) {
$('head').append($('<link>', {
'href': url,
'rel': 'stylesheet',
'type': 'text/css'
}));
}
};
function loadJS(url) {
var def = $.Deferred();
if ($('script[src="' + url + '"]').length) {
def.resolve();
} else {
var script = document.createElement('script');
script.type = 'text/javascript';
script.src = url;
script.onload = script.onreadystatechange = function() {
if ((script.readyState && script.readyState != "loaded" && script.readyState != "complete") || script.onload_done) {
return;
}
script.onload_done = true;
def.resolve(url);
};
script.onerror = function () {
console.error("Error loading file", script.src);
def.reject(url);
};
var head = document.head || document.getElementsByTagName('head')[0];
head.appendChild(script);
}
return def;
};
/**
* Cooperative file download implementation, for ajaxy APIs.
*
* Requires that the server side implements an httprequest correctly
* setting the `fileToken` cookie to the value provided as the `token`
* parameter. The cookie *must* be set on the `/` path and *must not* be
* `httpOnly`.
*
* It would probably also be a good idea for the response to use a
* `Content-Disposition: attachment` header, especially if the MIME is a
* "known" type (e.g. text/plain, or for some browsers application/json
*
* @param {Object} options
* @param {String} [options.url] used to dynamically create a form
* @param {Object} [options.data] data to add to the form submission. If can be used without a form, in which case a form is created from scratch. Otherwise, added to form data
* @param {HTMLFormElement} [options.form] the form to submit in order to fetch the file
* @param {Function} [options.success] callback in case of download success
* @param {Function} [options.error] callback in case of request error, provided with the error body
* @param {Function} [options.complete] called after both ``success`` and ``error` callbacks have executed
*/
function get_file(options) {
// need to detect when the file is done downloading (not used
// yet, but we'll need it to fix the UI e.g. with a throbber
// while dump is being generated), iframe load event only fires
// when the iframe content loads, so we need to go smarter:
// http://geekswithblogs.net/GruffCode/archive/2010/10/28/detecting-the-file-download-dialog-in-the-browser.aspx
var timer, token = new Date().getTime(),
cookie_name = 'fileToken', cookie_length = cookie_name.length,
CHECK_INTERVAL = 1000, id = _.uniqueId('get_file_frame'),
remove_form = false;
// iOS devices doesn't allow iframe use the way we do it,
// opening a new window seems the best way to workaround
if (navigator.userAgent.match(/(iPod|iPhone|iPad)/)) {
var params = _.extend({}, options.data || {}, {token: token});
var url = this.url(options.url, params);
return window.open(url);
}
var $form, $form_data = $('<div>');
var complete = function () {
if (options.complete) { options.complete(); }
clearTimeout(timer);
$form_data.remove();
$target.remove();
if (remove_form && $form) { $form.remove(); }
};
var $target = $('<iframe style="display: none;">')
.attr({id: id, name: id})
.appendTo(document.body)
.load(function () {
try {
if (options.error) {
var body = this.contentDocument.body;
var nodes = body.children.length === 0 ? body.childNodes : body.children;
var node = nodes[1] || nodes[0];
options.error(JSON.parse(node.textContent));
}
} finally {
complete();
}
});
if (options.form) {
$form = $(options.form);
} else {
remove_form = true;
$form = $('<form>', {
action: options.url,
method: 'POST'
}).appendTo(document.body);
}
var hparams = _.extend({}, options.data || {}, {token: token});
_.each(hparams, function (value, key) {
var $input = $form.find('[name=' + key +']');
if (!$input.length) {
$input = $('<input type="hidden" name="' + key + '">')
.appendTo($form_data);
}
$input.val(value);
});
$form
.append($form_data)
.attr('target', id)
.get(0).submit();
var waitLoop = function () {
var cookies = document.cookie.split(';');
// setup next check
timer = setTimeout(waitLoop, CHECK_INTERVAL);
for (var i=0; i<cookies.length; ++i) {
var cookie = cookies[i].replace(/^\s*/, '');
if (!cookie.indexOf(cookie_name === 0)) { continue; }
var cookie_val = cookie.substring(cookie_length + 1);
if (parseInt(cookie_val, 10) !== token) { continue; }
// clear cookie
document.cookie = _.str.sprintf("%s=;expires=%s;path=/",
cookie_name, new Date().toGMTString());
if (options.success) { options.success(); }
complete();
return;
}
};
timer = setTimeout(waitLoop, CHECK_INTERVAL);
};
return {
jsonRpc: jsonRpc,
jsonpRpc: jsonpRpc,
loadCSS: loadCSS,
loadJS: loadJS,
get_file: get_file,
};
});
+154
View File
@@ -0,0 +1,154 @@
odoo.define('web.Class', function () {
"use strict";
/**
* Improved John Resig's inheritance, based on:
*
* Simple JavaScript Inheritance
* By John Resig http://ejohn.org/
* MIT Licensed.
*
* Adds "include()"
*
* Defines The Class object. That object can be used to define and inherit classes using
* the extend() method.
*
* Example:
*
* var Person = openerp.Class.extend({
* init: function(isDancing){
* this.dancing = isDancing;
* },
* dance: function(){
* return this.dancing;
* }
* });
*
* The init() method act as a constructor. This class can be instancied this way:
*
* var person = new Person(true);
* person.dance();
*
* The Person class can also be extended again:
*
* var Ninja = Person.extend({
* init: function(){
* this._super( false );
* },
* dance: function(){
* // Call the inherited version of dance()
* return this._super();
* },
* swingSword: function(){
* return true;
* }
* });
*
* When extending a class, each re-defined method can use this._super() to call the previous
* implementation of that method.
*/
var initializing = false;
var fnTest = /xyz/.test(function(){xyz();}) ? /\b_super\b/ : /.*/;
// The web Class implementation (does nothing)
function OdooClass(){};
/**
* Subclass an existing class
*
* @param {Object} prop class-level properties (class attributes and instance methods) to set on the new class
*/
OdooClass.extend = function() {
var _super = this.prototype;
// Support mixins arguments
var args = _.toArray(arguments);
args.unshift({});
var prop = _.extend.apply(_,args);
// Instantiate a web class (but only create the instance,
// don't run the init constructor)
initializing = true;
var This = this;
var prototype = new This();
initializing = false;
// Copy the properties over onto the new prototype
_.each(prop, function(val, name) {
// Check if we're overwriting an existing function
prototype[name] = typeof prop[name] == "function" &&
fnTest.test(prop[name]) ?
(function(name, fn) {
return function() {
var tmp = this._super;
// Add a new ._super() method that is the same
// method but on the super-class
this._super = _super[name];
// The method only need to be bound temporarily, so
// we remove it when we're done executing
var ret = fn.apply(this, arguments);
this._super = tmp;
return ret;
};
})(name, prop[name]) :
prop[name];
});
// The dummy class constructor
function Class() {
if(this.constructor !== OdooClass){
throw new Error("You can only instanciate objects with the 'new' operator");
}
// All construction is actually done in the init method
this._super = null;
if (!initializing && this.init) {
var ret = this.init.apply(this, arguments);
if (ret) { return ret; }
}
return this;
}
Class.include = function (properties) {
_.each(properties, function(val, name) {
if (typeof properties[name] !== 'function'
|| !fnTest.test(properties[name])) {
prototype[name] = properties[name];
} else if (typeof prototype[name] === 'function'
&& prototype.hasOwnProperty(name)) {
prototype[name] = (function (name, fn, previous) {
return function () {
var tmp = this._super;
this._super = previous;
var ret = fn.apply(this, arguments);
this._super = tmp;
return ret;
};
})(name, properties[name], prototype[name]);
} else if (typeof _super[name] === 'function') {
prototype[name] = (function (name, fn) {
return function () {
var tmp = this._super;
this._super = _super[name];
var ret = fn.apply(this, arguments);
this._super = tmp;
return ret;
};
})(name, properties[name]);
}
});
};
// Populate our constructed prototype object
Class.prototype = prototype;
// Enforce the constructor to be what we expect
Class.constructor = Class;
// And make this class extendable
Class.extend = this.extend;
return Class;
};
return OdooClass;
});
+191
View File
@@ -0,0 +1,191 @@
odoo.define('web.core', ['web.Class', 'web.mixins', 'web.Registry', 'web.translation', 'qweb', '_', '$'], function (require) {
"use strict";
var Class = require('web.Class');
var mixins = require('web.mixins');
var Registry = require('web.Registry');
var translation = require('web.translation');
var qweb = require('qweb');
var _ = require('_');
var $ = require('$');
var debug = $.deparam($.param.querystring()).debug !== undefined;
var _t = translation._t;
var _lt = translation._lt;
/**
* Event Bus used to bind events scoped in the current instance
*/
var Bus = Class.extend(mixins.EventDispatcherMixin, {
init: function() {
mixins.EventDispatcherMixin.init.call(this, parent);
var self = this;
// TODO fme: allow user to bind keys for some global actions.
// check gtk bindings
// http://unixpapa.com/js/key.html
_.each('click,dblclick,keydown,keypress,keyup'.split(','), function(evtype) {
$('html').on(evtype, function(ev) {
self.trigger(evtype, ev);
});
});
_.each('resize,scroll'.split(','), function(evtype) {
$(window).on(evtype, function(ev) {
self.trigger(evtype, ev);
});
});
},
});
// Underscore customization
//-------------------------------------------------------------------------
_.str.toBoolElse = function (str, elseValues, trueValues, falseValues) {
var ret = _.str.toBool(str, trueValues, falseValues);
if (_.isUndefined(ret)) {
return elseValues;
}
return ret;
};
// IE patch
//-------------------------------------------------------------------------
if (typeof(console) === "undefined") {
// Even IE9 only exposes console object if debug window opened
window.console = {};
('log error debug info warn assert clear dir dirxml trace group'
+ ' groupCollapsed groupEnd time timeEnd profile profileEnd count'
+ ' exception').split(/\s+/).forEach(function(property) {
console[property] = _.identity;
});
}
/**
Some hack to make placeholders work in ie9.
*/
if (!('placeholder' in document.createElement('input'))) {
document.addEventListener("DOMNodeInserted",function(event){
var nodename = event.target.nodeName.toLowerCase();
if ( nodename === "input" || nodename == "textarea" ) {
$(event.target).placeholder();
}
});
}
/**
* Lazy translation function, only performs the translation when actually
* printed (e.g. inserted into a template)
*
* Useful when defining translatable strings in code evaluated before the
* translation database is loaded, as class attributes or at the top-level of
* an OpenERP Web module
*
* @param {String} s string to translate
* @returns {Object} lazy translation object
*/
qweb.debug = debug;
_.extend(qweb.default_dict, {
'__debug__': debug,
'moment': function(date) { return new moment(date); },
});
qweb.preprocess_node = function() {
// Note that 'this' is the Qweb Node
switch (this.node.nodeType) {
case Node.TEXT_NODE:
case Node.CDATA_SECTION_NODE:
// Text and CDATAs
var translation = this.node.parentNode.attributes['t-translation'];
if (translation && translation.value === 'off') {
return;
}
var match = /^(\s*)([\s\S]+?)(\s*)$/.exec(this.node.data);
if (match) {
this.node.data = match[1] + _t(match[2]) + match[3];
}
break;
case Node.ELEMENT_NODE:
// Element
var attr, attrs = ['label', 'title', 'alt', 'placeholder'];
while ((attr = attrs.pop())) {
if (this.attributes[attr]) {
this.attributes[attr] = _t(this.attributes[attr]);
}
}
}
};
/** Setup jQuery timeago */
/*
* Strings in timeago are "composed" with prefixes, words and suffixes. This
* makes their detection by our translating system impossible. Use all literal
* strings we're using with a translation mark here so the extractor can do its
* job.
*/
{
_t('less than a minute ago');
_t('about a minute ago');
_t('%d minutes ago');
_t('about an hour ago');
_t('%d hours ago');
_t('a day ago');
_t('%d days ago');
_t('about a month ago');
_t('%d months ago');
_t('about a year ago');
_t('%d years ago');
}
$.async_when = function() {
var async = false;
var def = $.Deferred();
$.when.apply($, arguments).done(function() {
var args = arguments;
var action = function() {
def.resolve.apply(def, args);
};
if (async)
action();
else
setTimeout(action, 0);
}).fail(function() {
var args = arguments;
var action = function() {
def.reject.apply(def, args);
};
if (async)
action();
else
setTimeout(action, 0);
});
async = true;
return def;
};
return {
debug: debug,
qweb: qweb,
// core classes and functions
Class: Class,
mixins: mixins,
bus: new Bus (),
_t: _t,
_lt: _lt,
// registries
view_registry: new Registry(),
crash_registry: new Registry(),
action_registry : new Registry(),
form_widget_registry: new Registry(),
form_tag_registry: new Registry(),
form_custom_registry: new Registry(),
list_widget_registry: new Registry(),
search_widgets_registry: new Registry(),
search_filters_registry: new Registry(),
};
});
@@ -0,0 +1,195 @@
odoo.define('web.crash_manager', ['web.CrashManager'], function (require) {
var CrashManager = require('web.CrashManager');
return new CrashManager();
});
odoo.define('web.CrashManager', ['web.ajax', 'web.core', 'web.Dialog', 'web.session'], function (require) {
"use strict";
var ajax = require('web.ajax');
var core = require('web.core');
var Dialog = require('web.Dialog');
var session = require('web.session');
var QWeb = core.qweb;
var _t = core._t;
var _lt = core._lt;
var map_title ={
user_error: _lt('Warning'),
warning: _lt('Warning'),
access_error: _lt('Access Error'),
missing_error: _lt('Missing Record'),
validation_error: _lt('Validation Error'),
except_orm: _lt('Global Business Error'),
access_denied: _lt('Access Denied'),
};
var CrashManager = core.Class.extend({
init: function() {
this.active = true;
},
enable: function () {
this.active = true;
},
disable: function () {
this.active = false;
},
rpc_error: function(error) {
if (!this.active) {
return;
}
if (error.code == -32098) {
$.blockUI({ message: '' , overlayCSS: {'z-index': 9999, backgroundColor: '#FFFFFF', opacity: 0.0, cursor: 'wait'}});
var $indicator = $('<div class="oe_indicator">' + _t("Trying to reconnect... ") + '<i class="fa fa-refresh fa-spin"></i></div>');
$indicator.prependTo("body");
var timeinterval = setInterval(function(){
ajax.jsonRpc('/web/webclient/version_info').then(function() {
clearInterval(timeinterval);
$indicator.html(_t("You are back online"));
$indicator.delay(2000).fadeOut('slow',function(){
$indicator.remove();
});
$.unblockUI();
});
}, 2000);
return;
}
var handler = core.crash_registry.get(error.data.name, true);
if (handler) {
new (handler)(this, error).display();
return;
}
if (error.data.name === "openerp.http.SessionExpiredException" || error.data.name === "werkzeug.exceptions.Forbidden") {
this.show_warning({type: "Session Expired", data: { message: _t("Your Odoo session expired. Please refresh the current web page.") }});
return;
}
if (_.has(map_title, error.data.exception_type)) {
if(error.data.exception_type == 'except_orm'){
if(error.data.arguments[1]) {
error = _.extend({}, error,
{
data: _.extend({}, error.data,
{
message: error.data.arguments[1],
title: error.data.arguments[0] !== 'Warning' ? (" - " + error.data.arguments[0]) : '',
})
});
}
else {
error = _.extend({}, error,
{
data: _.extend({}, error.data,
{
message: error.data.arguments[0],
title: '',
})
});
}
}
else {
error = _.extend({}, error,
{
data: _.extend({}, error.data,
{
message: error.data.arguments[0],
title: map_title[error.data.exception_type] !== 'Warning' ? (" - " + map_title[error.data.exception_type]) : '',
})
});
}
this.show_warning(error);
//InternalError
} else {
this.show_error(error);
}
},
show_warning: function(error) {
if (!this.active) {
return;
}
new Dialog(this, {
size: 'medium',
title: "Odoo " + (_.str.capitalize(error.type) || _t("Warning")),
subtitle: error.data.title,
buttons: [
{text: _t("Ok"), click: function() { this.parents('.modal').modal('hide'); }}
],
}, $('<div>' + QWeb.render('CrashManager.warning', {error: error}) + '</div>')).open();
},
show_error: function(error) {
if (!this.active) {
return;
}
var buttons = {};
buttons[_t("Ok")] = function() {
this.parents('.modal').modal('hide');
};
new Dialog(this, {
title: "Odoo " + _.str.capitalize(error.type),
buttons: buttons
}, QWeb.render('CrashManager.error', {session: session, error: error})).open();
},
show_message: function(exception) {
this.show_error({
type: _t("Client Error"),
message: exception,
data: {debug: ""}
});
},
});
/**
An interface to implement to handle exceptions. Register implementation in instance.web.crash_manager_registry.
*/
var ExceptionHandler = {
/**
@param parent The parent.
@param error The error object as returned by the JSON-RPC implementation.
*/
init: function(parent, error) {},
/**
Called to inform to display the widget, if necessary. A typical way would be to implement
this interface in a class extending instance.web.Dialog and simply display the dialog in this
method.
*/
display: function() {},
};
/**
* Handle redirection warnings, which behave more or less like a regular
* warning, with an additional redirection button.
*/
var RedirectWarningHandler = Dialog.extend(ExceptionHandler, {
init: function(parent, error) {
this._super(parent);
this.error = error;
},
display: function() {
var self = this;
var error = this.error;
error.data.message = error.data.arguments[0];
new Dialog(this, {
size: 'medium',
title: "Odoo " + (_.str.capitalize(error.type) || "Warning"),
buttons: [
{text: error.data.arguments[2],
oe_link_class : 'oe_highlight',
post_text : _t("or"),
click: function() {
window.location.href='#action='+error.data.arguments[1];
self.destroy();
}},
{text: _t("Cancel"), oe_link_class: 'oe_link', click: function() { self.$el.parents('.modal').modal('hide'); self.destroy();}}
],
}, QWeb.render('CrashManager.warning', {error: error})).open();
}
});
core.crash_registry.add('openerp.exceptions.RedirectWarning', RedirectWarningHandler);
return CrashManager;
});
@@ -1,8 +1,14 @@
odoo.define('web.data', ['web.core', 'web.Model', 'web.session', 'web.pyeval'], function (require) {
"use strict";
(function() {
var core = require('web.core');
var Model = require('web.Model');
var session = require('web.session');
var pyeval = require('web.pyeval');
var instance = openerp;
openerp.web.data = {};
var Class = core.Class;
var mixins = core.mixins;
var _t = core._t;
/**
* Serializes the sort criterion array of a dataset into a form which can be
@@ -11,7 +17,7 @@ openerp.web.data = {};
* @param {Array} criterion array of fields, from first to last criteria, prefixed with '-' for reverse sorting
* @returns {String} SQL-like sorting string (``ORDER BY``) clause
*/
instance.web.serialize_sort = function (criterion) {
function serialize_sort(criterion) {
return _.map(criterion,
function (criteria) {
if (criteria[0] === '-') {
@@ -19,20 +25,20 @@ instance.web.serialize_sort = function (criterion) {
}
return criteria + ' ASC';
}).join(', ');
};
}
/**
* Reverse of the serialize_sort function: convert an array of SQL-like sort
* descriptors into a list of fields prefixed with '-' if necessary.
*/
instance.web.deserialize_sort = function (criterion) {
function deserialize_sort(criterion) {
return _.map(criterion, function (criteria) {
var split = _.without(criteria.split(' '), '');
return (split[1] && split[1].toLowerCase() === 'desc' ? '-' : '') + split[0];
});
};
}
instance.web.Query = instance.web.Class.extend({
var Query = Class.extend({
init: function (model, fields) {
this._model = model;
this._fields = fields;
@@ -45,7 +51,7 @@ instance.web.Query = instance.web.Class.extend({
},
clone: function (to_set) {
to_set = to_set || {};
var q = new instance.web.Query(this._model, this._fields);
var q = new Query(this._model, this._fields);
q._context = this._context;
q._filter = this._filter;
q._lazy = this._lazy;
@@ -57,11 +63,11 @@ instance.web.Query = instance.web.Class.extend({
if (!to_set.hasOwnProperty(key)) { continue; }
switch(key) {
case 'filter':
q._filter = new instance.web.CompoundDomain(
q._filter = new CompoundDomain(
q._filter, to_set.filter);
break;
case 'context':
q._context = new instance.web.CompoundContext(
q._context = new CompoundContext(
q._context, to_set.context);
break;
case 'lazy':
@@ -76,16 +82,16 @@ instance.web.Query = instance.web.Class.extend({
_execute: function (options) {
var self = this;
options = options || {};
return instance.session.rpc('/web/dataset/search_read', {
return session.rpc('/web/dataset/search_read', {
model: this._model.name,
fields: this._fields || false,
domain: instance.web.pyeval.eval('domains',
domain: pyeval.eval('domains',
[this._model.domain(this._filter)]),
context: instance.web.pyeval.eval('contexts',
context: pyeval.eval('contexts',
[this._model.context(this._context)]),
offset: this._offset,
limit: this._limit,
sort: instance.web.serialize_sort(this._order_by)
sort: serialize_sort(this._order_by)
}, options).then(function (results) {
self._count = results.length;
return results.records;
@@ -132,7 +138,7 @@ instance.web.Query = instance.web.Class.extend({
* @returns {jQuery.Deferred<Array<openerp.web.QueryGroup>> | null}
*/
group_by: function (grouping) {
var ctx = instance.web.pyeval.eval(
var ctx = pyeval.eval(
'context', this._model.context(this._context));
// undefined passed in explicitly (!)
@@ -143,7 +149,7 @@ instance.web.Query = instance.web.Class.extend({
if (!(grouping instanceof Array)) {
grouping = _.toArray(arguments);
}
if (_.isEmpty(grouping) && !ctx['group_by_no_leaf']) {
if (_.isEmpty(grouping) && !ctx.group_by_no_leaf) {
return null;
}
var raw_fields = _.map(grouping.concat(this._fields || []), function (field) {
@@ -159,7 +165,7 @@ instance.web.Query = instance.web.Class.extend({
offset: this._offset,
lazy: this._lazy,
limit: this._limit,
orderby: instance.web.serialize_sort(this._order_by) || false
orderby: serialize_sort(this._order_by) || false
}).then(function (results) {
return _(results).map(function (result) {
// FIX: querygroup initialization
@@ -167,7 +173,7 @@ instance.web.Query = instance.web.Class.extend({
result.__context.group_by = result.__context.group_by || [];
_.defaults(result.__context, ctx);
var grouping_fields = self._lazy ? [grouping[0]] : grouping;
return new instance.web.QueryGroup(
return new QueryGroup(
self._model.name, grouping_fields, result);
});
});
@@ -243,7 +249,7 @@ instance.web.Query = instance.web.Class.extend({
}
});
instance.web.QueryGroup = instance.web.Class.extend({
var QueryGroup = Class.extend({
init: function (model, grouping_fields, read_group_group) {
// In cases where group_by_no_leaf and no group_by, the result of
// read_group has aggregate fields but no __context or __domain.
@@ -263,7 +269,7 @@ instance.web.QueryGroup = instance.web.Class.extend({
aggregates[key] = value || 0;
});
this.model = new instance.web.Model(
this.model = new Model(
model, fixed_group.__context, fixed_group.__domain);
var group_size = fixed_group[count_key] || fixed_group.__count || 0;
@@ -282,7 +288,7 @@ instance.web.QueryGroup = instance.web.Class.extend({
length: group_size,
value: value,
// A group is open-able if it's not a leaf in group_by_no_leaf mode
has_children: !(leaf_group && fixed_group.__context['group_by_no_leaf']),
has_children: !(leaf_group && fixed_group.__context.group_by_no_leaf),
aggregates: aggregates
};
@@ -298,136 +304,7 @@ instance.web.QueryGroup = instance.web.Class.extend({
}
});
instance.web.Model.include({
/**
new openerp.web.Model([session,] model_name[, context[, domain]])
@constructs instance.web.Model
@extends instance.web.Class
@param {openerp.web.Session} [session] The session object used to communicate with
the server.
@param {String} model_name name of the OpenERP model this object is bound to
@param {Object} [context]
@param {Array} [domain]
*/
init: function() {
var session, model_name, context, domain;
var args = _.toArray(arguments);
args.reverse();
session = args.pop();
if (session && ! (session instanceof openerp.web.Session)) {
model_name = session;
session = null;
} else {
model_name = args.pop();
}
context = args.length > 0 ? args.pop() : null;
domain = args.length > 0 ? args.pop() : null;
this._super(session, model_name, context, domain);
this._context = context || {};
this._domain = domain || [];
},
session: function() {
if (! this._session)
return instance.session;
return this._super();
},
/**
* @deprecated does not allow to specify kwargs, directly use call() instead
*/
get_func: function (method_name) {
var self = this;
return function () {
return self.call(method_name, _.toArray(arguments));
};
},
/**
* Call a method (over RPC) on the bound OpenERP model.
*
* @param {String} method name of the method to call
* @param {Array} [args] positional arguments
* @param {Object} [kwargs] keyword arguments
* @param {Object} [options] additional options for the rpc() method
* @returns {jQuery.Deferred<>} call result
*/
call: function (method, args, kwargs, options) {
args = args || [];
kwargs = kwargs || {};
if (!_.isArray(args)) {
// call(method, kwargs)
kwargs = args;
args = [];
}
instance.web.pyeval.ensure_evaluated(args, kwargs);
return this._super(method, args, kwargs, options);
},
/**
* Fetches a Query instance bound to this model, for searching
*
* @param {Array<String>} [fields] fields to ultimately fetch during the search
* @returns {instance.web.Query}
*/
query: function (fields) {
return new instance.web.Query(this, fields);
},
/**
* Executes a signal on the designated workflow, on the bound OpenERP model
*
* @param {Number} id workflow identifier
* @param {String} signal signal to trigger on the workflow
*/
exec_workflow: function (id, signal) {
return this.session().rpc('/web/dataset/exec_workflow', {
model: this.name,
id: id,
signal: signal
});
},
/**
* Fetches the model's domain, combined with the provided domain if any
*
* @param {Array} [domain] to combine with the model's internal domain
* @returns {instance.web.CompoundDomain} The model's internal domain, or the AND-ed union of the model's internal domain and the provided domain
*/
domain: function (domain) {
if (!domain) { return this._domain; }
return new instance.web.CompoundDomain(
this._domain, domain);
},
/**
* Fetches the combination of the user's context and the domain context,
* combined with the provided context if any
*
* @param {Object} [context] to combine with the model's internal context
* @returns {instance.web.CompoundContext} The union of the user's context and the model's internal context, as well as the provided context if any. In that order.
*/
context: function (context) {
return new instance.web.CompoundContext(
instance.session.user_context, this._context, context || {});
},
/**
* Button action caller, needs to perform cleanup if an action is returned
* from the button (parsing of context and domain, and fixup of the views
* collection for act_window actions)
*
* FIXME: remove when evaluator integrated
*/
call_button: function (method, args) {
instance.web.pyeval.ensure_evaluated(args, {});
return this.session().rpc('/web/dataset/call_button', {
model: this.name,
method: method,
// Should not be necessary anymore. Integrate remote in this?
domain_id: null,
context_id: args.length - 1,
args: args || []
});
},
});
instance.web.DataSet = instance.web.Class.extend(instance.web.PropertiesMixin, {
var DataSet = Class.extend(mixins.PropertiesMixin, {
/**
* Collection of OpenERP records, used to share records and the current selection between views.
*
@@ -436,12 +313,12 @@ instance.web.DataSet = instance.web.Class.extend(instance.web.PropertiesMixin,
* @param {String} model the OpenERP model this dataset will manage
*/
init: function(parent, model, context) {
instance.web.PropertiesMixin.init.call(this);
mixins.PropertiesMixin.init.call(this);
this.model = model;
this.context = context || {};
this.index = null;
this._sort = [];
this._model = new instance.web.Model(model, context);
this._model = new Model(model, context);
},
previous: function () {
this.index -= 1;
@@ -500,7 +377,7 @@ instance.web.DataSet = instance.web.Class.extend(instance.web.PropertiesMixin,
if (options.check_access_rule === true){
method = 'search_read';
ids_arg = [['id', 'in', ids]];
context = new instance.web.CompoundContext(context, {active_test: false});
context = new CompoundContext(context, {active_test: false});
}
return this._model.call(method,
[ids_arg, fields || false],
@@ -689,7 +566,7 @@ instance.web.DataSet = instance.web.Class.extend(instance.web.PropertiesMixin,
*/
sort: function (field, force_reverse) {
if (!field) {
return instance.web.serialize_sort(this._sort);
return serialize_sort(this._sort);
}
var reverse = force_reverse || (this._sort[0] === field);
this._sort.splice.apply(
@@ -706,7 +583,7 @@ instance.web.DataSet = instance.web.Class.extend(instance.web.PropertiesMixin,
* Odoo's ORM (such as 'name desc', 'product_id', 'order_date asc')
*/
set_sort: function (fields_list) {
this._sort = instance.web.deserialize_sort(fields_list);
this._sort = deserialize_sort(fields_list);
},
size: function () {
return this.ids.length;
@@ -729,10 +606,10 @@ instance.web.DataSet = instance.web.Class.extend(instance.web.PropertiesMixin,
*/
resequence: function (ids, options) {
options = options || {};
return instance.session.rpc('/web/dataset/resequence', {
return session.rpc('/web/dataset/resequence', {
model: this.model,
ids: ids,
context: instance.web.pyeval.eval(
context: pyeval.eval(
'context', this.get_context(options.context)),
}).then(function (results) {
return results;
@@ -740,9 +617,8 @@ instance.web.DataSet = instance.web.Class.extend(instance.web.PropertiesMixin,
},
});
instance.web.DataSetStatic = instance.web.DataSet.extend({
var DataSetStatic = DataSet.extend({
init: function(parent, model, context, ids) {
var self = this;
this._super(parent, model, context);
// all local records
this.ids = ids || [];
@@ -770,7 +646,7 @@ instance.web.DataSetStatic = instance.web.DataSet.extend({
},
});
instance.web.DataSetSearch = instance.web.DataSet.extend({
var DataSetSearch = DataSet.extend({
/**
* @constructs instance.web.DataSetSearch
* @extends instance.web.DataSet
@@ -785,7 +661,7 @@ instance.web.DataSetSearch = instance.web.DataSet.extend({
this.domain = domain || [];
this._length = null;
this.ids = [];
this._model = new instance.web.Model(model, context, domain);
this._model = new Model(model, context, domain);
},
/**
* Read a slice of the records represented by this DataSet, based on its
@@ -833,7 +709,7 @@ instance.web.DataSetSearch = instance.web.DataSet.extend({
},
unlink: function(ids, callback, error_callback) {
var self = this;
return this._super(ids).done(function(result) {
return this._super(ids).done(function() {
self.remove_ids( ids);
self.trigger("dataset_changed", ids, callback, error_callback);
});
@@ -846,7 +722,7 @@ instance.web.DataSetSearch = instance.web.DataSet.extend({
}
});
instance.web.BufferedDataSet = instance.web.DataSetStatic.extend({
var BufferedDataSet = DataSetStatic.extend({
virtual_id_prefix: "one2many_v_id_",
debug_mode: true,
init: function() {
@@ -969,8 +845,8 @@ instance.web.BufferedDataSet = instance.web.DataSetStatic.extend({
sign = -1;
field = field.slice(1);
}
if(!a[field] && a[field] !== 0){ return sign}
if(!b[field] && b[field] !== 0){ return (sign == -1) ? 1 : -1}
if(!a[field] && a[field] !== 0){ return sign; }
if(!b[field] && b[field] !== 0){ return (sign == -1) ? 1 : -1; }
//m2o should be searched based on value[1] not based whole value(i.e. [id, value])
if(_.isArray(a[field]) && a[field].length == 2 && _.isString(a[field][1])){
return sign * compare(a[field][1], b[field][1]);
@@ -1051,9 +927,10 @@ instance.web.BufferedDataSet = instance.web.DataSetStatic.extend({
this.trigger("dataset_changed", n_ids);
},
});
instance.web.BufferedDataSet.virtual_id_regex = /^one2many_v_id_.*$/;
instance.web.ProxyDataSet = instance.web.DataSetSearch.extend({
BufferedDataSet.virtual_id_regex = /^one2many_v_id_.*$/;
var ProxyDataSet = DataSetSearch.extend({
init: function() {
this._super.apply(this, arguments);
this.create_function = null;
@@ -1099,7 +976,7 @@ instance.web.ProxyDataSet = instance.web.DataSetSearch.extend({
},
});
instance.web.CompoundContext = instance.web.Class.extend({
var CompoundContext = Class.extend({
init: function () {
this.__ref = "compound_context";
this.__contexts = [];
@@ -1121,11 +998,11 @@ instance.web.CompoundContext = instance.web.Class.extend({
return this.__eval_context;
},
eval: function() {
return instance.web.pyeval.eval('context', this, undefined, {no_user_context: true});
return pyeval.eval('context', this, undefined, {no_user_context: true});
},
});
instance.web.CompoundDomain = instance.web.Class.extend({
var CompoundDomain = Class.extend({
init: function () {
this.__ref = "compound_domain";
this.__domains = [];
@@ -1147,198 +1024,94 @@ instance.web.CompoundDomain = instance.web.Class.extend({
return this.__eval_context;
},
eval: function() {
return instance.web.pyeval.eval('domain', this);
return pyeval.eval('domain', this);
},
});
instance.web.DropMisordered = instance.web.Class.extend({
/**
* @constructs instance.web.DropMisordered
* @extends instance.web.Class
*
* @param {Boolean} [failMisordered=false] whether mis-ordered responses should be failed or just ignored
*/
init: function (failMisordered) {
// local sequence number, for requests sent
this.lsn = 0;
// remote sequence number, seqnum of last received request
this.rsn = -1;
this.failMisordered = failMisordered || false;
},
/**
* Adds a deferred (usually an async request) to the sequencer
*
* @param {$.Deferred} deferred to ensure add
* @returns {$.Deferred}
*/
add: function (deferred) {
var res = $.Deferred();
var self = this, seq = this.lsn++;
deferred.done(function () {
if (seq > self.rsn) {
self.rsn = seq;
res.resolve.apply(res, arguments);
} else if (self.failMisordered) {
res.reject();
function compute_domain (expr, fields) {
if (! (expr instanceof Array))
return !! expr;
var stack = [];
for (var i = expr.length - 1; i >= 0; i--) {
var ex = expr[i];
if (ex.length == 1) {
var top = stack.pop();
switch (ex) {
case '|':
stack.push(stack.pop() || top);
continue;
case '&':
stack.push(stack.pop() && top);
continue;
case '!':
stack.push(!top);
continue;
default:
throw new Error(_.str.sprintf(
_t("Unknown operator %s in domain %s"),
ex, JSON.stringify(expr)));
}
}).fail(function () {
res.reject.apply(res, arguments);
});
return res.promise();
}
});
instance.web.SimpleIndexedDB = instance.web.Class.extend({
/**
* A simple wrapper around IndexedDB that provides an asynchronous
* localStorage-like Key-Value api that persists between browser
* restarts.
*
* It will not work if the browser doesn't support a recent version
* of IndexedDB, and it may fail if the user refuses db access.
*
* All instances of SimpleIndexedDB will by default refer to the same
* IndexedDB database, if you want to pick another one, use the 'name'
* option on instanciation.
*/
init: function(opts){
var self = this;
var opts = opts || {};
this.indexedDB = window.indexedDB || window.mozIndexedDB || window.webkitIndexedDB || window.msIndexedDB;
this.db = undefined;
this._ready = new $.Deferred();
if(this.indexedDB && this.indexedDB.open){
var request = this.indexedDB.open( opts.name || "SimpleIndexedDB" ,1);
request.onerror = function(event){
self.db = null;
self._ready.reject(event.target.error);
};
request.onsuccess = function(event){
self.db = request.result;
self._ready.resolve();
};
request.onupgradeneeded = function(event){
self.db = event.target.result;
var objectStore = self.db.createObjectStore("keyvals", { keyPath:"key" });
self._ready.resolve();
};
}else{
this.db = null;
this._ready.reject({type:'UnknownError', message:'IndexedDB is not supported by your Browser'});
}
},
/**
* returns true if the browser supports the necessary IndexedDB API
* (but doesn't tell if the db can be created, check ready() for that)
*/
isSupportedByBrowser: function(){
return this.indexedDB && this.indexedDB.open;
},
var field = fields[ex[0]];
if (!field) {
throw new Error(_.str.sprintf(
_t("Unknown field %s in domain %s"),
ex[0], JSON.stringify(expr)));
}
var field_value = field.get_value ? field.get_value() : field.value;
var op = ex[1];
var val = ex[2];
/**
* returns a deferred that resolves when the db has been successfully
* initialized. done/failed callbacks are optional.
*/
ready: function(done,failed){
this._ready.then(done,failed);
return this._ready.promise();
},
switch (op.toLowerCase()) {
case '=':
case '==':
stack.push(_.isEqual(field_value, val));
break;
case '!=':
case '<>':
stack.push(!_.isEqual(field_value, val));
break;
case '<':
stack.push(field_value < val);
break;
case '>':
stack.push(field_value > val);
break;
case '<=':
stack.push(field_value <= val);
break;
case '>=':
stack.push(field_value >= val);
break;
case 'in':
if (!_.isArray(val)) val = [val];
stack.push(_(val).contains(field_value));
break;
case 'not in':
if (!_.isArray(val)) val = [val];
stack.push(!_(val).contains(field_value));
break;
default:
console.warn(
_t("Unsupported operator %s in domain %s"),
op, JSON.stringify(expr));
}
}
return _.all(stack, _.identity);
}
/**
* fetches the value associated to 'key' in the db. if the key doesn't
* exists, it returns undefined. The returned value is provided as a
* deferred, or as the first parameter of the optional 'done' callback.
*/
getItem: function(key,done,failed){
var self = this;
var def = new $.Deferred();
def.then(done,failed);
this._ready.then(function(){
var request = self.db.transaction(["keyvals"],"readwrite")
.objectStore("keyvals")
.get(key);
return {
Query: Query,
DataSet: DataSet,
DataSetStatic: DataSetStatic,
DataSetSearch: DataSetSearch,
BufferedDataSet: BufferedDataSet,
ProxyDataSet: ProxyDataSet,
CompoundContext: CompoundContext,
CompoundDomain: CompoundDomain,
compute_domain: compute_domain,
};
request.onsuccess = function(){
def.resolve(request.result ? request.result.value : undefined);
};
request.onerror = function(event){
def.reject(event.target.error);
};
},function(){
def.reject({type:'UnknownError', message:'Could not initialize the IndexedDB database'});
});
return def.promise();
},
/**
* Associates a value to 'key' in the db, overwriting previous value if
* necessary. Contrary to localStorage, the value is not limited to strings and
* can be any javascript object, even with cyclic references !
*
* Be sure to check for failure as the user may refuse to have data localy stored.
*/
setItem: function(key,value,done,failed){
var self = this;
var def = new $.Deferred();
def.then(done,failed);
this._ready.then(function(){
var request = self.db.transaction(["keyvals"],"readwrite")
.objectStore("keyvals")
.put( {key:key, value:value} );
request.onsuccess = function(){
def.resolve();
};
request.onerror = function(event){
def.reject(event.target.error);
};
},function(){
def.reject({type:'UnknownError', message:'Could not initialize the IndexedDB database'});
});
return def.promise();
},
/**
* Removes the value associated with 'key' from the db.
*/
removeItem: function(key,done,failed){
var self = this;
var def = new $.Deferred();
def.then(done,failed);
this._ready.then(function(){
var request = self.db.transaction(["keyvals"],"readwrite")
.objectStore("keyvals")
.delete(key);
request.onsuccess = function(){
def.resolve();
};
request.onerror = function(event){
def.reject(event.target.error);
};
},function(){
def.reject({type:'UnknownError', message:'Could not initialize the IndexedDB database'});
});
return def.promise();
},
});
})();
});
@@ -0,0 +1,194 @@
odoo.define('web.Dialog', ['web.core', 'web.Widget'], function (require) {
"use strict";
var core = require('web.core');
var Widget = require('web.Widget');
var QWeb = core.qweb;
var opened_modals = [];
/**
A useful class to handle dialogs.
Attributes:
- $buttons: A jQuery element targeting a dom part where buttons can be added. It always exists
during the lifecycle of the dialog.
*/
var Dialog = Widget.extend({
dialog_title: "",
/**
Constructor.
@param {Widget} parent
@param {dictionary} options A dictionary that will be forwarded to jQueryUI Dialog. Additionaly, that
dictionary can contain the following keys:
- size: one of the following: 'large', 'medium', 'small'
- dialogClass: class to add to the body of dialog
- buttons: Deprecated. The buttons key is not propagated to jQueryUI Dialog. It must be a dictionary (key = button
label, value = click handler) or a list of dictionaries (each element in the dictionary is send to the
corresponding method of a jQuery element targeting the <button> tag). It is deprecated because all dialogs
in OpenERP must be personalized in some way (button in red, link instead of button, ...) and this
feature does not allow that kind of personalization.
- destroy_on_close: Default true. If true and the dialog is closed, it is automatically destroyed.
@param {jQuery object} content Some content to replace this.$el .
*/
init: function (parent, options, content) {
this._super(parent);
this.content_to_set = content;
this.dialog_options = {
destroy_on_close: true,
size: 'large', //'medium', 'small'
buttons: null,
};
if (options) {
_.extend(this.dialog_options, options);
}
this.on("closing", this, this._closing);
this.$buttons = $('<div class="modal-footer"><span class="oe_dialog_custom_buttons"/></div>');
},
renderElement: function() {
if (this.content_to_set) {
this.setElement(this.content_to_set);
} else if (this.template) {
this._super();
}
},
/**
Opens the popup. Inits the dialog if it is not already inited.
@return this
*/
open: function() {
if (!this.dialog_inited) {
this.init_dialog();
}
this.$buttons.insertAfter(this.$dialog_box.find(".modal-body"));
$('.tooltip').remove(); //remove open tooltip if any to prevent them staying when modal is opened
//add to list of currently opened modal
opened_modals.push(this.$dialog_box);
return this;
},
_add_buttons: function(buttons) {
var self = this;
var $customButons = this.$buttons.find('.oe_dialog_custom_buttons').empty();
_.each(buttons, function(fn, text) {
// buttons can be object or array
var pre_text = fn.pre_text || "";
var post_text = fn.post_text || "";
var oe_link_class = fn.oe_link_class;
if (!_.isFunction(fn)) {
text = fn.text;
fn = fn.click;
}
var $but = $(QWeb.render('WidgetButton', { widget : { pre_text: pre_text, post_text: post_text, string: text, node: { attrs: {'class': oe_link_class} }}}));
$customButons.append($but);
$but.filter('button').on('click', function(ev) {
fn.call(self.$el, ev);
});
});
},
/**
Initializes the popup.
@return The result returned by start().
*/
init_dialog: function() {
var self = this;
var options = _.extend({}, this.dialog_options);
options.title = options.title || this.dialog_title;
if (options.buttons) {
this._add_buttons(options.buttons);
delete(options.buttons);
}
this.renderElement();
this.$dialog_box = $(QWeb.render('Dialog', options)).appendTo("body");
this.$el.modal({
'backdrop': false,
'keyboard': true,
});
if (options.size !== 'large'){
var dialog_class_size = this.$dialog_box.find('.modal-lg').removeClass('modal-lg');
if (options.size === 'small'){
dialog_class_size.addClass('modal-sm');
}
}
this.$el.appendTo(this.$dialog_box.find(".modal-body"));
var $dialog_content = this.$dialog_box.find('.modal-content');
if (options.dialogClass){
$dialog_content.find(".modal-body").addClass(options.dialogClass);
}
$dialog_content.openerpClass();
this.$dialog_box.on('hidden.bs.modal', this, function() {
self.close();
});
this.$dialog_box.modal('show');
this.dialog_inited = true;
var res = this.start();
return res;
},
/**
Closes (hide) the popup, if destroy_on_close was passed to the constructor, it will be destroyed instead.
*/
close: function(reason) {
if (this.dialog_inited && !this.__tmp_dialog_hiding) {
$('.tooltip').remove(); //remove open tooltip if any to prevent them staying when modal has disappeared
if (this.$el.is(":data(bs.modal)")) { // may have been destroyed by closing signal
this.__tmp_dialog_hiding = true;
this.$dialog_box.modal('hide');
this.__tmp_dialog_hiding = undefined;
}
this.trigger("closing", reason);
}
},
_closing: function() {
if (this.__tmp_dialog_destroying)
return;
if (this.dialog_options.destroy_on_close) {
this.__tmp_dialog_closing = true;
this.destroy();
this.__tmp_dialog_closing = undefined;
}
},
/**
Destroys the popup, also closes it.
*/
destroy: function (reason) {
this.$buttons.remove();
_.each(this.getChildren(), function(el) {
el.destroy();
});
if (! this.__tmp_dialog_closing) {
this.__tmp_dialog_destroying = true;
this.close(reason);
this.__tmp_dialog_destroying = undefined;
}
if (this.dialog_inited && !this.isDestroyed() && this.$el.is(":data(bs.modal)")) {
//we need this to put the instruction to remove modal from DOM at the end
//of the queue, otherwise it might already have been removed before the modal-backdrop
//is removed when pressing escape key
var $element = this.$dialog_box;
setTimeout(function () {
//remove modal from list of opened modal since we just destroy it
var modal_list_index = $.inArray($element, opened_modals);
if (modal_list_index > -1){
opened_modals.splice(modal_list_index,1)[0].remove();
}
if (opened_modals.length > 0){
//we still have other opened modal so we should focus it
opened_modals[opened_modals.length-1].focus();
//keep class modal-open (deleted by bootstrap hide fnct) on body
//to allow scrolling inside the modal
$('body').addClass('modal-open');
}
},0);
}
this._super();
}
});
return Dialog;
});
@@ -0,0 +1,189 @@
odoo.define('web.formats', ['web.core', 'web.time', 'web.utils'], function (require) {
"use strict";
var core = require('web.core');
var time = require('web.time');
var utils = require('web.utils');
var _t = core._t;
/**
* Formats a single atomic value based on a field descriptor
*
* @param {Object} value read from OpenERP
* @param {Object} descriptor union of orm field and view field
* @param {Object} [descriptor.widget] widget to use to display the value
* @param {Object} descriptor.type fallback if no widget is provided, or if the provided widget is unknown
* @param {Object} [descriptor.digits] used for the formatting of floats
* @param {String} [value_if_empty=''] returned if the ``value`` argument is considered empty
*/
function format_value (value, descriptor, value_if_empty) {
var l10n = _t.database.parameters;
var date_format = time.strftime_to_moment_format(l10n.date_format);
var time_format = time.strftime_to_moment_format(l10n.time_format);
// If NaN value, display as with a `false` (empty cell)
if (typeof value === 'number' && isNaN(value)) {
value = false;
}
//noinspection FallthroughInSwitchStatementJS
switch (value) {
case '':
if (descriptor.type === 'char' || descriptor.type === 'text') {
return '';
}
console.warn('Field', descriptor, 'had an empty string as value, treating as false...');
return value_if_empty === undefined ? '' : value_if_empty;
case false:
case undefined:
case Infinity:
case -Infinity:
return value_if_empty === undefined ? '' : value_if_empty;
}
switch (descriptor.widget || descriptor.type || (descriptor.field && descriptor.field.type)) {
case 'id':
return value.toString();
case 'integer':
return utils.insert_thousand_seps(
_.str.sprintf('%d', value));
case 'float':
var digits = descriptor.digits ? descriptor.digits : [69,2];
digits = typeof digits === "string" ? py.eval(digits) : digits;
var precision = digits[1];
var formatted = _.str.sprintf('%.' + precision + 'f', value).split('.');
formatted[0] = utils.insert_thousand_seps(formatted[0]);
return formatted.join(l10n.decimal_point);
case 'float_time':
var pattern = '%02d:%02d';
if (value < 0) {
value = Math.abs(value);
pattern = '-' + pattern;
}
var hour = Math.floor(value);
var min = Math.round((value % 1) * 60);
if (min == 60){
min = 0;
hour = hour + 1;
}
return _.str.sprintf(pattern, hour, min);
case 'many2one':
// name_get value format
return value[1] ? value[1].split("\n")[0] : value[1];
case 'one2many':
case 'many2many':
if (typeof value === 'string') {
return value;
}
return _.str.sprintf(_t("(%d records)"), value.length);
case 'datetime':
if (typeof(value) == "string")
value = moment(time.auto_str_to_date(value));
else {
value = moment(value);
}
return value.format(date_format + ' ' + time_format);
case 'date':
if (typeof(value) == "string")
value = moment(time.auto_str_to_date(value));
else {
value = moment(value);
}
return value.format(date_format);
case 'time':
if (typeof(value) == "string")
value = moment(time.auto_str_to_date(value));
else {
value = moment(value);
}
return value.format(time_format);
case 'selection': case 'statusbar':
// Each choice is [value, label]
if(_.isArray(value)) {
return value[1];
}
var result = _(descriptor.selection).detect(function (choice) {
return choice[0] === value;
});
if (result) { return result[1]; }
return;
default:
return value;
}
}
function parse_value (value, descriptor, value_if_empty) {
var date_pattern = time.strftime_to_moment_format(_t.database.parameters.date_format),
time_pattern = time.strftime_to_moment_format(_t.database.parameters.time_format);
var date_pattern_wo_zero = date_pattern.replace('MM','M').replace('DD','D'),
time_pattern_wo_zero = time_pattern.replace('HH','H').replace('mm','m').replace('ss','s');
switch (value) {
case false:
case "":
return value_if_empty === undefined ? false : value_if_empty;
}
var tmp;
switch (descriptor.widget || descriptor.type || (descriptor.field && descriptor.field.type)) {
case 'integer':
do {
tmp = value;
value = value.replace(_t.database.parameters.thousands_sep, "");
} while(tmp !== value);
tmp = Number(value);
// do not accept not numbers or float values
if (isNaN(tmp) || tmp % 1)
throw new Error(_.str.sprintf(_t("'%s' is not a correct integer"), value));
return tmp;
case 'float':
tmp = Number(value);
if (!isNaN(tmp))
return tmp;
var tmp2 = value;
do {
tmp = tmp2;
tmp2 = tmp.replace(_t.database.parameters.thousands_sep, "");
} while(tmp !== tmp2);
var reformatted_value = tmp.replace(_t.database.parameters.decimal_point, ".");
var parsed = Number(reformatted_value);
if (isNaN(parsed))
throw new Error(_.str.sprintf(_t("'%s' is not a correct float"), value));
return parsed;
case 'float_time':
var factor = 1;
if (value[0] === '-') {
value = value.slice(1);
factor = -1;
}
var float_time_pair = value.split(":");
if (float_time_pair.length != 2)
return factor * parse_value(value, {type: "float"});
var hours = parse_value(float_time_pair[0], {type: "integer"});
var minutes = parse_value(float_time_pair[1], {type: "integer"});
return factor * (hours + (minutes / 60));
case 'progressbar':
return parse_value(value, {type: "float"});
case 'datetime':
var datetime = moment(value, [date_pattern + ' ' + time_pattern, date_pattern_wo_zero + ' ' + time_pattern_wo_zero], true);
if (datetime.isValid())
return time.datetime_to_str(datetime.toDate());
throw new Error(_.str.sprintf(_t("'%s' is not a correct datetime"), value));
case 'date':
var date = moment(value, [date_pattern, date_pattern_wo_zero], true);
if (date.isValid())
return time.date_to_str(date.toDate());
throw new Error(_.str.sprintf(_t("'%s' is not a correct date"), value));
case 'time':
var _time = moment(value, [time_pattern, time_pattern_wo_zero], true);
if (_time.isValid())
return time.time_to_str(_time.toDate());
throw new Error(_.str.sprintf(_t("'%s' is not a correct time"), value));
}
return value;
}
return {
format_value: format_value,
parse_value: parse_value,
};
});
+339
View File
@@ -0,0 +1,339 @@
odoo.define('web.framework', ['web.core', 'web.crash_manager', 'web.session', 'web.utils', 'web.Widget'], function (require) {
"use strict";
var core = require('web.core');
var crash_manager = require('web.crash_manager');
var session = require('web.session');
var utils = require('web.utils');
var Widget = require('web.Widget');
var _t = core._t;
var Spinner = window.Spinner;
var messages_by_seconds = function() {
return [
[0, _t("Loading...")],
[20, _t("Still loading...")],
[60, _t("Still loading...<br />Please be patient.")],
[120, _t("Don't leave yet,<br />it's still loading...")],
[300, _t("You may not believe it,<br />but the application is actually loading...")],
[420, _t("Take a minute to get a coffee,<br />because it's loading...")],
[3600, _t("Maybe you should consider reloading the application by pressing F5...")]
];
};
var Throbber = Widget.extend({
template: "Throbber",
start: function() {
var opts = {
lines: 13, // The number of lines to draw
length: 7, // The length of each line
width: 4, // The line thickness
radius: 10, // The radius of the inner circle
rotate: 0, // The rotation offset
color: '#FFF', // #rgb or #rrggbb
speed: 1, // Rounds per second
trail: 60, // Afterglow percentage
shadow: false, // Whether to render a shadow
hwaccel: false, // Whether to use hardware acceleration
className: 'spinner', // The CSS class to assign to the spinner
zIndex: 2e9, // The z-index (defaults to 2000000000)
top: 'auto', // Top position relative to parent in px
left: 'auto' // Left position relative to parent in px
};
this.spin = new Spinner(opts).spin(this.$el[0]);
this.start_time = new Date().getTime();
this.act_message();
},
act_message: function() {
var self = this;
setTimeout(function() {
if (self.isDestroyed())
return;
var seconds = (new Date().getTime() - self.start_time) / 1000;
var mes;
_.each(messages_by_seconds(), function(el) {
if (seconds >= el[0])
mes = el[1];
});
self.$(".oe_throbber_message").html(mes);
self.act_message();
}, 1000);
},
destroy: function() {
if (this.spin)
this.spin.stop();
this._super();
},
});
// special tweak for the web client
var old_async_when = $.async_when;
$.async_when = function() {
if (session.synch)
return $.when.apply(this, arguments);
else
return old_async_when.apply(this, arguments);
};
/** Setup blockui */
if ($.blockUI) {
$.blockUI.defaults.baseZ = 1100;
$.blockUI.defaults.message = '<div class="openerp oe_blockui_spin_container" style="background-color: transparent;">';
$.blockUI.defaults.css.border = '0';
$.blockUI.defaults.css["background-color"] = '';
}
var throbbers = [];
function blockUI () {
var tmp = $.blockUI.apply($, arguments);
var throbber = new Throbber();
throbbers.push(throbber);
throbber.appendTo($(".oe_blockui_spin_container"));
return tmp;
}
function unblockUI () {
_.invoke(throbbers, 'destroy');
throbbers = [];
return $.unblockUI.apply($, arguments);
}
/**
* Redirect to url by replacing window.location
* If wait is true, sleep 1s and wait for the server i.e. after a restart.
*/
function redirect (url, wait) {
// Dont display a dialog if some xmlhttprequest are in progress
crash_manager.disable();
var load = function() {
var old = "" + window.location;
var old_no_hash = old.split("#")[0];
var url_no_hash = url.split("#")[0];
location.assign(url);
if (old_no_hash === url_no_hash) {
location.reload(true);
}
};
var wait_server = function() {
session.rpc("/web/webclient/version_info", {}).done(load).fail(function() {
setTimeout(wait_server, 250);
});
};
if (wait) {
setTimeout(wait_server, 1000);
} else {
load();
}
}
// * Client action to reload the whole interface.
// * If params.menu_id, it opens the given menu entry.
// * If params.wait, reload will wait the openerp server to be reachable before reloading
function Reload(parent, action) {
var params = action.params || {};
var menu_id = params.menu_id || false;
var l = window.location;
var sobj = $.deparam(l.search.substr(1));
if (params.url_search) {
sobj = _.extend(sobj, params.url_search);
}
var search = '?' + $.param(sobj);
var hash = l.hash;
if (menu_id) {
hash = "#menu_id=" + menu_id;
}
var url = l.protocol + "//" + l.host + l.pathname + search + hash;
redirect(url, params.wait);
}
core.action_registry.add("reload", Reload);
/**
* Client action to go back home.
*/
function Home (parent, action) {
var url = '/' + (window.location.search || '');
redirect(url, action && action.params && action.params.wait);
}
core.action_registry.add("home", Home);
/**
* Client action to go back in breadcrumb history.
* If can't go back in history stack, will go back to home.
*/
function HistoryBack (parent) {
parent.history_back().fail(function () {
Home(parent);
});
}
core.action_registry.add("history_back", HistoryBack);
function login() {
redirect('/web/login');
}
core.action_registry.add("login", login);
function logout() {
redirect('/web/session/logout');
}
core.action_registry.add("logout", logout);
/**
* Client action to refresh the session context (making sure
* HTTP requests will have the right one) then reload the
* whole interface.
*/
function ReloadContext (parent, action) {
// side-effect of get_session_info is to refresh the session context
session.rpc("/web/session/get_session_info", {}).then(function() {
Reload(parent, action);
});
}
core.action_registry.add("reload_context", ReloadContext);
// nvd3 customization
//-------------------------------------------------------------------------
nv.dev = false; // sets nvd3 library in production mode
// monkey patch nvd3 to allow removing eventhandler on windowresize events
// see https://github.com/novus/nvd3/pull/396 for more details
// Adds a resize listener to the window.
nv.utils.onWindowResize = function(fun) {
if (fun === null) return;
window.addEventListener('resize', fun);
};
// Backwards compatibility with current API.
nv.utils.windowResize = nv.utils.onWindowResize;
// Removes a resize listener from the window.
nv.utils.offWindowResize = function(fun) {
if (fun === null) return;
window.removeEventListener('resize', fun);
};
// monkey patch nvd3 to prevent crashes when user changes view and nvd3 tries
// to remove tooltips after 500 ms... seriously nvd3, what were you thinking?
nv.tooltip.cleanup = function () {
$('.nvtooltip').remove();
};
// Bootstrap customization
//-------------------------------------------------------------------------
/* Bootstrap defaults overwrite */
$.fn.tooltip.Constructor.DEFAULTS.placement = 'auto top';
$.fn.tooltip.Constructor.DEFAULTS.html = true;
$.fn.tooltip.Constructor.DEFAULTS.trigger = 'hover focus click';
$.fn.tooltip.Constructor.DEFAULTS.container = 'body';
//overwrite bootstrap tooltip method to prevent showing 2 tooltip at the same time
var bootstrap_show_function = $.fn.tooltip.Constructor.prototype.show;
$.fn.modal.Constructor.prototype.enforceFocus = function () { };
$.fn.tooltip.Constructor.prototype.show = function () {
$('.tooltip').remove();
//the following fix the bug when using placement
//auto and the parent element does not exist anymore resulting in
//an error. This should be remove once we updade bootstrap to a version that fix the bug
//edit: bug has been fixed here : https://github.com/twbs/bootstrap/pull/13752
var e = $.Event('show.bs.' + this.type);
var inDom = $.contains(document.documentElement, this.$element[0]);
if (e.isDefaultPrevented() || !inDom) return;
return bootstrap_show_function.call(this);
};
// jquery customization
//-------------------------------------------------------------------------
jQuery.expr[":"].Contains = jQuery.expr.createPseudo(function(arg) {
return function( elem ) {
return jQuery(elem).text().toUpperCase().indexOf(arg.toUpperCase()) >= 0;
};
});
/** Custom jQuery plugins */
$.fn.getAttributes = function() {
var o = {};
if (this.length) {
for (var attr, i = 0, attrs = this[0].attributes, l = attrs.length; i < l; i++) {
attr = attrs.item(i);
o[attr.nodeName] = attr.value;
}
}
return o;
};
$.fn.openerpClass = function(additionalClass) {
// This plugin should be applied on top level elements
additionalClass = additionalClass || '';
if (!!$.browser.msie) {
additionalClass += ' openerp_ie';
}
return this.each(function() {
$(this).addClass('openerp ' + additionalClass);
});
};
$.fn.openerpBounce = function() {
return this.each(function() {
$(this).css('box-sizing', 'content-box').effect('bounce', {distance: 18, times: 5}, 250);
});
};
// jquery autocomplete tweak to allow html and classnames
var proto = $.ui.autocomplete.prototype,
initSource = proto._initSource;
function filter( array, term ) {
var matcher = new RegExp( $.ui.autocomplete.escapeRegex(term), "i" );
return $.grep( array, function(value_) {
return matcher.test( $( "<div>" ).html( value_.label || value_.value || value_ ).text() );
});
}
$.extend( proto, {
_initSource: function() {
if ( this.options.html && $.isArray(this.options.source) ) {
this.source = function( request, response ) {
response( filter( this.options.source, request.term ) );
};
} else {
initSource.call( this );
}
},
_renderItem: function( ul, item) {
return $( "<li></li>" )
.data( "item.autocomplete", item )
.append( $( "<a></a>" )[ this.options.html ? "html" : "text" ]( item.label ) )
.appendTo( ul )
.addClass(item.classname);
}
});
return {
blockUI: blockUI,
unblockUI: unblockUI,
redirect: redirect,
};
});
odoo.define('web.session', ['web.Session'], function (require) {
var Session = require('web.Session');
var modules = odoo._modules;
return new Session(undefined, undefined, {modules:modules});
});
@@ -0,0 +1,328 @@
odoo.define('web.mixins', ['web.Class', 'web.utils'], function (require) {
"use strict";
var Class = require('web.Class');
var utils = require('web.utils');
/**
* Mixin to structure objects' life-cycles folowing a parent-children
* relationship. Each object can a have a parent and multiple children.
* When an object is destroyed, all its children are destroyed too releasing
* any resource they could have reserved before.
*/
var ParentedMixin = {
__parentedMixin : true,
init: function() {
this.__parentedDestroyed = false;
this.__parentedChildren = [];
this.__parentedParent = null;
},
/**
* Set the parent of the current object. When calling this method, the
* parent will also be informed and will return the current object
* when its getChildren() method is called. If the current object did
* already have a parent, it is unregistered before, which means the
* previous parent will not return the current object anymore when its
* getChildren() method is called.
*/
setParent : function(parent) {
if (this.getParent()) {
if (this.getParent().__parentedMixin) {
this.getParent().__parentedChildren = _.without(this
.getParent().getChildren(), this);
}
}
this.__parentedParent = parent;
if (parent && parent.__parentedMixin) {
parent.__parentedChildren.push(this);
}
},
/**
* Return the current parent of the object (or null).
*/
getParent : function() {
return this.__parentedParent;
},
/**
* Return a list of the children of the current object.
*/
getChildren : function() {
return _.clone(this.__parentedChildren);
},
/**
* Returns true if destroy() was called on the current object.
*/
isDestroyed : function() {
return this.__parentedDestroyed;
},
/**
Utility method to only execute asynchronous actions if the current
object has not been destroyed.
@param {$.Deferred} promise The promise representing the asynchronous
action.
@param {bool} [reject=false] If true, the returned promise will be
rejected with no arguments if the current
object is destroyed. If false, the
returned promise will never be resolved
or rejected.
@returns {$.Deferred} A promise that will mirror the given promise if
everything goes fine but will either be rejected
with no arguments or never resolved if the
current object is destroyed.
*/
alive: function(promise, reject) {
var self = this;
return $.Deferred(function (def) {
promise.then(function () {
if (!self.isDestroyed()) {
def.resolve.apply(def, arguments);
}
}, function () {
if (!self.isDestroyed()) {
def.reject.apply(def, arguments);
}
}).always(function () {
if (reject) {
// noop if def already resolved or rejected
def.reject();
}
// otherwise leave promise in limbo
});
}).promise();
},
/**
* Inform the object it should destroy itself, releasing any
* resource it could have reserved.
*/
destroy : function() {
_.each(this.getChildren(), function(el) {
el.destroy();
});
this.setParent(undefined);
this.__parentedDestroyed = true;
},
/**
* Find the closest ancestor matching predicate
*/
findAncestor: function (predicate) {
var ancestor = this;
while (ancestor && !(predicate(ancestor)) && ancestor.getParent) {
ancestor = ancestor.getParent();
}
return ancestor;
},
};
/**
* Backbone's events. Do not ever use it directly, use EventDispatcherMixin instead.
*
* This class just handle the dispatching of events, it is not meant to be extended,
* nor used directly. All integration with parenting and automatic unregistration of
* events is done in EventDispatcherMixin.
*
* Copyright notice for the following Class:
*
* (c) 2010-2012 Jeremy Ashkenas, DocumentCloud Inc.
* Backbone may be freely distributed under the MIT license.
* For all details and documentation:
* http://backbonejs.org
*
*/
var Events = Class.extend({
on : function(events, callback, context) {
var ev;
events = events.split(/\s+/);
var calls = this._callbacks || (this._callbacks = {});
while ((ev = events.shift())) {
var list = calls[ev] || (calls[ev] = {});
var tail = list.tail || (list.tail = list.next = {});
tail.callback = callback;
tail.context = context;
list.tail = tail.next = {};
}
return this;
},
off : function(events, callback, context) {
var ev, calls, node;
if (!events) {
delete this._callbacks;
} else if ((calls = this._callbacks)) {
events = events.split(/\s+/);
while ((ev = events.shift())) {
node = calls[ev];
delete calls[ev];
if (!callback || !node)
continue;
while ((node = node.next) && node.next) {
if (node.callback === callback
&& (!context || node.context === context))
continue;
this.on(ev, node.callback, node.context);
}
}
}
return this;
},
callbackList: function() {
var lst = [];
_.each(this._callbacks || {}, function(el, eventName) {
var node = el;
while ((node = node.next) && node.next) {
lst.push([eventName, node.callback, node.context]);
}
});
return lst;
},
trigger : function(events) {
var event, node, calls, tail, args, all, rest;
if (!(calls = this._callbacks))
return this;
all = calls.all;
(events = events.split(/\s+/)).push(null);
// Save references to the current heads & tails.
while ((event = events.shift())) {
if (all)
events.push({
next : all.next,
tail : all.tail,
event : event
});
if (!(node = calls[event]))
continue;
events.push({
next : node.next,
tail : node.tail
});
}
rest = Array.prototype.slice.call(arguments, 1);
while ((node = events.pop())) {
tail = node.tail;
args = node.event ? [ node.event ].concat(rest) : rest;
while ((node = node.next) !== tail) {
node.callback.apply(node.context || this, args);
}
}
return this;
}
});
/**
Mixin containing an event system. Events are also registered by specifying the target object
(the object which will receive the event when it is raised). Both the event-emitting object
and the target object store or reference to each other. This is used to correctly remove all
reference to the event handler when any of the object is destroyed (when the destroy() method
from ParentedMixin is called). Removing those references is necessary to avoid memory leak
and phantom events (events which are raised and sent to a previously destroyed object).
*/
var EventDispatcherMixin = _.extend({}, ParentedMixin, {
__eventDispatcherMixin: true,
init: function() {
ParentedMixin.init.call(this);
this.__edispatcherEvents = new Events();
this.__edispatcherRegisteredEvents = [];
},
on: function(events, dest, func) {
var self = this;
if (typeof func !== "function") {
throw new Error("Event handler must be a function.");
}
events = events.split(/\s+/);
_.each(events, function(eventName) {
self.__edispatcherEvents.on(eventName, func, dest);
if (dest && dest.__eventDispatcherMixin) {
dest.__edispatcherRegisteredEvents.push({name: eventName, func: func, source: self});
}
});
return this;
},
off: function(events, dest, func) {
var self = this;
events = events.split(/\s+/);
_.each(events, function(eventName) {
self.__edispatcherEvents.off(eventName, func, dest);
if (dest && dest.__eventDispatcherMixin) {
dest.__edispatcherRegisteredEvents = _.filter(dest.__edispatcherRegisteredEvents, function(el) {
return !(el.name === eventName && el.func === func && el.source === self);
});
}
});
return this;
},
trigger: function() {
this.__edispatcherEvents.trigger.apply(this.__edispatcherEvents, arguments);
return this;
},
destroy: function() {
var self = this;
_.each(this.__edispatcherRegisteredEvents, function(event) {
event.source.__edispatcherEvents.off(event.name, event.func, self);
});
this.__edispatcherRegisteredEvents = [];
_.each(this.__edispatcherEvents.callbackList(), function(cal) {
this.off(cal[0], cal[2], cal[1]);
}, this);
this.__edispatcherEvents.off();
ParentedMixin.destroy.call(this);
}
});
var PropertiesMixin = _.extend({}, EventDispatcherMixin, {
init: function() {
EventDispatcherMixin.init.call(this);
this.__getterSetterInternalMap = {};
},
set: function(arg1, arg2, arg3) {
var map;
var options;
if (typeof arg1 === "string") {
map = {};
map[arg1] = arg2;
options = arg3 || {};
} else {
map = arg1;
options = arg2 || {};
}
var self = this;
var changed = false;
_.each(map, function(val, key) {
var tmp = self.__getterSetterInternalMap[key];
if (tmp === val)
return;
// seriously, why are you doing this? it is obviously a stupid design.
// the properties mixin should not be concerned with handling fields details.
// this also has the side effect of introducing a dependency on utils. Todo:
// remove this, or move it elsewhere. Also, learn OO programming.
if (key === 'value' && self.field && self.field.type === 'float' && tmp && val){
var precision = self.field.digits ? self.field.digits[1] : 2;
if (utils.float_is_zero(tmp - val, precision)) {
return;
}
}
changed = true;
self.__getterSetterInternalMap[key] = val;
if (! options.silent)
self.trigger("change:" + key, self, {
oldValue: tmp,
newValue: val
});
});
if (changed)
self.trigger("change", self);
},
get: function(key) {
return this.__getterSetterInternalMap[key];
}
});
return {
ParentedMixin: ParentedMixin,
EventDispatcherMixin: EventDispatcherMixin,
PropertiesMixin: PropertiesMixin,
};
});
+191
View File
@@ -0,0 +1,191 @@
odoo.define('web.Model', ['web.Class', 'web.session', 'web.utils'], function (require) {
"use strict";
var Class = require('web.Class');
var session = require('web.session');
var utils = require('web.utils');
var Model = Class.extend({
/**
new openerp.web.Model([session,] model_name[, context[, domain]])
@constructs instance.web.Model
@extends instance.web.Class
@param {openerp.web.Session} [session] The session object used to communicate with
the server.
@param {String} model_name name of the OpenERP model this object is bound to
@param {Object} [context]
@param {Array} [domain]
*/
init: function(name, context, domain) {
this.name = name;
this._context = context || {};
this._domain = domain || [];
},
/**
* @deprecated does not allow to specify kwargs, directly use call() instead
*/
get_func: function (method_name) {
var self = this;
return function () {
return self.call(method_name, _.toArray(arguments));
};
},
/**
* Call a method (over RPC) on the bound OpenERP model.
*
* @param {String} method name of the method to call
* @param {Array} [args] positional arguments
* @param {Object} [kwargs] keyword arguments
* @param {Object} [options] additional options for the rpc() method
* @returns {jQuery.Deferred<>} call result
*/
call: function (method, args, kwargs, options) {
args = args || [];
kwargs = kwargs || {};
if (!_.isArray(args)) {
// call(method, kwargs)
kwargs = args;
args = [];
}
var call_kw = '/web/dataset/call_kw/' + this.name + '/' + method;
return session.rpc(call_kw, {
model: this.name,
method: method,
args: args,
kwargs: kwargs
}, options);
},
/**
* Executes a signal on the designated workflow, on the bound OpenERP model
*
* @param {Number} id workflow identifier
* @param {String} signal signal to trigger on the workflow
*/
exec_workflow: function (id, signal) {
return session.rpc('/web/dataset/exec_workflow', {
model: this.name,
id: id,
signal: signal
});
},
/**
* Performs a fields_view_get and apply postprocessing.
* return a {$.Deferred} resolved with the fvg
*
* @param {Object} args
* @param {String|Object} args.model instance.web.Model instance or string repr of the model
* @param {Object} [args.context] context if args.model is a string
* @param {Number} [args.view_id] id of the view to be loaded, default view if null
* @param {String} [args.view_type] type of view to be loaded if view_id is null
* @param {Boolean} [args.toolbar=false] get the toolbar definition
*/
fields_view_get: function (options) {
return this.call('fields_view_get', {
view_id: options.view_id,
view_type: options.view_type,
context: options.context,
toolbar: options.toolbar || false
}).then(function postprocess(fvg) {
var doc = $.parseXML(fvg.arch).documentElement;
fvg.arch = utils.xml_to_json(doc, (doc.nodeName.toLowerCase() !== 'kanban'));
if ('id' in fvg.fields) {
// Special case for id's
var id_field = fvg.fields.id;
id_field.original_type = id_field.type;
id_field.type = 'id';
}
_.each(fvg.fields, function(field) {
_.each(field.views || {}, function(view) {
postprocess(view);
});
});
return fvg;
});
}
});
return Model;
});
odoo.define('web.DataModel', ['web.data', 'web.Model', 'web.pyeval', 'web.session'], function (require) {
"use strict";
var data = require('web.data');
var Model = require('web.Model');
var pyeval = require('web.pyeval');
var session = require('web.session');
Model.include({
/**
* Fetches a Query instance bound to this model, for searching
*
* @param {Array<String>} [fields] fields to ultimately fetch during the search
* @returns {instance.web.Query}
*/
query: function (fields) {
return new data.Query(this, fields);
},
/**
* Fetches the model's domain, combined with the provided domain if any
*
* @param {Array} [domain] to combine with the model's internal domain
* @returns {instance.web.CompoundDomain} The model's internal domain, or the AND-ed union of the model's internal domain and the provided domain
*/
domain: function (domain) {
if (!domain) { return this._domain; }
return new data.CompoundDomain(this._domain, domain);
},
/**
* Fetches the combination of the user's context and the domain context,
* combined with the provided context if any
*
* @param {Object} [context] to combine with the model's internal context
* @returns {instance.web.CompoundContext} The union of the user's context and the model's internal context, as well as the provided context if any. In that order.
*/
context: function (context) {
return new data.CompoundContext(session.user_context, this._context, context || {});
},
/**
* Call a method (over RPC) on the bound OpenERP model.
*
* @param {String} method name of the method to call
* @param {Array} [args] positional arguments
* @param {Object} [kwargs] keyword arguments
* @param {Object} [options] additional options for the rpc() method
* @returns {jQuery.Deferred<>} call result
*/
call: function (method, args, kwargs, options) {
args = args || [];
kwargs = kwargs || {};
if (!_.isArray(args)) {
// call(method, kwargs)
kwargs = args;
args = [];
}
pyeval.ensure_evaluated(args, kwargs);
var call_kw = '/web/dataset/call_kw/' + this.name + '/' + method;
return session.rpc(call_kw, {
model: this.name,
method: method,
args: args,
kwargs: kwargs
}, options);
},
call_button: function (method, args) {
pyeval.ensure_evaluated(args, {});
return session.rpc('/web/dataset/call_button', {
model: this.name,
method: method,
// Should not be necessary anymore. Integrate remote in this?
domain_id: null,
context_id: args.length - 1,
args: args || []
});
},
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,54 @@
odoo.define('web.Registry', ['web.Class'], function (require) {
"use strict";
var Class = require('web.Class');
var Registry = Class.extend({
init: function (mapping) {
this.map = Object.create(mapping || null);
},
get: function (key) {
return this.map[key];
},
contains: function (key) {
return (key in this.map);
},
/**
* Tries a number of keys, and returns the first object matching one of
* the keys.
*
* @param {Array} keys a sequence of keys to fetch the object for
* @returns {Class} the first class found matching an object
*/
get_any: function (keys) {
for (var i=0; i<keys.length; i++) {
if (keys[i] in this.map) {
return this.map[keys[i]];
}
}
return null;
},
add: function (key, value) {
this.map[key] = value;
return this;
},
/**
* Creates and returns a copy of the current mapping, with the provided
* mapping argument added in (replacing existing keys if needed)
*
* Parent and child remain linked, a new key in the parent (which is not
* overwritten by the child) will appear in the child.
*
* @param {Object} [mapping={}] a mapping of keys to object-paths
*/
extend: function (mapping) {
var child = new Registry(this.map);
_.extend(child.map, mapping);
return child;
},
});
return Registry;
});
@@ -0,0 +1,393 @@
odoo.define('web.Session', ['web.ajax', 'web.core', 'web.utils'], function (require) {
"use strict";
var ajax = require('web.ajax');
var core = require('web.core');
var utils = require('web.utils');
var _t = core._t;
var qweb = core.qweb;
var mixins = core.mixins;
var Session = core.Class.extend(mixins.EventDispatcherMixin, {
/**
@param parent The parent of the newly created object.
or `null` if the server to contact is the origin server.
@param {Dict} options A dictionary that can contain the following options:
* "override_session": Default to false. If true, the current session object will
not try to re-use a previously created session id stored in a cookie.
* "session_id": Default to null. If specified, the specified session_id will be used
by this session object. Specifying this option automatically implies that the option
"override_session" is set to true.
*/
init: function(parent, origin, options) {
mixins.EventDispatcherMixin.init.call(this, parent);
options = options || {};
this.module_list = (options.modules && options.modules.slice()) || (window.odoo._modules && window.odoo._modules.slice()) || [];
this.server = null;
this.session_id = options.session_id || null;
this.override_session = options.override_session || !!options.session_id || false;
this.avoid_recursion = false;
this.use_cors = options.use_cors || false;
this.setup(origin);
this.debug = ($.deparam($.param.querystring()).debug !== undefined);
// TODO: session store in cookie should be optional
this.qweb_mutex = new utils.Mutex();
},
setup: function(origin, options) {
// must be able to customize server
var window_origin = location.protocol + "//" + location.host;
origin = origin ? origin.replace( /\/+$/, '') : window_origin;
if (!_.isUndefined(this.origin) && this.origin !== origin)
throw new Error('Session already bound to ' + this.origin);
else
this.origin = origin;
this.prefix = this.origin;
this.server = this.origin; // keep chs happy
this.origin_server = this.origin === window_origin;
options = options || {};
if ('use_cors' in options) {
this.use_cors = options.use_cors;
}
},
/**
* Setup a session
*/
session_bind: function(origin) {
var self = this;
this.setup(origin);
qweb.default_dict._s = this.origin;
this.uid = null;
this.username = null;
this.user_context= {};
this.db = null;
this.module_loaded = {};
_(this.module_list).each(function (mod) {
self.module_loaded[mod] = true;
});
this.active_id = null;
return this.session_init();
},
/**
* Init a session, reloads from cookie, if it exists
*/
session_init: function () {
var self = this;
return this.session_reload().then(function() {
var modules = self.module_list.join(',');
var deferred = self.load_qweb(modules);
if(self.session_is_valid()) {
return deferred.then(function() { return self.load_modules(); });
}
return $.when(
deferred,
self.rpc('/web/webclient/bootstrap_translations', {mods: self.module_list}).then(function(trans) {
_t.database.set_bundle(trans);
})
);
});
},
session_is_valid: function() {
var db = $.deparam.querystring().db;
if (db && this.db !== db) {
return false;
}
return !!this.uid;
},
/**
* The session is validated by restoration of a previous session
*/
session_authenticate: function() {
var self = this;
return $.when(this._session_authenticate.apply(this, arguments)).then(function() {
return self.load_modules();
});
},
/**
* The session is validated either by login or by restoration of a previous session
*/
_session_authenticate: function(db, login, password) {
var self = this;
var params = {db: db, login: login, password: password};
return this.rpc("/web/session/authenticate", params).then(function(result) {
if (!result.uid) {
return $.Deferred().reject();
}
delete result.session_id;
_.extend(self, result);
});
},
session_logout: function() {
$.bbq.removeState();
return this.rpc("/web/session/destroy", {});
},
get_cookie: function (name) {
if (!this.name) { return null; }
var nameEQ = this.name + '|' + name + '=';
var cookies = document.cookie.split(';');
for(var i=0; i<cookies.length; ++i) {
var cookie = cookies[i].replace(/^\s*/, '');
if(cookie.indexOf(nameEQ) === 0) {
try {
return JSON.parse(decodeURIComponent(cookie.substring(nameEQ.length)));
} catch(err) {
// wrong cookie, delete it
this.set_cookie(name, '', -1);
}
}
}
return null;
},
/**
* Create a new cookie with the provided name and value
*
* @private
* @param name the cookie's name
* @param value the cookie's value
* @param ttl the cookie's time to live, 1 year by default, set to -1 to delete
*/
set_cookie: function (name, value, ttl) {
if (!this.name) { return; }
ttl = ttl || 24*60*60*365;
document.cookie = [
this.name + '|' + name + '=' + encodeURIComponent(JSON.stringify(value)),
'path=/',
'max-age=' + ttl,
'expires=' + new Date(new Date().getTime() + ttl*1000).toGMTString()
].join(';');
},
/**
* Load additional web addons of that instance and init them
*
*/
load_modules: function() {
var self = this;
return this.rpc('/web/session/modules', {}).then(function(result) {
var all_modules = _.uniq(self.module_list.concat(result));
var to_load = _.difference(result, self.module_list).join(',');
self.module_list = all_modules;
var loaded = self.load_translations();
var locale = "/web/webclient/locale/" + self.user_context.lang || 'en_US';
var file_list = [ locale ];
if(to_load.length) {
loaded = $.when(
loaded,
self.rpc('/web/webclient/csslist', {mods: to_load}).done(self.load_css.bind(self)),
self.load_qweb(to_load),
self.rpc('/web/webclient/jslist', {mods: to_load}).done(function(files) {
file_list = file_list.concat(files);
})
);
}
return loaded.then(function () {
return self.load_js(file_list);
}).done(function() {
self.on_modules_loaded();
self.trigger('module_loaded');
});
});
},
load_translations: function() {
return _t.database.load_translations(this, this.module_list, this.user_context.lang);
},
load_css: function (files) {
var self = this;
_.each(files, function (file) {
ajax.loadCSS(self.url(file, null));
});
},
load_js: function(files) {
var self = this;
var d = $.Deferred();
if (files.length !== 0) {
var file = files.shift();
var url = self.url(file, null);
ajax.loadJS(url).done(d.resolve);
} else {
d.resolve();
}
return d;
},
load_qweb: function(mods) {
var self = this;
self.qweb_mutex.exec(function() {
return self.rpc('/web/proxy/load', {path: '/web/webclient/qweb?mods=' + mods}).then(function(xml) {
if (!xml) { return; }
qweb.add_template(_.str.trim(xml));
});
});
return self.qweb_mutex.def;
},
on_modules_loaded: function() {
var openerp = window.openerp;
for(var j=0; j<this.module_list.length; j++) {
var mod = this.module_list[j];
if(this.module_loaded[mod])
continue;
openerp[mod] = {};
// init module mod
var fct = openerp._openerp[mod];
if(typeof(fct) === "function") {
openerp._openerp[mod] = {};
for (var k in fct) {
openerp._openerp[mod][k] = fct[k];
}
fct(openerp, openerp._openerp[mod]);
}
this.module_loaded[mod] = true;
}
},
get_file: function (options) {
if (this.override_session){
options.data.session_id = this.session_id;
}
ajax.get_file(options);
},
synchronized_mode: function(to_execute) {
var synch = this.synch;
this.synch = true;
try {
return to_execute();
} finally {
this.synch = synch;
}
},
/**
* (re)loads the content of a session: db name, username, user id, session
* context and status of the support contract
*
* @returns {$.Deferred} deferred indicating the session is done reloading
*/
session_reload: function () {
var self = this;
return self.rpc("/web/session/get_session_info", {}).then(function(result) {
delete result.session_id;
_.extend(self, result);
});
},
check_session_id: function() {
var self = this;
if (this.avoid_recursion)
return $.when();
if (this.session_id)
return $.when(); // we already have the session id
if (!this.use_cors && (this.override_session || ! this.origin_server)) {
// If we don't use the origin server we consider we should always create a new session.
// Even if some browsers could support cookies when using jsonp that behavior is
// not consistent and the browser creators are tending to removing that feature.
this.avoid_recursion = true;
return this.rpc("/gen_session_id", {}).then(function(result) {
self.session_id = result;
}).always(function() {
self.avoid_recursion = false;
});
} else {
// normal use case, just use the cookie
self.session_id = utils.get_cookie("session_id");
return $.when();
}
},
/**
* Executes an RPC call, registering the provided callbacks.
*
* Registers a default error callback if none is provided, and handles
* setting the correct session id and session context in the parameter
* objects
*
* @param {String} url RPC endpoint
* @param {Object} params call parameters
* @param {Object} options additional options for rpc call
* @param {Function} success_callback function to execute on RPC call success
* @param {Function} error_callback function to execute on RPC call failure
* @returns {jQuery.Deferred} jquery-provided ajax deferred
*/
rpc: function(url, params, options) {
var self = this;
options = _.clone(options || {});
var shadow = options.shadow || false;
delete options.shadow;
return self.check_session_id().then(function() {
// TODO: remove
if (! _.isString(url)) {
_.extend(options, url);
url = url.url;
}
// TODO correct handling of timeouts
if (! shadow)
self.trigger('request');
var fct;
if (self.origin_server) {
fct = ajax.jsonRpc;
if (self.override_session) {
options.headers = _.extend({}, options.headers, {
"X-Openerp-Session-Id": self.override_session ? self.session_id || '' : ''
});
}
} else if (self.use_cors) {
fct = ajax.jsonRpc;
url = self.url(url, null);
options.session_id = self.session_id || '';
if (self.override_session) {
options.headers = _.extend({}, options.headers, {
"X-Openerp-Session-Id": self.override_session ? self.session_id || '' : ''
});
}
} else {
fct = ajax.jsonpRpc;
url = self.url(url, null);
options.session_id = self.session_id || '';
}
var p = fct(url, "call", params, options);
p = p.then(function (result) {
if (! shadow)
self.trigger('response');
return result;
}, function(type, error, textStatus, errorThrown) {
if (type === "server") {
if (! shadow)
self.trigger('response');
if (error.code === 100) {
self.uid = false;
}
return $.Deferred().reject(error, $.Event());
} else {
if (! shadow)
self.trigger('response_failed');
var nerror = {
code: -32098,
message: "XmlHttpRequestError " + errorThrown,
data: {type: "xhr"+textStatus, debug: error.responseText, objects: [error, errorThrown] }
};
return $.Deferred().reject(nerror, $.Event());
}
});
return p.fail(function() { // Allow deferred user to disable rpc_error call in fail
p.fail(function(error, event) {
if (!event.isDefaultPrevented()) {
self.trigger('error', error, event);
}
});
});
});
},
url: function(path, params) {
params = _.extend(params || {});
if (this.override_session || (! this.origin_server))
params.session_id = this.session_id;
var qs = $.param(params);
if (qs.length > 0)
qs = "?" + qs;
var prefix = _.any(['http://', 'https://', '//'], function(el) {
return path.length >= el.length && path.slice(0, el.length) === el;
}) ? '' : this.prefix;
return prefix + path + qs;
},
});
return Session;
});
+285
View File
@@ -0,0 +1,285 @@
odoo.define('web.time', ['web.translation', 'web.utils'], function (require) {
"use strict";
var translation = require('web.translation');
var utils = require('web.utils');
var lpad = utils.lpad;
var rpad = utils.rpad;
var _t = translation._t;
/**
* Replacer function for JSON.stringify, serializes Date objects to UTC
* datetime in the OpenERP Server format.
*
* However, if a serialized value has a toJSON method that method is called
* *before* the replacer is invoked. Date#toJSON exists, and thus the value
* passed to the replacer is a string, the original Date has to be fetched
* on the parent object (which is provided as the replacer's context).
*
* @param {String} k
* @param {Object} v
* @returns {Object}
*/
function date_to_utc (k, v) {
var value = this[k];
if (!(value instanceof Date)) { return v; }
return datetime_to_str(value);
}
/**
* Converts a string to a Date javascript object using OpenERP's
* datetime string format (exemple: '2011-12-01 15:12:35.832').
*
* The time zone is assumed to be UTC (standard for OpenERP 6.1)
* and will be converted to the browser's time zone.
*
* @param {String} str A string representing a datetime.
* @returns {Date}
*/
function str_to_datetime (str) {
if(!str) {
return str;
}
var regex = /^(\d\d\d\d)-(\d\d)-(\d\d) (\d\d):(\d\d):(\d\d(?:\.(\d+))?)$/;
var res = regex.exec(str);
if ( !res ) {
throw new Error("'" + str + "' is not a valid datetime");
}
var tmp = new Date(2000,0,1);
tmp.setUTCMonth(1970);
tmp.setUTCMonth(0);
tmp.setUTCDate(1);
tmp.setUTCFullYear(parseFloat(res[1]));
tmp.setUTCMonth(parseFloat(res[2]) - 1);
tmp.setUTCDate(parseFloat(res[3]));
tmp.setUTCHours(parseFloat(res[4]));
tmp.setUTCMinutes(parseFloat(res[5]));
tmp.setUTCSeconds(parseFloat(res[6]));
tmp.setUTCSeconds(parseFloat(res[6]));
tmp.setUTCMilliseconds(parseFloat(utils.rpad((res[7] || "").slice(0, 3), 3)));
return tmp;
}
/**
* Converts a string to a Date javascript object using OpenERP's
* date string format (exemple: '2011-12-01').
*
* As a date is not subject to time zones, we assume it should be
* represented as a Date javascript object at 00:00:00 in the
* time zone of the browser.
*
* @param {String} str A string representing a date.
* @returns {Date}
*/
function str_to_date (str) {
if(!str) {
return str;
}
var regex = /^(\d\d\d\d)-(\d\d)-(\d\d)$/;
var res = regex.exec(str);
if ( !res ) {
throw new Error("'" + str + "' is not a valid date");
}
var tmp = new Date(2000,0,1);
tmp.setFullYear(parseFloat(res[1]));
tmp.setMonth(parseFloat(res[2]) - 1);
tmp.setDate(parseFloat(res[3]));
tmp.setHours(0);
tmp.setMinutes(0);
tmp.setSeconds(0);
return tmp;
}
/**
* Converts a string to a Date javascript object using OpenERP's
* time string format (exemple: '15:12:35').
*
* The OpenERP times are supposed to always be naive times. We assume it is
* represented using a javascript Date with a date 1 of January 1970 and a
* time corresponding to the meant time in the browser's time zone.
*
* @param {String} str A string representing a time.
* @returns {Date}
*/
function str_to_time (str) {
if(!str) {
return str;
}
var regex = /^(\d\d):(\d\d):(\d\d(?:\.(\d+))?)$/;
var res = regex.exec(str);
if ( !res ) {
throw new Error("'" + str + "' is not a valid time");
}
var tmp = new Date();
tmp.setFullYear(1970);
tmp.setMonth(0);
tmp.setDate(1);
tmp.setHours(parseFloat(res[1]));
tmp.setMinutes(parseFloat(res[2]));
tmp.setSeconds(parseFloat(res[3]));
tmp.setMilliseconds(parseFloat(rpad((res[4] || "").slice(0, 3), 3)));
return tmp;
}
/**
* Converts a Date javascript object to a string using OpenERP's
* datetime string format (exemple: '2011-12-01 15:12:35').
*
* The time zone of the Date object is assumed to be the one of the
* browser and it will be converted to UTC (standard for OpenERP 6.1).
*
* @param {Date} obj
* @returns {String} A string representing a datetime.
*/
function datetime_to_str (obj) {
if (!obj) {
return false;
}
return lpad(obj.getUTCFullYear(),4) + "-" + lpad(obj.getUTCMonth() + 1,2) + "-"
+ lpad(obj.getUTCDate(),2) + " " + lpad(obj.getUTCHours(),2) + ":"
+ lpad(obj.getUTCMinutes(),2) + ":" + lpad(obj.getUTCSeconds(),2);
}
/**
* Converts a Date javascript object to a string using OpenERP's
* date string format (exemple: '2011-12-01').
*
* As a date is not subject to time zones, we assume it should be
* represented as a Date javascript object at 00:00:00 in the
* time zone of the browser.
*
* @param {Date} obj
* @returns {String} A string representing a date.
*/
function date_to_str (obj) {
if (!obj) {
return false;
}
return lpad(obj.getFullYear(),4) + "-" + lpad(obj.getMonth() + 1,2) + "-"
+ lpad(obj.getDate(),2);
}
/**
* Converts a Date javascript object to a string using OpenERP's
* time string format (exemple: '15:12:35').
*
* The OpenERP times are supposed to always be naive times. We assume it is
* represented using a javascript Date with a date 1 of January 1970 and a
* time corresponding to the meant time in the browser's time zone.
*
* @param {Date} obj
* @returns {String} A string representing a time.
*/
function time_to_str (obj) {
if (!obj) {
return false;
}
return lpad(obj.getHours(),2) + ":" + lpad(obj.getMinutes(),2) + ":"
+ lpad(obj.getSeconds(),2);
}
function auto_str_to_date (value) {
try {
return str_to_datetime(value);
} catch(e) {}
try {
return str_to_date(value);
} catch(e) {}
try {
return str_to_time(value);
} catch(e) {}
throw new Error(_.str.sprintf(_t("'%s' is not a correct date, datetime nor time"), value));
}
function auto_date_to_str (value, type) {
switch(type) {
case 'datetime':
return datetime_to_str(value);
case 'date':
return date_to_str(value);
case 'time':
return time_to_str(value);
default:
throw new Error(_.str.sprintf(_t("'%s' is not convertible to date, datetime nor time"), type));
}
}
/**
* Convert Python strftime to escaped moment.js format.
*
* @param {String} value original format
*/
function strftime_to_moment_format (value) {
if (_normalize_format_cache[value] === undefined) {
var isletter = /[a-zA-Z]/,
output = [],
inToken = false;
for (var index=0; index < value.length; ++index) {
var character = value[index];
if (character === '%' && !inToken) {
inToken = true;
continue;
}
if (isletter.test(character)) {
if (inToken && normalize_format_table[character] !== undefined) {
character = normalize_format_table[character];
} else {
character = '[' + character + ']'; // moment.js escape
}
}
output.push(character);
inToken = false;
}
_normalize_format_cache[value] = output.join('');
}
return _normalize_format_cache[value];
}
var _normalize_format_cache = {};
var normalize_format_table = {
// Python strftime to moment.js conversion table
// See openerp/addons/base/res/res_lang_view.xml
// for details about supported directives
'a': 'ddd',
'A': 'dddd',
'b': 'MMM',
'B': 'MMMM',
'd': 'DD',
'H': 'HH',
'I': 'hh',
'j': 'DDDD',
'm': 'MM',
'M': 'mm',
'p': 'A',
'S': 'ss',
'U': 'ww',
'W': 'WW',
'w': 'd',
'y': 'YY',
'Y': 'YYYY',
// unsupported directives
'c': 'ddd MMM D HH:mm:ss YYYY',
'x': 'MM/DD/YY',
'X': 'HH:mm:ss'
};
return {
date_to_utc: date_to_utc,
str_to_datetime: str_to_datetime,
str_to_date: str_to_date,
str_to_time: str_to_time,
datetime_to_str: datetime_to_str,
date_to_str: date_to_str,
time_to_str: time_to_str,
auto_str_to_date: auto_str_to_date,
auto_date_to_str: auto_date_to_str,
strftime_to_moment_format: strftime_to_moment_format,
};
});
@@ -0,0 +1,98 @@
odoo.define('web.translation', ['web.Class', 'qweb'], function (require) {
"use strict";
var Class = require('web.Class');
var qweb = require('qweb');
var TranslationDataBase = Class.extend(/** @lends instance.TranslationDataBase# */{
init: function() {
this.db = {};
this.parameters = {"direction": 'ltr',
"date_format": '%m/%d/%Y',
"time_format": '%H:%M:%S',
"grouping": [],
"decimal_point": ".",
"thousands_sep": ","};
},
set_bundle: function(translation_bundle) {
var self = this;
this.db = {};
var modules = _.keys(translation_bundle.modules);
modules.sort();
if (_.include(modules, "web")) {
modules = ["web"].concat(_.without(modules, "web"));
}
_.each(modules, function(name) {
self.add_module_translation(translation_bundle.modules[name]);
});
if (translation_bundle.lang_parameters) {
this.parameters = translation_bundle.lang_parameters;
if (typeof(py) !== "undefined") {
this.parameters.grouping = py.eval(this.parameters.grouping);
}
}
},
add_module_translation: function(mod) {
var self = this;
_.each(mod.messages, function(message) {
self.db[message.id] = message.string;
});
},
build_translation_function: function() {
var self = this;
var fcnt = function(str) {
var tmp = self.get(str);
return tmp === undefined ? str : tmp;
};
fcnt.database = this;
return fcnt;
},
get: function(key) {
return this.db[key];
},
/**
Loads the translations from an OpenERP server.
@param {openerp.Session} session The session object to contact the server.
@param {Array} [modules] The list of modules to load the translation. If not specified,
it will default to all the modules installed in the current database.
@param {Object} [lang] lang The language. If not specified it will default to the language
of the current user.
@returns {jQuery.Deferred}
*/
load_translations: function(session, modules, lang) {
var self = this;
return session.rpc('/web/webclient/translations', {
"mods": modules || null,
"lang": lang || null
}).done(function(trans) {
self.set_bundle(trans);
});
}
});
var _t = new TranslationDataBase().build_translation_function();
var _lt = function (s) {
return {toString: function () { return _t(s); }};
};
// provide timeago.js with our own translator method
$.timeago.settings.translator = _t;
qweb.default_dict = {
'_' : _,
'JSON': JSON,
'_t' : _t,
};
return {
_t: _t,
_lt: _lt,
TranslationDataBase: TranslationDataBase,
};
});
+383
View File
@@ -0,0 +1,383 @@
odoo.define('web.utils', ['web.Class', 'web.translation'], function (require) {
"use strict";
var Class = require('web.Class');
var translation = require('web.translation');
var _t = translation._t;
/**
* computes (Math.floor(a/b), a%b and passes that to the callback.
*
* returns the callback's result
*/
function divmod (a, b, fn) {
var mod = a%b;
// in python, sign(a % b) === sign(b). Not in JS. If wrong side, add a
// round of b
if (mod > 0 && b < 0 || mod < 0 && b > 0) {
mod += b;
}
return fn(Math.floor(a/b), mod);
}
/**
* Passes the fractional and integer parts of x to the callback, returns
* the callback's result
*/
function modf (x, fn) {
var mod = x%1;
if (mod < 0) {
mod += 1;
}
return fn(mod, Math.floor(x));
}
/*
* Left-pad provided arg 1 with zeroes until reaching size provided by second
* argument.
*
* @param {Number|String} str value to pad
* @param {Number} size size to reach on the final padded value
* @returns {String} padded string
*/
function lpad (str, size) {
str = "" + str;
return new Array(size - str.length + 1).join('0') + str;
}
function rpad (str, size) {
str = "" + str;
return str + new Array(size - str.length + 1).join('0');
}
var id = -1;
function generate_id () {
return ++id;
}
/**
* Intersperses ``separator`` in ``str`` at the positions indicated by
* ``indices``.
*
* ``indices`` is an array of relative offsets (from the previous insertion
* position, starting from the end of the string) at which to insert
* ``separator``.
*
* There are two special values:
*
* ``-1``
* indicates the insertion should end now
* ``0``
* indicates that the previous section pattern should be repeated (until all
* of ``str`` is consumed)
*
* @param {String} str
* @param {Array<Number>} indices
* @param {String} separator
* @returns {String}
*/
function intersperse (str, indices, separator) {
separator = separator || '';
var result = [], last = str.length;
for(var i=0; i<indices.length; ++i) {
var section = indices[i];
if (section === -1 || last <= 0) {
// Done with string, or -1 (stops formatting string)
break;
} else if(section === 0 && i === 0) {
// repeats previous section, which there is none => stop
break;
} else if (section === 0) {
// repeat previous section forever
//noinspection AssignmentToForLoopParameterJS
section = indices[--i];
}
result.push(str.substring(last-section, last));
last -= section;
}
var s = str.substring(0, last);
if (s) { result.push(s); }
return result.reverse().join(separator);
}
function xml_to_json (node, strip_whitespace) {
switch (node.nodeType) {
case 9:
return xml_to_json(node.documentElement, strip_whitespace);
case 3:
case 4:
return (strip_whitespace && node.data.trim() === '') ? undefined : node.data;
case 1:
var attrs = $(node).getAttributes();
_.each(['domain', 'filter_domain', 'context', 'default_get'], function(key) {
if (attrs[key]) {
try {
attrs[key] = JSON.parse(attrs[key]);
} catch(e) { }
}
});
return {
tag: node.tagName.toLowerCase(),
attrs: attrs,
children: _.compact(_.map(node.childNodes, function(node) {
return xml_to_json(node, strip_whitespace);
})),
};
}
}
function json_node_to_xml (node, human_readable, indent) {
// For debugging purpose, this function will convert a json node back to xml
indent = indent || 0;
var sindent = (human_readable ? (new Array(indent + 1).join('\t')) : ''),
r = sindent + '<' + node.tag,
cr = human_readable ? '\n' : '';
if (typeof(node) === 'string') {
return sindent + node;
} else if (typeof(node.tag) !== 'string' || !node.children instanceof Array || !node.attrs instanceof Object) {
throw new Error(
_.str.sprintf(_t("Node [%s] is not a JSONified XML node"),
JSON.stringify(node)));
}
for (var attr in node.attrs) {
var vattr = node.attrs[attr];
if (typeof(vattr) !== 'string') {
// domains, ...
vattr = JSON.stringify(vattr);
}
vattr = vattr.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
if (human_readable) {
vattr = vattr.replace(/&quot;/g, "'");
}
r += ' ' + attr + '="' + vattr + '"';
}
if (node.children && node.children.length) {
r += '>' + cr;
var childs = [];
for (var i = 0, ii = node.children.length; i < ii; i++) {
childs.push(json_node_to_xml(node.children[i], human_readable, indent + 1));
}
r += childs.join(cr);
r += cr + sindent + '</' + node.tag + '>';
return r;
} else {
return r + '/>';
}
}
function xml_to_str (node) {
var str = "";
if (window.XMLSerializer) {
str = (new XMLSerializer()).serializeToString(node);
} else if (window.ActiveXObject) {
str = node.xml;
} else {
throw new Error(_t("Could not serialize XML"));
}
// Browsers won't deal with self closing tags except void elements:
// http://www.w3.org/TR/html-markup/syntax.html
var void_elements = 'area base br col command embed hr img input keygen link meta param source track wbr'.split(' ');
// The following regex is a bit naive but it's ok for the xmlserializer output
str = str.replace(/<([a-z]+)([^<>]*)\s*\/\s*>/g, function(match, tag, attrs) {
if (void_elements.indexOf(tag) < 0) {
return "<" + tag + attrs + "></" + tag + ">";
} else {
return match;
}
});
return str;
}
function Mutex () {
this.def = $.Deferred().resolve();
}
Mutex.prototype.exec = function (action) {
var current = this.def;
var next = this.def = $.Deferred();
return current.then(function() {
return $.when(action()).always(function() {
next.resolve();
});
});
};
function get_cookie (c_name) {
var cookies = document.cookie ? document.cookie.split('; ') : [];
for (var i = 0, l = cookies.length; i < l; i++) {
var parts = cookies[i].split('=');
var name = parts.shift();
var cookie = parts.join('=');
if (c_name && c_name === name) {
return cookie;
}
}
return "";
}
/**
* Insert "thousands" separators in the provided number (which is actually
* a string)
*
* @param {String} num
* @returns {String}
*/
function insert_thousand_seps (num) {
var negative = num[0] === '-';
num = (negative ? num.slice(1) : num);
return (negative ? '-' : '') + intersperse(
num, _t.database.parameters.grouping, _t.database.parameters.thousands_sep);
}
function is_bin_size (v) {
return (/^\d+(\.\d*)? \w+$/).test(v);
}
/**
* Check with a scary heuristic if the value is a bin_size or not.
* If not, compute an approximate size out of the base64 encoded string.
*
* @param {String} value original format
*/
function binary_to_binsize (value) {
if (!value) {
return human_size(0);
}
if (value.substr(0, 10).indexOf(' ') == -1) {
// Computing approximate size out of base64 encoded string
// http://en.wikipedia.org/wiki/Base64#MIME
return human_size(value.length / 1.37);
} else {
// already bin_size
return value;
}
}
/**
* Returns a human readable size
*
* @param {Number} numner of bytes
*/
function human_size (size) {
var units = _t("Bytes,Kb,Mb,Gb,Tb,Pb,Eb,Zb,Yb").split(',');
var i = 0;
while (size >= 1024) {
size /= 1024;
++i;
}
return size.toFixed(2) + ' ' + units[i];
}
/**
* performs a half up rounding with arbitrary precision, correcting for float loss of precision
* See the corresponding float_round() in server/tools/float_utils.py for more info
* @param {Number} the value to be rounded
* @param {Number} a precision parameter. eg: 0.01 rounds to two digits.
*/
function round_precision (value, precision) {
if (!value) {
return 0;
} else if (!precision || precision < 0) {
precision = 1;
}
var normalized_value = value / precision;
var epsilon_magnitude = Math.log(Math.abs(normalized_value))/Math.log(2);
var epsilon = Math.pow(2, epsilon_magnitude - 53);
normalized_value += normalized_value >= 0 ? epsilon : -epsilon;
var rounded_value = Math.round(normalized_value);
return rounded_value * precision;
}
/**
* performs a half up rounding with a fixed amount of decimals, correcting for float loss of precision
* See the corresponding float_round() in server/tools/float_utils.py for more info
* @param {Number} the value to be rounded
* @param {Number} the number of decimals. eg: round_decimals(3.141592,2) -> 3.14
*/
function round_decimals (value, decimals) {
return round_precision(value, Math.pow(10,-decimals));
}
function float_is_zero (value, decimals) {
var epsilon = Math.pow(10, -decimals);
return Math.abs(round_precision(value, epsilon)) < epsilon;
};
function assert (bool) {
if (!bool) {
throw new Error("AssertionError");
}
}
var DropMisordered = Class.extend({
/**
* @constructs instance.web.DropMisordered
* @extends instance.web.Class
*
* @param {Boolean} [failMisordered=false] whether mis-ordered responses should be failed or just ignored
*/
init: function (failMisordered) {
// local sequence number, for requests sent
this.lsn = 0;
// remote sequence number, seqnum of last received request
this.rsn = -1;
this.failMisordered = failMisordered || false;
},
/**
* Adds a deferred (usually an async request) to the sequencer
*
* @param {$.Deferred} deferred to ensure add
* @returns {$.Deferred}
*/
add: function (deferred) {
var res = $.Deferred();
var self = this, seq = this.lsn++;
deferred.done(function () {
if (seq > self.rsn) {
self.rsn = seq;
res.resolve.apply(res, arguments);
} else if (self.failMisordered) {
res.reject();
}
}).fail(function () {
res.reject.apply(res, arguments);
});
return res.promise();
},
});
return {
divmod: divmod,
modf: modf,
lpad: lpad,
rpad: rpad,
generate_id: generate_id,
intersperse: intersperse,
xml_to_json: xml_to_json,
json_node_to_xml: json_node_to_xml,
xml_to_str: xml_to_str,
Mutex: Mutex,
get_cookie: get_cookie,
insert_thousand_seps: insert_thousand_seps,
is_bin_size: is_bin_size,
binary_to_binsize: binary_to_binsize,
human_size: human_size,
round_precision: round_precision,
round_decimals: round_decimals,
float_is_zero: float_is_zero,
assert: assert,
DropMisordered: DropMisordered,
};
});
+239
View File
@@ -0,0 +1,239 @@
odoo.define('web.View', ['web.core', 'web.data', 'web.pyeval', 'web.Widget'], function (require) {
"use strict";
var core = require('web.core');
var data = require('web.data');
var pyeval = require('web.pyeval');
var Widget = require('web.Widget');
var View = Widget.extend({
// name displayed in view switchers
display_name: '',
/**
* Define a view type for each view to allow automatic call to fields_view_get.
*/
view_type: undefined,
init: function(parent, dataset, view_id, options) {
this._super(parent);
this.ViewManager = parent;
this.dataset = dataset;
this.view_id = view_id;
this.$buttons = options && options.$buttons;
this.set_default_options(options);
},
start: function () {
return this.load_view();
},
load_view: function() {
var self = this;
var view_loaded_def;
if (this.embedded_view) {
view_loaded_def = $.Deferred();
$.async_when().done(function() {
view_loaded_def.resolve(self.embedded_view);
});
} else {
if (! this.view_type)
console.warn("view_type is not defined", this);
view_loaded_def = this.dataset._model.fields_view_get({
"view_id": this.view_id,
"view_type": this.view_type,
"toolbar": !!this.options.$sidebar,
"context": this.dataset.get_context(),
});
}
return this.alive(view_loaded_def).then(function(r) {
self.fields_view = r;
// add css classes that reflect the (absence of) access rights
self.$el.addClass('oe_view')
.toggleClass('oe_cannot_create', !self.is_action_enabled('create'))
.toggleClass('oe_cannot_edit', !self.is_action_enabled('edit'))
.toggleClass('oe_cannot_delete', !self.is_action_enabled('delete'));
return $.when(self.view_loading(r)).then(function() {
self.trigger('view_loaded', r);
});
});
},
view_loading: function(r) {
},
set_default_options: function(options) {
this.options = options || {};
_.defaults(this.options, {
// All possible views options should be defaulted here
$sidebar: null,
sidebar_id: null,
action: null,
action_views_ids: {}
});
},
/**
* Fetches and executes the action identified by ``action_data``.
*
* @param {Object} action_data the action descriptor data
* @param {String} action_data.name the action name, used to uniquely identify the action to find and execute it
* @param {String} [action_data.special=null] special action handlers (currently: only ``'cancel'``)
* @param {String} [action_data.type='workflow'] the action type, if present, one of ``'object'``, ``'action'`` or ``'workflow'``
* @param {Object} [action_data.context=null] additional action context, to add to the current context
* @param {instance.web.DataSet} dataset a dataset object used to communicate with the server
* @param {Object} [record_id] the identifier of the object on which the action is to be applied
* @param {Function} on_closed callback to execute when dialog is closed or when the action does not generate any result (no new action)
*/
do_execute_action: function (action_data, dataset, record_id, on_closed) {
var self = this;
var result_handler = function () {
if (on_closed) { on_closed.apply(null, arguments); }
if (self.getParent() && self.getParent().on_action_executed) {
return self.getParent().on_action_executed.apply(null, arguments);
}
};
var context = new data.CompoundContext(dataset.get_context(), action_data.context || {});
// response handler
var handler = function (action) {
if (action && action.constructor == Object) {
// filter out context keys that are specific to the current action.
// Wrong default_* and search_default_* values will no give the expected result
// Wrong group_by values will simply fail and forbid rendering of the destination view
var ncontext = new data.CompoundContext(
_.object(_.reject(_.pairs(dataset.get_context().eval()), function(pair) {
return pair[0].match('^(?:(?:default_|search_default_).+|.+_view_ref|group_by|group_by_no_leaf|active_id|active_ids)$') !== null;
}))
);
ncontext.add(action_data.context || {});
ncontext.add({active_model: dataset.model});
if (record_id) {
ncontext.add({
active_id: record_id,
active_ids: [record_id],
});
}
ncontext.add(action.context || {});
action.context = ncontext;
return self.do_action(action, {
on_close: result_handler,
});
} else {
self.do_action({"type":"ir.actions.act_window_close"});
return result_handler();
}
};
if (action_data.special === 'cancel') {
return handler({"type":"ir.actions.act_window_close"});
} else if (action_data.type=="object") {
var args = [[record_id]];
if (action_data.args) {
try {
// Warning: quotes and double quotes problem due to json and xml clash
// Maybe we should force escaping in xml or do a better parse of the args array
var additional_args = JSON.parse(action_data.args.replace(/'/g, '"'));
args = args.concat(additional_args);
} catch(e) {
console.error("Could not JSON.parse arguments", action_data.args);
}
}
args.push(context);
return dataset.call_button(action_data.name, args).then(handler).then(function () {
core.bus.trigger('do_reload_needaction');
});
} else if (action_data.type=="action") {
return this.rpc('/web/action/load', {
action_id: action_data.name,
context: _.extend(pyeval.eval('context', context), {'active_model': dataset.model, 'active_ids': dataset.ids, 'active_id': record_id}),
do_not_eval: true
}).then(handler);
} else {
return dataset.exec_workflow(record_id, action_data.name).then(handler);
}
},
/**
* Directly set a view to use instead of calling fields_view_get. This method must
* be called before start(). When an embedded view is set, underlying implementations
* of instance.web.View must use the provided view instead of any other one.
*
* @param embedded_view A view.
*/
set_embedded_view: function(embedded_view) {
this.embedded_view = embedded_view;
},
do_show: function () {
var self = this;
if (this.$buttons) {
this.$buttons.show();
}
this.$el.show();
setTimeout(function () {
self.$el.parent().addClass('in');
}, 0);
core.bus.trigger('view_shown', this);
},
do_hide: function () {
if (this.$buttons) {
this.$buttons.hide();
}
this.$el.parent().removeClass('in');
this.$el.hide();
},
is_active: function () {
return this.ViewManager.active_view.controller === this;
}, /**
* Wraps fn to only call it if the current view is the active one. If the
* current view is not active, doesn't call fn.
*
* fn can not return anything, as a non-call to fn can't return anything
* either
*
* @param {Function} fn function to wrap in the active guard
*/
guard_active: function (fn) {
var self = this;
return function () {
if (self.is_active()) {
fn.apply(self, arguments);
}
};
},
do_push_state: function(state) {
if (this.getParent() && this.getParent().do_push_state) {
this.getParent().do_push_state(state);
}
},
do_load_state: function (state, warm) {
},
/**
* Switches to a specific view type
*/
do_switch_view: function() {
this.trigger.apply(this, ['switch_mode'].concat(_.toArray(arguments)));
},
do_search: function(domain, context, group_by) {
},
sidebar_eval_context: function () {
return $.when({});
},
/**
* Asks the view to reload itself, if the reloading is asynchronous should
* return a {$.Deferred} indicating when the reloading is done.
*/
reload: function () {
return $.when();
},
/**
* Return whether the user can perform the action ('create', 'edit', 'delete') in this view.
* An action is disabled by setting the corresponding attribute in the view's main element,
* like: <form string="" create="false" edit="false" delete="false">
*/
is_action_enabled: function(action) {
var attrs = this.fields_view.arch.attrs;
return (action in attrs) ? JSON.parse(attrs[action]) : true;
},
get_context: function () {
return {};
},
});
return View;
});
@@ -0,0 +1,367 @@
odoo.define('web.Widget', ['web.core', 'web.session'], function (require) {
"use strict";
var core = require('web.core');
var session = require('web.session');
var qweb = core.qweb;
var mixins = core.mixins;
/**
* Base class for all visual components. Provides a lot of functionalities helpful
* for the management of a part of the DOM.
*
* Widget handles:
* - Rendering with QWeb.
* - Life-cycle management and parenting (when a parent is destroyed, all its children are
* destroyed too).
* - Insertion in DOM.
*
* Guide to create implementations of the Widget class:
* ==============================================
*
* Here is a sample child class:
*
* MyWidget = openerp.base.Widget.extend({
* // the name of the QWeb template to use for rendering
* template: "MyQWebTemplate",
*
* init: function(parent) {
* this._super(parent);
* // stuff that you want to init before the rendering
* },
* start: function() {
* // stuff you want to make after the rendering, `this.$el` holds a correct value
* this.$el.find(".my_button").click(/* an example of event binding * /);
*
* // if you have some asynchronous operations, it's a good idea to return
* // a promise in start()
* var promise = this.rpc(...);
* return promise;
* }
* });
*
* Now this class can simply be used with the following syntax:
*
* var my_widget = new MyWidget(this);
* my_widget.appendTo($(".some-div"));
*
* With these two lines, the MyWidget instance was inited, rendered, it was inserted into the
* DOM inside the ".some-div" div and its events were binded.
*
* And of course, when you don't need that widget anymore, just do:
*
* my_widget.destroy();
*
* That will kill the widget in a clean way and erase its content from the dom.
*/
var Widget = core.Class.extend(mixins.PropertiesMixin, {
// Backbone-ish API
tagName: 'div',
id: null,
className: null,
attributes: {},
events: {},
/**
* The name of the QWeb template that will be used for rendering. Must be
* redefined in subclasses or the default render() method can not be used.
*
* @type string
*/
template: null,
/**
* Constructs the widget and sets its parent if a parent is given.
*
* @constructs openerp.Widget
*
* @param {openerp.Widget} parent Binds the current instance to the given Widget instance.
* When that widget is destroyed by calling destroy(), the current instance will be
* destroyed too. Can be null.
*/
init: function(parent) {
mixins.PropertiesMixin.init.call(this);
this.setParent(parent);
// Bind on_/do_* methods to this
// We might remove this automatic binding in the future
for (var name in this) {
if(typeof(this[name]) == "function") {
if((/^on_|^do_/).test(name)) {
this[name] = _.bind(this[name], this);
}
}
}
// FIXME: this should not be
this.setElement(this._make_descriptive());
},
/**
* Destroys the current widget, also destroys all its children before destroying itself.
*/
destroy: function() {
_.each(this.getChildren(), function(el) {
el.destroy();
});
if(this.$el) {
this.$el.remove();
}
mixins.PropertiesMixin.destroy.call(this);
},
/**
* Renders the current widget and appends it to the given jQuery object or Widget.
*
* @param target A jQuery object or a Widget instance.
*/
appendTo: function(target) {
var self = this;
return this.__widgetRenderAndInsert(function(t) {
self.$el.appendTo(t);
}, target);
},
/**
* Renders the current widget and prepends it to the given jQuery object or Widget.
*
* @param target A jQuery object or a Widget instance.
*/
prependTo: function(target) {
var self = this;
return this.__widgetRenderAndInsert(function(t) {
self.$el.prependTo(t);
}, target);
},
/**
* Renders the current widget and inserts it after to the given jQuery object or Widget.
*
* @param target A jQuery object or a Widget instance.
*/
insertAfter: function(target) {
var self = this;
return this.__widgetRenderAndInsert(function(t) {
self.$el.insertAfter(t);
}, target);
},
/**
* Renders the current widget and inserts it before to the given jQuery object or Widget.
*
* @param target A jQuery object or a Widget instance.
*/
insertBefore: function(target) {
var self = this;
return this.__widgetRenderAndInsert(function(t) {
self.$el.insertBefore(t);
}, target);
},
/**
* Renders the current widget and replaces the given jQuery object.
*
* @param target A jQuery object or a Widget instance.
*/
replace: function(target) {
return this.__widgetRenderAndInsert(_.bind(function(t) {
this.$el.replaceAll(t);
}, this), target);
},
__widgetRenderAndInsert: function(insertion, target) {
this.renderElement();
insertion(target);
return this.start();
},
/**
* Method called after rendering. Mostly used to bind actions, perform asynchronous
* calls, etc...
*
* By convention, this method should return an object that can be passed to $.when()
* to inform the caller when this widget has been initialized.
*
* @returns {jQuery.Deferred or any}
*/
start: function() {
return $.when();
},
/**
* Renders the element. The default implementation renders the widget using QWeb,
* `this.template` must be defined. The context given to QWeb contains the "widget"
* key that references `this`.
*/
renderElement: function() {
var $el;
if (this.template) {
$el = $(qweb.render(this.template, {widget: this}).trim());
} else {
$el = this._make_descriptive();
}
this.replaceElement($el);
},
/**
* Re-sets the widget's root element and replaces the old root element
* (if any) by the new one in the DOM.
*
* @param {HTMLElement | jQuery} $el
* @returns {*} this
*/
replaceElement: function ($el) {
var $oldel = this.$el;
this.setElement($el);
if ($oldel && !$oldel.is(this.$el)) {
if ($oldel.length > 1) {
$oldel.wrapAll('<div/>');
$oldel.parent().replaceWith(this.$el);
} else {
$oldel.replaceWith(this.$el);
}
}
return this;
},
/**
* Re-sets the widget's root element (el/$el/$el).
*
* Includes:
* * re-delegating events
* * re-binding sub-elements
* * if the widget already had a root element, replacing the pre-existing
* element in the DOM
*
* @param {HTMLElement | jQuery} element new root element for the widget
* @return {*} this
*/
setElement: function (element) {
// NB: completely useless, as WidgetMixin#init creates a $el
// always
if (this.$el) {
this.undelegateEvents();
}
this.$el = (element instanceof $) ? element : $(element);
this.el = this.$el[0];
this.delegateEvents();
return this;
},
/**
* Utility function to build small DOM elements.
*
* @param {String} tagName name of the DOM element to create
* @param {Object} [attributes] map of DOM attributes to set on the element
* @param {String} [content] HTML content to set on the element
* @return {Element}
*/
make: function (tagName, attributes, content) {
var el = document.createElement(tagName);
if (!_.isEmpty(attributes)) {
$(el).attr(attributes);
}
if (content) {
$(el).html(content);
}
return el;
},
/**
* Makes a potential root element from the declarative builder of the
* widget
*
* @return {jQuery}
* @private
*/
_make_descriptive: function () {
var attrs = _.extend({}, this.attributes || {});
if (this.id) { attrs.id = this.id; }
if (this.className) { attrs['class'] = this.className; }
return $(this.make(this.tagName, attrs));
},
delegateEvents: function () {
var events = this.events;
if (_.isEmpty(events)) { return; }
for(var key in events) {
if (!events.hasOwnProperty(key)) { continue; }
var method = this.proxy(events[key]);
var match = /^(\S+)(\s+(.*))?$/.exec(key);
var event = match[1];
var selector = match[3];
event += '.widget_events';
if (!selector) {
this.$el.on(event, method);
} else {
this.$el.on(event, selector, method);
}
}
},
undelegateEvents: function () {
this.$el.off('.widget_events');
},
/**
* Shortcut for ``this.$el.find(selector)``
*
* @param {String} selector CSS selector, rooted in $el
* @returns {jQuery} selector match
*/
$: function(selector) {
if (selector === undefined)
return this.$el;
return this.$el.find(selector);
},
do_show: function () {
this.$el.show();
},
do_hide: function () {
this.$el.hide();
},
/**
* Proxies a method of the object, in order to keep the right ``this`` on
* method invocations.
*
* This method is similar to ``Function.prototype.bind`` or ``_.bind``, and
* even more so to ``jQuery.proxy`` with a fundamental difference: its
* resolution of the method being called is lazy, meaning it will use the
* method as it is when the proxy is called, not when the proxy is created.
*
* Other methods will fix the bound method to what it is when creating the
* binding/proxy, which is fine in most javascript code but problematic in
* OpenERP Web where developers may want to replace existing callbacks with
* theirs.
*
* The semantics of this precisely replace closing over the method call.
*
* @param {String|Function} method function or name of the method to invoke
* @returns {Function} proxied method
*/
proxy: function (method) {
var self = this;
return function () {
var fn = (typeof method === 'string') ? self[method] : method;
return fn.apply(self, arguments);
};
},
/**
* Informs the action manager to do an action. This supposes that
* the action manager can be found amongst the ancestors of the current widget.
* If that's not the case this method will simply return `false`.
*/
do_action: function() {
var parent = this.getParent();
if (parent) {
return parent.do_action.apply(parent, arguments);
}
return false;
},
do_notify: function() {
if (this.getParent()) {
return this.getParent().do_notify.apply(this,arguments);
}
return false;
},
do_warn: function(title, message) {
core.bus.trigger('display_notification_warning', title, message);
},
rpc: function(url, data, options) {
return this.alive(session.rpc(url, data, options));
},
});
return Widget;
});
+261
View File
@@ -0,0 +1,261 @@
odoo.define('web.Menu', ['web.core', 'web.session', 'web.Widget'], function (require) {
"use strict";
var core = require('web.core');
var session = require('web.session');
var Widget = require('web.Widget');
var QWeb = core.qweb;
var Menu = Widget.extend({
init: function() {
var self = this;
this._super.apply(this, arguments);
this.is_bound = $.Deferred();
this.data = {data:{children:[]}};
this.on("menu_bound", this, function() {
// launch the fetch of needaction counters, asynchronous
var $all_menus = self.$el.parents('body').find('.oe_webclient').find('[data-menu]');
var all_menu_ids = _.map($all_menus, function (menu) {return parseInt($(menu).attr('data-menu'), 10);});
if (!_.isEmpty(all_menu_ids)) {
this.do_load_needaction(all_menu_ids);
}
});
core.bus.on('do_reload_needaction', this, this.do_reload_needaction);
},
start: function() {
this._super.apply(this, arguments);
return this.bind_menu();
},
do_reload: function() {
var self = this;
self.bind_menu();
},
bind_menu: function() {
var self = this;
this.$secondary_menus = this.$el.parents().find('.oe_secondary_menus_container');
this.$secondary_menus.on('click', 'a[data-menu]', this.on_menu_click);
this.$el.on('click', 'a[data-menu]', this.on_top_menu_click);
// Hide second level submenus
this.$secondary_menus.find('.oe_menu_toggler').siblings('.oe_secondary_submenu').hide();
if (self.current_menu) {
self.open_menu(self.current_menu);
}
this.trigger('menu_bound');
var lazyreflow = _.debounce(this.reflow.bind(this), 200);
core.bus.on('resize', this, function() {
if ($(window).width() < 768 ) {
lazyreflow('all_outside');
} else {
lazyreflow();
}
});
core.bus.trigger('resize');
this.is_bound.resolve();
},
do_load_needaction: function (menu_ids) {
var self = this;
menu_ids = _.compact(menu_ids);
if (_.isEmpty(menu_ids)) {
return $.when();
}
return this.rpc("/web/menu/load_needaction", {'menu_ids': menu_ids}).done(function(r) {
self.on_needaction_loaded(r);
});
},
on_needaction_loaded: function(data) {
var self = this;
this.needaction_data = data;
_.each(this.needaction_data, function (item, menu_id) {
var $item = self.$secondary_menus.find('a[data-menu="' + menu_id + '"]');
$item.find('.badge').remove();
if (item.needaction_counter && item.needaction_counter > 0) {
$item.append(QWeb.render("Menu.needaction_counter", { widget : item }));
}
});
},
/**
* Reflow the menu items and dock overflowing items into a "More" menu item.
* Automatically called when 'menu_bound' event is triggered and on window resizing.
*
* @param {string} behavior If set to 'all_outside', all the items are displayed.
* If not set, only the overflowing items are hidden.
*/
reflow: function(behavior) {
var self = this;
var $more_container = this.$('#menu_more_container').hide();
var $more = this.$('#menu_more');
var $systray = this.$el.parents().find('.oe_systray');
$more.children('li').insertBefore($more_container); // Pull all the items out of the more menu
// 'all_outside' beahavior should display all the items, so hide the more menu and exit
if (behavior === 'all_outside') {
// Show list of menu items
self.$el.show();
this.$el.find('li').show();
$more_container.hide();
return;
}
// Hide all menu items
var $toplevel_items = this.$el.find('li').not($more_container).not($systray.find('li')).hide();
// Show list of menu items (which is empty for now since all menu items are hidden)
self.$el.show();
$toplevel_items.each(function() {
var remaining_space = self.$el.parent().width() - $more_container.outerWidth();
self.$el.parent().children(':visible').each(function() {
remaining_space -= $(this).outerWidth();
});
if ($(this).width() >= remaining_space) {
return false; // the current item will be appended in more_container
}
$(this).show(); // show the current item in menu bar
});
$more.append($toplevel_items.filter(':hidden').show());
$more_container.toggle(!!$more.children().length);
// Hide toplevel item if there is only one
var $toplevel = self.$el.children("li:visible");
if ($toplevel.length === 1) {
$toplevel.hide();
}
},
/**
* Opens a given menu by id, as if a user had browsed to that menu by hand
* except does not trigger any event on the way
*
* @param {Number} id database id of the terminal menu to select
*/
open_menu: function (id) {
this.current_menu = id;
session.active_id = id;
var $clicked_menu, $sub_menu, $main_menu;
$clicked_menu = this.$el.add(this.$secondary_menus).find('a[data-menu=' + id + ']');
this.trigger('open_menu', id, $clicked_menu);
if (this.$secondary_menus.has($clicked_menu).length) {
$sub_menu = $clicked_menu.parents('.oe_secondary_menu');
$main_menu = this.$el.find('a[data-menu=' + $sub_menu.data('menu-parent') + ']');
} else {
$sub_menu = this.$secondary_menus.find('.oe_secondary_menu[data-menu-parent=' + $clicked_menu.attr('data-menu') + ']');
$main_menu = $clicked_menu;
}
// Activate current main menu
this.$el.find('.active').removeClass('active');
$main_menu.parent().addClass('active');
// Show current sub menu
this.$secondary_menus.find('.oe_secondary_menu').hide();
$sub_menu.show();
// Hide/Show the leftbar menu depending of the presence of sub-items
this.$secondary_menus.parent('.oe_leftbar').toggle(!!$sub_menu.children().length);
// Activate current menu item and show parents
this.$secondary_menus.find('.active').removeClass('active');
if ($main_menu !== $clicked_menu) {
$clicked_menu.parents().show();
if ($clicked_menu.is('.oe_menu_toggler')) {
$clicked_menu.toggleClass('oe_menu_opened').siblings('.oe_secondary_submenu:first').toggle();
} else {
$clicked_menu.parent().addClass('active');
}
}
// add a tooltip to cropped menu items
this.$secondary_menus.find('.oe_secondary_submenu li a span').each(function() {
$(this).tooltip(this.scrollWidth > this.clientWidth ? {title: $(this).text().trim(), placement: 'right'} :'destroy');
});
},
/**
* Call open_menu with the first menu_item matching an action_id
*
* @param {Number} id the action_id to match
*/
open_action: function (id) {
var $menu = this.$el.add(this.$secondary_menus).find('a[data-action-id="' + id + '"]');
var menu_id = $menu.data('menu');
if (menu_id) {
this.open_menu(menu_id);
}
},
/**
* Process a click on a menu item
*
* @param {Number} id the menu_id
* @param {Boolean} [needaction=false] whether the triggered action should execute in a `needs action` context
*/
menu_click: function(id, needaction) {
if (!id) { return; }
// find back the menuitem in dom to get the action
var $item = this.$el.find('a[data-menu=' + id + ']');
if (!$item.length) {
$item = this.$secondary_menus.find('a[data-menu=' + id + ']');
}
var action_id = $item.data('action-id');
// If first level menu doesnt have action trigger first leaf
if (!action_id) {
if(this.$el.has($item).length) {
var $sub_menu = this.$secondary_menus.find('.oe_secondary_menu[data-menu-parent=' + id + ']');
var $items = $sub_menu.find('a[data-action-id]').filter('[data-action-id!=""]');
if($items.length) {
action_id = $items.data('action-id');
id = $items.data('menu');
}
}
}
if (action_id) {
this.trigger('menu_click', {
action_id: action_id,
needaction: needaction,
id: id,
previous_menu_id: this.current_menu // Here we don't know if action will fail (in which case we have to revert menu)
}, $item);
} else {
console.log('Menu no action found web test 04 will fail');
}
this.open_menu(id);
},
do_reload_needaction: function () {
var self = this;
if (self.current_menu) {
self.do_load_needaction([self.current_menu]).then(function () {
self.trigger("need_action_reloaded");
});
}
},
/**
* Jquery event handler for menu click
*
* @param {Event} ev the jquery event
*/
on_top_menu_click: function(ev) {
ev.preventDefault();
var self = this;
var id = $(ev.currentTarget).data('menu');
// Fetch the menu leaves ids in order to check if they need a 'needaction'
var $secondary_menu = this.$el.parents().find('.oe_secondary_menu[data-menu-parent=' + id + ']');
var $menu_leaves = $secondary_menu.children().find('.oe_menu_leaf');
var menu_ids = _.map($menu_leaves, function (leave) {return parseInt($(leave).attr('data-menu'), 10);});
self.do_load_needaction(menu_ids).then(function () {
self.trigger("need_action_reloaded");
});
this.$el.parents().find(".oe_secondary_menus_container").scrollTop(0,0);
this.on_menu_click(ev);
},
on_menu_click: function(ev) {
ev.preventDefault();
var needaction = $(ev.target).is('div#menu_counter');
this.menu_click($(ev.currentTarget).data('menu'), needaction);
},
});
return Menu;
});
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-83
View File
@@ -1,83 +0,0 @@
(function() {
openerp.test_support = {
setup_session: function (session) {
var origin = location.protocol+"//"+location.host;
_.extend(session, {
origin: origin,
prefix: origin,
server: origin, // keep chs happy
//openerp.web.qweb.default_dict['_s'] = this.origin;
rpc_function: session.rpc_json,
session_id: false,
uid: false,
username: false,
user_context: {},
db: false,
// this.module_list = openerp._modules.slice();
// this.module_loaded = {};
// _(this.module_list).each(function (mod) {
// self.module_loaded[mod] = true;
// });
context: {},
shortcuts: [],
active_id: null
});
return session.session_reload();
},
module: function (title, tested_core, nonliterals) {
var conf = QUnit.config.openerp = {};
QUnit.module(title, {
setup: function () {
QUnit.stop();
var oe = conf.openerp = window.openerp.init();
window.openerp.web[tested_core](oe);
var done = openerp.test_support.setup_session(oe.session);
if (nonliterals) {
done = done.then(function () {
return oe.session.rpc('/tests/add_nonliterals', {
domains: nonliterals.domains || [],
contexts: nonliterals.contexts || []
}).done(function (r) {
oe.domains = r.domains;
oe.contexts = r.contexts;
});
});
}
done.always(QUnit.start)
.done(function () {
conf.openerp = oe;
}).fail(function (e) {
QUnit.test(title, function () {
console.error(e);
QUnit.ok(false, 'Could not obtain a session:' + e.debug);
});
});
}
});
},
test: function (title, fn) {
var conf = QUnit.config.openerp;
QUnit.test(title, function () {
QUnit.stop();
fn(conf.openerp);
});
},
expect: function (promise, fn) {
promise.always(QUnit.start)
.done(function () { QUnit.ok(false, 'RPC requests should not succeed'); })
.fail(function (e) {
if (e.code !== 200) {
QUnit.equal(e.code, 200, 'Testing connector should raise RPC faults');
if (typeof console !== 'undefined' && console.error) {
console.error(e.data.debug);
}
return;
}
fn(e.data.name);
});
}
};
})();
-304
View File
@@ -1,304 +0,0 @@
// Test support structures and methods for OpenERP
openerp.testing = {};
(function (testing) {
var dependencies = {
pyeval: [],
core: ['pyeval'],
data: ['core'],
dates: [],
formats: ['core', 'dates'],
chrome: ['core'],
views: ['core', 'data', 'chrome'],
search: ['views', 'formats'],
list: ['views', 'data'],
form: ['data', 'views', 'list', 'formats'],
list_editable: ['list', 'form', 'data'],
};
var initialized = [];
/**
* openerp.init is a broken-ass piece of shit, makes it impossible to
* progressively add modules, using it, retarded openerp_inited flag
* means the first tests being run get their dependencies loaded
* (basically just base and web), and for *every other module* the tests
* run without the dependencies being correctly loaded
*/
function init(modules) {
if (!initialized.length) {
openerp.init(modules);
initialized = openerp._modules.slice();
return;
}
var to_initialize = _.difference(modules, initialized);
for (var i = 0; i < to_initialize.length; i++) {
var modname = to_initialize[i];
var fct = openerp[modname];
if (typeof fct === 'function') {
var module = openerp[modname] = {};
for(var k in fct) {
if (!fct.hasOwnProperty(k)) { continue; }
module[k] = fct[k];
}
fct(openerp, module)
}
initialized.push(modname);
openerp._modules.push(modname);
}
}
testing.dependencies = window['oe_all_dependencies'] || [];
testing.current_module = null;
/**
* Function which does not do anything
*/
testing.noop = function () { };
/**
* Alter provided instance's ``session`` attribute to make response
* mockable:
*
* * The ``responses`` parameter can be used to provide a map of (RPC)
* paths (e.g. ``/web/view/load``) to a function returning a response
* to the query.
* * ``instance.session`` grows a ``responses`` attribute which is
* a map of the same (and is in fact initialized to the ``responses``
* parameter if one is provided)
*
* Note that RPC requests to un-mocked URLs will be rejected with an
* error message: only explicitly specified urls will get a response.
*
* Mocked sessions will *never* perform an actual RPC connection.
*
* @param instance openerp instance being initialized
* @param {Object} [responses]
*/
testing.mockifyRPC = function (instance, responses) {
var session = instance.session;
session.responses = responses || {};
session.rpc = function(url, rparams, options) {
if (_.isString(url)) {
url = {url: url};
}
var fn, params;
var needle = rparams.model + ':' + rparams.method;
if (url.url.substr(0, 20) === '/web/dataset/call_kw' && needle in this.responses) {
fn = this.responses[needle];
params = [
rparams.args || [],
rparams.kwargs || {}
];
} else {
fn = this.responses[url.url];
params = [{params: rparams}];
}
if (!fn) {
return $.Deferred().reject({}, 'failed',
_.str.sprintf("Url %s not found in mock responses, with arguments %s",
url.url, JSON.stringify(rparams))
).promise();
}
try {
return $.when(fn.apply(null, params)).then(function (result) {
// Wrap for RPC layer unwrapper thingy
return result;
});
} catch (e) {
// not sure why this looks like that
return $.Deferred().reject({}, 'failed', String(e));
}
};
};
var StackProto = {
execute: function (fn) {
var args = [].slice.call(arguments, 1);
// Warning: here be dragons
var i = 0, setups = this.setups, teardowns = this.teardowns;
var d = $.Deferred();
var succeeded, failed;
var success = function () {
succeeded = _.toArray(arguments);
return teardown();
};
var failure = function () {
// save first failure
if (!failed) {
failed = _.toArray(arguments);
}
// chain onto next teardown
return teardown();
};
var setup = function () {
// if setup to execute
if (i < setups.length) {
var f = setups[i] || testing.noop;
$.when(f.apply(null, args)).then(function () {
++i;
setup();
}, failure);
} else {
$.when(fn.apply(null, args)).then(success, failure);
}
};
var teardown = function () {
// if teardown to execute
if (i > 0) {
var f = teardowns[--i] || testing.noop;
$.when(f.apply(null, args)).then(teardown, failure);
} else {
if (failed) {
d.reject.apply(d, failed);
} else if (succeeded) {
d.resolve.apply(d, succeeded);
} else {
throw new Error("Didn't succeed or fail?");
}
}
};
setup();
return d;
},
push: function (setup, teardown) {
return _.extend(Object.create(StackProto), {
setups: this.setups.concat([setup]),
teardowns: this.teardowns.concat([teardown])
});
},
unshift: function (setup, teardown) {
return _.extend(Object.create(StackProto), {
setups: [setup].concat(this.setups),
teardowns: [teardown].concat(this.teardowns)
});
}
};
/**
*
* @param {Function} [setup]
* @param {Function} [teardown]
* @return {*}
*/
testing.Stack = function (setup, teardown) {
return _.extend(Object.create(StackProto), {
setups: setup ? [setup] : [],
teardowns: teardown ? [teardown] : []
});
};
testing.section = function (name, options, body) {
if (_.isFunction(options)) {
body = options;
options = {};
}
_.defaults(options, {
setup: testing.noop,
teardown: testing.noop
});
QUnit.module(name, {_oe: options});
body(testing['case']);
};
testing['case'] = function (name, options, callback) {
if (_.isFunction(options)) {
callback = options;
options = {};
}
var module = testing.current_module;
var module_index = _.indexOf(testing.dependencies, module);
var module_deps = testing.dependencies.slice(
// If module not in deps (because only tests, no JS) -> indexOf
// returns -1 -> index becomes 0 -> replace with ``undefined`` so
// Array#slice returns a full copy
0, module_index + 1 || undefined);
// Serialize options for this precise test case
var env = QUnit.config.currentModuleTestEnvironment;
// section setup
// case setup
// test
// case teardown
// section teardown
var case_stack = testing.Stack()
.push(env._oe.setup, env._oe.teardown)
.push(options.setup, options.teardown);
var opts = _.defaults({}, options, env._oe);
QUnit.test(name, function () {
var instance = openerp;
init(module_deps);
instance.session = new instance.web.Session();
instance.session.uid = 42;
if (_.isNumber(opts.asserts)) {
expect(opts.asserts);
}
var $fixture = $('#qunit-fixture');
var mock, async = false;
switch (opts.rpc) {
case 'mock':
async = true;
testing.mockifyRPC(instance);
mock = function (spec, handler) {
instance.session.responses[spec] = handler;
};
break;
}
// Always execute tests asynchronously
stop();
var timeout;
case_stack.execute(function () {
var result = callback.apply(null, arguments);
if (!(result && _.isFunction(result.then))) {
if (async) {
ok(false, "asynchronous test cases must return a promise");
}
} else {
if (!_.isNumber(opts.asserts)) {
ok(false, "asynchronous test cases must specify the "
+ "number of assertions they expect");
}
}
return $.Deferred(function (d) {
$.when(result).then(function () {
d.resolve.apply(d, arguments);
}, function () {
d.reject.apply(d, arguments);
});
if (async || (result && result.then)) {
// async test can be either implicit async (rpc) or
// promise-returning
timeout = setTimeout(function () {
QUnit.config.semaphore = 1;
d.reject({message: "Test timed out"});
}, 2000);
}
});
}, instance, $fixture, mock).always(function () {
if (timeout) { clearTimeout(timeout); }
start();
}).fail(function (error) {
if (options.fail_on_rejection === false) {
return;
}
var message;
if (typeof error !== 'object'
|| typeof error.message !== 'string') {
message = JSON.stringify([].slice.apply(arguments));
} else {
message = error.message;
if (error.data && error.data.debug) {
message += '\n\n' + error.data.debug;
}
}
ok(false, message);
});
});
};
})(openerp.testing);
+31 -24
View File
@@ -1,24 +1,29 @@
(function () {
'use strict';
odoo.define('web.Tour', ['web.core'], function (require) {
"use strict";
var core = require('web.core');
var _t = core._t;
var qweb = core.qweb;
// make this work
// raise an error in test mode if openerp don't exist
if (typeof openerp === "undefined") {
var error = "openerp is undefined"
+ "\nhref: " + window.location.href
+ "\nreferrer: " + document.referrer
+ "\nlocalStorage: " + window.localStorage.getItem("tour");
if (typeof $ !== "undefined") {
error += '\n\n' + $("body").html();
}
throw new Error(error);
}
// if (typeof openerp === "undefined") {
// var error = "openerp is undefined"
// + "\nhref: " + window.location.href
// + "\nreferrer: " + document.referrer
// + "\nlocalStorage: " + window.localStorage.getItem("tour");
// if (typeof $ !== "undefined") {
// error += '\n\n' + $("body").html();
// }
// throw new Error(error);
// }
var website = openerp.website;
// don't rewrite T in test mode
if (typeof openerp.Tour !== "undefined") {
return;
}
// // don't rewrite T in test mode
// if (typeof openerp.Tour !== "undefined") {
// return;
// }
/////////////////////////////////////////////////
@@ -130,8 +135,8 @@ var Tour = {
id: index,
waitNot: step.waitNot,
waitFor: step.waitFor,
title: openerp._t("Move your mouse"),
content: openerp._t("Move your mouse here to open the insert block"),
title: _t("Move your mouse"),
content: _t("Move your mouse here to open the insert block"),
element: '#oe_snippets',
onend: function () {
$("#oe_snippets").addClass("o_open");
@@ -321,7 +326,7 @@ var Tour = {
Tour._load_template = true;
if (typeof QWeb2 === "undefined") return $.when();
var def = $.Deferred();
openerp.qweb.add_template('/web/static/src/xml/website.tour.xml', function(err) {
qweb.add_template('/web/static/src/xml/website.tour.xml', function(err) {
if (err) {
def.reject(err);
} else {
@@ -331,10 +336,10 @@ var Tour = {
return def;
},
popoverTitle: function (tour, options) {
return typeof QWeb2 !== "undefined" ? openerp.qweb.render('tour.popover_title', options) : options.title;
return typeof QWeb2 !== "undefined" ? qweb.render('tour.popover_title', options) : options.title;
},
popover: function (options) {
return typeof QWeb2 !== "undefined" ? openerp.qweb.render('tour.popover', options) : options.title;
return typeof QWeb2 !== "undefined" ? qweb.render('tour.popover', options) : options.title;
},
getLang: function () {
return $("html").attr("lang") ? "/" + $("html").attr("lang").replace(/-/, '_') : "";
@@ -697,10 +702,12 @@ var Tour = {
}
}
};
openerp.Tour = Tour;
// openerp.Tour = Tour;
/////////////////////////////////////////////////
$(document).ready(Tour.running);
}());
return Tour;
});
File diff suppressed because it is too large Load Diff
@@ -1,830 +0,0 @@
/**
* handles editability case for lists, because it depends on form and forms already depends on lists it had to be split out
* @namespace
*/
(function() {
var instance = openerp;
openerp.web.list_editable = {};
var _t = instance.web._t;
// editability status of list rows
instance.web.ListView.prototype.defaults.editable = null;
// TODO: not sure second @lends on existing item is correct, to check
instance.web.ListView.include(/** @lends instance.web.ListView# */{
init: function () {
var self = this;
this._super.apply(this, arguments);
this.saving_mutex = new $.Mutex();
this._force_editability = null;
this._context_editable = false;
this.editor = this.make_editor();
// Stores records of {field, cell}, allows for re-rendering fields
// depending on cell state during and after resize events
this.fields_for_resize = [];
instance.web.bus.on('resize', this, this.resize_fields);
$(this.groups).bind({
'edit': function (e, id, dataset) {
self.do_edit(dataset.index, id, dataset);
},
'saved': function () {
if (self.groups.get_selection().length) {
return;
}
self.configure_pager(self.dataset);
self.compute_aggregates();
}
});
this.records.bind('remove', function () {
if (self.editor.is_editing()) {
self.cancel_edition();
}
});
this.on('edit:before', this, function (event) {
if (!self.editable() || self.editor.is_editing()) {
event.cancel = true;
}
});
this.on('edit:after', this, function () {
self.$el.add(self.$buttons).addClass('oe_editing');
self.$('.ui-sortable').sortable('disable');
});
this.on('save:after cancel:after', this, function () {
self.$('.ui-sortable').sortable('enable');
self.$el.add(self.$buttons).removeClass('oe_editing');
});
},
destroy: function () {
instance.web.bus.off('resize', this, this.resize_fields);
this._super();
},
do_hide: function () {
if (this.editor.is_editing()) {
this.cancel_edition(true);
}
this._super();
},
sort_by_column: function (e) {
e.stopPropagation();
if (!this.editor.is_editing()) {
this._super.apply(this, arguments);
}
},
/**
* Handles the activation of a record in editable mode (making a record
* editable), called *after* the record has become editable.
*
* The default behavior is to setup the listview's dataset to match
* whatever dataset was provided by the editing List
*
* @param {Number} index index of the record in the dataset
* @param {Object} id identifier of the record being edited
* @param {instance.web.DataSet} dataset dataset in which the record is available
*/
do_edit: function (index, id, dataset) {
_.extend(this.dataset, dataset);
},
do_delete: function (ids) {
var nonfalse = _.compact(ids);
var _super = this._super.bind(this);
var next = this.editor.is_editing()
? this.cancel_edition(true)
: $.when();
return next.then(function () {
return _super(nonfalse);
});
},
editable: function () {
return !this.grouped
&& !this.options.disable_editable_mode
&& (this.fields_view.arch.attrs.editable
|| this._context_editable
|| this.options.editable);
},
/**
* Replace do_search to handle editability process
*/
do_search: function(domain, context, group_by) {
var self=this, _super = self._super, args=arguments;
var ready = this.editor.is_editing()
? this.cancel_edition(true)
: $.when();
return ready.then(function () {
self._context_editable = !!context.set_editable;
return _super.apply(self, args);
});
},
/**
* Replace do_add_record to handle editability (and adding new record
* as an editable row at the top or bottom of the list)
*/
do_add_record: function () {
var self = this;
if (this.editable()) {
this.$el.find('table:first').show();
this.$el.find('.oe_view_nocontent').remove();
this.start_edition();
} else {
this._super();
}
},
load_list: function (data, grouped) {
var self = this;
// tree/@editable takes priority on everything else if present.
var result = this._super(data, grouped);
// In case current editor was started previously, also has to run
// when toggling from editable to non-editable in case form widgets
// have setup global behaviors expecting themselves to exist
// somehow.
this.editor.destroy();
// Editor is not restartable due to formview not being restartable
this.editor = this.make_editor();
if (this.editable()) {
this.$el.addClass('oe_list_editable');
// FIXME: any hook available to ensure this is only done once?
this.$buttons
.off('click', '.oe_list_save')
.on('click', '.oe_list_save', this.proxy('save_edition'))
.off('click', '.oe_list_discard')
.on('click', '.oe_list_discard', function (e) {
e.preventDefault();
self.cancel_edition();
});
var editor_ready = this.editor.prependTo(this.$el)
.done(this.proxy('setup_events'));
return $.when(result, editor_ready);
} else {
this.$el.removeClass('oe_list_editable');
}
return result;
},
/**
* Builds a new editor object
*
* @return {instance.web.list.Editor}
*/
make_editor: function () {
return new instance.web.list.Editor(this);
},
do_button_action: function (name, id, callback) {
var self = this, args = arguments;
this.ensure_saved().done(function (done) {
if (!id && done.created) {
id = done.record.get('id');
}
self.handle_button(name, id, callback);
});
},
/**
* Ensures the editable list is saved (saves any pending edition if
* needed, or tries to)
*
* Returns a deferred to the end of the saving.
*
* @returns {$.Deferred}
*/
ensure_saved: function () {
return this.save_edition();
},
/**
* Builds a record with the provided id (``false`` for a creation),
* setting all columns with ``false`` value so code which relies on
* having an actual value behaves correctly
*
* @param {*} id
* @return {instance.web.list.Record}
*/
make_empty_record: function (id) {
var attrs = {id: id};
_(this.columns).chain()
.filter(function (x) { return x.tag === 'field';})
.pluck('name')
.each(function (field) { attrs[field] = false; });
return new instance.web.list.Record(attrs);
},
/**
* Set up the edition of a record of the list view "inline"
*
* @param {instance.web.list.Record} [record] record to edit, leave empty to create a new record
* @param {Object} [options]
* @param {String} [options.focus_field] field to focus at start of edition
* @return {jQuery.Deferred}
*/
start_edition: function (record, options) {
var self = this;
var item = false;
if (record) {
item = record.attributes;
this.dataset.select_id(record.get('id'));
} else {
record = this.make_empty_record(false);
this.records.add(record, {
at: this.prepends_on_create() ? 0 : null});
}
return this.ensure_saved().then(function(){
return $.when.apply(null, self.editor.form.render_value_defs);
}).then(function () {
var $recordRow = self.groups.get_row_for(record);
var cells = self.get_cells_for($recordRow);
var fields = {};
self.fields_for_resize.splice(0, self.fields_for_resize.length);
return self.with_event('edit', {
record: record.attributes,
cancel: false
}, function () {
return self.editor.edit(item, function (field_name, field) {
var cell = cells[field_name];
if (!cell) {
return;
}
// FIXME: need better way to get the field back from bubbling (delegated) DOM events somehow
field.$el.attr('data-fieldname', field_name);
fields[field_name] = field;
self.fields_for_resize.push({field: field, cell: cell});
}, options).then(function () {
$recordRow.addClass('oe_edition');
self.resize_fields();
var focus_field = options && options.focus_field ? options.focus_field : undefined;
if (!focus_field || !fields[focus_field]){
focus_field = _.find(self.editor.form.fields_order, function(field){ return fields[field] && fields[field].$el.is(':visible:has(input)'); });
}
if (focus_field && fields[focus_field]) fields[focus_field].$el.find('input').select();
return record.attributes;
});
}).fail(function () {
// if the start_edition event is cancelled and it was a
// creation, remove the newly-created empty record
if (!record.get('id')) {
self.records.remove(record);
}
});
});
},
get_cells_for: function ($row) {
var cells = {};
$row.children('td').each(function (index, el) {
cells[el.getAttribute('data-field')] = el;
});
return cells;
},
/**
* If currently editing a row, resizes all registered form fields based
* on the corresponding row cell
*/
resize_fields: function () {
if (!this.editor.is_editing()) { return; }
for(var i=0, len=this.fields_for_resize.length; i<len; ++i) {
var item = this.fields_for_resize[i];
this.resize_field(item.field, item.cell);
}
},
/**
* Resizes a field's root element based on the corresponding cell of
* a listview row
*
* @param {instance.web.form.AbstractField} field
* @param {jQuery} cell
*/
resize_field: function (field, cell) {
var $cell = $(cell);
field.set_dimensions($cell.outerHeight(), $cell.outerWidth());
field.$el.position({
my: 'left top',
at: 'left top',
of: $cell
});
if (field.get('effective_readonly')) {
field.$el.addClass('oe_readonly');
}
if(field.widget == "handle")
field.$el.addClass('oe_list_field_handle');
},
/**
* @return {jQuery.Deferred}
*/
save_edition: function () {
var self = this;
return self.saving_mutex.exec(function() {
if (!self.editor.is_editing()) {
return $.when();
}
return self.with_event('save', {
editor: self.editor,
form: self.editor.form,
cancel: false
}, function () {
return self.editor.save().then(function (attrs) {
var created = false;
var record = self.records.get(attrs.id);
if (!record) {
// new record
created = true;
record = self.records.find(function (r) {
return !r.get('id');
}).set('id', attrs.id);
}
// onwrite callback could be altering & reloading the
// record which has *just* been saved, so first perform all
// onwrites then do a final reload of the record
return self.handle_onwrite(record)
.then(function () {
return self.reload_record(record); })
.then(function () {
return { created: created, record: record }; });
});
});
});
},
/**
* @param {Boolean} [force=false] discards the data even if the form has been edited
* @return {jQuery.Deferred}
*/
cancel_edition: function (force) {
var self = this;
return this.with_event('cancel', {
editor: this.editor,
form: this.editor.form,
cancel: false
}, function () {
return this.editor.cancel(force).then(function (attrs) {
if (attrs.id) {
var record = self.records.get(attrs.id);
if (!record) {
// Record removed by third party during edition
return;
}
return self.reload_record(record, {do_not_evict: true});
}
var to_delete = self.records.find(function (r) {
return !r.get('id');
});
if (to_delete) {
self.records.remove(to_delete);
}
});
});
},
/**
* Executes an action on the view's editor bracketed by a cancellable
* event of the name provided.
*
* The event name provided will be post-fixed with ``:before`` and
* ``:after``, the ``event`` parameter will be passed alongside the
* ``:before`` variant and if the parameter's ``cancel`` key is set to
* ``true`` the action *will not be called* and the method will return
* a rejection
*
* @param {String} event_name name of the event
* @param {Object} event event object, provided to ``:before`` sub-event
* @param {Function} action callable, called with the view's editor as its context
* @param {Array} [args] supplementary arguments provided to the action
* @param {Array} [trigger_params] supplementary arguments provided to the ``:after`` sub-event, before anything fetched by the ``action`` function
* @return {jQuery.Deferred}
*/
with_event: function (event_name, event, action) {
var self = this;
event = event || {};
this.trigger(event_name + ':before', event);
if (event.cancel) {
return $.Deferred().reject({
message: _.str.sprintf("Event %s:before cancelled",
event_name)});
}
return $.when(action.call(this)).done(function () {
self.trigger.apply(self, [event_name + ':after']
.concat(_.toArray(arguments)));
});
},
edition_view: function (editor) {
var view = $.extend(true, {}, this.fields_view);
view.arch.tag = 'form';
_.extend(view.arch.attrs, {
'class': 'oe_form_container',
version: '7.0'
});
_(view.arch.children).chain()
.zip(_(this.columns).filter(function (c) {
return !(c instanceof instance.web.list.MetaColumn);}))
.each(function (ar) {
var widget = ar[0], column = ar[1];
var modifiers = _.extend({}, column.modifiers);
widget.attrs.nolabel = true;
if (modifiers['tree_invisible'] || widget.tag === 'button') {
modifiers.invisible = true;
}
widget.attrs.modifiers = JSON.stringify(modifiers);
});
return view;
},
handle_onwrite: function (source_record) {
var self = this;
var on_write_callback = self.fields_view.arch.attrs.on_write;
if (!on_write_callback) { return $.when(); }
return this.dataset.call(on_write_callback, [source_record.get('id')])
.then(function (ids) {
return $.when.apply(
null, _(ids).map(
_.bind(self.handle_onwrite_record, self, source_record)));
});
},
handle_onwrite_record: function (source_record, id) {
var record = this.records.get(id);
if (!record) {
// insert after the source record
var index = this.records.indexOf(source_record) + 1;
record = this.make_empty_record(id);
this.records.add(record, {at: index});
}
return this.reload_record(record);
},
prepends_on_create: function () {
return this.editable() === 'top';
},
setup_events: function () {
var self = this;
_.each(this.editor.form.fields, function(field, field_name) {
field.on("change:effective_readonly", self, function(){
var item = _(self.fields_for_resize).find(function (item) {
return item.field === field;
});
if (item) {
setTimeout(function() {
self.resize_field(item.field, item.cell);
}, 0);
}
});
});
this.editor.$el.on('keyup keypress keydown', function (e) {
if (!self.editor.is_editing()) { return true; }
var key = _($.ui.keyCode).chain()
.map(function (v, k) { return {name: k, code: v}; })
.find(function (o) { return o.code === e.which; })
.value();
if (!key) { return true; }
var method = e.type + '_' + key.name;
if (!(method in self)) { return true; }
return self[method](e);
});
},
/**
* Saves the current record, and goes to the next one (creation or
* edition)
*
* @private
* @param {String} [next_record='succ'] method to call on the records collection to get the next record to edit
* @param {Object} [options]
* @param {String} [options.focus_field]
* @return {*}
*/
_next: function (next_record, options) {
next_record = next_record || 'succ';
var self = this;
return this.save_edition().then(function (saveInfo) {
if (!saveInfo) { return null; }
if (saveInfo.created) {
return self.start_edition();
}
var record = self.records[next_record](
saveInfo.record, {wraparound: true});
return self.start_edition(record, options);
});
},
keypress_ENTER: function () {
return this._next();
},
keydown_ESCAPE: function (e) {
return false;
},
keyup_ESCAPE: function (e) {
return this.cancel_edition();
},
/**
* Gets the selection range (start, end) for the provided element,
* returns ``null`` if it can't get a range.
*
* @private
*/
_text_selection_range: function (el) {
var selectionStart;
try {
selectionStart = el.selectionStart;
} catch (e) {
// radio or checkbox throw on selectionStart access
return null;
}
if (selectionStart !== undefined) {
return {
start: selectionStart,
end: el.selectionEnd
};
} else if (document.body.createTextRange) {
throw new Error("Implement text range handling for MSIE");
}
// Element without selection ranges (select, div/@contenteditable)
return null;
},
_text_cursor: function (el) {
var selection = this._text_selection_range(el);
if (!selection) {
return null;
}
if (selection.start !== selection.end) {
return {position: null, collapsed: false};
}
return {position: selection.start, collapsed: true};
},
/**
* Checks if the cursor is at the start of the provided el
*
* @param {HTMLInputElement | HTMLTextAreaElement}
* @returns {Boolean}
* @private
*/
_at_start: function (cursor, el) {
return cursor.collapsed && (cursor.position === 0);
},
/**
* Checks if the cursor is at the end of the provided el
*
* @param {HTMLInputElement | HTMLTextAreaElement}
* @returns {Boolean}
* @private
*/
_at_end: function (cursor, el) {
return cursor.collapsed && (cursor.position === el.value.length);
},
/**
* @param DOMEvent event
* @param {String} record_direction direction to move into to get the next record (pred | succ)
* @param {Function} is_valid_move whether the edition should be moved to the next record
* @private
*/
_key_move_record: function (event, record_direction, is_valid_move) {
if (!this.editor.is_editing('edit')) { return $.when(); }
var cursor = this._text_cursor(event.target);
// if text-based input (has a cursor)
// and selecting (not collapsed) or not at a field boundary
// don't move to the next record
if (cursor && !is_valid_move(event.target, cursor)) { return $.when(); }
event.preventDefault();
var source_field = $(event.target).closest('[data-fieldname]')
.attr('data-fieldname');
return this._next(record_direction, {focus_field: source_field});
},
keydown_UP: function (e) {
var self = this;
return this._key_move_record(e, 'pred', function (el, cursor) {
return self._at_start(cursor, el);
});
},
keydown_DOWN: function (e) {
var self = this;
return this._key_move_record(e, 'succ', function (el, cursor) {
return self._at_end(cursor, el);
});
},
keydown_LEFT: function (e) {
// If the cursor is at the beginning of the field
var source_field = $(e.target).closest('[data-fieldname]')
.attr('data-fieldname');
var cursor = this._text_cursor(e.target);
if (cursor && !this._at_start(cursor, e.target)) { return $.when(); }
var fields_order = this.editor.form.fields_order;
var field_index = _(fields_order).indexOf(source_field);
// Look for the closest visible form field to the left
var fields = this.editor.form.fields;
var field;
do {
if (--field_index < 0) { return $.when(); }
field = fields[fields_order[field_index]];
} while (!field.$el.is(':visible'));
// and focus it
field.focus();
return $.when();
},
keydown_RIGHT: function (e) {
// same as above, but with cursor at the end of the field and
// looking for new fields at the right
var source_field = $(e.target).closest('[data-fieldname]')
.attr('data-fieldname');
var cursor = this._text_cursor(e.target);
if (cursor && !this._at_end(cursor, e.target)) { return $.when(); }
var fields_order = this.editor.form.fields_order;
var field_index = _(fields_order).indexOf(source_field);
var fields = this.editor.form.fields;
var field;
do {
if (++field_index >= fields_order.length) { return $.when(); }
field = fields[fields_order[field_index]];
} while (!field.$el.is(':visible'));
field.focus();
return $.when();
},
keydown_TAB: function (e) {
var form = this.editor.form;
var last_field = _(form.fields_order).chain()
.map(function (name) { return form.fields[name]; })
.filter(function (field) { return field.$el.is(':visible') && !field.get('effective_readonly'); })
.last()
.value();
// tabbed from last field in form
if (last_field && last_field.$el.has(e.target).length) {
e.preventDefault();
return this._next();
}
return $.when();
}
});
instance.web.list.Editor = instance.web.Widget.extend({
/**
* @constructs instance.web.list.Editor
* @extends instance.web.Widget
*
* Adapter between listview and formview for editable-listview purposes
*
* @param {instance.web.Widget} parent
* @param {Object} options
* @param {instance.web.FormView} [options.formView=instance.web.FormView]
* @param {Object} [options.delegate]
*/
init: function (parent, options) {
this._super(parent);
this.options = options || {};
_.defaults(this.options, {
formView: instance.web.FormView,
delegate: this.getParent()
});
this.delegate = this.options.delegate;
this.record = null;
this.form = new (this.options.formView)(
this, this.delegate.dataset, false, {
initial_mode: 'edit',
disable_autofocus: true,
$buttons: $(),
$pager: $()
});
},
start: function () {
var self = this;
var _super = this._super();
this.form.embedded_view = this._validate_view(
this.delegate.edition_view(this));
var form_ready = this.form.appendTo(this.$el).done(
self.form.proxy('do_hide'));
return $.when(_super, form_ready);
},
_validate_view: function (edition_view) {
if (!edition_view) {
throw new Error("editor delegate's #edition_view must return "
+ "a view descriptor");
}
var arch = edition_view.arch;
if (!(arch && arch.children instanceof Array)) {
throw new Error("Editor delegate's #edition_view must have a" +
" non-empty arch");
}
if (arch.tag !== "form") {
throw new Error("Editor delegate's #edition_view must have a" +
" 'form' root node");
}
if (!(arch.attrs && arch.attrs.version === "7.0")) {
throw new Error("Editor delegate's #edition_view must be a" +
" version 7 view");
}
if (!/\boe_form_container\b/.test(arch.attrs['class'])) {
throw new Error("Editor delegate's #edition_view must have the" +
" class 'oe_form_container' on its root" +
" element");
}
return edition_view;
},
/**
*
* @param {String} [state] either ``new`` or ``edit``
* @return {Boolean}
*/
is_editing: function (state) {
if (!this.record) {
return false;
}
switch(state) {
case null: case undefined:
return true;
case 'new': return !this.record.id;
case 'edit': return !!this.record.id;
}
throw new Error("is_editing's state filter must be either `new` or" +
" `edit` if provided");
},
edit: function (record, configureField, options) {
// TODO: specify sequence of edit calls
var self = this;
var form = self.form;
var loaded = record
? form.trigger('load_record', _.extend({}, record))
: form.load_defaults();
return $.when(loaded).then(function () {
return form.do_show({reload: false});
}).then(function () {
self.record = form.datarecord;
_(form.fields).each(function (field, name) {
configureField(name, field);
});
return form;
});
},
save: function () {
var self = this;
return this.form
.save(this.delegate.prepends_on_create())
.then(function (result) {
var created = result.created && !self.record.id;
if (created) {
self.record.id = result.result;
}
return self.cancel();
});
},
cancel: function (force) {
if (!(force || this.form.can_be_discarded())) {
return $.Deferred().reject({
message: _t("The form's data can not be discarded")}).promise();
}
var record = this.record;
this.record = null;
this.form.do_hide();
return $.when(record);
},
});
instance.web.ListView.Groups.include(/** @lends instance.web.ListView.Groups# */{
passthrough_events: instance.web.ListView.Groups.prototype.passthrough_events + " edit saved",
get_row_for: function (record) {
return _(this.children).chain()
.invoke('get_row_for', record)
.compact()
.first()
.value();
}
});
instance.web.ListView.List.include(/** @lends instance.web.ListView.List# */{
row_clicked: function (event) {
if (!this.view.editable() || ! this.view.is_action_enabled('edit')) {
return this._super.apply(this, arguments);
}
var record_id = $(event.currentTarget).data('id');
return this.view.start_edition(
record_id ? this.records.get(record_id) : null, {
focus_field: $(event.target).not(".oe_readonly").data('field')
});
},
/**
* If a row mapping to the record (@data-id matching the record's id or
* no @data-id if the record has no id), returns it. Otherwise returns
* ``null``.
*
* @param {Record} record the record to get a row for
* @return {jQuery|null}
*/
get_row_for: function (record) {
var id;
var $row = this.$current.children('[data-id=' + record.get('id') + ']');
if ($row.length) {
return $row;
}
return null;
}
});
})();
+514
View File
@@ -0,0 +1,514 @@
odoo.define('web.ViewManager', ['web.core', 'web.data', 'web.Dialog', 'web.formats', 'web.framework', 'web.Model', 'web.pyeval', 'web.utils', 'web.SearchView', 'web.session', 'web.Widget'], function (require) {
"use strict";
var core = require('web.core');
var data = require('web.data');
var Dialog = require('web.Dialog');
var formats = require('web.formats');
var framework = require('web.framework');
var Model = require('web.Model');
var pyeval = require('web.pyeval');
var utils = require('web.utils');
var SearchView = require('web.SearchView');
var session = require('web.session');
var Widget = require('web.Widget');
var _t = core._t;
var QWeb = core.qweb;
var ViewManager = Widget.extend({
template: "ViewManager",
/**
* @param {Object} [dataset] null object (... historical reasons)
* @param {Array} [views] List of [view_id, view_type]
* @param {Object} [flags] various boolean describing UI state
*/
init: function(parent, dataset, views, flags, action) {
if (action) {
flags = action.flags || {};
if (!('auto_search' in flags)) {
flags.auto_search = action.auto_search !== false;
}
if (action.res_model === 'board.board' && action.view_mode === 'form') {
action.target = 'inline';
// Special case for Dashboards
_.extend(flags, {
views_switcher : false,
display_title : false,
search_view : false,
pager : false,
sidebar : false,
action_buttons : false
});
}
this.action = action;
this.action_manager = parent;
this.debug = session.debug;
dataset = new data.DataSetSearch(this, action.res_model, action.context, action.domain);
if (action.res_id) {
dataset.ids.push(action.res_id);
dataset.index = 0;
}
views = action.views;
}
var self = this;
this._super(parent);
this.flags = flags || {};
this.dataset = dataset;
this.view_order = [];
this.url_states = {};
this.views = {};
this.view_stack = []; // used for breadcrumbs
this.active_view = null;
this.searchview = null;
this.active_search = null;
this.registry = core.view_registry;
this.title = this.action && this.action.name;
_.each(views, function (view) {
var view_type = view[1] || view.view_type,
View = core.view_registry.get(view_type, true),
view_label = View ? View.prototype.display_name: (void 'nope'),
view_descr = {
controller: null,
options: view.options || {},
view_id: view[0] || view.view_id,
type: view_type,
label: view_label,
embedded_view: view.embedded_view,
title: self.action && self.action.name,
button_label: View ? _.str.sprintf(_t('%(view_type)s view'), {'view_type': (view_label || view_type)}) : (void 'nope'),
};
self.view_order.push(view_descr);
self.views[view_type] = view_descr;
});
this.multiple_views = (self.view_order.length > 1);
},
/**
* @returns {jQuery.Deferred} initial view loading promise
*/
start: function() {
var self = this;
var default_view = this.flags.default_view || this.view_order[0].type,
default_options = this.flags[default_view] && this.flags[default_view].options;
if (this.flags.headless) {
this.$('.oe-view-manager-header').hide();
}
this._super();
var $sidebar = this.flags.sidebar ? this.$('.oe-view-manager-sidebar') : undefined,
$pager = this.$('.oe-view-manager-pager');
this.$breadcrumbs = this.$('.oe-view-title');
this.$switch_buttons = this.$('.oe-view-manager-switch button');
this.$header = this.$('.oe-view-manager-header');
this.$header_col = this.$header.find('.oe-header-title');
this.$search_col = this.$header.find('.oe-view-manager-search-view');
this.$switch_buttons.click(function (event) {
var view_type = $(this).data('view-type');
if ((view_type === 'form') && (self.active_view.type === 'form')) {
self._display_view(view_type);
} else {
self.switch_mode(view_type);
}
});
var views_ids = {};
_.each(this.views, function (view) {
views_ids[view.type] = view.view_id;
view.options = _.extend({
$buttons: self.$('.oe-' + view.type + '-buttons'),
$sidebar : $sidebar,
$pager : $pager,
action : self.action,
action_views_ids : views_ids,
}, self.flags, self.flags[view.type], view.options);
view.$container = self.$(".oe-view-manager-view-" + view.type);
// show options.$buttons as views will put their $buttons inside it
// and call show/hide on them
view.options.$buttons.show();
self.$('.oe-vm-switch-' + view.type).tooltip();
});
this.$('.oe_debug_view').click(this.on_debug_changed);
this.$el.addClass("oe_view_manager_" + ((this.action && this.action.target) || 'current'));
this.search_view_loaded = this.setup_search_view();
var main_view_loaded = this.switch_mode(default_view, null, default_options);
return $.when(main_view_loaded, this.search_view_loaded);
},
switch_mode: function(view_type, no_store, view_options) {
var self = this,
view = this.views[view_type];
if (!view) {
return $.Deferred().reject();
}
if ((view_type !== 'form') && (view_type !== 'diagram')) {
this.view_stack = [];
}
this.view_stack.push(view);
// Hide active view (at first rendering, there is no view to hide)
if (this.active_view && this.active_view !== view) {
if (this.active_view.controller) this.active_view.controller.do_hide();
if (this.active_view.$container) this.active_view.$container.hide();
}
this.active_view = view;
if (!view.created) {
view.created = this.create_view.bind(this)(view, view_options);
}
this.active_search = $.Deferred();
if (this.searchview &&
this.flags.auto_search &&
view.controller.searchable !== false) {
$.when(this.search_view_loaded, view.created).done(this.searchview.do_search);
} else {
this.active_search.resolve();
}
self.update_header();
return $.when(view.created, this.active_search).done(function () {
self.active_view = view;
self._display_view(view_options);
self.trigger('switch_mode', view_type, no_store, view_options);
if (self.debug) {
self.$('.oe_debug_view').html(QWeb.render('ViewManagerDebug', {
view: self.active_view.controller,
view_manager: self,
uid: session.uid,
}));
}
});
},
update_header: function () {
this.$switch_buttons.removeClass('active');
this.$('.oe-vm-switch-' + this.active_view.type).addClass('active');
},
_display_view: function (view_options) {
var self = this;
this.active_view.$container.show();
$.when(this.active_view.controller.do_show(view_options)).done(function () {
if (self.searchview) {
var is_hidden = self.active_view.controller.searchable === false;
self.searchview.toggle_visibility(!is_hidden);
self.$header_col.toggleClass('col-md-6', !is_hidden).toggleClass('col-md-12', is_hidden);
self.$search_col.toggle(!is_hidden);
}
self.display_breadcrumbs();
});
},
display_breadcrumbs: function () {
var self = this;
if (!this.action_manager) return;
var breadcrumbs = this.action_manager.get_breadcrumbs();
if (!breadcrumbs.length) return;
var $breadcrumbs = breadcrumbs.map(function (bc, index) {
return make_breadcrumb(bc, index === breadcrumbs.length - 1);
});
this.$breadcrumbs
.empty()
.append($breadcrumbs);
function make_breadcrumb (bc, is_last) {
var $bc = $('<li>')
.append(is_last ? bc.title : $('<a>').text(bc.title))
.toggleClass('active', is_last);
if (!is_last) {
$bc.click(function () {
self.action_manager.select_widget(bc.widget, bc.index);
});
}
return $bc;
}
},
create_view: function(view, view_options) {
var self = this,
View = this.registry.get(view.type),
options = _.clone(view.options),
view_loaded = $.Deferred();
if (view.type === "form" && ((this.action && (this.action.target === 'new' || this.action.target === 'inline'))
|| (view_options && view_options.mode === 'edit'))) {
options.initial_mode = 'edit';
}
var controller = new View(this, this.dataset, view.view_id, options),
$container = view.$container;
$container.hide();
view.controller = controller;
view.$container = $container;
if (view.embedded_view) {
controller.set_embedded_view(view.embedded_view);
}
controller.on('switch_mode', this, this.switch_mode.bind(this));
controller.on('history_back', this, function () {
if (self.action_manager) self.action_manager.trigger('history_back');
});
controller.on("change:title", this, function() {
self.display_breadcrumbs();
});
controller.on('view_loaded', this, function () {
view_loaded.resolve();
});
this.$('.oe-view-manager-pager > span').hide();
return $.when(controller.appendTo($container), view_loaded)
.done(function () {
self.trigger("controller_inited", view.type, controller);
});
},
select_view: function (index) {
var view_type = this.view_stack[index].type;
this.view_stack.splice(index);
return this.switch_mode(view_type);
},
/**
* @returns {Number|Boolean} the view id of the given type, false if not found
*/
get_view_id: function(view_type) {
return this.views[view_type] && this.views[view_type].view_id || false;
},
/**
* Sets up the current viewmanager's search view.
*
* @param {Number|false} view_id the view to use or false for a default one
* @returns {jQuery.Deferred} search view startup deferred
*/
setup_search_view: function() {
if (this.searchview) {
this.searchview.destroy();
}
var view_id = (this.action && this.action.search_view_id && this.action.search_view_id[0]) || false;
var search_defaults = {};
var context = this.action ? this.action.context : [];
_.each(context, function (value, key) {
var match = /^search_default_(.*)$/.exec(key);
if (match) {
search_defaults[match[1]] = value;
}
});
var options = {
hidden: this.flags.search_view === false,
disable_custom_filters: this.flags.search_disable_custom_filters,
$buttons: this.$('.oe-search-options'),
action: this.action,
};
this.searchview = new SearchView(this, this.dataset, view_id, search_defaults, options);
this.searchview.on('search_data', this, this.search.bind(this));
return this.searchview.appendTo(this.$(".oe-view-manager-search-view:first"));
},
search: function(domains, contexts, groupbys) {
var self = this,
controller = this.active_view.controller,
action_context = this.action.context || {},
view_context = controller.get_context();
pyeval.eval_domains_and_contexts({
domains: [this.action.domain || []].concat(domains || []),
contexts: [action_context, view_context].concat(contexts || []),
group_by_seq: groupbys || []
}).done(function (results) {
if (results.error) {
self.active_search.resolve();
throw new Error(
_.str.sprintf(_t("Failed to evaluate search criterions")+": \n%s",
JSON.stringify(results.error)));
}
self.dataset._model = new Model(
self.dataset.model, results.context, results.domain);
var groupby = results.group_by.length
? results.group_by
: action_context.group_by;
if (_.isString(groupby)) {
groupby = [groupby];
}
if (!controller.grouped && !_.isEmpty(groupby)){
self.dataset.set_sort([]);
}
$.when(controller.do_search(results.domain, results.context, groupby || [])).then(function() {
self.active_search.resolve();
});
});
},
do_push_state: function(state) {
if (this.action_manager) {
state.view_type = this.active_view.type;
this.action_manager.do_push_state(state);
}
},
do_load_state: function(state, warm) {
if (state.view_type && state.view_type !== this.active_view.type) {
// warning: this code relies on the fact that switch_mode has an immediate side
// effect (setting the 'active_view' to its new value) AND an async effect (the
// view is created/loaded). So, the next statement (do_load_state) is executed
// on the new view, after it was initialized, but before it is fully loaded and
// in particular, before the do_show method is called.
this.switch_mode(state.view_type, true);
}
this.active_view.controller.do_load_state(state, warm);
},
on_debug_changed: function (evt) {
var self = this,
params = $(evt.target).data(),
val = params.action,
current_view = this.active_view.controller;
switch (val) {
case 'fvg':
var dialog = new Dialog(this, { title: _t("Fields View Get") }).open();
$('<pre>').text(utils.json_node_to_xml(current_view.fields_view.arch, true)).appendTo(dialog.$el);
break;
case 'tests':
this.do_action({
name: _t("JS Tests"),
target: 'new',
type : 'ir.actions.act_url',
url: '/web/tests?mod=*'
});
break;
case 'get_metadata':
var ids = current_view.get_selected_ids();
if (ids.length === 1) {
this.dataset.call('get_metadata', [ids]).done(function(result) {
var dialog = new Dialog(this, {
title: _.str.sprintf(_t("Metadata (%s)"), self.dataset.model),
size: 'medium',
buttons: {
Ok: function() { this.parents('.modal').modal('hide');}
},
}, QWeb.render('ViewManagerDebugViewLog', {
perm : result[0],
format : formats.format_value
}));
dialog.open();
});
}
break;
case 'toggle_layout_outline':
current_view.rendering_engine.toggle_layout_debugging();
break;
case 'set_defaults':
current_view.open_defaults_dialog();
break;
case 'translate':
this.do_action({
name: _t("Technical Translation"),
res_model : 'ir.translation',
domain : [['type', '!=', 'object'], '|', ['name', '=', this.dataset.model], ['name', 'ilike', this.dataset.model + ',']],
views: [[false, 'list'], [false, 'form']],
type : 'ir.actions.act_window',
view_type : "list",
view_mode : "list"
});
break;
case 'fields':
this.dataset.call('fields_get', [false, {}]).done(function (fields) {
var $root = $('<dl>');
_(fields).each(function (attributes, name) {
$root.append($('<dt>').append($('<h4>').text(name)));
var $attrs = $('<dl>').appendTo($('<dd>').appendTo($root));
_(attributes).each(function (def, name) {
if (def instanceof Object) {
def = JSON.stringify(def);
}
$attrs
.append($('<dt>').text(name))
.append($('<dd style="white-space: pre-wrap;">').text(def));
});
});
new Dialog(self, {
title: _.str.sprintf(_t("Model %s fields"),
self.dataset.model),
buttons: {
Ok: function() { this.parents('.modal').modal('hide');}
},
}, $root).open();
});
break;
case 'edit_workflow':
return this.do_action({
res_model : 'workflow',
name: _t('Edit Workflow'),
domain : [['osv', '=', this.dataset.model]],
views: [[false, 'list'], [false, 'form'], [false, 'diagram']],
type : 'ir.actions.act_window',
view_type : 'list',
view_mode : 'list'
});
case 'edit':
this.do_edit_resource(params.model, params.id, evt.target.text);
break;
case 'manage_filters':
this.do_action({
res_model: 'ir.filters',
name: _t('Manage Filters'),
views: [[false, 'list'], [false, 'form']],
type: 'ir.actions.act_window',
context: {
search_default_my_filters: true,
search_default_model_id: this.dataset.model
}
});
break;
case 'print_workflow':
if (current_view.get_selected_ids && current_view.get_selected_ids().length == 1) {
framework.blockUI();
var action = {
context: { active_ids: current_view.get_selected_ids() },
report_name: "workflow.instance.graph",
datas: {
model: this.dataset.model,
id: current_view.get_selected_ids()[0],
nested: true,
}
};
this.session.get_file({
url: '/web/report',
data: {action: JSON.stringify(action)},
complete: framework.unblockUI
});
} else {
self.do_warn("Warning", "No record selected.");
}
break;
case 'leave_debug':
window.location.search="?";
break;
default:
if (val) {
console.warn("No debug handler for ", val);
}
}
},
do_edit_resource: function(model, id, name) {
this.do_action({
res_model : model,
res_id : id,
name: name,
type : 'ir.actions.act_window',
view_type : 'form',
view_mode : 'form',
views : [[false, 'form']],
target : 'new',
flags : {
action_buttons : true,
headless: true,
}
});
},
});
return ViewManager;
});
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,128 @@
odoo.define('web.GraphView', ['web.core', 'web.GraphWidget', 'web.Model', 'web.View'], function (require) {
"use strict";
/*---------------------------------------------------------
* Odoo Graph view
*---------------------------------------------------------*/
var core = require('web.core');
var GraphWidget = require('web.GraphWidget');
var Model = require('web.Model');
var View = require('web.View');
var _lt = core._lt;
var _t = core._t;
var QWeb = core.qweb;
var GraphView = View.extend({
className: 'oe_graph',
display_name: _lt('Graph'),
view_type: 'graph',
init: function(parent, dataset, view_id, options) {
this._super(parent, dataset, view_id, options);
this.model = new Model(dataset.model, {group_by_no_leaf: true});
this.measures = [];
this.active_measure = '__count__';
this.initial_groupbys = [];
this.widget = undefined;
},
start: function () {
var load_fields = this.model.call('fields_get', [])
.then(this.prepare_fields.bind(this));
return $.when(this._super(), load_fields).then(this.render_buttons.bind(this));
},
render_buttons: function () {
var context = {measures: _.pairs(_.omit(this.measures, '__count__'))};
this.$buttons.html(QWeb.render('GraphView.buttons', context));
this.$measure_list = this.$buttons.find('.oe-measure-list');
this.update_measure();
this.$buttons.find('button').tooltip();
this.$buttons.click(this.on_button_click.bind(this));
},
update_measure: function () {
var self = this;
this.$measure_list.find('li').each(function (index, li) {
$(li).toggleClass('selected', $(li).data('field') === self.active_measure);
});
},
view_loading: function (fvg) {
var self = this;
fvg.arch.children.forEach(function (field) {
var name = field.attrs.name;
if (field.attrs.interval) {
name += ':' + field.attrs.interval;
}
if (field.attrs.type === 'measure') {
self.active_measure = name;
} else {
self.initial_groupbys.push(name);
}
});
},
do_show: function () {
this.do_push_state({});
this.$el.show();
return this._super();
},
prepare_fields: function (fields) {
var self = this;
this.fields = fields;
_.each(fields, function (field, name) {
if ((name !== 'id') && (field.store === true)) {
if (field.type === 'integer' || field.type === 'float') {
self.measures[name] = field;
}
}
});
this.measures.__count__ = {string: _t("Quantity"), type: "integer"};
},
do_search: function (domain, context, group_by) {
if (!this.widget) {
this.initial_groupbys = context.graph_groupbys || this.initial_groupbys;
this.widget = new GraphWidget(this, this.dataset.model, {
measure: context.graph_measure || this.active_measure,
mode: context.graph_mode || this.active_mode,
domain: domain,
groupbys: this.initial_groupbys,
context: context,
fields: this.fields,
});
// append widget
this.$el.hide();
this.widget.appendTo(this.$el);
} else {
var groupbys = group_by.length ? group_by : this.initial_groupbys.slice(0);
this.widget.update_data(domain, groupbys);
}
},
get_context: function () {
return !this.widget ? {} : {
graph_mode: this.widget.mode,
graph_measure: this.widget.measure,
graph_groupbys: this.widget.groupbys
};
},
on_button_click: function (event) {
var $target = $(event.target);
if ($target.hasClass('oe-bar-mode')) {this.widget.set_mode('bar');}
if ($target.hasClass('oe-line-mode')) {this.widget.set_mode('line');}
if ($target.hasClass('oe-pie-mode')) {this.widget.set_mode('pie');}
if ($target.parents('.oe-measure-list').length) {
var parent = $target.parent(),
field = parent.data('field');
this.active_measure = field;
parent.toggleClass('selected');
event.stopPropagation();
this.update_measure();
this.widget.set_measure(this.active_measure);
}
},
});
core.view_registry.add('graph', GraphView);
return GraphView;
});
@@ -1,134 +1,20 @@
/*---------------------------------------------------------
* Odoo Pivot Table view
*---------------------------------------------------------*/
odoo.define('web.GraphWidget', ['web.core', 'web.Model', 'web.Widget'], function (require) {
"use strict";
(function (a, b, c) {
'use strict';
var core = require('web.core');
var Model = require('web.Model');
var Widget = require('web.Widget');
var instance = openerp,
_lt = instance.web._lt,
_t = instance.web._t,
QWeb = instance.web.qweb;
var _t = core._t;
var QWeb = core.qweb;
nv.dev = false; // sets nvd3 library in production mode
instance.web.views.add('graph', 'instance.web.GraphView');
instance.web.GraphView = instance.web.View.extend({
className: 'oe_graph',
display_name: _lt('Graph'),
view_type: 'graph',
init: function(parent, dataset, view_id, options) {
this._super(parent, dataset, view_id, options);
this.model = new instance.web.Model(dataset.model, {group_by_no_leaf: true});
this.measures = [];
this.active_measure = '__count__';
this.initial_groupbys = [];
this.widget = undefined;
},
start: function () {
var load_fields = this.model.call('fields_get', [])
.then(this.prepare_fields.bind(this));
return $.when(this._super(), load_fields).then(this.render_buttons.bind(this));
},
render_buttons: function () {
var context = {measures: _.pairs(_.omit(this.measures, '__count__'))};
this.$buttons.html(QWeb.render('GraphView.buttons', context));
this.$measure_list = this.$buttons.find('.oe-measure-list');
this.update_measure();
this.$buttons.find('button').tooltip();
this.$buttons.click(this.on_button_click.bind(this));
},
update_measure: function () {
var self = this;
this.$measure_list.find('li').each(function (index, li) {
$(li).toggleClass('selected', $(li).data('field') === self.active_measure);
});
},
view_loading: function (fvg) {
var self = this;
fvg.arch.children.forEach(function (field) {
var name = field.attrs.name;
if (field.attrs.interval) {
name += ':' + field.attrs.interval;
}
if (field.attrs.type === 'measure') {
self.active_measure = name;
} else {
self.initial_groupbys.push(name);
}
});
},
do_show: function () {
this.do_push_state({});
this.$el.show();
return this._super();
},
prepare_fields: function (fields) {
var self = this;
this.fields = fields;
_.each(fields, function (field, name) {
if ((name !== 'id') && (field.store === true)) {
if (field.type === 'integer' || field.type === 'float') {
self.measures[name] = field;
}
}
});
this.measures.__count__ = {string: _t("Quantity"), type: "integer"};
},
do_search: function (domain, context, group_by) {
if (!this.widget) {
this.initial_groupbys = context.graph_groupbys || this.initial_groupbys;
this.widget = new instance.web.GraphWidget(this, this.dataset.model, {
measure: context.graph_measure || this.active_measure,
mode: context.graph_mode || this.active_mode,
domain: domain,
groupbys: this.initial_groupbys,
context: context,
fields: this.fields,
});
// append widget
this.$el.hide();
this.widget.appendTo(this.$el);
} else {
var groupbys = group_by.length ? group_by : this.initial_groupbys.slice(0);
this.widget.update_data(domain, groupbys);
}
},
get_context: function () {
return !this.widget ? {} : {
graph_mode: this.widget.mode,
graph_measure: this.widget.measure,
graph_groupbys: this.widget.groupbys
};
},
on_button_click: function (event) {
var $target = $(event.target);
if ($target.hasClass('oe-bar-mode')) {this.widget.set_mode('bar');}
if ($target.hasClass('oe-line-mode')) {this.widget.set_mode('line');}
if ($target.hasClass('oe-pie-mode')) {this.widget.set_mode('pie');}
if ($target.parents('.oe-measure-list').length) {
var parent = $target.parent(),
field = parent.data('field');
this.active_measure = field;
parent.toggleClass('selected');
event.stopPropagation();
this.update_measure();
this.widget.set_measure(this.active_measure);
}
},
});
instance.web.GraphWidget = instance.web.Widget.extend({
return Widget.extend({
init: function (parent, model, options) {
this._super(parent);
this.context = options.context;
this.fields = options.fields;
this.fields.__count__ = _t("Quantity");
this.model = new instance.web.Model(model, {group_by_no_leaf: true});
this.model = new Model(model, {group_by_no_leaf: true});
this.domain = options.domain || [];
this.groupbys = options.groupbys || [];
@@ -288,17 +174,14 @@ instance.web.GraphWidget = instance.web.Widget.extend({
},
display_pie: function () {
var data = [],
tickValues,
tickFormat,
measure = this.fields[this.measure].string;
var all_negative = true,
all_negative = true,
some_negative = false,
all_zero = true;
this.data.forEach(function (datapt) {
all_negative = all_negative && (datapt.value < 0);
some_negative = some_negative || (datapt.value < 0);
all_zero = all_zero && (datapt.value === 0)
all_zero = all_zero && (datapt.value === 0);
});
if (some_negative && !all_negative) {
return this.$el.append(QWeb.render('GraphView.error', {
@@ -417,29 +300,4 @@ instance.web.GraphWidget = instance.web.Widget.extend({
}
});
// monkey patch nvd3 to allow removing eventhandler on windowresize events
// see https://github.com/novus/nvd3/pull/396 for more details
// Adds a resize listener to the window.
nv.utils.onWindowResize = function(fun) {
if (fun === null) return;
window.addEventListener('resize', fun);
};
// Backwards compatibility with current API.
nv.utils.windowResize = nv.utils.onWindowResize;
// Removes a resize listener from the window.
nv.utils.offWindowResize = function(fun) {
if (fun === null) return;
window.removeEventListener('resize', fun);
};
// monkey patch nvd3 to prevent crashes when user changes view and nvd3 tries
// to remove tooltips after 500 ms... seriously nvd3, what were you thinking?
nv.tooltip.cleanup = function () {
$('.nvtooltip').remove();
};
})();
});
@@ -0,0 +1,402 @@
odoo.define('web.list_common', ['web.core'], function (require) {
"use strict";
var core = require('web.core');
var _t = core._t;
var Class = core.Class;
/**
* @mixin Events
*/
var Events = /** @lends Events# */{
/**
* @param {String} event event to listen to on the current object, null for all events
* @param {Function} handler event handler to bind to the relevant event
* @returns this
*/
bind: function (event, handler) {
var calls = this['_callbacks'] || (this._callbacks = {});
if (event in calls) {
calls[event].push(handler);
} else {
calls[event] = [handler];
}
return this;
},
/**
* @param {String} event event to unbind on the current object
* @param {function} [handler] specific event handler to remove (otherwise unbind all handlers for the event)
* @returns this
*/
unbind: function (event, handler) {
var calls = this._callbacks || {};
if (!(event in calls)) { return this; }
if (!handler) {
delete calls[event];
} else {
var handlers = calls[event];
handlers.splice(
_(handlers).indexOf(handler),
1);
}
return this;
},
/**
* @param {String} event
* @returns this
*/
trigger: function (event) {
var calls;
if (!(calls = this._callbacks)) { return this; }
var callbacks = (calls[event] || []).concat(calls[null] || []);
for(var i=0, length=callbacks.length; i<length; ++i) {
callbacks[i].apply(this, arguments);
}
return this;
}
};
var Record = Class.extend(/** @lends Record# */{
/**
* @constructs Record
* @extends instance.web.Class
*
* @mixes Events
* @param {Object} [data]
*/
init: function (data) {
this.attributes = data || {};
},
/**
* @param {String} key
* @returns {Object}
*/
get: function (key) {
return this.attributes[key];
},
/**
* @param key
* @param value
* @param {Object} [options]
* @param {Boolean} [options.silent=false]
* @returns {Record}
*/
set: function (key, value, options) {
options = options || {};
var old_value = this.attributes[key];
if (old_value === value) {
return this;
}
this.attributes[key] = value;
if (!options.silent) {
this.trigger('change:' + key, this, value, old_value);
this.trigger('change', this, key, value, old_value);
}
return this;
},
/**
* Converts the current record to the format expected by form views:
*
* .. code-block:: javascript
*
* data: {
* $fieldname: {
* value: $value
* }
* }
*
*
* @returns {Object} record displayable in a form view
*/
toForm: function () {
var form_data = {}, attrs = this.attributes;
for(var k in attrs) {
form_data[k] = {value: attrs[k]};
}
return {data: form_data};
},
/**
* Converts the current record to a format expected by context evaluations
* (identical to record.attributes, except m2o fields are their integer
* value rather than a pair)
*/
toContext: function () {
var output = {}, attrs = this.attributes;
for(var k in attrs) {
var val = attrs[k];
if (typeof val !== 'object') {
output[k] = val;
} else if (val instanceof Array) {
output[k] = val[0];
} else {
throw new Error(_.str.sprintf(_t("Can't convert value %s to context"), val));
}
}
return output;
}
});
Record.include(Events);
var Collection = Class.extend(/** @lends Collection# */{
/**
* Smarter collections, with events, very strongly inspired by Backbone's.
*
* Using a "dumb" array of records makes synchronization between the
* various serious
*
* @constructs Collection
* @extends instance.web.Class
*
* @mixes Events
* @param {Array} [records] records to initialize the collection with
* @param {Object} [options]
*/
init: function (records, options) {
options = options || {};
_.bindAll(this, '_onRecordEvent');
this.length = 0;
this.records = [];
this._byId = {};
this._proxies = {};
this._key = options.key;
this._parent = options.parent;
if (records) {
this.add(records);
}
},
/**
* @param {Object|Array} record
* @param {Object} [options]
* @param {Number} [options.at]
* @param {Boolean} [options.silent=false]
* @returns this
*/
add: function (record, options) {
options = options || {};
var records = record instanceof Array ? record : [record];
for(var i=0, length=records.length; i<length; ++i) {
var instance_ = (records[i] instanceof Record) ? records[i] : new Record(records[i]);
instance_.bind(null, this._onRecordEvent);
this._byId[instance_.get('id')] = instance_;
if (options.at === undefined || options.at === null) {
this.records.push(instance_);
if (!options.silent) {
this.trigger('add', this, instance_, this.records.length-1);
}
} else {
var insertion_index = options.at + i;
this.records.splice(insertion_index, 0, instance_);
if (!options.silent) {
this.trigger('add', this, instance_, insertion_index);
}
}
this.length++;
}
return this;
},
/**
* Get a record by its index in the collection, can also take a group if
* the collection is not degenerate
*
* @param {Number} index
* @param {String} [group]
* @returns {Record|undefined}
*/
at: function (index, group) {
if (group) {
var groups = group.split('.');
return this._proxies[groups[0]].at(index, groups.join('.'));
}
return this.records[index];
},
/**
* Get a record by its database id
*
* @param {Number} id
* @returns {Record|undefined}
*/
get: function (id) {
if (!_(this._proxies).isEmpty()) {
var record = null;
_(this._proxies).detect(function (proxy) {
record = proxy.get(id);
return record;
});
return record;
}
return this._byId[id];
},
/**
* Builds a proxy (insert/retrieve) to a subtree of the collection, by
* the subtree's group
*
* @param {String} section group path section
* @returns {Collection}
*/
proxy: function (section) {
this._proxies[section] = new Collection(null, {
parent: this,
key: section
}).bind(null, this._onRecordEvent);
return this._proxies[section];
},
/**
* @param {Array} [records]
* @returns this
*/
reset: function (records, options) {
options = options || {};
_(this._proxies).each(function (proxy) {
proxy.reset();
});
this._proxies = {};
_(this.records).invoke('unbind', null, this._onRecordEvent);
this.length = 0;
this.records = [];
this._byId = {};
if (records) {
this.add(records);
}
if (!options.silent) {
this.trigger('reset', this);
}
return this;
},
/**
* Removes the provided record from the collection
*
* @param {Record} record
* @returns this
*/
remove: function (record) {
var index = this.indexOf(record);
if (index === -1) {
_(this._proxies).each(function (proxy) {
proxy.remove(record);
});
return this;
}
record.unbind(null, this._onRecordEvent);
this.records.splice(index, 1);
delete this._byId[record.get('id')];
this.length--;
this.trigger('remove', record, this);
return this;
},
_onRecordEvent: function (event) {
switch(event) {
// don't propagate reset events
case 'reset': return;
case 'change:id':
var record = arguments[1];
var new_value = arguments[2];
var old_value = arguments[3];
// [change:id, record, new_value, old_value]
if (this._byId[old_value] === record) {
delete this._byId[old_value];
this._byId[new_value] = record;
}
break;
}
this.trigger.apply(this, arguments);
},
// underscore-type methods
find: function (callback) {
var record;
for(var section in this._proxies) {
if (!this._proxies.hasOwnProperty(section)) {
continue;
}
if ((record = this._proxies[section].find(callback))) {
return record;
}
}
for(var i=0; i<this.length; ++i) {
record = this.records[i];
if (callback(record)) {
return record;
}
}
},
each: function (callback) {
for(var section in this._proxies) {
if (this._proxies.hasOwnProperty(section)) {
this._proxies[section].each(callback);
}
}
for(var i=0; i<this.length; ++i) {
callback(this.records[i]);
}
},
map: function (callback) {
var results = [];
this.each(function (record) {
results.push(callback(record));
});
return results;
},
pluck: function (fieldname) {
return this.map(function (record) {
return record.get(fieldname);
});
},
indexOf: function (record) {
return _(this.records).indexOf(record);
},
succ: function (record, options) {
options = options || {wraparound: false};
var result;
for(var section in this._proxies) {
if (!this._proxies.hasOwnProperty(section)) {
continue;
}
if ((result = this._proxies[section].succ(record, options))) {
return result;
}
}
var index = this.indexOf(record);
if (index === -1) { return null; }
var next_index = index + 1;
if (options.wraparound && (next_index === this.length)) {
return this.at(0);
}
return this.at(next_index);
},
pred: function (record, options) {
options = options || {wraparound: false};
var result;
for (var section in this._proxies) {
if (!this._proxies.hasOwnProperty(section)) {
continue;
}
if ((result = this._proxies[section].pred(record, options))) {
return result;
}
}
var index = this.indexOf(record);
if (index === -1) { return null; }
var next_index = index - 1;
if (options.wraparound && (next_index === -1)) {
return this.at(this.length - 1);
}
return this.at(next_index);
}
});
Collection.include(Events);
return {
Events: Events,
Record: Record,
Collection: Collection
};
});
@@ -1,12 +1,26 @@
(function() {
var instance = openerp;
openerp.web.list = {};
var _t = instance.web._t,
_lt = instance.web._lt;
var QWeb = instance.web.qweb;
instance.web.views.add('list', 'instance.web.ListView');
instance.web.ListView = instance.web.View.extend( /** @lends instance.web.ListView# */ {
odoo.define('web.ListView', ['web.core', 'web.data', 'web.DataExport', 'web.formats', 'web.list_common', 'web.Model', 'web.pyeval', 'web.session', 'web.Sidebar', 'web.utils', 'web.View'], function (require) {
"use strict";
var core = require('web.core');
var data = require('web.data');
var DataExport = require('web.DataExport');
var formats = require('web.formats');
var common = require('web.list_common');
var Model = require('web.Model');
var pyeval = require('web.pyeval');
var session = require('web.session');
var Sidebar = require('web.Sidebar');
var utils = require('web.utils');
var View = require('web.View');
var Class = core.Class;
var _t = core._t;
var _lt = core._lt;
var QWeb = core.qweb;
var list_widget_registry = core.list_widget_registry;
var ListView = View.extend( /** @lends instance.web.ListView# */ {
_template: 'ListView',
display_name: _lt('List'),
defaults: {
@@ -68,11 +82,11 @@ instance.web.ListView = instance.web.View.extend( /** @lends instance.web.ListVi
this.columns = [];
this.records = new Collection();
this.records = new common.Collection();
this.set_groups(new (this.options.GroupsType)(this));
if (this.dataset instanceof instance.web.DataSetStatic) {
if (this.dataset instanceof data.DataSetStatic) {
this.groups.datagroup = new StaticDataGroup(this.dataset);
} else {
this.groups.datagroup = new DataGroup(
@@ -100,8 +114,8 @@ instance.web.ListView = instance.web.View.extend( /** @lends instance.web.ListVi
set_default_options: function (options) {
this._super(options);
_.defaults(this.options, {
GroupsType: instance.web.ListView.Groups,
ListType: instance.web.ListView.List
GroupsType: ListView.Groups,
ListType: ListView.List
});
},
@@ -170,7 +184,7 @@ instance.web.ListView = instance.web.View.extend( /** @lends instance.web.ListVi
var len, style= '';
var context = _.extend({}, record.attributes, {
uid: this.session.uid,
uid: session.uid,
current_date: moment().format('YYYY-MM-DD')
// TODO: time, datetime, relativedelta
});
@@ -347,7 +361,7 @@ instance.web.ListView = instance.web.View.extend( /** @lends instance.web.ListVi
// Sidebar
if (!this.sidebar && this.options.$sidebar) {
this.sidebar = new instance.web.Sidebar(this);
this.sidebar = new Sidebar(this);
this.sidebar.appendTo(this.options.$sidebar);
this.sidebar.add_items('other', _.compact([
{ label: _t("Export"), callback: this.on_sidebar_export },
@@ -433,16 +447,15 @@ instance.web.ListView = instance.web.View.extend( /** @lends instance.web.ListVi
* @param {Boolean} [grouped] Should the grouping columns (group and count) be displayed
*/
setup_columns: function (fields, grouped) {
var registry = instance.web.list.columns;
this.columns.splice(0, this.columns.length);
this.columns.push.apply(this.columns,
_(this.fields_view.arch.children).map(function (field) {
var id = field.attrs.name;
return registry.for_(id, fields[id], field);
return for_(id, fields[id], field);
}));
if (grouped) {
this.columns.unshift(
new instance.web.list.MetaColumn('_group', _t("Group")));
new ListView.MetaColumn('_group', _t("Group")));
}
this.visible_columns = _.filter(this.columns, function (column) {
@@ -495,7 +508,7 @@ instance.web.ListView = instance.web.View.extend( /** @lends instance.web.ListVi
* @param {Boolean} [grouped] Should the list be displayed grouped
* @param {Object} [context] context to send the server while loading the view
*/
reload_view: function (grouped, context, initial) {
reload_view: function (grouped, context) {
return this.load_view(context);
},
/**
@@ -513,7 +526,7 @@ instance.web.ListView = instance.web.View.extend( /** @lends instance.web.ListVi
});
this.$el.find('.oe_list_content').append(
this.groups.render(function () {
if (self.dataset.index == null) {
if (self.dataset.index === null) {
if (self.records.length) {
self.dataset.index = 0;
}
@@ -576,6 +589,9 @@ instance.web.ListView = instance.web.View.extend( /** @lends instance.web.ListVi
this.reload_content();
}
},
on_sidebar_export: function() {
new DataExport(this, this.dataset).open();
},
/**
* Handler for the result of eval_domain_and_context, actually perform the
* searching
@@ -689,7 +705,7 @@ instance.web.ListView = instance.web.View.extend( /** @lends instance.web.ListVi
return;
}
var c = new instance.web.CompoundContext();
var c = new data.CompoundContext();
c.set_eval_context(_.extend({
active_id: id,
active_ids: [id],
@@ -808,7 +824,6 @@ instance.web.ListView = instance.web.View.extend( /** @lends instance.web.ListVi
this.display_aggregates(aggregates);
},
display_aggregates: function (aggregation) {
var self = this;
var $footer_cells = this.$el.find('.oe_list_footer');
_(this.aggregate_columns).each(function (column) {
if (!column['function']) {
@@ -833,13 +848,13 @@ instance.web.ListView = instance.web.View.extend( /** @lends instance.web.ListVi
if (this.$('.oe_list_record_selector').prop('checked')) {
var search_view = this.getParent().searchview;
var search_data = search_view.build_search_data();
return instance.web.pyeval.eval_domains_and_contexts({
return pyeval.eval_domains_and_contexts({
domains: search_data.domains,
contexts: search_data.contexts,
group_by_seq: search_data.groupbys || []
}).then(function (results) {
var domain = self.dataset.domain.concat(results.domain || []);
return domain
return domain;
});
}
else {
@@ -906,7 +921,9 @@ instance.web.ListView = instance.web.View.extend( /** @lends instance.web.ListVi
});
}
});
instance.web.ListView.List = instance.web.Class.extend( /** @lends instance.web.ListView.List# */{
core.view_registry.add('list', ListView);
ListView.List = Class.extend( /** @lends instance.web.ListView.List# */{
/**
* List display for the ListView, handles basic DOM events and transforms
* them in the relevant higher-level events, to which the list view (or
@@ -1073,7 +1090,7 @@ instance.web.ListView.List = instance.web.Class.extend( /** @lends instance.web.
// to get a correctly displayable value in the field
var model = ref_match[1],
id = parseInt(ref_match[2], 10);
new instance.web.DataSet(this.view, model).name_get([id]).done(function(names) {
new data.DataSet(this.view, model).name_get([id]).done(function(names) {
if (!names.length) { return; }
record.set(column.id + '__display', names[0][1]);
});
@@ -1088,7 +1105,7 @@ instance.web.ListView.List = instance.web.Class.extend( /** @lends instance.web.
// fetch the name, set it on the record (in the right field)
// and let the various registered events handle refreshing the
// row
new instance.web.DataSet(this.view, column.relation)
new data.DataSet(this.view, column.relation)
.name_get([value]).done(function (names) {
if (!names.length) { return; }
record.set(column.id, names[0]);
@@ -1112,7 +1129,7 @@ instance.web.ListView.List = instance.web.Class.extend( /** @lends instance.web.
// 2. an array of ids
ids = value;
}
new instance.web.Model(column.relation)
new Model(column.relation)
.call('name_get', [ids, this.dataset.context]).done(function (names) {
// FIXME: nth horrible hack in this poor listview
record.set(column.id + '__display',
@@ -1129,6 +1146,7 @@ instance.web.ListView.List = instance.web.Class.extend( /** @lends instance.web.
});
},
render: function () {
var self = this;
this.$current.empty().append(
QWeb.render('ListView.rows', _.extend({
render_cell: function () {
@@ -1234,7 +1252,8 @@ instance.web.ListView.List = instance.web.Class.extend( /** @lends instance.web.
});
}
});
instance.web.ListView.Groups = instance.web.Class.extend( /** @lends instance.web.ListView.Groups# */{
ListView.Groups = Class.extend( /** @lends instance.web.ListView.Groups# */{
passthrough_events: 'action deleted row_link',
/**
* Grouped display for the ListView. Handles basic DOM events and interacts
@@ -1683,19 +1702,20 @@ instance.web.ListView.Groups = instance.web.Class.extend( /** @lends instance.we
* having concurrent list views becomes possible and common.
*/
function synchronized(fn) {
var fn_mutex = new $.Mutex();
var fn_mutex = new utils.Mutex();
return function () {
var obj = this;
var args = _.toArray(arguments);
return fn_mutex.exec(function () {
if (obj.isDestroyed()) { return $.when(); }
return fn.apply(obj, args)
return fn.apply(obj, args);
});
};
}
var DataGroup = instance.web.Class.extend({
var DataGroup = Class.extend({
init: function(parent, model, domain, context, group_by, level) {
this.model = new instance.web.Model(model, context, domain);
this.model = new Model(model, context, domain);
this.group_by = group_by;
this.context = context;
this.domain = domain;
@@ -1712,7 +1732,7 @@ var DataGroup = instance.web.Class.extend({
$.when(query).done(function (querygroups) {
// leaf node
if (!querygroups) {
var ds = new instance.web.DataSetSearch(self, self.model.name, self.model.context(), self.model.domain());
var ds = new data.DataSetSearch(self, self.model.name, self.model.context(), self.model.domain());
ds._sort = self.sort;
ifRecords(ds);
return;
@@ -1741,6 +1761,7 @@ var DataGroup = instance.web.Class.extend({
});
}
});
var StaticDataGroup = DataGroup.extend({
init: function (dataset) {
this.dataset = dataset;
@@ -1750,436 +1771,7 @@ var StaticDataGroup = DataGroup.extend({
}
});
/**
* @mixin Events
*/
var Events = /** @lends Events# */{
/**
* @param {String} event event to listen to on the current object, null for all events
* @param {Function} handler event handler to bind to the relevant event
* @returns this
*/
bind: function (event, handler) {
var calls = this['_callbacks'] || (this._callbacks = {});
if (event in calls) {
calls[event].push(handler);
} else {
calls[event] = [handler];
}
return this;
},
/**
* @param {String} event event to unbind on the current object
* @param {function} [handler] specific event handler to remove (otherwise unbind all handlers for the event)
* @returns this
*/
unbind: function (event, handler) {
var calls = this._callbacks || {};
if (!(event in calls)) { return this; }
if (!handler) {
delete calls[event];
} else {
var handlers = calls[event];
handlers.splice(
_(handlers).indexOf(handler),
1);
}
return this;
},
/**
* @param {String} event
* @returns this
*/
trigger: function (event) {
var calls;
if (!(calls = this._callbacks)) { return this; }
var callbacks = (calls[event] || []).concat(calls[null] || []);
for(var i=0, length=callbacks.length; i<length; ++i) {
callbacks[i].apply(this, arguments);
}
return this;
}
};
var Record = instance.web.Class.extend(/** @lends Record# */{
/**
* @constructs Record
* @extends instance.web.Class
*
* @mixes Events
* @param {Object} [data]
*/
init: function (data) {
this.attributes = data || {};
},
/**
* @param {String} key
* @returns {Object}
*/
get: function (key) {
return this.attributes[key];
},
/**
* @param key
* @param value
* @param {Object} [options]
* @param {Boolean} [options.silent=false]
* @returns {Record}
*/
set: function (key, value, options) {
options = options || {};
var old_value = this.attributes[key];
if (old_value === value) {
return this;
}
this.attributes[key] = value;
if (!options.silent) {
this.trigger('change:' + key, this, value, old_value);
this.trigger('change', this, key, value, old_value);
}
return this;
},
/**
* Converts the current record to the format expected by form views:
*
* .. code-block:: javascript
*
* data: {
* $fieldname: {
* value: $value
* }
* }
*
*
* @returns {Object} record displayable in a form view
*/
toForm: function () {
var form_data = {}, attrs = this.attributes;
for(var k in attrs) {
form_data[k] = {value: attrs[k]};
}
return {data: form_data};
},
/**
* Converts the current record to a format expected by context evaluations
* (identical to record.attributes, except m2o fields are their integer
* value rather than a pair)
*/
toContext: function () {
var output = {}, attrs = this.attributes;
for(var k in attrs) {
var val = attrs[k];
if (typeof val !== 'object') {
output[k] = val;
} else if (val instanceof Array) {
output[k] = val[0];
} else {
throw new Error(_.str.sprintf(_t("Can't convert value %s to context"), val));
}
}
return output;
}
});
Record.include(Events);
var Collection = instance.web.Class.extend(/** @lends Collection# */{
/**
* Smarter collections, with events, very strongly inspired by Backbone's.
*
* Using a "dumb" array of records makes synchronization between the
* various serious
*
* @constructs Collection
* @extends instance.web.Class
*
* @mixes Events
* @param {Array} [records] records to initialize the collection with
* @param {Object} [options]
*/
init: function (records, options) {
options = options || {};
_.bindAll(this, '_onRecordEvent');
this.length = 0;
this.records = [];
this._byId = {};
this._proxies = {};
this._key = options.key;
this._parent = options.parent;
if (records) {
this.add(records);
}
},
/**
* @param {Object|Array} record
* @param {Object} [options]
* @param {Number} [options.at]
* @param {Boolean} [options.silent=false]
* @returns this
*/
add: function (record, options) {
options = options || {};
var records = record instanceof Array ? record : [record];
for(var i=0, length=records.length; i<length; ++i) {
var instance_ = (records[i] instanceof Record) ? records[i] : new Record(records[i]);
instance_.bind(null, this._onRecordEvent);
this._byId[instance_.get('id')] = instance_;
if (options.at === undefined || options.at === null) {
this.records.push(instance_);
if (!options.silent) {
this.trigger('add', this, instance_, this.records.length-1);
}
} else {
var insertion_index = options.at + i;
this.records.splice(insertion_index, 0, instance_);
if (!options.silent) {
this.trigger('add', this, instance_, insertion_index);
}
}
this.length++;
}
return this;
},
/**
* Get a record by its index in the collection, can also take a group if
* the collection is not degenerate
*
* @param {Number} index
* @param {String} [group]
* @returns {Record|undefined}
*/
at: function (index, group) {
if (group) {
var groups = group.split('.');
return this._proxies[groups[0]].at(index, groups.join('.'));
}
return this.records[index];
},
/**
* Get a record by its database id
*
* @param {Number} id
* @returns {Record|undefined}
*/
get: function (id) {
if (!_(this._proxies).isEmpty()) {
var record = null;
_(this._proxies).detect(function (proxy) {
record = proxy.get(id);
return record;
});
return record;
}
return this._byId[id];
},
/**
* Builds a proxy (insert/retrieve) to a subtree of the collection, by
* the subtree's group
*
* @param {String} section group path section
* @returns {Collection}
*/
proxy: function (section) {
this._proxies[section] = new Collection(null, {
parent: this,
key: section
}).bind(null, this._onRecordEvent);
return this._proxies[section];
},
/**
* @param {Array} [records]
* @returns this
*/
reset: function (records, options) {
options = options || {};
_(this._proxies).each(function (proxy) {
proxy.reset();
});
this._proxies = {};
_(this.records).invoke('unbind', null, this._onRecordEvent);
this.length = 0;
this.records = [];
this._byId = {};
if (records) {
this.add(records);
}
if (!options.silent) {
this.trigger('reset', this);
}
return this;
},
/**
* Removes the provided record from the collection
*
* @param {Record} record
* @returns this
*/
remove: function (record) {
var index = this.indexOf(record);
if (index === -1) {
_(this._proxies).each(function (proxy) {
proxy.remove(record);
});
return this;
}
record.unbind(null, this._onRecordEvent);
this.records.splice(index, 1);
delete this._byId[record.get('id')];
this.length--;
this.trigger('remove', record, this);
return this;
},
_onRecordEvent: function (event) {
switch(event) {
// don't propagate reset events
case 'reset': return;
case 'change:id':
var record = arguments[1];
var new_value = arguments[2];
var old_value = arguments[3];
// [change:id, record, new_value, old_value]
if (this._byId[old_value] === record) {
delete this._byId[old_value];
this._byId[new_value] = record;
}
break;
}
this.trigger.apply(this, arguments);
},
// underscore-type methods
find: function (callback) {
var record;
for(var section in this._proxies) {
if (!this._proxies.hasOwnProperty(section)) {
continue;
}
if ((record = this._proxies[section].find(callback))) {
return record;
}
}
for(var i=0; i<this.length; ++i) {
record = this.records[i];
if (callback(record)) {
return record;
}
}
},
each: function (callback) {
for(var section in this._proxies) {
if (this._proxies.hasOwnProperty(section)) {
this._proxies[section].each(callback);
}
}
for(var i=0; i<this.length; ++i) {
callback(this.records[i]);
}
},
map: function (callback) {
var results = [];
this.each(function (record) {
results.push(callback(record));
});
return results;
},
pluck: function (fieldname) {
return this.map(function (record) {
return record.get(fieldname);
});
},
indexOf: function (record) {
return _(this.records).indexOf(record);
},
succ: function (record, options) {
options = options || {wraparound: false};
var result;
for(var section in this._proxies) {
if (!this._proxies.hasOwnProperty(section)) {
continue;
}
if ((result = this._proxies[section].succ(record, options))) {
return result;
}
}
var index = this.indexOf(record);
if (index === -1) { return null; }
var next_index = index + 1;
if (options.wraparound && (next_index === this.length)) {
return this.at(0);
}
return this.at(next_index);
},
pred: function (record, options) {
options = options || {wraparound: false};
var result;
for (var section in this._proxies) {
if (!this._proxies.hasOwnProperty(section)) {
continue;
}
if ((result = this._proxies[section].pred(record, options))) {
return result;
}
}
var index = this.indexOf(record);
if (index === -1) { return null; }
var next_index = index - 1;
if (options.wraparound && (next_index === -1)) {
return this.at(this.length - 1);
}
return this.at(next_index);
}
});
Collection.include(Events);
instance.web.list = {
Events: Events,
Record: Record,
Collection: Collection
};
/**
* Registry for column objects used to format table cells (and some other tasks
* e.g. aggregation computations).
*
* Maps a field or button to a Column type via its ``$tag.$widget``,
* ``$tag.$type`` or its ``$tag`` (alone).
*
* This specific registry has a dedicated utility method ``for_`` taking a
* field (from fields_get/fields_view_get.field) and a node (from a view) and
* returning the right object *already instantiated from the data provided*.
*
* @type {instance.web.Registry}
*/
instance.web.list.columns = new instance.web.Registry({
'field': 'instance.web.list.Column',
'field.boolean': 'instance.web.list.Boolean',
'field.binary': 'instance.web.list.Binary',
'field.char': 'instance.web.list.Char',
'field.progressbar': 'instance.web.list.ProgressBar',
'field.handle': 'instance.web.list.Handle',
'button': 'instance.web.list.Button',
'field.many2onebutton': 'instance.web.list.Many2OneButton',
'field.reference': 'instance.web.list.Reference',
'field.many2many': 'instance.web.list.Many2Many',
'field.url': 'instance.web.list.Url',
'button.toggle_button': 'instance.web.list.toggle_button',
});
instance.web.list.columns.for_ = function (id, field, node) {
var description = _.extend({tag: node.tag}, field, node.attrs);
var tag = description.tag;
var Type = this.get_any([
tag + '.' + description.widget,
tag + '.'+ description.type,
tag
]);
return new Type(id, node.tag, description);
};
instance.web.list.Column = instance.web.Class.extend({
var Column = Class.extend({
init: function (id, tag, attrs) {
_.extend(attrs, {
id: id,
@@ -2196,7 +1788,7 @@ instance.web.list.Column = instance.web.Class.extend({
},
modifiers_for: function (fields) {
var out = {};
var domain_computer = instance.web.form.compute_domain;
var domain_computer = data.compute_domain;
for (var attr in this.modifiers) {
if (!this.modifiers.hasOwnProperty(attr)) { continue; }
@@ -2259,17 +1851,21 @@ instance.web.list.Column = instance.web.Class.extend({
* @private
*/
_format: function (row_data, options) {
return _.escape(instance.web.format_value(
return _.escape(formats.format_value(
row_data[this.id].value, this, options.value_if_empty));
}
});
instance.web.list.MetaColumn = instance.web.list.Column.extend({
var MetaColumn = Column.extend({
meta: true,
init: function (id, string) {
this._super(id, '', {string: string});
}
});
instance.web.list.Button = instance.web.list.Column.extend({
// to do: do this in a better way (communicate with view_list_editable)
ListView.MetaColumn = MetaColumn;
var ColumnButton = Column.extend({
/**
* Return an actual ``<button>`` tag
*/
@@ -2283,14 +1879,15 @@ instance.web.list.Button = instance.web.list.Column.extend({
var template = this.icon && 'ListView.row.button' || 'ListView.row.text_button';
return QWeb.render(template, {
widget: this,
prefix: instance.session.prefix,
prefix: session.prefix,
disabled: attrs.readonly
|| isNaN(row_data.id.value)
|| instance.web.BufferedDataSet.virtual_id_regex.test(row_data.id.value)
|| data.BufferedDataSet.virtual_id_regex.test(row_data.id.value)
});
}
});
instance.web.list.Boolean = instance.web.list.Column.extend({
var ColumnBoolean = Column.extend({
/**
* Return a potentially disabled checkbox input
*
@@ -2301,7 +1898,8 @@ instance.web.list.Boolean = instance.web.list.Column.extend({
row_data[this.id].value ? 'checked="checked"' : '');
}
});
instance.web.list.Binary = instance.web.list.Column.extend({
var ColumnBinary = Column.extend({
/**
* Return a link to the binary data as a file
*
@@ -2318,23 +1916,24 @@ instance.web.list.Binary = instance.web.list.Column.extend({
if (value.substr(0, 10).indexOf(' ') == -1) {
download_url = "data:application/octet-stream;base64," + value;
} else {
download_url = instance.session.url('/web/binary/saveas', {model: options.model, field: this.id, id: options.id});
download_url = session.url('/web/binary/saveas', {model: options.model, field: this.id, id: options.id});
if (this.filename) {
download_url += '&filename_field=' + this.filename;
}
}
if (this.filename && row_data[this.filename]) {
text = _.str.sprintf(_t("Download \"%s\""), instance.web.format_value(
text = _.str.sprintf(_t("Download \"%s\""), formats.format_value(
row_data[this.filename].value, {type: 'char'}));
}
return _.template('<a href="<%-href%>"><%-text%></a> (<%-size%>)', {
text: text,
href: download_url,
size: instance.web.binary_to_binsize(value),
size: utils.binary_to_binsize(value),
});
}
});
instance.web.list.Char = instance.web.list.Column.extend({
var ColumnChar = Column.extend({
replacement: '*',
/**
* If password field, only display replacement characters (if value is
@@ -2348,7 +1947,8 @@ instance.web.list.Char = instance.web.list.Column.extend({
return this._super(row_data, options);
}
});
instance.web.list.ProgressBar = instance.web.list.Column.extend({
var ColumnProgressBar = Column.extend({
/**
* Return a formatted progress bar display
*
@@ -2361,7 +1961,8 @@ instance.web.list.ProgressBar = instance.web.list.Column.extend({
});
}
});
instance.web.list.Handle = instance.web.list.Column.extend({
var ColumnHandle = Column.extend({
init: function () {
this._super.apply(this, arguments);
// Handle overrides the field to not be form-editable.
@@ -2376,18 +1977,20 @@ instance.web.list.Handle = instance.web.list.Column.extend({
return '<i class="oe_list_handle fa fa-circle"/>';
}
});
instance.web.list.Many2OneButton = instance.web.list.Column.extend({
var ColumnMany2OneButton = Column.extend({
_format: function (row_data, options) {
this.has_value = !!row_data[this.id].value;
this.icon = this.has_value ? 'gtk-yes' : 'gtk-no';
this.string = this.has_value ? _t('View') : _t('Create');
return QWeb.render('Many2OneButton.cell', {
'widget': this,
'prefix': instance.session.prefix,
'prefix': session.prefix,
});
},
});
instance.web.list.Many2Many = instance.web.list.Column.extend({
var ColumnMany2Many = Column.extend({
_format: function (row_data, options) {
if (!_.isEmpty(row_data[this.id].value)) {
// If value, use __display version for printing
@@ -2396,7 +1999,8 @@ instance.web.list.Many2Many = instance.web.list.Column.extend({
return this._super(row_data, options);
}
});
instance.web.list.Reference = instance.web.list.Column.extend({
var ColumnReference = Column.extend({
_format: function (row_data, options) {
if (!_.isEmpty(row_data[this.id].value)) {
// If value, use __display version for printing
@@ -2409,7 +2013,8 @@ instance.web.list.Reference = instance.web.list.Column.extend({
return this._super(row_data, options);
}
});
instance.web.list.Url = instance.web.list.Column.extend({
var ColumnUrl = Column.extend({
/**
* Regex checking if a URL has a scheme
*/
@@ -2435,7 +2040,8 @@ instance.web.list.Url = instance.web.list.Column.extend({
return this._super(row_data, options);
}
});
instance.web.list.toggle_button = instance.web.list.Column.extend({
var ColumnToggleButton = Column.extend({
format: function (row_data, options) {
this._super(row_data, options);
var button_tips = JSON.parse(this.options);
@@ -2445,8 +2051,50 @@ instance.web.list.toggle_button = instance.web.list.Column.extend({
this.string = has_value ? _t(button_tips ? button_tips['active']: ''): _t(button_tips ? button_tips['inactive']: '');
return QWeb.render('toggle_button', {
widget: this,
prefix: instance.session.prefix,
prefix: session.prefix,
});
},
});
})();
/**
* Registry for column objects used to format table cells (and some other tasks
* e.g. aggregation computations).
*
* Maps a field or button to a Column type via its ``$tag.$widget``,
* ``$tag.$type`` or its ``$tag`` (alone).
*
* This specific registry has a dedicated utility method ``for_`` taking a
* field (from fields_get/fields_view_get.field) and a node (from a view) and
* returning the right object *already instantiated from the data provided*.
*
* @type {instance.web.Registry}
*/
list_widget_registry
.add('field', Column)
.add('field.boolean', ColumnBoolean)
.add('field.binary', ColumnBinary)
.add('field.char', ColumnChar)
.add('field.progressbar', ColumnProgressBar)
.add('field.handle', ColumnHandle)
.add('button', ColumnButton)
.add('field.many2onebutton', ColumnMany2OneButton)
.add('field.reference', ColumnReference)
.add('field.many2many', ColumnMany2Many)
.add('field.url', ColumnUrl)
.add('button.toggle_button', ColumnToggleButton);
function for_ (id, field, node) {
var description = _.extend({tag: node.tag}, field, node.attrs);
var tag = description.tag;
var Type = list_widget_registry.get_any([
tag + '.' + description.widget,
tag + '.'+ description.type,
tag
]);
return new Type(id, node.tag, description);
}
return ListView;
});
@@ -0,0 +1,843 @@
odoo.define('web.ListEditor', ['web.core', 'web.FormView', 'web.list_common', 'web.ListView', 'web.utils', 'web.Widget'], function (require) {
"use strict";
/*---------------------------------------------------------
* Odoo Editable List view
*---------------------------------------------------------*/
/**
* handles editability case for lists, because it depends on form and forms already depends on lists it had to be split out
* @namespace
*/
var core = require('web.core');
var FormView = require('web.FormView');
var common = require('web.list_common');
var ListView = require('web.ListView');
var utils = require('web.utils');
var Widget = require('web.Widget');
var _t = core._t;
var Editor = Widget.extend({
/**
* @constructs instance.web.list.Editor
* @extends instance.web.Widget
*
* Adapter between listview and formview for editable-listview purposes
*
* @param {instance.web.Widget} parent
* @param {Object} options
* @param {instance.web.FormView} [options.formView=instance.web.FormView]
* @param {Object} [options.delegate]
*/
init: function (parent, options) {
this._super(parent);
this.options = options || {};
_.defaults(this.options, {
formView: FormView,
delegate: this.getParent()
});
this.delegate = this.options.delegate;
this.record = null;
this.form = new (this.options.formView)(
this, this.delegate.dataset, false, {
initial_mode: 'edit',
disable_autofocus: true,
$buttons: $(),
$pager: $()
});
},
start: function () {
var self = this;
var _super = this._super();
this.form.embedded_view = this._validate_view(
this.delegate.edition_view(this));
var form_ready = this.form.appendTo(this.$el).done(
self.form.proxy('do_hide'));
return $.when(_super, form_ready);
},
_validate_view: function (edition_view) {
if (!edition_view) {
throw new Error("editor delegate's #edition_view must return "
+ "a view descriptor");
}
var arch = edition_view.arch;
if (!(arch && arch.children instanceof Array)) {
throw new Error("Editor delegate's #edition_view must have a" +
" non-empty arch");
}
if (arch.tag !== "form") {
throw new Error("Editor delegate's #edition_view must have a" +
" 'form' root node");
}
if (!(arch.attrs && arch.attrs.version === "7.0")) {
throw new Error("Editor delegate's #edition_view must be a" +
" version 7 view");
}
if (!/\boe_form_container\b/.test(arch.attrs['class'])) {
throw new Error("Editor delegate's #edition_view must have the" +
" class 'oe_form_container' on its root" +
" element");
}
return edition_view;
},
/**
*
* @param {String} [state] either ``new`` or ``edit``
* @return {Boolean}
*/
is_editing: function (state) {
if (!this.record) {
return false;
}
switch(state) {
case null: case undefined:
return true;
case 'new': return !this.record.id;
case 'edit': return !!this.record.id;
}
throw new Error("is_editing's state filter must be either `new` or" +
" `edit` if provided");
},
edit: function (record, configureField, options) {
// TODO: specify sequence of edit calls
var self = this;
var form = self.form;
var loaded = record
? form.trigger('load_record', _.extend({}, record))
: form.load_defaults();
return $.when(loaded).then(function () {
return form.do_show({reload: false});
}).then(function () {
self.record = form.datarecord;
_(form.fields).each(function (field, name) {
configureField(name, field);
});
return form;
});
},
save: function () {
var self = this;
return this.form
.save(this.delegate.prepends_on_create())
.then(function (result) {
var created = result.created && !self.record.id;
if (created) {
self.record.id = result.result;
}
return self.cancel();
});
},
cancel: function (force) {
if (!(force || this.form.can_be_discarded())) {
return $.Deferred().reject({
message: _t("The form's data can not be discarded")}).promise();
}
var record = this.record;
this.record = null;
this.form.do_hide();
return $.when(record);
},
});
// editability status of list rows
ListView.prototype.defaults.editable = null;
// TODO: not sure second @lends on existing item is correct, to check
ListView.include(/** @lends instance.web.ListView# */{
init: function () {
var self = this;
this._super.apply(this, arguments);
this.saving_mutex = new utils.Mutex();
this._force_editability = null;
this._context_editable = false;
this.editor = this.make_editor();
// Stores records of {field, cell}, allows for re-rendering fields
// depending on cell state during and after resize events
this.fields_for_resize = [];
core.bus.on('resize', this, this.resize_fields);
$(this.groups).bind({
'edit': function (e, id, dataset) {
self.do_edit(dataset.index, id, dataset);
},
'saved': function () {
if (self.groups.get_selection().length) {
return;
}
self.configure_pager(self.dataset);
self.compute_aggregates();
}
});
this.records.bind('remove', function () {
if (self.editor.is_editing()) {
self.cancel_edition();
}
});
this.on('edit:before', this, function (event) {
if (!self.editable() || self.editor.is_editing()) {
event.cancel = true;
}
});
this.on('edit:after', this, function () {
self.$el.add(self.$buttons).addClass('oe_editing');
self.$('.ui-sortable').sortable('disable');
});
this.on('save:after cancel:after', this, function () {
self.$('.ui-sortable').sortable('enable');
self.$el.add(self.$buttons).removeClass('oe_editing');
});
},
destroy: function () {
core.bus.off('resize', this, this.resize_fields);
this._super();
},
do_hide: function () {
if (this.editor.is_editing()) {
this.cancel_edition(true);
}
this._super();
},
sort_by_column: function (e) {
e.stopPropagation();
if (!this.editor.is_editing()) {
this._super.apply(this, arguments);
}
},
/**
* Handles the activation of a record in editable mode (making a record
* editable), called *after* the record has become editable.
*
* The default behavior is to setup the listview's dataset to match
* whatever dataset was provided by the editing List
*
* @param {Number} index index of the record in the dataset
* @param {Object} id identifier of the record being edited
* @param {instance.web.DataSet} dataset dataset in which the record is available
*/
do_edit: function (index, id, dataset) {
_.extend(this.dataset, dataset);
},
do_delete: function (ids) {
var nonfalse = _.compact(ids);
var _super = this._super.bind(this);
var next = this.editor.is_editing()
? this.cancel_edition(true)
: $.when();
return next.then(function () {
return _super(nonfalse);
});
},
editable: function () {
return !this.grouped
&& !this.options.disable_editable_mode
&& (this.fields_view.arch.attrs.editable
|| this._context_editable
|| this.options.editable);
},
/**
* Replace do_search to handle editability process
*/
do_search: function(domain, context, group_by) {
var self=this, _super = self._super, args=arguments;
var ready = this.editor.is_editing()
? this.cancel_edition(true)
: $.when();
return ready.then(function () {
self._context_editable = !!context.set_editable;
return _super.apply(self, args);
});
},
/**
* Replace do_add_record to handle editability (and adding new record
* as an editable row at the top or bottom of the list)
*/
do_add_record: function () {
var self = this;
if (this.editable()) {
this.$el.find('table:first').show();
this.$el.find('.oe_view_nocontent').remove();
this.start_edition();
} else {
this._super();
}
},
load_list: function (data, grouped) {
var self = this;
// tree/@editable takes priority on everything else if present.
var result = this._super(data, grouped);
// In case current editor was started previously, also has to run
// when toggling from editable to non-editable in case form widgets
// have setup global behaviors expecting themselves to exist
// somehow.
this.editor.destroy();
// Editor is not restartable due to formview not being restartable
this.editor = this.make_editor();
if (this.editable()) {
this.$el.addClass('oe_list_editable');
// FIXME: any hook available to ensure this is only done once?
this.$buttons
.off('click', '.oe_list_save')
.on('click', '.oe_list_save', this.proxy('save_edition'))
.off('click', '.oe_list_discard')
.on('click', '.oe_list_discard', function (e) {
e.preventDefault();
self.cancel_edition();
});
var editor_ready = this.editor.prependTo(this.$el)
.done(this.proxy('setup_events'));
return $.when(result, editor_ready);
} else {
this.$el.removeClass('oe_list_editable');
}
return result;
},
/**
* Builds a new editor object
*
* @return {instance.web.list.Editor}
*/
make_editor: function () {
return new Editor(this);
},
do_button_action: function (name, id, callback) {
var self = this, args = arguments;
this.ensure_saved().done(function (done) {
if (!id && done.created) {
id = done.record.get('id');
}
self.handle_button(name, id, callback);
});
},
/**
* Ensures the editable list is saved (saves any pending edition if
* needed, or tries to)
*
* Returns a deferred to the end of the saving.
*
* @returns {$.Deferred}
*/
ensure_saved: function () {
return this.save_edition();
},
/**
* Builds a record with the provided id (``false`` for a creation),
* setting all columns with ``false`` value so code which relies on
* having an actual value behaves correctly
*
* @param {*} id
* @return {instance.web.list.Record}
*/
make_empty_record: function (id) {
var attrs = {id: id};
_(this.columns).chain()
.filter(function (x) { return x.tag === 'field';})
.pluck('name')
.each(function (field) { attrs[field] = false; });
return new common.Record(attrs);
},
/**
* Set up the edition of a record of the list view "inline"
*
* @param {instance.web.list.Record} [record] record to edit, leave empty to create a new record
* @param {Object} [options]
* @param {String} [options.focus_field] field to focus at start of edition
* @return {jQuery.Deferred}
*/
start_edition: function (record, options) {
var self = this;
var item = false;
if (record) {
item = record.attributes;
this.dataset.select_id(record.get('id'));
} else {
record = this.make_empty_record(false);
this.records.add(record, {
at: this.prepends_on_create() ? 0 : null});
}
return this.ensure_saved().then(function(){
return $.when.apply(null, self.editor.form.render_value_defs);
}).then(function () {
var $recordRow = self.groups.get_row_for(record);
var cells = self.get_cells_for($recordRow);
var fields = {};
self.fields_for_resize.splice(0, self.fields_for_resize.length);
return self.with_event('edit', {
record: record.attributes,
cancel: false
}, function () {
return self.editor.edit(item, function (field_name, field) {
var cell = cells[field_name];
if (!cell) {
return;
}
// FIXME: need better way to get the field back from bubbling (delegated) DOM events somehow
field.$el.attr('data-fieldname', field_name);
fields[field_name] = field;
self.fields_for_resize.push({field: field, cell: cell});
}, options).then(function () {
$recordRow.addClass('oe_edition');
self.resize_fields();
var focus_field = options && options.focus_field ? options.focus_field : undefined;
if (!focus_field || !fields[focus_field]){
focus_field = _.find(self.editor.form.fields_order, function(field){ return fields[field] && fields[field].$el.is(':visible:has(input)'); });
}
if (focus_field && fields[focus_field]) fields[focus_field].$el.find('input').select();
return record.attributes;
});
}).fail(function () {
// if the start_edition event is cancelled and it was a
// creation, remove the newly-created empty record
if (!record.get('id')) {
self.records.remove(record);
}
});
});
},
get_cells_for: function ($row) {
var cells = {};
$row.children('td').each(function (index, el) {
cells[el.getAttribute('data-field')] = el;
});
return cells;
},
/**
* If currently editing a row, resizes all registered form fields based
* on the corresponding row cell
*/
resize_fields: function () {
if (!this.editor.is_editing()) { return; }
for(var i=0, len=this.fields_for_resize.length; i<len; ++i) {
var item = this.fields_for_resize[i];
this.resize_field(item.field, item.cell);
}
},
/**
* Resizes a field's root element based on the corresponding cell of
* a listview row
*
* @param {instance.web.form.AbstractField} field
* @param {jQuery} cell
*/
resize_field: function (field, cell) {
var $cell = $(cell);
field.set_dimensions($cell.outerHeight(), $cell.outerWidth());
field.$el.position({
my: 'left top',
at: 'left top',
of: $cell
});
if (field.get('effective_readonly')) {
field.$el.addClass('oe_readonly');
}
if(field.widget == "handle")
field.$el.addClass('oe_list_field_handle');
},
/**
* @return {jQuery.Deferred}
*/
save_edition: function () {
var self = this;
return self.saving_mutex.exec(function() {
if (!self.editor.is_editing()) {
return $.when();
}
return self.with_event('save', {
editor: self.editor,
form: self.editor.form,
cancel: false
}, function () {
return self.editor.save().then(function (attrs) {
var created = false;
var record = self.records.get(attrs.id);
if (!record) {
// new record
created = true;
record = self.records.find(function (r) {
return !r.get('id');
}).set('id', attrs.id);
}
// onwrite callback could be altering & reloading the
// record which has *just* been saved, so first perform all
// onwrites then do a final reload of the record
return self.handle_onwrite(record)
.then(function () {
return self.reload_record(record); })
.then(function () {
return { created: created, record: record }; });
});
});
});
},
/**
* @param {Boolean} [force=false] discards the data even if the form has been edited
* @return {jQuery.Deferred}
*/
cancel_edition: function (force) {
var self = this;
return this.with_event('cancel', {
editor: this.editor,
form: this.editor.form,
cancel: false
}, function () {
return this.editor.cancel(force).then(function (attrs) {
if (attrs.id) {
var record = self.records.get(attrs.id);
if (!record) {
// Record removed by third party during edition
return;
}
return self.reload_record(record, {do_not_evict: true});
}
var to_delete = self.records.find(function (r) {
return !r.get('id');
});
if (to_delete) {
self.records.remove(to_delete);
}
});
});
},
/**
* Executes an action on the view's editor bracketed by a cancellable
* event of the name provided.
*
* The event name provided will be post-fixed with ``:before`` and
* ``:after``, the ``event`` parameter will be passed alongside the
* ``:before`` variant and if the parameter's ``cancel`` key is set to
* ``true`` the action *will not be called* and the method will return
* a rejection
*
* @param {String} event_name name of the event
* @param {Object} event event object, provided to ``:before`` sub-event
* @param {Function} action callable, called with the view's editor as its context
* @param {Array} [args] supplementary arguments provided to the action
* @param {Array} [trigger_params] supplementary arguments provided to the ``:after`` sub-event, before anything fetched by the ``action`` function
* @return {jQuery.Deferred}
*/
with_event: function (event_name, event, action) {
var self = this;
event = event || {};
this.trigger(event_name + ':before', event);
if (event.cancel) {
return $.Deferred().reject({
message: _.str.sprintf("Event %s:before cancelled",
event_name)});
}
return $.when(action.call(this)).done(function () {
self.trigger.apply(self, [event_name + ':after']
.concat(_.toArray(arguments)));
});
},
edition_view: function (editor) {
var view = $.extend(true, {}, this.fields_view);
view.arch.tag = 'form';
_.extend(view.arch.attrs, {
'class': 'oe_form_container',
version: '7.0'
});
_(view.arch.children).chain()
.zip(_(this.columns).filter(function (c) {
return !(c instanceof ListView.MetaColumn);}))
.each(function (ar) {
var widget = ar[0], column = ar[1];
var modifiers = _.extend({}, column.modifiers);
widget.attrs.nolabel = true;
if (modifiers['tree_invisible'] || widget.tag === 'button') {
modifiers.invisible = true;
}
widget.attrs.modifiers = JSON.stringify(modifiers);
});
return view;
},
handle_onwrite: function (source_record) {
var self = this;
var on_write_callback = self.fields_view.arch.attrs.on_write;
if (!on_write_callback) { return $.when(); }
return this.dataset.call(on_write_callback, [source_record.get('id')])
.then(function (ids) {
return $.when.apply(
null, _(ids).map(
_.bind(self.handle_onwrite_record, self, source_record)));
});
},
handle_onwrite_record: function (source_record, id) {
var record = this.records.get(id);
if (!record) {
// insert after the source record
var index = this.records.indexOf(source_record) + 1;
record = this.make_empty_record(id);
this.records.add(record, {at: index});
}
return this.reload_record(record);
},
prepends_on_create: function () {
return this.editable() === 'top';
},
setup_events: function () {
var self = this;
_.each(this.editor.form.fields, function(field, field_name) {
field.on("change:effective_readonly", self, function(){
var item = _(self.fields_for_resize).find(function (item) {
return item.field === field;
});
if (item) {
setTimeout(function() {
self.resize_field(item.field, item.cell);
}, 0);
}
});
});
this.editor.$el.on('keyup keypress keydown', function (e) {
if (!self.editor.is_editing()) { return true; }
var key = _($.ui.keyCode).chain()
.map(function (v, k) { return {name: k, code: v}; })
.find(function (o) { return o.code === e.which; })
.value();
if (!key) { return true; }
var method = e.type + '_' + key.name;
if (!(method in self)) { return true; }
return self[method](e);
});
},
/**
* Saves the current record, and goes to the next one (creation or
* edition)
*
* @private
* @param {String} [next_record='succ'] method to call on the records collection to get the next record to edit
* @param {Object} [options]
* @param {String} [options.focus_field]
* @return {*}
*/
_next: function (next_record, options) {
next_record = next_record || 'succ';
var self = this;
return this.save_edition().then(function (saveInfo) {
if (!saveInfo) { return null; }
if (saveInfo.created) {
return self.start_edition();
}
var record = self.records[next_record](
saveInfo.record, {wraparound: true});
return self.start_edition(record, options);
});
},
keypress_ENTER: function () {
return this._next();
},
keydown_ESCAPE: function (e) {
return false;
},
keyup_ESCAPE: function (e) {
return this.cancel_edition();
},
/**
* Gets the selection range (start, end) for the provided element,
* returns ``null`` if it can't get a range.
*
* @private
*/
_text_selection_range: function (el) {
var selectionStart;
try {
selectionStart = el.selectionStart;
} catch (e) {
// radio or checkbox throw on selectionStart access
return null;
}
if (selectionStart !== undefined) {
return {
start: selectionStart,
end: el.selectionEnd
};
} else if (document.body.createTextRange) {
throw new Error("Implement text range handling for MSIE");
}
// Element without selection ranges (select, div/@contenteditable)
return null;
},
_text_cursor: function (el) {
var selection = this._text_selection_range(el);
if (!selection) {
return null;
}
if (selection.start !== selection.end) {
return {position: null, collapsed: false};
}
return {position: selection.start, collapsed: true};
},
/**
* Checks if the cursor is at the start of the provided el
*
* @param {HTMLInputElement | HTMLTextAreaElement}
* @returns {Boolean}
* @private
*/
_at_start: function (cursor, el) {
return cursor.collapsed && (cursor.position === 0);
},
/**
* Checks if the cursor is at the end of the provided el
*
* @param {HTMLInputElement | HTMLTextAreaElement}
* @returns {Boolean}
* @private
*/
_at_end: function (cursor, el) {
return cursor.collapsed && (cursor.position === el.value.length);
},
/**
* @param DOMEvent event
* @param {String} record_direction direction to move into to get the next record (pred | succ)
* @param {Function} is_valid_move whether the edition should be moved to the next record
* @private
*/
_key_move_record: function (event, record_direction, is_valid_move) {
if (!this.editor.is_editing('edit')) { return $.when(); }
var cursor = this._text_cursor(event.target);
// if text-based input (has a cursor)
// and selecting (not collapsed) or not at a field boundary
// don't move to the next record
if (cursor && !is_valid_move(event.target, cursor)) { return $.when(); }
event.preventDefault();
var source_field = $(event.target).closest('[data-fieldname]')
.attr('data-fieldname');
return this._next(record_direction, {focus_field: source_field});
},
keydown_UP: function (e) {
var self = this;
return this._key_move_record(e, 'pred', function (el, cursor) {
return self._at_start(cursor, el);
});
},
keydown_DOWN: function (e) {
var self = this;
return this._key_move_record(e, 'succ', function (el, cursor) {
return self._at_end(cursor, el);
});
},
keydown_LEFT: function (e) {
// If the cursor is at the beginning of the field
var source_field = $(e.target).closest('[data-fieldname]')
.attr('data-fieldname');
var cursor = this._text_cursor(e.target);
if (cursor && !this._at_start(cursor, e.target)) { return $.when(); }
var fields_order = this.editor.form.fields_order;
var field_index = _(fields_order).indexOf(source_field);
// Look for the closest visible form field to the left
var fields = this.editor.form.fields;
var field;
do {
if (--field_index < 0) { return $.when(); }
field = fields[fields_order[field_index]];
} while (!field.$el.is(':visible'));
// and focus it
field.focus();
return $.when();
},
keydown_RIGHT: function (e) {
// same as above, but with cursor at the end of the field and
// looking for new fields at the right
var source_field = $(e.target).closest('[data-fieldname]')
.attr('data-fieldname');
var cursor = this._text_cursor(e.target);
if (cursor && !this._at_end(cursor, e.target)) { return $.when(); }
var fields_order = this.editor.form.fields_order;
var field_index = _(fields_order).indexOf(source_field);
var fields = this.editor.form.fields;
var field;
do {
if (++field_index >= fields_order.length) { return $.when(); }
field = fields[fields_order[field_index]];
} while (!field.$el.is(':visible'));
field.focus();
return $.when();
},
keydown_TAB: function (e) {
var form = this.editor.form;
var last_field = _(form.fields_order).chain()
.map(function (name) { return form.fields[name]; })
.filter(function (field) { return field.$el.is(':visible') && !field.get('effective_readonly'); })
.last()
.value();
// tabbed from last field in form
if (last_field && last_field.$el.has(e.target).length) {
e.preventDefault();
return this._next();
}
return $.when();
}
});
ListView.Groups.include(/** @lends instance.web.ListView.Groups# */{
passthrough_events: ListView.Groups.prototype.passthrough_events + " edit saved",
get_row_for: function (record) {
return _(this.children).chain()
.invoke('get_row_for', record)
.compact()
.first()
.value();
}
});
ListView.List.include(/** @lends instance.web.ListView.List# */{
row_clicked: function (event) {
if (!this.view.editable() || ! this.view.is_action_enabled('edit')) {
return this._super.apply(this, arguments);
}
var record_id = $(event.currentTarget).data('id');
return this.view.start_edition(
record_id ? this.records.get(record_id) : null, {
focus_field: $(event.target).not(".oe_readonly").data('field')
});
},
/**
* If a row mapping to the record (@data-id matching the record's id or
* no @data-id if the record has no id), returns it. Otherwise returns
* ``null``.
*
* @param {Record} record the record to get a row for
* @return {jQuery|null}
*/
get_row_for: function (record) {
var $row = this.$current.children('[data-id=' + record.get('id') + ']');
if ($row.length) {
return $row;
}
return null;
}
});
return Editor;
});
@@ -1,20 +1,25 @@
odoo.define('web.PivotView', ['web.core', 'web.crash_manager', 'web.formats', 'web.framework', 'web.Model', 'web.session', 'web.Sidebar', 'web.utils', 'web.View'], function (require) {
"use strict";
/*---------------------------------------------------------
* Odoo Pivot Table view
*---------------------------------------------------------*/
(function () {
'use strict';
var core = require('web.core');
var crash_manager = require('web.crash_manager');
var formats = require('web.formats');
var framework = require('web.framework');
var Model = require('web.Model');
var session = require('web.session');
var Sidebar = require('web.Sidebar');
var utils = require('web.utils');
var View = require('web.View');
var instance = openerp,
_lt = instance.web._lt,
_t = instance.web._t,
QWeb = instance.web.qweb,
format_value = instance.web.format_value,
total = _t("Total");
var _lt = core._lt;
var _t = core._t;
var QWeb = core.qweb;
var total = _t("Total");
instance.web.views.add('pivot', 'instance.web.PivotView');
instance.web.PivotView = instance.web.View.extend({
var PivotView = View.extend({
template: 'PivotView',
display_name: _lt('Pivot'),
view_type: 'pivot',
@@ -28,7 +33,7 @@ instance.web.PivotView = instance.web.View.extend({
init: function(parent, dataset, view_id, options) {
this._super(parent, dataset, view_id, options);
this.model = new instance.web.Model(dataset.model, {group_by_no_leaf: true});
this.model = new Model(dataset.model, {group_by_no_leaf: true});
this.$sidebar = options.$sidebar;
this.fields = {};
@@ -65,9 +70,9 @@ instance.web.PivotView = instance.web.View.extend({
.then(this.prepare_fields.bind(this));
if (this.$sidebar) {
openerp.session.rpc('/web/pivot/check_xlwt').then(function (result) {
this.rpc('/web/pivot/check_xlwt').then(function (result) {
if (result) {
self.sidebar = new instance.web.Sidebar(self);
self.sidebar = new Sidebar(self);
self.sidebar.appendTo(self.$sidebar);
self.sidebar.add_items('other', [{
label: _t("Download xls"),
@@ -90,7 +95,7 @@ instance.web.PivotView = instance.web.View.extend({
var another_ctx = {fields: _.chain(this.groupable_fields).pairs().sortBy(function(f){return f[1].string;}).value()};
this.$field_selection = this.$('.o-field-selection');
this.$field_selection.html(QWeb.render('PivotView.FieldSelection', another_ctx));
openerp.web.bus.on('click', self, function () {
core.bus.on('click', self, function () {
self.$field_selection.find('ul').first().hide();
});
this.$buttons.find('button').tooltip();
@@ -284,7 +289,6 @@ instance.web.PivotView = instance.web.View.extend({
}
},
on_field_menu_selection: function (event) {
var self = this;
var field = $(event.target).parent().data('field'),
interval = $(event.target).data('interval'),
header = this.headers[this.last_header_selected];
@@ -299,7 +303,6 @@ instance.web.PivotView = instance.web.View.extend({
var self = this;
var other_root = header.root.other_root,
this_groupbys = header.root.groupbys,
other_groupbys = header.root.other_root.groupbys,
fields = [].concat(field, other_groupbys, this.active_measures),
groupbys = [];
@@ -481,7 +484,7 @@ instance.web.PivotView = instance.web.View.extend({
parent = value.length ? find_path_in_tree(root, path) : null;
}
var header = {
id: generate_id(),
id: utils.generate_id(),
expanded: false,
domain: data_pt.model._domain,
children: [],
@@ -531,8 +534,7 @@ instance.web.PivotView = instance.web.View.extend({
},
draw_headers: function ($thead, headers) {
var self = this,
i, j, cell, $row, $cell,
display_total = this.main_col.width > 1;
i, j, cell, $row, $cell;
var groupby_labels = _.map(this.main_col.groupbys, function (gb) {
return self.groupable_fields[gb.split(':')[0]].string;
@@ -593,7 +595,7 @@ instance.web.PivotView = instance.web.View.extend({
if (rows[i].indent > 0) $header.attr('title', groupby_labels[rows[i].indent - 1]);
$header.appendTo($row);
for (j = 0; j < length; j++) {
value = format_value(rows[i].values[j], {type: measure_types[j % nbr_measures]});
value = formats.format_value(rows[i].values[j], {type: measure_types[j % nbr_measures]});
$cell = $('<td>')
.data('id', rows[i].id)
.data('col_id', rows[i].col_ids[Math.floor(j / nbr_measures)])
@@ -678,8 +680,7 @@ instance.web.PivotView = instance.web.View.extend({
compute_rows: function () {
var self = this,
aggregates, i,
result = [],
nbr_measures = this.active_measures.length;
result = [];
traverse_tree(this.main_row.root, function (header) {
var values = [],
col_ids = [];
@@ -732,7 +733,7 @@ instance.web.PivotView = instance.web.View.extend({
}
},
download_table: function () {
openerp.web.blockUI();
framework.blockUI();
var nbr_measures = this.active_measures.length,
headers = this.compute_headers(),
measure_row = nbr_measures > 1 ? _.last(headers) : [],
@@ -760,23 +761,15 @@ instance.web.PivotView = instance.web.View.extend({
nbr_measures: nbr_measures,
title: this.title,
};
var c = openerp.webclient.crashmanager;
this.session.get_file({
session.get_file({
url: '/web/pivot/export_xls',
data: {data: JSON.stringify(table)},
complete: openerp.web.unblockUI,
error: c.rpc_error.bind(c)
complete: framework.unblockUI,
error: crash_manager.rpc_error.bind(crash_manager)
});
},
});
// helpers
var id = -1;
function generate_id () {
return ++id;
}
function traverse_tree(root, f, arg1, arg2, arg3) {
f(root, arg1, arg2, arg3);
if (!root.expanded) return;
@@ -799,4 +792,9 @@ function find_path_in_tree(root, path) {
return null;
}
})();
core.view_registry.add('pivot', PivotView);
return PivotView;
});
@@ -0,0 +1,311 @@
odoo.define('web.search_filters', ['web.core', 'web.datepicker', 'web.formats', 'web.Widget'], function (require) {
"use strict";
var core = require('web.core');
var datepicker = require('web.datepicker');
var formats = require('web.formats');
var Widget = require('web.Widget');
var _t = core._t;
var _lt = core._lt;
var ExtendedSearchProposition = Widget.extend(/** @lends instance.web.search.ExtendedSearchProposition# */{
template: 'SearchView.extended_search.proposition',
events: {
'change .searchview_extended_prop_field': 'changed',
'change .searchview_extended_prop_op': 'operator_changed',
'click .searchview_extended_delete_prop': function (e) {
e.stopPropagation();
this.getParent().remove_proposition(this);
},
},
/**
* @constructs instance.web.search.ExtendedSearchProposition
* @extends instance.web.Widget
*
* @param parent
* @param fields
*/
init: function (parent, fields) {
this._super(parent);
this.fields = _(fields).chain()
.map(function(val, key) { return _.extend({}, val, {'name': key}); })
.filter(function (field) { return !field.deprecated && field.searchable; })
.sortBy(function(field) {return field.string;})
.value();
this.attrs = {_: _, fields: this.fields, selected: null};
this.value = null;
},
start: function () {
return this._super().done(this.proxy('changed'));
},
changed: function() {
var nval = this.$(".searchview_extended_prop_field").val();
if(this.attrs.selected === null || this.attrs.selected === undefined || nval != this.attrs.selected.name) {
this.select_field(_.detect(this.fields, function(x) {return x.name == nval;}));
}
},
operator_changed: function (e) {
var $value = this.$('.searchview_extended_prop_value');
switch ($(e.target).val()) {
case '∃':
case '∄':
$value.hide();
break;
default:
$value.show();
}
},
/**
* Selects the provided field object
*
* @param field a field descriptor object (as returned by fields_get, augmented by the field name)
*/
select_field: function(field) {
var self = this;
if(this.attrs.selected !== null && this.attrs.selected !== undefined) {
this.value.destroy();
this.value = null;
this.$('.searchview_extended_prop_op').html('');
}
this.attrs.selected = field;
if(field === null || field === undefined) {
return;
}
var type = field.type;
var Field = core.search_filters_registry.get_any([type, "char"]);
this.value = new Field(this, field);
_.each(this.value.operators, function(operator) {
$('<option>', {value: operator.value})
.text(String(operator.text))
.appendTo(self.$('.searchview_extended_prop_op'));
});
var $value_loc = this.$('.searchview_extended_prop_value').show().empty();
this.value.appendTo($value_loc);
},
get_filter: function () {
if (this.attrs.selected === null || this.attrs.selected === undefined)
return null;
var field = this.attrs.selected,
op_select = this.$('.searchview_extended_prop_op')[0],
operator = op_select.options[op_select.selectedIndex];
return {
attrs: {
domain: [this.value.get_domain(field, operator)],
string: this.value.get_label(field, operator),
},
children: [],
tag: 'filter',
};
},
});
ExtendedSearchProposition.Field = Widget.extend({
init: function (parent, field) {
this._super(parent);
this.field = field;
},
get_label: function (field, operator) {
var format;
switch (operator.value) {
case '∃': case '∄': format = _t('%(field)s %(operator)s'); break;
default: format = _t('%(field)s %(operator)s "%(value)s"'); break;
}
return this.format_label(format, field, operator);
},
format_label: function (format, field, operator) {
return _.str.sprintf(format, {
field: field.string,
// According to spec, HTMLOptionElement#label should return
// HTMLOptionElement#text when not defined/empty, but it does
// not in older Webkit (between Safari 5.1.5 and Chrome 17) and
// Gecko (pre Firefox 7) browsers, so we need a manual fallback
// for those
operator: operator.label || operator.text,
value: this
});
},
get_domain: function (field, operator) {
switch (operator.value) {
case '∃': return this.make_domain(field.name, '!=', false);
case '∄': return this.make_domain(field.name, '=', false);
default: return this.make_domain(
field.name, operator.value, this.get_value());
}
},
make_domain: function (field, operator, value) {
return [field, operator, value];
},
/**
* Returns a human-readable version of the value, in case the "logical"
* and the "semantic" values of a field differ (as for selection fields,
* for instance).
*
* The default implementation simply returns the value itself.
*
* @return {String} human-readable version of the value
*/
toString: function () {
return this.get_value();
}
});
ExtendedSearchProposition.Char = ExtendedSearchProposition.Field.extend({
template: 'SearchView.extended_search.proposition.char',
operators: [
{value: "ilike", text: _lt("contains")},
{value: "not ilike", text: _lt("doesn't contain")},
{value: "=", text: _lt("is equal to")},
{value: "!=", text: _lt("is not equal to")},
{value: "∃", text: _lt("is set")},
{value: "∄", text: _lt("is not set")}
],
get_value: function() {
return this.$el.val();
}
});
ExtendedSearchProposition.DateTime = ExtendedSearchProposition.Field.extend({
template: 'SearchView.extended_search.proposition.empty',
operators: [
{value: "=", text: _lt("is equal to")},
{value: "!=", text: _lt("is not equal to")},
{value: ">", text: _lt("greater than")},
{value: "<", text: _lt("less than")},
{value: ">=", text: _lt("greater or equal than")},
{value: "<=", text: _lt("less or equal than")},
{value: "∃", text: _lt("is set")},
{value: "∄", text: _lt("is not set")}
],
widget: function () { return datepicker.DateTimeWidget; },
get_value: function() {
return this.datewidget.get_value();
},
toString: function () {
return formats.format_value(this.get_value(), { type:"datetime" });
},
start: function() {
var ready = this._super();
this.datewidget = new (this.widget())(this);
this.datewidget.appendTo(this.$el);
return ready;
}
});
ExtendedSearchProposition.Date = ExtendedSearchProposition.DateTime.extend({
widget: function () { return datepicker.DateWidget; },
toString: function () {
return formats.format_value(this.get_value(), { type:"date" });
}
});
ExtendedSearchProposition.Integer = ExtendedSearchProposition.Field.extend({
template: 'SearchView.extended_search.proposition.integer',
operators: [
{value: "=", text: _lt("is equal to")},
{value: "!=", text: _lt("is not equal to")},
{value: ">", text: _lt("greater than")},
{value: "<", text: _lt("less than")},
{value: ">=", text: _lt("greater or equal than")},
{value: "<=", text: _lt("less or equal than")},
{value: "∃", text: _lt("is set")},
{value: "∄", text: _lt("is not set")}
],
toString: function () {
return this.$el.val();
},
get_value: function() {
try {
var val =this.$el.val();
return formats.parse_value(val === "" ? 0 : val, {'widget': 'integer'});
} catch (e) {
return "";
}
}
});
ExtendedSearchProposition.Id = ExtendedSearchProposition.Integer.extend({
operators: [{value: "=", text: _lt("is")}]
});
ExtendedSearchProposition.Float = ExtendedSearchProposition.Field.extend({
template: 'SearchView.extended_search.proposition.float',
operators: [
{value: "=", text: _lt("is equal to")},
{value: "!=", text: _lt("is not equal to")},
{value: ">", text: _lt("greater than")},
{value: "<", text: _lt("less than")},
{value: ">=", text: _lt("greater or equal than")},
{value: "<=", text: _lt("less or equal than")},
{value: "∃", text: _lt("is set")},
{value: "∄", text: _lt("is not set")}
],
toString: function () {
return this.$el.val();
},
get_value: function() {
try {
var val =this.$el.val();
return formats.parse_value(val === "" ? 0.0 : val, {'widget': 'float'});
} catch (e) {
return "";
}
}
});
ExtendedSearchProposition.Selection = ExtendedSearchProposition.Field.extend({
template: 'SearchView.extended_search.proposition.selection',
operators: [
{value: "=", text: _lt("is")},
{value: "!=", text: _lt("is not")},
{value: "∃", text: _lt("is set")},
{value: "∄", text: _lt("is not set")}
],
toString: function () {
var select = this.$el[0];
var option = select.options[select.selectedIndex];
return option.label || option.text;
},
get_value: function() {
return this.$el.val();
}
});
ExtendedSearchProposition.Boolean = ExtendedSearchProposition.Field.extend({
template: 'SearchView.extended_search.proposition.empty',
operators: [
{value: "=", text: _lt("is true")},
{value: "!=", text: _lt("is false")}
],
get_label: function (field, operator) {
return this.format_label(
_t('%(field)s %(operator)s'), field, operator);
},
get_value: function() {
return true;
}
});
core.search_filters_registry
.add('char', ExtendedSearchProposition.Char)
.add('text', ExtendedSearchProposition.Char)
.add('one2many', ExtendedSearchProposition.Char)
.add('many2one', ExtendedSearchProposition.Char)
.add('many2many', ExtendedSearchProposition.Char)
.add('datetime', ExtendedSearchProposition.DateTime)
.add('date', ExtendedSearchProposition.Date)
.add('integer', ExtendedSearchProposition.Integer)
.add('float', ExtendedSearchProposition.Float)
.add('boolean', ExtendedSearchProposition.Boolean)
.add('selection', ExtendedSearchProposition.Selection)
.add('id', ExtendedSearchProposition.Id);
return {
ExtendedSearchProposition: ExtendedSearchProposition
};
});
@@ -0,0 +1,736 @@
odoo.define('web.search_inputs', ['web.core', 'web.data', 'web.formats', 'web.Model', 'web.pyeval', 'web.time', 'web.utils', 'web.Widget'], function (require) {
"use strict";
var core = require('web.core');
var data = require('web.data');
var formats = require('web.formats');
var Model = require('web.Model');
var pyeval = require('web.pyeval');
var time = require('web.time');
var utils = require('web.utils');
var Widget = require('web.Widget');
var _t = core._t;
var _lt = core._lt;
var Input = Widget.extend( /** @lends instance.web.search.Input# */{
/**
* @constructs instance.web.search.Input
* @extends instance.web.Widget
*
* @param parent
*/
init: function (parent) {
this._super(parent);
this.load_attrs({});
},
/**
* Fetch auto-completion values for the widget.
*
* The completion values should be an array of objects with keys category,
* label, value prefixed with an object with keys type=section and label
*
* @param {String} value value to complete
* @returns {jQuery.Deferred<null|Array>}
*/
complete: function (value) {
return $.when(null);
},
/**
* Returns a Facet instance for the provided defaults if they apply to
* this widget, or null if they don't.
*
* This default implementation will try calling
* :js:func:`instance.web.search.Input#facet_for` if the widget's name
* matches the input key
*
* @param {Object} defaults
* @returns {jQuery.Deferred<null|Object>}
*/
facet_for_defaults: function (defaults) {
if (!this.attrs ||
!(this.attrs.name in defaults && defaults[this.attrs.name])) {
return $.when(null);
}
return this.facet_for(defaults[this.attrs.name]);
},
get_context: function () {
throw new Error(
"get_context not implemented for widget " + this.attrs.type);
},
get_groupby: function () {
throw new Error(
"get_groupby not implemented for widget " + this.attrs.type);
},
get_domain: function () {
throw new Error(
"get_domain not implemented for widget " + this.attrs.type);
},
load_attrs: function (attrs) {
attrs.modifiers = attrs.modifiers ? JSON.parse(attrs.modifiers) : {};
this.attrs = attrs;
},
/**
* Returns whether the input is "visible". The default behavior is to
* query the ``modifiers.invisible`` flag on the input's description or
* view node.
*
* @returns {Boolean}
*/
visible: function () {
return !this.attrs.modifiers.invisible;
},
});
var Field = Input.extend( /** @lends instance.web.search.Field# */ {
template: 'SearchView.field',
default_operator: '=',
/**
* @constructs instance.web.search.Field
* @extends instance.web.search.Input
*
* @param view_section
* @param field
* @param parent
*/
init: function (view_section, field, parent) {
this._super(parent);
this.load_attrs(_.extend({}, field, view_section.attrs));
},
facet_for: function (value) {
return $.when({
field: this,
category: this.attrs.string || this.attrs.name,
values: [{label: String(value), value: value}]
});
},
value_from: function (facetValue) {
return facetValue.get('value');
},
get_context: function (facet) {
var self = this;
// A field needs a context to send when active
var context = this.attrs.context;
if (_.isEmpty(context) || !facet.values.length) {
return;
}
var contexts = facet.values.map(function (facetValue) {
return new data.CompoundContext(context)
.set_eval_context({self: self.value_from(facetValue)});
});
if (contexts.length === 1) { return contexts[0]; }
return _.extend(new data.CompoundContext(), {
__contexts: contexts
});
},
get_groupby: function () { },
/**
* Function creating the returned domain for the field, override this
* methods in children if you only need to customize the field's domain
* without more complex alterations or tests (and without the need to
* change override the handling of filter_domain)
*
* @param {String} name the field's name
* @param {String} operator the field's operator (either attribute-specified or default operator for the field
* @param {Number|String} facet parsed value for the field
* @returns {Array<Array>} domain to include in the resulting search
*/
make_domain: function (name, operator, facet) {
return [[name, operator, this.value_from(facet)]];
},
get_domain: function (facet) {
if (!facet.values.length) { return; }
var value_to_domain;
var self = this;
var domain = this.attrs.filter_domain;
if (domain) {
value_to_domain = function (facetValue) {
return new data.CompoundDomain(domain)
.set_eval_context({self: self.value_from(facetValue)});
};
} else {
value_to_domain = function (facetValue) {
return self.make_domain(
self.attrs.name,
self.attrs.operator || self.default_operator,
facetValue);
};
}
var domains = facet.values.map(value_to_domain);
if (domains.length === 1) { return domains[0]; }
for (var i = domains.length; --i;) {
domains.unshift(['|']);
}
return _.extend(new data.CompoundDomain(), {
__domains: domains
});
}
});
/**
* Implementation of the ``char`` OpenERP field type:
*
* * Default operator is ``ilike`` rather than ``=``
*
* * The Javascript and the HTML values are identical (strings)
*
* @class
* @extends instance.web.search.Field
*/
var CharField = Field.extend( /** @lends instance.web.search.CharField# */ {
default_operator: 'ilike',
complete: function (value) {
if (_.isEmpty(value)) { return $.when(null); }
var label = _.str.sprintf(_.str.escapeHTML(
_t("Search %(field)s for: %(value)s")), {
field: '<em>' + _.escape(this.attrs.string) + '</em>',
value: '<strong>' + _.escape(value) + '</strong>'});
return $.when([{
label: label,
facet: {
category: this.attrs.string,
field: this,
values: [{label: value, value: value}]
}
}]);
}
});
var NumberField = Field.extend(/** @lends instance.web.search.NumberField# */{
complete: function (value) {
var val = this.parse(value);
if (isNaN(val)) { return $.when(); }
var label = _.str.sprintf(
_t("Search %(field)s for: %(value)s"), {
field: '<em>' + _.escape(this.attrs.string) + '</em>',
value: '<strong>' + _.escape(value) + '</strong>'});
return $.when([{
label: label,
facet: {
category: this.attrs.string,
field: this,
values: [{label: value, value: val}]
}
}]);
},
});
/**
* @class
* @extends instance.web.search.NumberField
*/
var IntegerField = NumberField.extend(/** @lends instance.web.search.IntegerField# */{
error_message: _t("not a valid integer"),
parse: function (value) {
try {
return formats.parse_value(value, {'widget': 'integer'});
} catch (e) {
return NaN;
}
}
});
/**
* @class
* @extends instance.web.search.NumberField
*/
var FloatField = NumberField.extend(/** @lends instance.web.search.FloatField# */{
error_message: _t("not a valid number"),
parse: function (value) {
try {
return formats.parse_value(value, {'widget': 'float'});
} catch (e) {
return NaN;
}
}
});
/**
* Utility function for m2o & selection fields taking a selection/name_get pair
* (value, name) and converting it to a Facet descriptor
*
* @param {instance.web.search.Field} field holder field
* @param {Array} pair pair value to convert
*/
function facet_from(field, pair) {
return {
field: field,
category: field.attrs.string,
values: [{label: pair[1], value: pair[0]}]
};
}
/**
* @class
* @extends instance.web.search.Field
*/
var SelectionField = Field.extend(/** @lends instance.web.search.SelectionField# */{
// This implementation is a basic <select> field, but it may have to be
// altered to be more in line with the GTK client, which uses a combo box
// (~ jquery.autocomplete):
// * If an option was selected in the list, behave as currently
// * If something which is not in the list was entered (via the text input),
// the default domain should become (`ilike` string_value) but **any
// ``context`` or ``filter_domain`` becomes falsy, idem if ``@operator``
// is specified. So at least get_domain needs to be quite a bit
// overridden (if there's no @value and there is no filter_domain and
// there is no @operator, return [[name, 'ilike', str_val]]
template: 'SearchView.field.selection',
init: function () {
this._super.apply(this, arguments);
// prepend empty option if there is no empty option in the selection list
this.prepend_empty = !_(this.attrs.selection).detect(function (item) {
return !item[1];
});
},
complete: function (needle) {
var self = this;
var results = _(this.attrs.selection).chain()
.filter(function (sel) {
var value = sel[0], label = sel[1];
if (value === undefined || !label) { return false; }
return label.toLowerCase().indexOf(needle.toLowerCase()) !== -1;
})
.map(function (sel) {
return {
label: _.escape(sel[1]),
indent: true,
facet: facet_from(self, sel)
};
}).value();
if (_.isEmpty(results)) { return $.when(null); }
return $.when.call(null, [{
label: _.escape(this.attrs.string)
}].concat(results));
},
facet_for: function (value) {
var match = _(this.attrs.selection).detect(function (sel) {
return sel[0] === value;
});
if (!match) { return $.when(null); }
return $.when(facet_from(this, match));
}
});
var BooleanField = SelectionField.extend(/** @lends instance.web.search.BooleanField# */{
/**
* @constructs instance.web.search.BooleanField
* @extends instance.web.search.BooleanField
*/
init: function () {
this._super.apply(this, arguments);
this.attrs.selection = [
[true, _t("Yes")],
[false, _t("No")]
];
}
});
/**
* @class
* @extends instance.web.search.DateField
*/
var DateField = Field.extend(/** @lends instance.web.search.DateField# */{
value_from: function (facetValue) {
return time.date_to_str(facetValue.get('value'));
},
complete: function (needle) {
var m = moment(needle);
if (!m.isValid()) { return $.when(null); }
var d = m.toDate();
var date_string = formats.format_value(d, this.attrs);
var label = _.str.sprintf(_.str.escapeHTML(
_t("Search %(field)s at: %(value)s")), {
field: '<em>' + _.escape(this.attrs.string) + '</em>',
value: '<strong>' + date_string + '</strong>'});
return $.when([{
label: label,
facet: {
category: this.attrs.string,
field: this,
values: [{label: date_string, value: d}]
}
}]);
}
});
/**
* Implementation of the ``datetime`` openerp field type:
*
* * Uses the same widget as the ``date`` field type (a simple date)
*
* * Builds a slighly more complex, it's a datetime range (includes time)
* spanning the whole day selected by the date widget
*
* @class
* @extends instance.web.DateField
*/
var DateTimeField = DateField.extend(/** @lends instance.web.search.DateTimeField# */{
value_from: function (facetValue) {
return time.datetime_to_str(facetValue.get('value'));
}
});
var ManyToOneField = CharField.extend({
default_operator: {},
init: function (view_section, field, parent) {
this._super(view_section, field, parent);
this.model = new Model(this.attrs.relation);
this.searchview = parent;
},
complete: function (value) {
if (_.isEmpty(value)) { return $.when(null); }
var label = _.str.sprintf(_.str.escapeHTML(
_t("Search %(field)s for: %(value)s")), {
field: '<em>' + _.escape(this.attrs.string) + '</em>',
value: '<strong>' + _.escape(value) + '</strong>'});
return $.when([{
label: label,
facet: {
category: this.attrs.string,
field: this,
values: [{label: value, value: value, operator: 'ilike'}]
},
expand: this.expand.bind(this),
}]);
},
expand: function (needle) {
var self = this;
// FIXME: "concurrent" searches (multiple requests, mis-ordered responses)
var context = pyeval.eval(
'contexts', [this.searchview.dataset.get_context()]);
return this.model.call('name_search', [], {
name: needle,
args: (typeof this.attrs.domain === 'string') ? [] : this.attrs.domain,
limit: 8,
context: context
}).then(function (results) {
if (_.isEmpty(results)) { return null; }
return _(results).map(function (result) {
return {
label: _.escape(result[1]),
facet: facet_from(self, result)
};
});
});
},
facet_for: function (value) {
var self = this;
if (value instanceof Array) {
if (value.length === 2 && _.isString(value[1])) {
return $.when(facet_from(this, value));
}
utils.assert(value.length <= 1,
_t("M2O search fields do not currently handle multiple default values"));
// there are many cases of {search_default_$m2ofield: [id]}, need
// to handle this as if it were a single value.
value = value[0];
}
return this.model.call('name_get', [value]).then(function (names) {
if (_(names).isEmpty()) { return null; }
return facet_from(self, names[0]);
});
},
value_from: function (facetValue) {
return facetValue.get('label');
},
make_domain: function (name, operator, facetValue) {
operator = facetValue.get('operator') || operator;
switch(operator){
case this.default_operator:
return [[name, '=', facetValue.get('value')]];
case 'ilike':
return [[name, 'ilike', facetValue.get('value')]];
case 'child_of':
return [[name, 'child_of', facetValue.get('value')]];
}
return this._super(name, operator, facetValue);
},
get_context: function (facet) {
var values = facet.values;
if (_.isEmpty(this.attrs.context) && values.length === 1) {
var c = {};
var v = values.at(0);
if (v.get('operator') !== 'ilike') {
c['default_' + this.attrs.name] = v.get('value');
}
return c;
}
return this._super(facet);
}
});
var FilterGroup = Input.extend(/** @lends instance.web.search.FilterGroup# */{
template: 'SearchView.filters',
icon: "fa-filter",
completion_label: _lt("Filter on: %s"),
/**
* Inclusive group of filters, creates a continuous "button" with clickable
* sections (the normal display for filters is to be a self-contained button)
*
* @constructs instance.web.search.FilterGroup
* @extends instance.web.search.Input
*
* @param {Array<instance.web.search.Filter>} filters elements of the group
* @param {instance.web.SearchView} parent parent in which the filters are contained
*/
init: function (filters, parent) {
// If all filters are group_by and we're not initializing a GroupbyGroup,
// create a GroupbyGroup instead of the current FilterGroup
if (!(this instanceof GroupbyGroup) &&
_(filters).all(function (f) {
if (!f.attrs.context) { return false; }
var c = pyeval.eval('context', f.attrs.context);
return !_.isEmpty(c.group_by);})) {
return new GroupbyGroup(filters, parent);
}
this._super(parent);
this.filters = filters;
this.searchview = parent;
this.searchview.query.on('add remove change reset', this.proxy('search_change'));
},
start: function () {
this.$el.on('click', 'a', this.proxy('toggle_filter'));
return $.when(null);
},
/**
* Handles change of the search query: any of the group's filter which is
* in the search query should be visually checked in the drawer
*/
search_change: function () {
var self = this;
var $filters = this.$el.removeClass('selected');
var facet = this.searchview.query.find(_.bind(this.match_facet, this));
if (!facet) { return; }
facet.values.each(function (v) {
var i = _(self.filters).indexOf(v.get('value'));
if (i === -1) { return; }
$filters.filter(function () {
return Number($(this).data('index')) === i;
}).addClass('selected');
});
},
/**
* Matches the group to a facet, in order to find if the group is
* represented in the current search query
*/
match_facet: function (facet) {
return facet.get('field') === this;
},
make_facet: function (values) {
return {
category: _t("Filter"),
icon: this.icon,
values: values,
field: this
};
},
make_value: function (filter) {
return {
label: filter.attrs.string || filter.attrs.help || filter.attrs.name,
value: filter
};
},
facet_for_defaults: function (defaults) {
var self = this;
var fs = _(this.filters).chain()
.filter(function (f) {
return f.attrs && f.attrs.name && !!defaults[f.attrs.name];
}).map(function (f) {
return self.make_value(f);
}).value();
if (_.isEmpty(fs)) { return $.when(null); }
return $.when(this.make_facet(fs));
},
/**
* Fetches contexts for all enabled filters in the group
*
* @param {openerp.web.search.Facet} facet
* @return {*} combined contexts of the enabled filters in this group
*/
get_context: function (facet) {
var contexts = facet.values.chain()
.map(function (f) { return f.get('value').attrs.context; })
.without('{}')
.reject(_.isEmpty)
.value();
if (!contexts.length) { return; }
if (contexts.length === 1) { return contexts[0]; }
return _.extend(new data.CompoundContext(), {
__contexts: contexts
});
},
/**
* Fetches group_by sequence for all enabled filters in the group
*
* @param {VS.model.SearchFacet} facet
* @return {Array} enabled filters in this group
*/
get_groupby: function (facet) {
return facet.values.chain()
.map(function (f) { return f.get('value').attrs.context; })
.without('{}')
.reject(_.isEmpty)
.value();
},
/**
* Handles domains-fetching for all the filters within it: groups them.
*
* @param {VS.model.SearchFacet} facet
* @return {*} combined domains of the enabled filters in this group
*/
get_domain: function (facet) {
var domains = facet.values.chain()
.map(function (f) { return f.get('value').attrs.domain; })
.without('[]')
.reject(_.isEmpty)
.value();
if (!domains.length) { return; }
if (domains.length === 1) { return domains[0]; }
for (var i=domains.length; --i;) {
domains.unshift(['|']);
}
return _.extend(new data.CompoundDomain(), {
__domains: domains
});
},
toggle_filter: function (e) {
e.stopPropagation();
this.toggle(this.filters[Number($(e.target).parent().data('index'))]);
},
toggle: function (filter, options) {
this.searchview.query.toggle(this.make_facet([this.make_value(filter)]), options);
},
is_visible: function () {
return _.some(this.filters, function (filter) {
return !filter.attrs.invisible;
});
},
complete: function (item) {
var self = this;
item = item.toLowerCase();
var facet_values = _(this.filters).chain()
.filter(function (filter) { return filter.visible(); })
.filter(function (filter) {
var at = {
string: filter.attrs.string || '',
help: filter.attrs.help || '',
name: filter.attrs.name || ''
};
var include = _.str.include;
return include(at.string.toLowerCase(), item)
|| include(at.help.toLowerCase(), item)
|| include(at.name.toLowerCase(), item);
})
.map(this.make_value)
.value();
if (_(facet_values).isEmpty()) { return $.when(null); }
return $.when(_.map(facet_values, function (facet_value) {
return {
label: _.str.sprintf(self.completion_label.toString(),
_.escape(facet_value.label)),
facet: self.make_facet([facet_value])
};
}));
}
});
var GroupbyGroup = FilterGroup.extend({
icon: 'fa-bars',
completion_label: _lt("Group by: %s"),
init: function (filters, parent) {
this._super(filters, parent);
this.searchview = parent;
// Not flanders: facet unicity is handled through the
// (category, field) pair of facet attributes. This is all well and
// good for regular filter groups where a group matches a facet, but for
// groupby we want a single facet. So cheat: add an attribute on the
// view which proxies to the first GroupbyGroup, so it can be used
// for every GroupbyGroup and still provides the various methods needed
// by the search view. Use weirdo name to avoid risks of conflicts
if (!this.searchview._s_groupby) {
this.searchview._s_groupby = {
help: "See GroupbyGroup#init",
get_context: this.proxy('get_context'),
get_domain: this.proxy('get_domain'),
get_groupby: this.proxy('get_groupby')
};
}
},
match_facet: function (facet) {
return facet.get('field') === this.searchview._s_groupby;
},
make_facet: function (values) {
return {
category: _t("GroupBy"),
icon: this.icon,
values: values,
field: this.searchview._s_groupby
};
}
});
var Filter = Input.extend(/** @lends instance.web.search.Filter# */{
template: 'SearchView.filter',
/**
* Implementation of the OpenERP filters (button with a context and/or
* a domain sent as-is to the search view)
*
* Filters are only attributes holder, the actual work (compositing
* domains and contexts, converting between facets and filters) is
* performed by the filter group.
*
* @constructs instance.web.search.Filter
* @extends instance.web.search.Input
*
* @param node
* @param parent
*/
init: function (node, parent) {
this._super(parent);
this.load_attrs(node.attrs);
},
facet_for: function () { return $.when(null); },
get_context: function () { },
get_domain: function () { },
});
/**
* Registry of search fields, called by :js:class:`instance.web.SearchView` to
* find and instantiate its field widgets.
*/
core.search_widgets_registry
.add('char', CharField)
.add('text', CharField)
.add('html', CharField)
.add('boolean', BooleanField)
.add('integer', IntegerField)
.add('id', IntegerField)
.add('float', FloatField)
.add('selection', SelectionField)
.add('datetime', DateTimeField)
.add('date', DateField)
.add('many2one', ManyToOneField)
.add('many2many', CharField)
.add('one2many', CharField);
return {
FilterGroup: FilterGroup,
Filter: Filter,
Field: Field,
GroupbyGroup: GroupbyGroup,
};
});
@@ -0,0 +1,437 @@
odoo.define('web.FavoriteMenu', ['web.core', 'web.Model','web.pyeval', 'web.session', 'web.Widget'], function (require) {
"use strict";
var core = require('web.core');
var Model = require('web.Model');
var pyeval = require('web.pyeval');
var session = require('web.session');
var Widget = require('web.Widget');
var _t = core._t;
return Widget.extend({
template: 'SearchView.FavoriteMenu',
events: {
'click li': function (event) {
event.stopImmediatePropagation();
},
'click .oe-save-search a': function () {
this.toggle_save_menu();
},
'click .oe-save-name button': 'save_favorite',
'hidden.bs.dropdown': 'close_menus',
},
init: function (parent, query, target_model, action_id) {
this._super.apply(this,arguments);
this.searchview = parent;
this.query = query;
this.target_model = target_model;
this.model = new Model('ir.filters');
this.filters = {};
this.$filters = {};
this.action_id = action_id;
},
start: function () {
var self = this;
this.$save_search = this.$('.oe-save-search');
this.$save_name = this.$('.oe-save-name');
this.$inputs = this.$save_name.find('input');
this.$divider = this.$('.divider');
this.$inputs.eq(0).val(this.searchview.getParent().title);
var $shared_filter = this.$inputs.eq(1),
$default_filter = this.$inputs.eq(2);
$shared_filter.click(function () {$default_filter.prop('checked', false);});
$default_filter.click(function () {$shared_filter.prop('checked', false);});
this.query
.on('remove', function (facet) {
if (facet.get('is_custom_filter')) {
self.clear_selection();
}
})
.on('reset', this.proxy('clear_selection'));
if (!this.action_id) {
this.prepare_dropdown_menu([]);
return $.when();
}
return this.model.call('get_filters', [this.target_model, this.action_id])
.done(this.proxy('prepare_dropdown_menu'));
},
prepare_dropdown_menu: function (filters) {
filters.map(this.append_filter.bind(this));
},
toggle_save_menu: function (is_open) {
this.$save_search
.toggleClass('closed-menu', !(_.isUndefined(is_open)) ? !is_open : undefined)
.toggleClass('open-menu', is_open);
this.$save_name.toggle(is_open);
if (this.$save_search.hasClass('open-menu')) {
this.$save_name.find('input').first().focus();
}
},
close_menus: function () {
this.toggle_save_menu(false);
},
save_favorite: function () {
var self = this,
filter_name = this.$inputs[0].value,
default_filter = this.$inputs[1].checked,
shared_filter = this.$inputs[2].checked;
if (!filter_name.length){
this.do_warn(_t("Error"), _t("Filter name is required."));
this.$inputs.first().focus();
return;
}
if (_.chain(this.filters)
.pluck('name')
.contains(filter_name).value()) {
this.do_warn(_t("Error"), _t("Filter with same name already exists."));
this.$inputs.first().focus();
return;
}
var search = this.searchview.build_search_data(),
view_manager = this.findAncestor(function (a) {
// HORRIBLE HACK. PLEASE SAVE ME FROM MYSELF (BUT IN A PAINLESS WAY IF POSSIBLE)
return 'active_view' in a;
}),
view_context = view_manager ? view_manager.active_view.controller.get_context() : {},
results = pyeval.sync_eval_domains_and_contexts({
domains: search.domains,
contexts: search.contexts.concat(view_context || []),
group_by_seq: search.groupbys || [],
});
if (!_.isEmpty(results.group_by)) {
results.context.group_by = results.group_by;
}
// Don't save user_context keys in the custom filter, otherwise end
// up with e.g. wrong uid or lang stored *and used in subsequent
// reqs*
var ctx = results.context;
_(_.keys(session.user_context)).each(function (key) {
delete ctx[key];
});
var filter = {
name: filter_name,
user_id: shared_filter ? false : session.uid,
model_id: this.searchview.dataset.model,
context: results.context,
domain: results.domain,
is_default: default_filter,
action_id: this.action_id,
};
return this.model.call('create_or_replace', [filter]).done(function (id) {
filter.id = id;
self.toggle_save_menu(false);
self.$save_name.find('input').val('').prop('checked', false);
self.append_filter(filter);
self.toggle_filter(filter, true);
});
},
get_default_filter: function () {
var personal_filter = _.find(this.filters, function (filter) {
return filter.user_id && filter.is_default;
});
if (personal_filter) {
return personal_filter;
}
return _.find(this.filters, function (filter) {
return !filter.user_id && filter.is_default;
});
},
/**
* Generates a mapping key (in the filters and $filter mappings) for the
* filter descriptor object provided (as returned by ``get_filters``).
*
* The mapping key is guaranteed to be unique for a given (user_id, name)
* pair.
*
* @param {Object} filter
* @param {String} filter.name
* @param {Number|Pair<Number, String>} [filter.user_id]
* @return {String} mapping key corresponding to the filter
*/
key_for: function (filter) {
var user_id = filter.user_id,
action_id = filter.action_id,
uid = (user_id instanceof Array) ? user_id[0] : user_id,
act_id = (action_id instanceof Array) ? action_id[0] : action_id;
return _.str.sprintf('(%s)(%s)%s', uid, act_id, filter.name);
},
/**
* Generates a :js:class:`~instance.web.search.Facet` descriptor from a
* filter descriptor
*
* @param {Object} filter
* @param {String} filter.name
* @param {Object} [filter.context]
* @param {Array} [filter.domain]
* @return {Object}
*/
facet_for: function (filter) {
return {
category: _t("Custom Filter"),
icon: 'fa-star',
field: {
get_context: function () { return filter.context; },
get_groupby: function () { return [filter.context]; },
get_domain: function () { return filter.domain; }
},
_id: filter.id,
is_custom_filter: true,
values: [{label: filter.name, value: null}]
};
},
clear_selection: function () {
this.$('li.selected').removeClass('selected');
},
append_filter: function (filter) {
var self = this,
key = this.key_for(filter),
$filter;
this.$divider.show();
if (key in this.$filters) {
$filter = this.$filters[key];
} else {
this.filters[key] = filter;
$filter = $('<li></li>')
.insertBefore(this.$divider)
.toggleClass('oe_searchview_custom_default', filter.is_default)
.append($('<a>').text(filter.name));
this.$filters[key] = $filter;
this.$filters[key].addClass(filter.user_id ? 'oe_searchview_custom_private'
: 'oe_searchview_custom_public');
$('<span>')
.addClass('fa fa-trash-o remove-filter')
.click(function (event) {
event.stopImmediatePropagation();
self.remove_filter(filter, $filter, key);
})
.appendTo($filter);
}
this.$filters[key].unbind('click').click(function () {
self.toggle_filter(filter);
});
},
toggle_filter: function (filter, preventSearch) {
var current = this.query.find(function (facet) {
return facet.get('_id') === filter.id;
});
if (current) {
this.query.remove(current);
this.$filters[this.key_for(filter)].removeClass('selected');
return;
}
this.query.reset([this.facet_for(filter)], {
preventSearch: preventSearch || false});
this.$filters[this.key_for(filter)].addClass('selected');
},
remove_filter: function (filter, $filter, key) {
var self = this;
var global_warning = _t("This filter is global and will be removed for everybody if you continue."),
warning = _t("Are you sure that you want to remove this filter?");
if (!confirm(filter.user_id ? warning : global_warning)) {
return;
}
this.model.call('unlink', [filter.id]).done(function () {
$filter.remove();
delete self.$filters[key];
delete self.filters[key];
if (_.isEmpty(self.filters)) {
self.$divider.hide();
}
});
},
});
});
odoo.define('web.FilterMenu', ['web.Widget', 'web.search_filters', 'web.search_inputs'], function (require) {
var search_filters = require('web.search_filters'),
search_inputs = require('web.search_inputs'),
Widget = require('web.Widget');
return Widget.extend({
template: 'SearchView.FilterMenu',
events: {
'click .oe-add-filter': function () {
this.toggle_custom_filter_menu();
},
'click li': function (event) {event.stopImmediatePropagation();},
'hidden.bs.dropdown': function () {
this.toggle_custom_filter_menu(false);
},
'click .oe-add-condition': 'append_proposition',
'click .oe-apply-filter': 'commit_search',
},
init: function (parent, filters, fields_def) {
this._super(parent);
this.filters = filters || [];
this.searchview = parent;
this.propositions = [];
this.fields_def = fields_def.then(function (data) {
var fields = {
id: { string: 'ID', type: 'id', searchable: true }
};
_.each(data, function(field_def, field_name) {
if (field_def.selectable !== false && field_name !== 'id') {
fields[field_name] = field_def;
}
});
return fields;
});
},
start: function () {
var self = this;
this.$menu = this.$('.filters-menu');
this.$add_filter = this.$('.oe-add-filter');
this.$apply_filter = this.$('.oe-apply-filter');
this.$add_filter_menu = this.$('.oe-add-filter-menu');
_.each(this.filters, function (group) {
if (group.is_visible()) {
group.insertBefore(self.$add_filter);
$('<li class="divider">').insertBefore(self.$add_filter);
}
});
this.append_proposition().then(function (prop) {
prop.$el.hide();
});
},
update_max_height: function () {
var max_height = $(window).height() - this.$menu[0].getBoundingClientRect().top - 10;
this.$menu.css('max-height', max_height);
},
toggle_custom_filter_menu: function (is_open) {
this.$add_filter
.toggleClass('closed-menu', !(_.isUndefined(is_open)) ? !is_open : undefined)
.toggleClass('open-menu', is_open);
this.$add_filter_menu.toggle(is_open);
if (this.$add_filter.hasClass('closed-menu') && (!this.propositions.length)) {
this.append_proposition();
}
this.$('.oe-filter-condition').toggle(is_open);
this.update_max_height();
},
append_proposition: function () {
var self = this;
return this.fields_def.then(function (fields) {
var prop = new search_filters.ExtendedSearchProposition(self, fields);
self.propositions.push(prop);
prop.insertBefore(self.$add_filter_menu);
self.$apply_filter.prop('disabled', false);
self.update_max_height();
return prop;
});
},
remove_proposition: function (prop) {
this.propositions = _.without(this.propositions, prop);
if (!this.propositions.length) {
this.$apply_filter.prop('disabled', true);
}
prop.destroy();
},
commit_search: function () {
var filters = _.invoke(this.propositions, 'get_filter'),
filters_widgets = _.map(filters, function (filter) {
return new search_inputs.Filter(filter, this);
}),
filter_group = new search_inputs.FilterGroup(filters_widgets, this.searchview),
facets = filters_widgets.map(function (filter) {
return filter_group.make_facet([filter_group.make_value(filter)]);
});
filter_group.insertBefore(this.$add_filter);
$('<li class="divider">').insertBefore(this.$add_filter);
this.searchview.query.add(facets, {silent: true});
this.searchview.query.trigger('reset');
_.invoke(this.propositions, 'destroy');
this.propositions = [];
this.append_proposition();
this.toggle_custom_filter_menu(false);
},
});
});
odoo.define('web.GroupByMenu', ['web.Widget', 'web.core', 'web.search_inputs'], function (require) {
var Widget = require('web.Widget'),
core = require('web.core'),
search_inputs = require('web.search_inputs');
var QWeb = core.qweb;
return Widget.extend({
template: 'SearchView.GroupByMenu',
events: {
'click li': function (event) {
event.stopImmediatePropagation();
},
'hidden.bs.dropdown': function () {
this.toggle_add_menu(false);
},
'click .add-custom-group a': function () {
this.toggle_add_menu();
},
},
init: function (parent, groups, fields_def) {
this._super(parent);
this.groups = groups || [];
this.groupable_fields = {};
this.searchview = parent;
this.fields_def = fields_def.then(this.proxy('get_groupable_fields'));
},
start: function () {
var self = this;
this.$menu = this.$('.group-by-menu');
var divider = this.$menu.find('.divider');
_.invoke(this.groups, 'insertBefore', divider);
if (this.groups.length) {
divider.show();
}
this.$add_group = this.$menu.find('.add-custom-group');
this.fields_def.then(function () {
self.$menu.append(QWeb.render('GroupByMenuSelector', self));
self.$add_group_menu = self.$('.oe-add-group');
self.$group_selector = self.$('.oe-group-selector');
self.$('.oe-select-group').click(function () {
self.toggle_add_menu(false);
var field = self.$group_selector.find(':selected').data('name');
self.add_groupby_to_menu(field);
});
});
},
get_groupable_fields: function (fields) {
var self = this,
groupable_types = ['many2one', 'char', 'boolean', 'selection', 'date', 'datetime'];
_.each(fields, function (field, name) {
if (field.store && _.contains(groupable_types, field.type)) {
self.groupable_fields[name] = field;
}
});
},
toggle_add_menu: function (is_open) {
this.$add_group
.toggleClass('closed-menu', !(_.isUndefined(is_open)) ? !is_open : undefined)
.toggleClass('open-menu', is_open);
this.$add_group_menu.toggle(is_open);
if (this.$add_group.hasClass('open-menu')) {
this.$group_selector.focus();
}
},
add_groupby_to_menu: function (field_name) {
var filter = new search_inputs.Filter({attrs:{
context:"{'group_by':'" + field_name + "''}",
name: this.groupable_fields[field_name].string,
}}, this.searchview);
var group = new search_inputs.FilterGroup([filter], this.searchview),
divider = this.$('.divider').show();
group.insertBefore(divider);
group.toggle(filter);
},
});
});
@@ -0,0 +1,945 @@
odoo.define('web.SearchView', ['web.AutoComplete', 'web.core', 'web.FavoriteMenu', 'web.FilterMenu', 'web.GroupByMenu', 'web.Model', 'web.pyeval', 'web.search_inputs', 'web.utils', 'web.Widget'], function (require) {
"use strict";
var AutoComplete = require('web.AutoComplete');
var core = require('web.core');
var FavoriteMenu = require('web.FavoriteMenu');
var FilterMenu = require('web.FilterMenu');
var GroupByMenu = require('web.GroupByMenu');
var Model = require('web.Model');
var pyeval = require('web.pyeval');
var search_inputs = require('web.search_inputs');
var utils = require('web.utils');
var Widget = require('web.Widget');
var Backbone = window.Backbone;
var FacetValue = Backbone.Model.extend({});
var FacetValues = Backbone.Collection.extend({
model: FacetValue
});
var Facet = Backbone.Model.extend({
initialize: function (attrs) {
var values = attrs.values;
delete attrs.values;
Backbone.Model.prototype.initialize.apply(this, arguments);
this.values = new FacetValues(values || []);
this.values.on('add remove change reset', function (_, options) {
this.trigger('change', this, options);
}, this);
},
get: function (key) {
if (key !== 'values') {
return Backbone.Model.prototype.get.call(this, key);
}
return this.values.toJSON();
},
set: function (key, value) {
if (key !== 'values') {
return Backbone.Model.prototype.set.call(this, key, value);
}
this.values.reset(value);
},
toJSON: function () {
var out = {};
var attrs = this.attributes;
for(var att in attrs) {
if (!attrs.hasOwnProperty(att) || att === 'field') {
continue;
}
out[att] = attrs[att];
}
out.values = this.values.toJSON();
return out;
}
});
var SearchQuery = Backbone.Collection.extend({
model: Facet,
initialize: function () {
Backbone.Collection.prototype.initialize.apply(
this, arguments);
this.on('change', function (facet) {
if(!facet.values.isEmpty()) { return; }
this.remove(facet, {silent: true});
}, this);
},
add: function (values, options) {
options = options || {};
if (!values) {
values = [];
} else if (!(values instanceof Array)) {
values = [values];
}
_(values).each(function (value) {
var model = this._prepareModel(value, options);
var previous = this.detect(function (facet) {
return facet.get('category') === model.get('category') &&
facet.get('field') === model.get('field');
});
if (previous) {
previous.values.add(model.get('values'), _.omit(options, 'at', 'merge'));
return;
}
Backbone.Collection.prototype.add.call(this, model, options);
}, this);
// warning: in backbone 1.0+ add is supposed to return the added models,
// but here toggle may delegate to add and return its value directly.
// return value of neither seems actually used but should be tested
// before change, probably
return this;
},
toggle: function (value, options) {
options = options || {};
var facet = this.detect(function (facet) {
return facet.get('category') === value.category
&& facet.get('field') === value.field;
});
if (!facet) {
return this.add(value, options);
}
var changed = false;
_(value.values).each(function (val) {
var already_value = facet.values.detect(function (v) {
return v.get('value') === val.value
&& v.get('label') === val.label;
});
// toggle value
if (already_value) {
facet.values.remove(already_value, {silent: true});
} else {
facet.values.add(val, {silent: true});
}
changed = true;
});
// "Commit" changes to values array as a single call, so observers of
// change event don't get misled by intermediate incomplete toggling
// states
facet.trigger('change', facet);
return this;
}
});
var InputView = Widget.extend({
template: 'SearchView.InputView',
events: {
focus: function () { this.trigger('focused', this); },
blur: function () { this.$el.text(''); this.trigger('blurred', this); },
keydown: 'onKeydown',
paste: 'onPaste',
},
getSelection: function () {
this.el.normalize();
// get Text node
var root = this.el.childNodes[0];
if (!root || !root.textContent) {
// if input does not have a child node, or the child node is an
// empty string, then the selection can only be (0, 0)
return {start: 0, end: 0};
}
var range = window.getSelection().getRangeAt(0);
// In Firefox, depending on the way text is selected (drag, double- or
// triple-click) the range may start or end on the parent of the
// selected text node‽ Check for this condition and fixup the range
// note: apparently with C-a this can go even higher?
if (range.startContainer === this.el && range.startOffset === 0) {
range.setStart(root, 0);
}
if (range.endContainer === this.el && range.endOffset === 1) {
range.setEnd(root, root.length);
}
utils.assert(range.startContainer === root,
"selection should be in the input view");
utils.assert(range.endContainer === root,
"selection should be in the input view");
return {
start: range.startOffset,
end: range.endOffset
};
},
onKeydown: function (e) {
this.el.normalize();
var sel;
switch (e.which) {
// Do not insert newline, but let it bubble so searchview can use it
case $.ui.keyCode.ENTER:
e.preventDefault();
break;
// FIXME: may forget content if non-empty but caret at index 0, ok?
case $.ui.keyCode.BACKSPACE:
sel = this.getSelection();
if (sel.start === 0 && sel.start === sel.end) {
e.preventDefault();
var preceding = this.getParent().siblingSubview(this, -1);
if (preceding && (preceding instanceof FacetView)) {
preceding.model.destroy();
}
}
break;
// let left/right events propagate to view if caret is at input border
// and not a selection
case $.ui.keyCode.LEFT:
sel = this.getSelection();
if (sel.start !== 0 || sel.start !== sel.end) {
e.stopPropagation();
}
break;
case $.ui.keyCode.RIGHT:
sel = this.getSelection();
var len = this.$el.text().length;
if (sel.start !== len || sel.start !== sel.end) {
e.stopPropagation();
}
break;
}
},
setCursorAtEnd: function () {
this.el.normalize();
var sel = window.getSelection();
sel.removeAllRanges();
var range = document.createRange();
// in theory, range.selectNodeContents should work here. In practice,
// MSIE9 has issues from time to time, instead of selecting the inner
// text node it would select the reference node instead (e.g. in demo
// data, company news, copy across the "Company News" link + the title,
// from about half the link to half the text, paste in search box then
// hit the left arrow key, getSelection would blow up).
//
// Explicitly selecting only the inner text node (only child node
// since we've normalized the parent) avoids the issue
range.selectNode(this.el.childNodes[0]);
range.collapse(false);
sel.addRange(range);
},
onPaste: function () {
this.el.normalize();
// In MSIE and Webkit, it is possible to get various representations of
// the clipboard data at this point e.g.
// window.clipboardData.getData('Text') and
// event.clipboardData.getData('text/plain') to ensure we have a plain
// text representation of the object (and probably ensure the object is
// pastable as well, so nobody puts an image in the search view)
// (nb: since it's not possible to alter the content of the clipboard
// — at least in Webkit — to ensure only textual content is available,
// using this would require 1. getting the text data; 2. manually
// inserting the text data into the content; and 3. cancelling the
// paste event)
//
// But Firefox doesn't support the clipboard API (as of FF18)
// although it correctly triggers the paste event (Opera does not even
// do that) => implement lowest-denominator system where onPaste
// triggers a followup "cleanup" pass after the data has been pasted
setTimeout(function () {
// Read text content (ignore pasted HTML)
var data = this.$el.text();
if (!data)
return;
// paste raw text back in
this.$el.empty().text(data);
this.el.normalize();
// Set the cursor at the end of the text, so the cursor is not lost
// in some kind of error-spawning limbo.
this.setCursorAtEnd();
}.bind(this), 0);
}
});
var FacetView = Widget.extend({
template: 'SearchView.FacetView',
events: {
'focus': function () { this.trigger('focused', this); },
'blur': function () { this.trigger('blurred', this); },
'click': function (e) {
if ($(e.target).is('.oe_facet_remove')) {
this.model.destroy();
return false;
}
this.$el.focus();
e.stopPropagation();
},
'keydown': function (e) {
var keys = $.ui.keyCode;
switch (e.which) {
case keys.BACKSPACE:
case keys.DELETE:
this.model.destroy();
return false;
}
}
},
init: function (parent, model) {
this._super(parent);
this.model = model;
this.model.on('change', this.model_changed, this);
},
destroy: function () {
this.model.off('change', this.model_changed, this);
this._super();
},
start: function () {
var self = this;
var $e = this.$('> span:last-child');
return $.when(this._super()).then(function () {
return $.when.apply(null, self.model.values.map(function (value) {
return new FacetValueView(self, value).appendTo($e);
}));
});
},
model_changed: function () {
this.$el.text(this.$el.text() + '*');
}
});
var FacetValueView = Widget.extend({
template: 'SearchView.FacetView.Value',
init: function (parent, model) {
this._super(parent);
this.model = model;
this.model.on('change', this.model_changed, this);
},
destroy: function () {
this.model.off('change', this.model_changed, this);
this._super();
},
model_changed: function () {
this.$el.text(this.$el.text() + '*');
}
});
var SearchView = Widget.extend(/** @lends instance.web.SearchView# */{
template: "SearchView",
events: {
// focus last input if view itself is clicked
'click': function (e) {
if (e.target === this.$('.oe_searchview_facets')[0]) {
this.$('.oe_searchview_input:last').focus();
}
},
// search button
'click div.oe_searchview_search': function (e) {
e.stopImmediatePropagation();
this.do_search();
},
'click .oe_searchview_unfold_drawer': function (e) {
e.stopImmediatePropagation();
$(e.target).toggleClass('fa-caret-down fa-caret-up');
localStorage.visible_search_menu = (localStorage.visible_search_menu !== 'true');
this.toggle_buttons();
},
'keydown .oe_searchview_input, .oe_searchview_facet': function (e) {
switch(e.which) {
case $.ui.keyCode.LEFT:
this.focusPreceding(e.target);
e.preventDefault();
break;
case $.ui.keyCode.RIGHT:
if (!this.autocomplete.is_expandable()) {
this.focusFollowing(e.target);
}
e.preventDefault();
break;
}
},
'autocompleteopen': function () {
this.$el.autocomplete('widget').css('z-index', 9999);
},
},
/**
* @constructs instance.web.SearchView
* @extends instance.web.Widget
*
* @param parent
* @param dataset
* @param view_id
* @param defaults
* @param {Object} [options]
* @param {Boolean} [options.hidden=false] hide the search view
* @param {Boolean} [options.disable_custom_filters=false] do not load custom filters from ir.filters
*/
init: function(parent, dataset, view_id, defaults, options) {
this.options = _.defaults(options || {}, {
hidden: false,
disable_custom_filters: false,
});
this._super(parent);
this.query = undefined;
this.dataset = dataset;
this.view_id = view_id;
this.search_fields = [];
this.filters = [];
this.groupbys = [];
this.visible_filters = (localStorage.visible_search_menu === 'true');
this.input_subviews = []; // for user input in searchbar
this.defaults = defaults || {};
this.headless = this.options.hidden && _.isEmpty(this.defaults);
this.$buttons = this.options.$buttons;
this.filter_menu = undefined;
this.groupby_menu = undefined;
this.favorite_menu = undefined;
this.action_id = this.options && this.options.action && this.options.action.id;
},
start: function() {
if (this.headless) {
this.$el.hide();
}
this.toggle_visibility(false);
this.$facets_container = this.$('div.oe_searchview_facets');
this.setup_global_completion();
this.query = new SearchQuery()
.on('add change reset remove', this.proxy('do_search'))
.on('change', this.proxy('renderChangedFacets'))
.on('add reset remove', this.proxy('renderFacets'));
var load_view = this.dataset._model.fields_view_get({
view_id: this.view_id,
view_type: 'search',
context: this.dataset.get_context(),
});
this.$('.oe_searchview_unfold_drawer')
.toggleClass('fa-caret-down', !this.visible_filters)
.toggleClass('fa-caret-up', this.visible_filters);
return this.alive($.when(this._super(), this.alive(load_view).then(this.view_loaded.bind(this))));
},
view_loaded: function (r) {
var custom_filters_ready;
this.fields_view_get = r;
this.view_id = this.view_id || r.view_id;
this.prepare_search_inputs();
if (this.$buttons) {
var fields_def = new Model(this.dataset.model).call('fields_get', {
context: this.dataset.context
});
this.groupby_menu = new GroupByMenu(this, this.groupbys, fields_def);
this.filter_menu = new FilterMenu(this, this.filters, fields_def);
this.favorite_menu = new FavoriteMenu(this, this.query, this.dataset.model, this.action_id);
this.filter_menu.appendTo(this.$buttons);
this.groupby_menu.appendTo(this.$buttons);
custom_filters_ready = this.favorite_menu.appendTo(this.$buttons);
}
return $.when(custom_filters_ready).then(this.proxy('set_default_filters'));
},
set_default_filters: function () {
var self = this,
default_custom_filter = this.$buttons && this.favorite_menu.get_default_filter();
if (!self.options.disable_custom_filters && default_custom_filter) {
return this.favorite_menu.toggle_filter(default_custom_filter, true);
}
if (!_.isEmpty(this.defaults)) {
var inputs = this.search_fields.concat(this.filters, this.groupbys),
defaults = _.invoke(inputs, 'facet_for_defaults', this.defaults);
return $.when.apply(null, defaults).then(function () {
self.query.reset(_(arguments).compact(), {preventSearch: true});
});
}
this.query.reset([], {preventSearch: true});
return $.when();
},
/**
* Performs the search view collection of widget data.
*
* If the collection went well (all fields are valid), then triggers
* :js:func:`instance.web.SearchView.on_search`.
*
* If at least one field failed its validation, triggers
* :js:func:`instance.web.SearchView.on_invalid` instead.
*
* @param [_query]
* @param {Object} [options]
*/
do_search: function (_query, options) {
if (options && options.preventSearch) {
return;
}
var search = this.build_search_data();
this.trigger('search_data', search.domains, search.contexts, search.groupbys);
},
/**
* Extract search data from the view's facets.
*
* Result is an object with 3 (own) properties:
*
* domains
* Array of domains
* contexts
* Array of contexts
* groupbys
* Array of domains, in groupby order rather than view order
*
* @return {Object}
*/
build_search_data: function () {
var domains = [], contexts = [], groupbys = [];
this.query.each(function (facet) {
var field = facet.get('field');
var domain = field.get_domain(facet);
if (domain) {
domains.push(domain);
}
var context = field.get_context(facet);
if (context) {
contexts.push(context);
}
var group_by = field.get_groupby(facet);
if (group_by) {
groupbys.push.apply(groupbys, group_by);
}
});
return {
domains: domains,
contexts: contexts,
groupbys: groupbys,
};
},
toggle_visibility: function (is_visible) {
this.$el.toggle(!this.headless && is_visible);
if (this.$buttons) {
this.$buttons.toggle(!this.headless && is_visible && this.visible_filters);
}
if (!this.headless && is_visible) {
this.$('div.oe_searchview_input').last().focus();
}
},
toggle_buttons: function (is_visible) {
this.visible_filters = is_visible || !this.visible_filters;
if (this.$buttons) {
this.$buttons.toggle(this.visible_filters);
}
},
/**
* Sets up search view's view-wide auto-completion widget
*/
setup_global_completion: function () {
var self = this;
this.autocomplete = new AutoComplete(this, {
source: this.proxy('complete_global_search'),
select: this.proxy('select_completion'),
delay: 0,
get_search_string: function () {
return self.$('div.oe_searchview_input').text();
},
});
this.autocomplete.appendTo(this.$el);
},
/**
* Provide auto-completion result for req.term (an array to `resp`)
*
* @param {Object} req request to complete
* @param {String} req.term searched term to complete
* @param {Function} resp response callback
*/
complete_global_search: function (req, resp) {
var inputs = this.search_fields.concat(this.filters, this.groupbys);
$.when.apply(null, _(inputs).chain()
.filter(function (input) { return input.visible(); })
.invoke('complete', req.term)
.value()).then(function () {
resp(_(arguments).chain()
.compact()
.flatten(true)
.value());
});
},
/**
* Action to perform in case of selection: create a facet (model)
* and add it to the search collection
*
* @param {Object} e selection event, preventDefault to avoid setting value on object
* @param {Object} ui selection information
* @param {Object} ui.item selected completion item
*/
select_completion: function (e, ui) {
e.preventDefault();
var input_index = _(this.input_subviews).indexOf(
this.subviewForRoot(
this.$('div.oe_searchview_input:focus')[0]));
this.query.add(ui.item.facet, {at: input_index / 2});
},
subviewForRoot: function (subview_root) {
return _(this.input_subviews).detect(function (subview) {
return subview.$el[0] === subview_root;
});
},
siblingSubview: function (subview, direction, wrap_around) {
var index = _(this.input_subviews).indexOf(subview) + direction;
if (wrap_around && index < 0) {
index = this.input_subviews.length - 1;
} else if (wrap_around && index >= this.input_subviews.length) {
index = 0;
}
return this.input_subviews[index];
},
focusPreceding: function (subview_root) {
return this.siblingSubview(
this.subviewForRoot(subview_root), -1, true)
.$el.focus();
},
focusFollowing: function (subview_root) {
return this.siblingSubview(
this.subviewForRoot(subview_root), +1, true)
.$el.focus();
},
/**
* @param {openerp.web.search.SearchQuery | undefined} Undefined if event is change
* @param {openerp.web.search.Facet}
* @param {Object} [options]
*/
renderFacets: function (collection, model, options) {
var self = this;
var started = [];
_.invoke(this.input_subviews, 'destroy');
this.input_subviews = [];
var i = new InputView(this);
started.push(i.appendTo(this.$facets_container));
this.input_subviews.push(i);
this.query.each(function (facet) {
var f = new FacetView(this, facet);
started.push(f.appendTo(self.$facets_container));
self.input_subviews.push(f);
var i = new InputView(this);
started.push(i.appendTo(self.$facets_container));
self.input_subviews.push(i);
}, this);
_.each(this.input_subviews, function (childView) {
childView.on('focused', self, self.proxy('childFocused'));
childView.on('blurred', self, self.proxy('childBlurred'));
});
$.when.apply(null, started).then(function () {
if (options && options.focus_input === false) return;
var input_to_focus;
// options.at: facet inserted at given index, focus next input
// otherwise just focus last input
if (!options || typeof options.at !== 'number') {
input_to_focus = _.last(self.input_subviews);
} else {
input_to_focus = self.input_subviews[(options.at + 1) * 2];
}
input_to_focus.$el.focus();
});
},
childFocused: function () {
this.$el.addClass('active');
},
childBlurred: function () {
this.$el.val('').removeClass('active').trigger('blur');
this.autocomplete.close();
},
/**
* Call the renderFacets method with the correct arguments.
* This is due to the fact that change events are called with two arguments
* (model, options) while add, reset and remove events are called with
* (collection, model, options) as arguments
*/
renderChangedFacets: function (model, options) {
this.renderFacets(undefined, model, options);
},
// it should parse the arch field of the view, instantiate the corresponding
// filters/fields, and put them in the correct variables:
// * this.search_fields is a list of all the fields,
// * this.filters: groups of filters
// * this.group_by: group_bys
prepare_search_inputs: function () {
var self = this,
arch = this.fields_view_get.arch;
var filters = [].concat.apply([], _.map(arch.children, function (item) {
return item.tag !== 'group' ? eval_item(item) : item.children.map(eval_item);
}));
function eval_item (item) {
var category = 'filters';
if (item.attrs.context) {
try {
var context = pyeval.eval('context', item.attrs.context);
if (context.group_by) {
category = 'group_by';
}
} catch (e) {}
}
return {
item: item,
category: category,
};
}
var current_group = [],
current_category = 'filters',
categories = {filters: this.filters, group_by: this.groupbys};
_.each(filters.concat({category:'filters', item: 'separator'}), function (filter) {
if (filter.item.tag === 'filter' && filter.category === current_category) {
return current_group.push(new search_inputs.Filter(filter.item, self));
}
if (current_group.length) {
var group = new search_inputs.FilterGroup(current_group, self);
categories[current_category].push(group);
current_group = [];
}
if (filter.item.tag === 'field') {
var attrs = filter.item.attrs,
field = self.fields_view_get.fields[attrs.name],
Obj = core.search_widgets_registry.get_any([attrs.widget, field.type]);
if (Obj) {
self.search_fields.push(new (Obj) (filter.item, field, self));
}
}
if (filter.item.tag === 'filter') {
current_group.push(new search_inputs.Filter(filter.item, self));
}
current_category = filter.category;
});
},
});
_.extend(SearchView, {
SearchQuery: SearchQuery,
Facet: Facet,
});
return SearchView;
});
odoo.define('web.AutoComplete', ['web.Widget'], function (require) {
"use strict";
var Widget = require('web.Widget');
return Widget.extend({
template: "SearchView.autocomplete",
// Parameters for autocomplete constructor:
//
// parent: this is used to detect keyboard events
//
// options.source: function ({term:query}, callback). This function will be called to
// obtain the search results corresponding to the query string. It is assumed that
// options.source will call callback with the results.
// options.delay: delay in millisecond before calling source. Useful if you don't want
// to make too many rpc calls
// options.select: function (ev, {item: {facet:facet}}). Autocomplete widget will call
// that function when a selection is made by the user
// options.get_search_string: function (). This function will be called by autocomplete
// to obtain the current search string.
init: function (parent, options) {
this._super(parent);
this.$input = parent.$el;
this.source = options.source;
this.delay = options.delay;
this.select = options.select;
this.get_search_string = options.get_search_string;
this.current_result = null;
this.searching = true;
this.search_string = null;
this.current_search = null;
},
start: function () {
var self = this;
this.$input.on('keyup', function (ev) {
if (ev.which === $.ui.keyCode.RIGHT) {
self.searching = true;
ev.preventDefault();
return;
}
// ENTER is caugth at KeyUp rather than KeyDown to avoid firing
// before all regular keystrokes have been processed
if (ev.which === $.ui.keyCode.ENTER) {
if (self.current_result && self.get_search_string().length) {
self.select_item(ev);
}
return;
}
var search_string = self.get_search_string();
if (self.search_string !== search_string) {
if (search_string.length) {
self.search_string = search_string;
setTimeout(function () { self.initiate_search(search_string);}, self.delay);
} else {
self.close();
}
}
});
this.$input.on('keypress', function (ev) {
self.search_string = self.get_search_string() + String.fromCharCode(ev.which);
if (self.search_string.length) {
self.searching = true;
var search_string = self.search_string;
setTimeout(function () { self.initiate_search(search_string);}, self.delay);
} else {
self.close();
}
});
this.$input.on('keydown', function (ev) {
switch (ev.which) {
// TAB and direction keys are handled at KeyDown because KeyUp
// is not guaranteed to fire.
// See e.g. https://github.com/aef-/jquery.masterblaster/issues/13
case $.ui.keyCode.TAB:
if (self.current_result && self.get_search_string().length) {
self.select_item(ev);
}
break;
case $.ui.keyCode.DOWN:
self.move('down');
self.searching = false;
ev.preventDefault();
break;
case $.ui.keyCode.UP:
self.move('up');
self.searching = false;
ev.preventDefault();
break;
case $.ui.keyCode.RIGHT:
self.searching = false;
var current = self.current_result;
if (current && current.expand && !current.expanded) {
self.expand();
self.searching = true;
}
ev.preventDefault();
break;
case $.ui.keyCode.ESCAPE:
self.close();
self.searching = false;
break;
}
});
},
initiate_search: function (query) {
if (query === this.search_string && query !== this.current_search) {
this.search(query);
}
},
search: function (query) {
var self = this;
this.current_search = query;
this.source({term:query}, function (results) {
if (results.length) {
self.render_search_results(results);
self.focus_element(self.$('li:first-child'));
} else {
self.close();
}
});
},
render_search_results: function (results) {
var self = this;
var $list = this.$('ul');
$list.empty();
var render_separator = false;
results.forEach(function (result) {
if (result.is_separator) {
if (render_separator)
$list.append($('<li>').addClass('oe-separator'));
render_separator = false;
} else {
var $item = self.make_list_item(result).appendTo($list);
result.$el = $item;
render_separator = true;
}
});
this.show();
},
make_list_item: function (result) {
var self = this;
var $li = $('<li>')
.hover(function () {self.focus_element($li);})
.mousedown(function (ev) {
if (ev.button === 0) { // left button
self.select(ev, {item: {facet: result.facet}});
self.close();
} else {
ev.preventDefault();
}
})
.data('result', result);
if (result.expand) {
var $expand = $('<span class="oe-expand">').text('▶').appendTo($li);
$expand.mousedown(function (ev) {
ev.preventDefault();
ev.stopPropagation();
if (result.expanded)
self.fold();
else
self.expand();
});
result.expanded = false;
}
if (result.indent) $li.addClass('oe-indent');
$li.append($('<span>').html(result.label));
return $li;
},
expand: function () {
var self = this;
this.current_result.expand(this.get_search_string()).then(function (results) {
(results || [{label: '(no result)'}]).reverse().forEach(function (result) {
result.indent = true;
var $li = self.make_list_item(result);
self.current_result.$el.after($li);
});
self.current_result.expanded = true;
self.current_result.$el.find('span.oe-expand').html('▼');
});
},
fold: function () {
var $next = this.current_result.$el.next();
while ($next.hasClass('oe-indent')) {
$next.remove();
$next = this.current_result.$el.next();
}
this.current_result.expanded = false;
this.current_result.$el.find('span.oe-expand').html('▶');
},
focus_element: function ($li) {
this.$('li').removeClass('oe-selection-focus');
$li.addClass('oe-selection-focus');
this.current_result = $li.data('result');
},
select_item: function (ev) {
if (this.current_result.facet) {
this.select(ev, {item: {facet: this.current_result.facet}});
this.close();
}
},
show: function () {
this.$el.show();
},
close: function () {
this.current_search = null;
this.search_string = null;
this.searching = true;
this.$el.hide();
},
move: function (direction) {
var $next;
if (direction === 'down') {
$next = this.$('li.oe-selection-focus').nextAll(':not(.oe-separator)').first();
if (!$next.length) $next = this.$('li:first-child');
} else {
$next = this.$('li.oe-selection-focus').prevAll(':not(.oe-separator)').first();
if (!$next.length) $next = this.$('li:not(.oe-separator)').last();
}
this.focus_element($next);
},
is_expandable: function () {
return !!this.$('.oe-selection-focus .oe-expand').length;
},
});
});
@@ -1,16 +1,20 @@
odoo.define('web.TreeView', ['web.core', 'web.data', 'web.formats', 'web.pyeval', 'web.session', 'web.View'], function (require) {
"use strict";
/*---------------------------------------------------------
* OpenERP web library
* Odoo Tree view
*---------------------------------------------------------*/
(function() {
var core = require('web.core');
var data = require('web.data');
var formats = require('web.formats');
var pyeval = require('web.pyeval');
var session = require('web.session');
var View = require('web.View');
var instance = openerp;
openerp.web.view_tree = {};
var QWeb = instance.web.qweb,
_lt = instance.web._lt;
var _lt = core._lt;
var QWeb = core.qweb;
instance.web.views.add('tree', 'instance.web.TreeView');
instance.web.TreeView = instance.web.View.extend(/** @lends instance.web.TreeView# */{
var TreeView = View.extend(/** @lends instance.web.TreeView# */{
display_name: _lt('Tree'),
view_type: 'tree',
/**
@@ -68,7 +72,7 @@ instance.web.TreeView = instance.web.View.extend(/** @lends instance.web.TreeVie
// field name in OpenERP is kinda stupid: this is the name of the field
// holding the ids to the children of the current node, why call it
// field_parent?
this.children_field = fields_view['field_parent'];
this.children_field = fields_view.field_parent;
this.fields_view = fields_view;
_(this.fields_view.arch.children).each(function (field) {
if (field.attrs.modifiers) {
@@ -137,7 +141,7 @@ instance.web.TreeView = instance.web.View.extend(/** @lends instance.web.TreeVie
color_for: function (record) {
if (!this.colors) { return ''; }
var context = _.extend({}, record, {
uid: this.session.uid,
uid: session.uid,
current_date: moment().format('YYYY-MM-DD')
// TODO: time, datetime, relativedelta
});
@@ -203,7 +207,7 @@ instance.web.TreeView = instance.web.View.extend(/** @lends instance.web.TreeVie
'fields_view': self.fields_view.arch.children,
'fields': self.fields,
'level': $curr_node.data('level') || 0,
'render': instance.web.format_value,
'render': formats.format_value,
'color_for': self.color_for,
'row_parent_id': id
});
@@ -224,8 +228,8 @@ instance.web.TreeView = instance.web.View.extend(/** @lends instance.web.TreeVie
active_id: id,
active_ids: [id]
};
var ctx = instance.web.pyeval.eval(
'context', new instance.web.CompoundContext(
var ctx = pyeval.eval(
'context', new data.CompoundContext(
this.dataset.get_context(), local_context));
return this.rpc('/web/treeview/action', {
id: id,
@@ -234,7 +238,7 @@ instance.web.TreeView = instance.web.View.extend(/** @lends instance.web.TreeVie
}).then(function (actions) {
if (!actions.length) { return; }
var action = actions[0][2];
var c = new instance.web.CompoundContext(local_context).set_eval_context(ctx);
var c = new data.CompoundContext(local_context).set_eval_context(ctx);
if (action.context) {
c.add(action.context);
}
@@ -267,4 +271,10 @@ instance.web.TreeView = instance.web.View.extend(/** @lends instance.web.TreeVie
this._super();
}
});
})();
core.view_registry.add('tree', TreeView);
return TreeView;
});
-58
View File
@@ -1,58 +0,0 @@
(function() {
/*
* object.watch polyfill
*
* 2012-04-03
*
* By Eli Grey, http://eligrey.com
* Public Domain.
* NO WARRANTY EXPRESSED OR IMPLIED. USE AT YOUR OWN RISK.
*/
// object.watch
if (!Object.prototype.watch) {
Object.defineProperty(Object.prototype, "watch", {
enumerable: false
, configurable: true
, writable: false
, value: function (prop, handler) {
var
oldval = this[prop]
, newval = oldval
, getter = function () {
return newval;
}
, setter = function (val) {
oldval = newval;
return newval = handler.call(this, prop, oldval, val);
}
;
if (delete this[prop]) { // can't watch constants
Object.defineProperty(this, prop, {
get: getter
, set: setter
, enumerable: true
, configurable: true
});
}
}
});
}
// object.unwatch
if (!Object.prototype.unwatch) {
Object.defineProperty(Object.prototype, "unwatch", {
enumerable: false
, configurable: true
, writable: false
, value: function (prop) {
var val = this[prop];
delete this[prop]; // remove accessors
this[prop] = val;
}
});
}
})();
+368
View File
@@ -0,0 +1,368 @@
odoo.define('web.WebClient', ['web.ActionManager', 'web.core', 'web.crash_manager', 'web.data', 'web.Loading', 'web.Menu', 'web.Model', 'web.Notification', 'web.session', 'web.SystrayMenu', 'web.UserMenu', 'web.utils', 'web.Widget'], function (require) {
"use strict";
var ActionManager = require('web.ActionManager');
var core = require('web.core');
var crash_manager = require('web.crash_manager');
var data = require('web.data');
var Loading = require('web.Loading');
var Menu = require('web.Menu');
var Model = require('web.Model');
var Notification = require('web.Notification');
var session = require('web.session');
var SystrayMenu = require('web.SystrayMenu');
var UserMenu = require('web.UserMenu');
var utils = require('web.utils');
var Widget = require('web.Widget');
var QWeb = core.qweb;
var _t = core._t;
var WebClient = Widget.extend({
events: {
'click .oe_logo_edit_admin': 'logo_edit',
'click .oe_logo img': 'on_logo_click',
},
init: function(parent, client_options) {
this.client_options = {};
this._super(parent);
this.origin = undefined;
if (client_options) {
_.extend(this.client_options, client_options);
}
this._current_state = null;
this.menu_dm = new utils.DropMisordered();
this.action_mutex = new utils.Mutex();
this.set('title_part', {"zopenerp": "Odoo"});
},
start: function() {
var self = this;
this.on("change:title_part", this, this._title_changed);
this._title_changed();
core.bus.on('web_client_toggle_bars', this, function () {
this.toggle_bars.apply(this, arguments);
});
return session.session_bind(this.origin).then(function() {
self.bind_events();
return self.show_common();
}).then(function() {
if (session.session_is_valid()) {
self.show_application();
}
if (self.client_options.action) {
self.action_manager.do_action(self.client_options.action);
delete(self.client_options.action);
}
self.trigger('web_client_ready');
});
},
bind_events: function() {
var self = this;
$('.oe_systray').show();
this.$el.on('mouseenter', '.oe_systray > div:not([data-toggle=tooltip])', function() {
$(this).attr('data-toggle', 'tooltip').tooltip().trigger('mouseenter');
});
this.$el.on('click', '.oe_dropdown_toggle', function(ev) {
ev.preventDefault();
var $toggle = $(this);
var doc_width = $(document).width();
var $menu = $toggle.siblings('.oe_dropdown_menu');
$menu = $menu.size() >= 1 ? $menu : $toggle.find('.oe_dropdown_menu');
var state = $menu.is('.oe_opened');
setTimeout(function() {
// Do not alter propagation
$toggle.add($menu).toggleClass('oe_opened', !state);
if (!state) {
// Move $menu if outside window's edge
var offset = $menu.offset();
var menu_width = $menu.width();
var x = doc_width - offset.left - menu_width - 2;
if (x < 0) {
$menu.offset({ left: offset.left + x }).width(menu_width);
}
}
}, 0);
});
core.bus.on('click', this, function(ev) {
$('.tooltip').remove();
if (!$(ev.target).is('input[type=file]')) {
self.$el.find('.oe_dropdown_menu.oe_opened, .oe_dropdown_toggle.oe_opened').removeClass('oe_opened');
}
});
core.bus.on('set_full_screen', this, function (full_screen) {
this.set_content_full_screen(full_screen);
});
},
on_logo_click: function(ev){
if (!this.has_uncommitted_changes()) {
return;
} else {
ev.preventDefault();
}
},
show_common: function() {
var self = this;
session.on('error', crash_manager, crash_manager.rpc_error);
self.notification = new Notification(this);
self.notification.appendTo(self.$('.openerp'));
self.loading = new Loading(self);
self.loading.appendTo(self.$('.openerp_webclient_container'));
self.action_manager = new ActionManager(self);
self.action_manager.replace(self.$('.oe_application'));
core.bus.on('display_notification_warning', this, function (title, message) {
self.notification.warn(title, message);
});
window.onerror = function (message, file, line) {
crash_manager.show_error({
type: _t("Client Error"),
message: message,
data: {debug: file + ':' + line}
});
};
},
toggle_bars: function(value) {
this.$('tr:has(td.navbar),.oe_leftbar').toggle(value);
},
has_uncommitted_changes: function() {
var $e = $.Event('clear_uncommitted_changes');
core.bus.trigger('clear_uncommitted_changes', $e);
return $e.isDefaultPrevented();
},
/**
Sets the first part of the title of the window, dedicated to the current action.
*/
set_title: function(title) {
this.set_title_part("action", title);
},
/**
Sets an arbitrary part of the title of the window. Title parts are identified by strings. Each time
a title part is changed, all parts are gathered, ordered by alphabetical order and displayed in the
title of the window separated by '-'.
*/
set_title_part: function(part, title) {
var tmp = _.clone(this.get("title_part"));
tmp[part] = title;
this.set("title_part", tmp);
},
_title_changed: function() {
var parts = _.sortBy(_.keys(this.get("title_part")), function(x) { return x; });
var tmp = "";
_.each(parts, function(part) {
var str = this.get("title_part")[part];
if (str) {
tmp = tmp ? tmp + " - " + str : str;
}
}, this);
document.title = tmp;
},
show_application: function() {
var self = this;
self.toggle_bars(true);
self.update_logo();
// Menu is rendered server-side thus we don't want the widget to create any dom
self.menu = new Menu(self);
self.menu.setElement(this.$el.parents().find('.oe_application_menu_placeholder'));
self.menu.on('menu_click', this, this.on_menu_action);
// Create the user menu (rendered client-side)
self.user_menu = new UserMenu(self);
var user_menu_loaded = self.user_menu.appendTo(this.$el.parents().find('.oe_user_menu_placeholder'));
self.user_menu.on('user_logout', self, self.on_logout);
self.user_menu.do_update();
// Create the systray menu (rendered server-side)
self.systray_menu = new SystrayMenu(self);
self.systray_menu.setElement(this.$el.parents().find('.oe_systray'));
var systray_menu_loaded = self.systray_menu.start();
// Start the menu once both systray and user menus are rendered
// to prevent overflows while loading
$.when(systray_menu_loaded, user_menu_loaded).done(function() {
self.menu.start();
});
self.bind_hashchange();
self.set_title();
self.check_timezone();
if (self.client_options.action_post_login) {
self.action_manager.do_action(self.client_options.action_post_login);
delete(self.client_options.action_post_login);
}
},
update_logo: function() {
var company = session.company_id;
var img = session.url('/web/binary/company_logo' + '?db=' + session.db + (company ? '&company=' + company : ''));
this.$('.oe_logo img').attr('src', '').attr('src', img);
this.$('.oe_logo_edit').toggleClass('oe_logo_edit_admin', session.uid === 1);
},
logo_edit: function(ev) {
var self = this;
ev.preventDefault();
self.alive(new Model("res.users").get_func("read")(session.uid, ["company_id"])).then(function(res) {
self.rpc("/web/action/load", { action_id: "base.action_res_company_form" }).done(function(result) {
result.res_id = res.company_id[0];
result.target = "new";
result.views = [[false, 'form']];
result.flags = {
action_buttons: true,
headless: true,
};
self.action_manager.do_action(result);
var form = self.action_manager.dialog_widget.views.form.controller;
form.on("on_button_cancel", self.action_manager, self.action_manager.dialog_stop);
form.on('record_saved', self, function() {
self.action_manager.dialog_stop();
self.update_logo();
});
});
});
return false;
},
check_timezone: function() {
var self = this;
return self.alive(new Model('res.users').call('read', [[session.uid], ['tz_offset']])).then(function(result) {
var user_offset = result[0]['tz_offset'];
var offset = -(new Date().getTimezoneOffset());
// _.str.sprintf()'s zero front padding is buggy with signed decimals, so doing it manually
var browser_offset = (offset < 0) ? "-" : "+";
browser_offset += _.str.sprintf("%02d", Math.abs(offset / 60));
browser_offset += _.str.sprintf("%02d", Math.abs(offset % 60));
if (browser_offset !== user_offset) {
var $icon = $(QWeb.render('WebClient.timezone_systray'));
$icon.on('click', function() {
var notification = self.do_warn(_t("Timezone Mismatch"), QWeb.render('WebClient.timezone_notification', {
user_timezone: session.user_context.tz || 'UTC',
user_offset: user_offset,
browser_offset: browser_offset,
}), true);
notification.element.find('.oe_webclient_timezone_notification').on('click', function() {
notification.close();
}).find('a').on('click', function() {
notification.close();
self.user_menu.on_menu_settings();
return false;
});
});
$icon.prependTo(window.$('.oe_systray'));
}
});
},
destroy_content: function() {
_.each(_.clone(this.getChildren()), function(el) {
el.destroy();
});
this.$el.children().remove();
},
do_reload: function() {
var self = this;
return this.session.session_reload().then(function () {
session.load_modules(true).then(
self.menu.proxy('do_reload')); });
},
do_notify: function() {
var n = this.notification;
return n.notify.apply(n, arguments);
},
do_warn: function() {
var n = this.notification;
return n.warn.apply(n, arguments);
},
on_logout: function() {
var self = this;
if (!this.has_uncommitted_changes()) {
self.action_manager.do_action('logout');
}
},
bind_hashchange: function() {
var self = this;
$(window).bind('hashchange', this.on_hashchange);
var state = $.bbq.getState(true);
if (_.isEmpty(state) || state.action == "login") {
self.menu.is_bound.done(function() {
new Model("res.users").call("read", [session.uid, ["action_id"]]).done(function(data) {
if(data.action_id) {
self.action_manager.do_action(data.action_id[0]);
self.menu.open_action(data.action_id[0]);
} else {
var first_menu_id = self.menu.$el.find("a:first").data("menu");
if(first_menu_id) {
self.menu.menu_click(first_menu_id);
} }
});
});
} else {
$(window).trigger('hashchange');
}
},
on_hashchange: function(event) {
var self = this;
var stringstate = event.getState(false);
if (!_.isEqual(this._current_state, stringstate)) {
var state = event.getState(true);
if(!state.action && state.menu_id) {
self.menu.is_bound.done(function() {
self.menu.menu_click(state.menu_id);
});
} else {
state._push_me = false; // no need to push state back...
this.action_manager.do_load_state(state, !!this._current_state);
}
}
this._current_state = stringstate;
},
do_push_state: function(state) {
this.set_title(state.title);
delete state.title;
var url = '#' + $.param(state);
this._current_state = $.deparam($.param(state), false); // stringify all values
$.bbq.pushState(url);
this.trigger('state_pushed', state);
},
on_menu_action: function(options) {
var self = this;
return this.menu_dm.add(this.rpc("/web/action/load", { action_id: options.action_id }))
.then(function (result) {
return self.action_mutex.exec(function() {
if (options.needaction) {
result.context = new data.CompoundContext(result.context, {
search_default_message_unread: true,
search_disable_custom_filters: true,
});
}
var completed = $.Deferred();
$.when(self.action_manager.do_action(result, {
clear_breadcrumbs: true,
action_menu_id: self.menu.current_menu,
})).fail(function() {
self.menu.open_menu(options.previous_menu_id);
}).always(function() {
completed.resolve();
});
setTimeout(function() {
completed.resolve();
}, 2000);
// We block the menu when clicking on an element until the action has correctly finished
// loading. If something crash, there is a 2 seconds timeout before it's unblocked.
return completed;
});
});
},
set_content_full_screen: function(fullscreen) {
$(document.body).css('overflow-y', fullscreen ? 'hidden' : 'scroll');
this.$('.oe_webclient').toggleClass(
'oe_content_full_screen', fullscreen);
},
});
return WebClient;
});
@@ -0,0 +1,47 @@
odoo.define('web.ChangePassword', ['web.core', 'web.Dialog', 'web.Widget'], function (require) {
"use strict";
var core = require('web.core');
var Dialog = require('web.Dialog');
var Widget = require('web.Widget');
var _t = core._t;
var ChangePassword = Widget.extend({
template: "ChangePassword",
start: function() {
var self = this;
this.getParent().dialog_title = _t("Change Password");
var $button = self.$el.find('.oe_form_button');
$button.appendTo(this.getParent().$buttons);
$button.eq(2).click(function(){
self.$el.parents('.modal').modal('hide');
});
$button.eq(0).click(function(){
self.rpc("/web/session/change_password",{
'fields': $("form[name=change_password_form]").serializeArray()
}).done(function(result) {
if (result.error) {
self.display_error(result);
return;
} else {
self.do_action('logout');
}
});
});
},
display_error: function (error) {
return new Dialog(this, {
size: 'medium',
title: error.title,
buttons: [
{text: _t("Ok"), click: function() { this.parents('.modal').modal('hide'); }}
]
}, $('<div>').html(error.error)).open();
},
});
core.action_registry.add("change_password", ChangePassword);
return ChangePassword;
});
@@ -1,12 +1,16 @@
odoo.define('web.DataExport', ['web.core', 'web.crash_manager', 'web.data', 'web.Dialog', 'web.framework'], function (require) {
"use strict";
(function() {
var core = require('web.core');
var crash_manager = require('web.crash_manager');
var data = require('web.data');
var Dialog = require('web.Dialog');
var framework = require('web.framework');
var instance = openerp;
openerp.web.data_export = {};
var QWeb = core.qweb;
var _t = core._t;
var QWeb = instance.web.qweb,
_t = instance.web._t;
instance.web.DataExport = instance.web.Dialog.extend({
var DataExport = Dialog.extend({
template: 'ExportTreeView',
dialog_title: {toString: function () { return _t("Export Data"); }},
events: {
@@ -42,7 +46,7 @@ instance.web.DataExport = instance.web.Dialog.extend({
this._super(parent, options);
this.records = {};
this.dataset = dataset;
this.exports = new instance.web.DataSetSearch(
this.exports = new data.DataSetSearch(
this, 'ir.exports', this.dataset.get_context());
},
start: function() {
@@ -82,16 +86,16 @@ instance.web.DataExport = instance.web.Dialog.extend({
this.show_exports_list());
},
on_click_move_up: function () {
var prev_row = this.$el.find('#fields_list option:selected').first().prev();
var prev_row = this.$('#fields_list option:selected').first().prev();
if(prev_row.length){
var selected_rows = self.$('#fields_list option:selected').detach();
var selected_rows = this.$('#fields_list option:selected').detach();
prev_row.before(selected_rows);
}
},
on_click_move_down: function () {
var next_row = this.$el.find('#fields_list option:selected').last().next();
var next_row = this.$('#fields_list option:selected').last().next();
if(next_row.length){
var selected_rows = self.$('#fields_list option:selected').detach();
var selected_rows = this.$('#fields_list option:selected').detach();
next_row.after(selected_rows);
}
},
@@ -413,9 +417,8 @@ instance.web.DataExport = instance.web.Dialog.extend({
exported_fields.unshift({name: 'id', label: 'External ID'});
var export_format = this.$el.find("#export_format").val();
var c = instance.webclient.crashmanager;
instance.web.blockUI();
framework.blockUI();
this.session.get_file({
url: '/web/export/' + export_format,
data: {data: JSON.stringify({
@@ -426,8 +429,8 @@ instance.web.DataExport = instance.web.Dialog.extend({
context: this.dataset.context,
import_compat: !!this.$el.find("#import_compat").val(),
})},
complete: instance.web.unblockUI,
error: c.rpc_error.bind(c),
complete: framework.unblockUI,
error: crash_manager.rpc_error.bind(crash_manager),
});
},
close: function() {
@@ -435,4 +438,5 @@ instance.web.DataExport = instance.web.Dialog.extend({
}
});
})();
return DataExport;
});
@@ -0,0 +1,217 @@
odoo.define('web.DatabaseManager', ['web.core', 'web.Dialog', 'web.framework', 'web.web_client', 'web.Widget'], function (require) {
"use strict";
var core = require('web.core');
var Dialog = require('web.Dialog');
var framework = require('web.framework');
var Widget = require('web.Widget');
var _t = core._t;
var QWeb = core.qweb;
var DataBaseManager = Widget.extend({
init: function(parent) {
this._super(parent);
$.validator.addMethod('matches', function (s, _, re) {
return new RegExp(re).test(s);
}, _t("Invalid database name"));
},
start: function() {
var self = this;
$('.oe_secondary_menus_container,.oe_user_menu_placeholder').empty();
var fetch_db = this.rpc("/web/database/get_list", {}).then(
function(result) {
self.db_list = result;
},
function (_, ev) {
ev.preventDefault();
self.db_list = null;
});
var fetch_langs = this.rpc("/web/session/get_lang_list", {}).done(function(result) {
self.lang_list = result;
});
return $.when(fetch_db, fetch_langs).always(self.do_render);
},
do_render: function() {
var self = this;
core.bus.trigger('web_client_toggle_bars', true);
self.$el.html(QWeb.render("DatabaseManager", { widget : self }));
$('.oe_user_menu_placeholder').append(QWeb.render("DatabaseManager.user_menu",{ widget : self }));
$('.oe_secondary_menus_container').append(QWeb.render("DatabaseManager.menu",{ widget : self }));
$('ul.oe_secondary_submenu > li:first').addClass('active');
$('ul.oe_secondary_submenu > li').bind('click', function (event) {
var menuitem = $(this);
menuitem.addClass('active').siblings().removeClass('active');
var form_id =menuitem.find('a').attr('href');
$(form_id).show().siblings().hide();
event.preventDefault();
});
$('#back-to-login').click(self.do_exit);
self.$el.find("td").addClass("oe_form_group_cell");
self.$el.find("tr td:first-child").addClass("oe_form_group_cell_label");
self.$el.find("label").addClass("oe_form_label");
self.$el.find("form[name=create_db_form]").validate({ submitHandler: self.do_create });
self.$el.find("form[name=duplicate_db_form]").validate({ submitHandler: self.do_duplicate });
self.$el.find("form[name=drop_db_form]").validate({ submitHandler: self.do_drop });
self.$el.find("form[name=backup_db_form]").validate({ submitHandler: self.do_backup });
self.$el.find("form[name=restore_db_form]").validate({ submitHandler: self.do_restore });
self.$el.find("form[name=change_pwd_form]").validate({
messages: {
old_pwd: _t("Please enter your previous password"),
new_pwd: _t("Please enter your new password"),
confirm_pwd: {
required: _t("Please confirm your new password"),
equalTo: _t("The confirmation does not match the password")
}
},
submitHandler: self.do_change_password
});
},
destroy: function () {
this.$el.find('#db-create, #db-drop, #db-backup, #db-restore, #db-change-password, #back-to-login').unbind('click').end().empty();
this._super();
},
/**
* Blocks UI and replaces $.unblockUI by a noop to prevent third parties
* from unblocking the UI
*/
blockUI: function () {
framework.blockUI();
$.unblockUI = function () {};
},
/**
* Reinstates $.unblockUI so third parties can play with blockUI, and
* unblocks the UI
*/
unblockUI: function () {
$.unblockUI = framework.unblockUI;
framework.unblockUI();
},
/**
* Displays an error dialog resulting from the various RPC communications
* failing over themselves
*
* @param {Object} error error description
* @param {String} error.title title of the error dialog
* @param {String} error.error message of the error dialog
*/
display_error: function (error) {
return new Dialog(this, {
size: 'medium',
title: error.title,
buttons: [
{text: _t("Ok"), click: function() { this.parents('.modal').modal('hide'); }}
]
}, $('<div>').html(error.error)).open();
},
do_create: function(form) {
var fields = $(form).serializeArray();
this.rpc("/web/database/create", {'fields': fields}).done(function(result) {
if (result) {
framework.redirect('/web');
} else {
alert("Failed to create database");
}
});
},
do_duplicate: function(form) {
var self = this;
var fields = $(form).serializeArray();
self.rpc("/web/database/duplicate", {'fields': fields}).then(function(result) {
if (result.error) {
self.display_error(result);
return;
}
self.do_notify(_t("Duplicating database"), _t("The database has been duplicated."));
self.start();
});
},
do_drop: function(form) {
var self = this;
var $form = $(form),
fields = $form.serializeArray(),
$db_list = $form.find('[name=drop_db]'),
db = $db_list.val();
if (!db || !confirm(_.str.sprintf(_t("Do you really want to delete the database: %s ?"), db))) {
return;
}
self.rpc("/web/database/drop", {'fields': fields}).done(function(result) {
if (result.error) {
self.display_error(result);
return;
}
self.do_notify(_t("Dropping database"), _.str.sprintf(_t("The database %s has been dropped"), db));
self.start();
});
},
do_backup: function(form) {
var self = this;
self.blockUI();
self.session.get_file({
form: form,
success: function () {
self.do_notify(_t("Backed"), _t("Database backed up successfully"));
},
error: function(error){
if (error && error[1]) {
self.display_error(error[1][0]);
}
},
complete: function() {
self.unblockUI();
}
});
},
do_restore: function(form) {
var self = this;
self.blockUI();
$(form).ajaxSubmit({
url: '/web/database/restore',
type: 'POST',
resetForm: true,
success: function (body) {
// If empty body, everything went fine
if (!body) { return; }
if (body.indexOf('403 Forbidden') !== -1) {
self.display_error({
title: _t("Access Denied"),
error: _t("Incorrect super-administrator password")
});
} else {
self.display_error({
title: _t("Restore Database"),
error: _t("Could not restore the database")
});
}
},
complete: function() {
self.unblockUI();
self.do_notify(_t("Restored"), _t("Database restored successfully"));
}
});
},
do_change_password: function(form) {
var self = this;
self.rpc("/web/database/change_password", {
'fields': $(form).serializeArray()
}).done(function(result) {
if (result.error) {
self.display_error(result);
return;
}
self.unblockUI();
self.do_notify(_t("Changed Password"), _t("Password has been changed successfully"));
});
},
do_exit: function () {
this.$el.remove();
framework.redirect('/web');
}
});
core.action_registry.add("database_manager", DataBaseManager);
return DataBaseManager;
});
@@ -0,0 +1,114 @@
odoo.define('web.datepicker', ['web.core', 'web.formats', 'web.time', 'web.Widget'], function (require) {
"use strict";
var core = require('web.core');
var formats = require('web.formats');
var time = require('web.time');
var Widget = require('web.Widget');
var _t = core._t;
var DateTimeWidget = Widget.extend({
template: "web.datepicker",
type_of_date: "datetime",
events: {
'dp.change .oe_datepicker_main': 'change_datetime',
'dp.show .oe_datepicker_main': 'set_datetime_default',
'keypress .oe_datepicker_master': 'change_datetime',
},
init: function(parent) {
this._super(parent);
this.name = parent.name;
},
start: function() {
var l10n = _t.database.parameters;
var options = {
pickTime: true,
useSeconds: true,
startDate: moment({ y: 1900 }),
endDate: moment().add(200, "y"),
calendarWeeks: true,
icons : {
time: 'fa fa-clock-o',
date: 'fa fa-calendar',
up: 'fa fa-chevron-up',
down: 'fa fa-chevron-down'
},
language : moment.locale(),
format : time.strftime_to_moment_format(l10n.date_format +' '+ l10n.time_format),
};
this.$input = this.$el.find('input.oe_datepicker_master');
if (this.type_of_date === 'date') {
options.pickTime = false;
options.useSeconds = false;
options.format = time.strftime_to_moment_format(l10n.date_format);
}
this.picker = this.$('.oe_datepicker_main').datetimepicker(options);
this.set_readonly(false);
this.set({'value': false});
},
set_value: function(value_) {
this.set({'value': value_});
this.$input.val(value_ ? this.format_client(value_) : '');
},
get_value: function() {
return this.get('value');
},
set_value_from_ui_: function() {
var value_ = this.$input.val() || false;
this.set_value(this.parse_client(value_));
},
set_readonly: function(readonly) {
this.readonly = readonly;
this.$input.prop('readonly', this.readonly);
},
is_valid_: function() {
var value_ = this.$input.val();
if (value_ === "") {
return true;
} else {
try {
this.parse_client(value_);
return true;
} catch(e) {
return false;
}
}
},
parse_client: function(v) {
return formats.parse_value(v, {"widget": this.type_of_date});
},
format_client: function(v) {
return formats.format_value(v, {"widget": this.type_of_date});
},
set_datetime_default: function(){
//when opening datetimepicker the date and time by default should be the one from
//the input field if any or the current day otherwise
if (this.type_of_date === 'datetime') {
var value = moment().second(0);
if (this.$input.val().length !== 0 && this.is_valid_()){
value = this.$input.val();
}
this.$('.oe_datepicker_main').data('DateTimePicker').setValue(value);
}
},
change_datetime: function(e) {
if ((e.type !== "keypress" || e.which === 13) && this.is_valid_()) {
this.set_value_from_ui_();
this.trigger("datetime_changed");
}
},
commit_value: function () {
this.change_datetime();
},
});
var DateWidget = DateTimeWidget.extend({
type_of_date: "date"
});
return {
DateTimeWidget: DateTimeWidget,
DateWidget: DateWidget,
};
});
@@ -0,0 +1,66 @@
odoo.define('web.Loading', ['web.core', 'web.framework', 'web.session', 'web.Widget'], function (require) {
"use strict";
var core = require('web.core');
var framework = require('web.framework');
var session = require('web.session');
var Widget = require('web.Widget');
var _t = core._t;
var Loading = Widget.extend({
template: _t("Loading"),
init: function(parent) {
this._super(parent);
this.count = 0;
this.blocked_ui = false;
session.on("request", this, this.request_call);
session.on("response", this, this.response_call);
session.on("response_failed", this, this.response_call);
},
destroy: function() {
this.on_rpc_event(-this.count);
this._super();
},
request_call: function() {
this.on_rpc_event(1);
},
response_call: function() {
this.on_rpc_event(-1);
},
on_rpc_event : function(increment) {
var self = this;
if (!this.count && increment === 1) {
// Block UI after 3s
this.long_running_timer = setTimeout(function () {
self.blocked_ui = true;
framework.blockUI();
}, 3000);
}
this.count += increment;
if (this.count > 0) {
if (session.debug) {
this.$el.text(_.str.sprintf( _t("Loading (%d)"), this.count));
} else {
this.$el.text(_t("Loading"));
}
this.$el.show();
this.getParent().$el.addClass('oe_wait');
} else {
this.count = 0;
clearTimeout(this.long_running_timer);
// Don't unblock if blocked by somebody else
if (self.blocked_ui) {
this.blocked_ui = false;
framework.unblockUI();
}
this.$el.fadeOut();
this.getParent().$el.removeClass('oe_wait');
}
}
});
return Loading;
});
@@ -0,0 +1,44 @@
odoo.define('web.Notification', ['web.Widget'], function (require) {
"use strict";
var Widget = require('web.Widget');
var Notification = Widget.extend({
template: 'Notification',
init: function() {
this._super.apply(this, arguments);
},
start: function() {
this._super.apply(this, arguments);
this.$el.notify({
speed: 500,
expires: 2500
});
},
notify: function(title, text, sticky) {
sticky = !!sticky;
var opts = {};
if (sticky) {
opts.expires = false;
}
return this.$el.notify('create', {
title: title,
text: text
}, opts);
},
warn: function(title, text, sticky) {
sticky = !!sticky;
var opts = {};
if (sticky) {
opts.expires = false;
}
return this.$el.notify('create', 'oe_notification_alert', {
title: title,
text: text
}, opts);
}
});
return Notification;
});
+215
View File
@@ -0,0 +1,215 @@
odoo.define('web.Sidebar', ['web.core', 'web.data', 'web.Dialog', 'web.framework', 'web.pyeval', 'web.session', 'web.Widget'], function (require) {
"use strict";
var core = require('web.core');
var data = require('web.data');
var Dialog = require('web.Dialog');
var framework = require('web.framework');
var pyeval = require('web.pyeval');
var session = require('web.session');
var Widget = require('web.Widget');
var QWeb = core.qweb;
var _t = core._t;
var Sidebar = Widget.extend({
init: function(parent) {
var self = this;
this._super(parent);
this.sections = [
{ 'name' : 'print', 'label' : _t('Print'), },
{ 'name' : 'other', 'label' : _t('More'), }
];
this.items = {
'print' : [],
'other' : []
};
this.fileupload_id = _.uniqueId('oe_fileupload');
$(window).on(this.fileupload_id, function() {
var args = [].slice.call(arguments).slice(1);
self.do_attachement_update(self.dataset, self.model_id,args);
framework.unblockUI();
});
},
start: function() {
var self = this;
this._super(this);
this.redraw();
this.$el.on('click','.dropdown-menu li a', function(event) {
var section = $(this).data('section');
var index = $(this).data('index');
var item = self.items[section][index];
if (item.callback) {
item.callback.apply(self, [item]);
} else if (item.action) {
self.on_item_action_clicked(item);
} else if (item.url) {
return true;
}
event.preventDefault();
});
},
redraw: function() {
var self = this;
self.$el.html(QWeb.render('Sidebar', {widget: self}));
// Hides Sidebar sections when item list is empty
this.$('.oe_form_dropdown_section').each(function() {
$(this).toggle(!!$(this).find('li').length);
});
self.$("[title]").tooltip({
delay: { show: 500, hide: 0}
});
},
/**
* For each item added to the section:
*
* ``label``
* will be used as the item's name in the sidebar, can be html
*
* ``action``
* descriptor for the action which will be executed, ``action`` and
* ``callback`` should be exclusive
*
* ``callback``
* function to call when the item is clicked in the sidebar, called
* with the item descriptor as its first argument (so information
* can be stored as additional keys on the object passed to
* ``add_items``)
*
* ``classname`` (optional)
* ``@class`` set on the sidebar serialization of the item
*
* ``title`` (optional)
* will be set as the item's ``@title`` (tooltip)
*
* @param {String} section_code
* @param {Array<{label, action | callback[, classname][, title]}>} items
*/
add_items: function(section_code, items) {
if (items) {
this.items[section_code].unshift.apply(this.items[section_code],items);
this.redraw();
}
},
add_toolbar: function(toolbar) {
var self = this;
_.each(['print','action','relate'], function(type) {
var items = toolbar[type];
if (items) {
for (var i = 0; i < items.length; i++) {
items[i] = {
label: items[i]['name'],
action: items[i],
classname: 'oe_sidebar_' + type
};
}
self.add_items(type=='print' ? 'print' : 'other', items);
}
});
},
on_item_action_clicked: function(item) {
var self = this;
self.getParent().sidebar_eval_context().done(function (sidebar_eval_context) {
var ids = self.getParent().get_selected_ids();
var domain;
if (self.getParent().get_active_domain) {
domain = self.getParent().get_active_domain();
}
else {
domain = $.Deferred().resolve(undefined);
}
if (ids.length === 0) {
new Dialog(this, { title: _t("Warning"), size: 'medium',}, $("<div />").text(_t("You must choose at least one record."))).open();
return false;
}
var dataset = self.getParent().dataset;
var active_ids_context = {
active_id: ids[0],
active_ids: ids,
active_model: dataset.model,
};
$.when(domain).done(function (domain) {
if (domain !== undefined) {
active_ids_context.active_domain = domain;
}
var c = pyeval.eval('context',
new data.CompoundContext(
sidebar_eval_context, active_ids_context));
self.rpc("/web/action/load", {
action_id: item.action.id,
context: new data.CompoundContext(
dataset.get_context(), active_ids_context).eval()
}).done(function(result) {
result.context = new data.CompoundContext(
result.context || {}, active_ids_context)
.set_eval_context(c);
result.flags = result.flags || {};
result.flags.new_window = true;
self.do_action(result, {
on_close: function() {
// reload view
self.getParent().reload();
},
});
});
});
});
},
do_attachement_update: function(dataset, model_id, args) {
this.dataset = dataset;
this.model_id = model_id;
if (args && args[0].error) {
this.do_warn(_t('Uploading Error'), args[0].error);
}
if (!model_id) {
this.on_attachments_loaded([]);
} else {
var dom = [ ['res_model', '=', dataset.model], ['res_id', '=', model_id], ['type', 'in', ['binary', 'url']] ];
var ds = new data.DataSetSearch(this, 'ir.attachment', dataset.get_context(), dom);
ds.read_slice(['name', 'url', 'type', 'create_uid', 'create_date', 'write_uid', 'write_date'], {}).done(this.on_attachments_loaded);
}
},
on_attachments_loaded: function(attachments) {
var self = this;
var prefix = session.url('/web/binary/saveas', {model: 'ir.attachment', field: 'datas', filename_field: 'name'});
_.each(attachments,function(a) {
a.label = a.name;
if(a.type === "binary") {
a.url = prefix + '&id=' + a.id + '&t=' + (new Date().getTime());
}
});
self.items.files = attachments;
self.redraw();
this.$('.oe_sidebar_add_attachment .oe_form_binary_file').change(this.on_attachment_changed);
this.$el.find('.oe_sidebar_delete_item').click(this.on_attachment_delete);
},
on_attachment_changed: function(e) {
var $e = $(e.target);
if ($e.val() !== '') {
this.$el.find('form.oe_form_binary_form').submit();
$e.parent().find('input[type=file]').prop('disabled', true);
$e.parent().find('button').prop('disabled', true).find('img, span').toggle();
this.$('.oe_sidebar_add_attachment a').text(_t('Uploading...'));
framework.blockUI();
}
},
on_attachment_delete: function(e) {
e.preventDefault();
e.stopPropagation();
var self = this;
var $e = $(e.currentTarget);
if (confirm(_t("Do you really want to delete this attachment ?"))) {
(new data.DataSet(this, 'ir.attachment')).unlink([parseInt($e.attr('data-id'), 10)]).done(function() {
self.do_attachement_update(self.dataset, self.model_id);
});
}
}
});
return Sidebar;
});
@@ -0,0 +1,38 @@
odoo.define('web.SystrayMenu', ['web.Widget'], function (require) {
"use strict";
var Widget = require('web.Widget');
var SystrayMenu = Widget.extend({
/**
* This widget renders the systray menu. It creates and renders widgets
* pushed in instance.web.SystrayItems.
*/
init: function(parent) {
this._super(parent);
this.items = [];
this.load = $.Deferred();
},
start: function() {
var self = this;
self._super.apply(this, arguments);
self.load_items();
return $.when.apply($, self.items).done(function () {
self.load.resolve();
});
},
load_items: function() {
var self = this;
_.each(SystrayMenu.Items, function(widgetCls) {
var cur_systray_item = new widgetCls(self);
self.items.push(cur_systray_item.appendTo(self.$el));
});
},
});
SystrayMenu.Items = [];
return SystrayMenu;
});
@@ -0,0 +1,112 @@
odoo.define('web.UserMenu', ['web.core', 'web.Dialog', 'web.framework', 'web.Model', 'web.session', 'web.Widget'], function (require) {
"use strict";
var core = require('web.core');
var Dialog = require('web.Dialog');
var framework = require('web.framework');
var Model = require('web.Model');
var session = require('web.session');
var Widget = require('web.Widget');
var _t = core._t;
var QWeb = core.qweb;
var SystrayMenu = Widget.extend({
template: "UserMenu",
init: function(parent) {
this._super(parent);
this.update_promise = $.Deferred().resolve();
},
start: function() {
var self = this;
this.$el.on('click', '.dropdown-menu li a[data-menu]', function(ev) {
ev.preventDefault();
var f = self['on_menu_' + $(this).data('menu')];
if (f) {
f($(this));
}
});
this.$el.parent().show();
return this._super.apply(this, arguments);
},
do_update: function () {
var self = this;
var fct = function() {
var $avatar = self.$el.find('.oe_topbar_avatar');
$avatar.attr('src', $avatar.data('default-src'));
if (!session.uid)
return;
var func = new Model("res.users").get_func("read");
return self.alive(func(session.uid, ["name", "company_id"])).then(function(res) {
var topbar_name = res.name;
if(session.debug)
topbar_name = _.str.sprintf("%s (%s)", topbar_name, session.db);
if(res.company_id[0] > 1)
topbar_name = _.str.sprintf("%s (%s)", topbar_name, res.company_id[1]);
self.$el.find('.oe_topbar_name').text(topbar_name);
if (!session.debug) {
topbar_name = _.str.sprintf("%s (%s)", topbar_name, session.db);
}
var avatar_src = session.url('/web/binary/image', {model:'res.users', field: 'image_small', id: session.uid});
$avatar.attr('src', avatar_src);
core.bus.trigger('resize'); // Re-trigger the reflow logic
});
};
this.update_promise = this.update_promise.then(fct, fct);
},
on_menu_help: function() {
window.open('http://help.odoo.com', '_blank');
},
on_menu_logout: function() {
this.trigger('user_logout');
},
on_menu_settings: function() {
var self = this;
if (!this.getParent().has_uncommitted_changes()) {
self.rpc("/web/action/load", { action_id: "base.action_res_users_my" }).done(function(result) {
result.res_id = session.uid;
self.getParent().action_manager.do_action(result);
});
}
},
on_menu_account: function() {
if (!this.getParent().has_uncommitted_changes()) {
var P = new Model('ir.config_parameter');
P.call('get_param', ['database.uuid']).then(function(dbuuid) {
var state = {
'd': session.db,
'u': window.location.protocol + '//' + window.location.host,
};
var params = {
response_type: 'token',
client_id: dbuuid || '',
state: JSON.stringify(state),
scope: 'userinfo',
};
framework.redirect('https://accounts.odoo.com/oauth2/auth?'+$.param(params));
}).fail(function(result, ev){
ev.preventDefault();
framework.redirect('https://accounts.odoo.com/web');
});
}
},
on_menu_about: function() {
var self = this;
self.rpc("/web/webclient/version_info", {}).done(function(res) {
var $help = $(QWeb.render("UserMenu.about", {version_info: res}));
$help.find('a.oe_activate_debug_mode').click(function (e) {
e.preventDefault();
window.location = $.param.querystring( window.location.href, 'debug');
});
new Dialog(this, {
size: 'medium',
dialogClass: 'oe_act_window',
title: _t("About"),
}, $help).open();
});
},
});
return SystrayMenu;
});
+2 -2
View File
@@ -473,7 +473,7 @@
<div class="oe-view-manager-header container-fluid">
<div class="row">
<div class="col-md-6 oe-header-title">
<div t-if="widget.session.debug" class="oe_debug_view btn-group btn-group-sm"/>
<div t-if="widget.debug" class="oe_debug_view btn-group btn-group-sm"/>
<ol class="oe-view-title breadcrumb" t-if="widget.flags.display_title !== false">
</ol>
</div>
@@ -523,7 +523,7 @@
<li><a data-action="fields">View Fields</a></li>
<li><a data-action="fvg">Fields View Get</a></li>
<li><a data-action="manage_filters">Manage Filters</a></li>
<t t-if="view_manager.session.uid === 1">
<t t-if="uid === 1">
<li><a data-action="translate">Technical Translation</a></li>
<li><a data-action="manage_views">Manage Views</a></li>
<li><a data-action="edit" data-model="ir.ui.view" t-att-data-id="view.fields_view.view_id">Edit <t t-esc="_.str.capitalize(view.fields_view.type)"/> View</a></li>
+69 -49
View File
@@ -20,7 +20,18 @@
<script type="text/javascript" src="/web/static/lib/jquery.ba-bbq/jquery.ba-bbq.js"></script>
<script type="text/javascript" src="/web/static/lib/qweb/qweb2.js"></script>
<script type="text/javascript" src="/web/static/src/js/openerpframework.js"></script>
<script type="text/javascript" src="/web/static/src/js/boot.js"></script>
<script type="text/javascript" src="/web/static/src/js/framework/class.js"></script>
<script type="text/javascript" src="/web/static/src/js/framework/translation.js"></script>
<script type="text/javascript" src="/web/static/src/js/framework/ajax.js"></script>
<script type="text/javascript" src="/web/static/src/js/framework/time.js"></script>
<script type="text/javascript" src="/web/static/src/js/framework/mixins.js"></script>
<script type="text/javascript" src="/web/static/src/js/framework/widget.js"></script>
<script type="text/javascript" src="/web/static/src/js/framework/registry.js"></script>
<script type="text/javascript" src="/web/static/src/js/framework/session.js"></script>
<script type="text/javascript" src="/web/static/src/js/framework/model.js"></script>
<script type="text/javascript" src="/web/static/src/js/framework/utils.js"></script>
<script type="text/javascript" src="/web/static/src/js/framework/core.js"></script>
<script type="text/javascript" src="/web/static/src/js/tour.js"></script>
<script type="text/javascript" src="/web/static/test/menu.js"></script>
@@ -83,33 +94,53 @@
<!-- Internals -->
<script type="text/javascript" charset="utf-8">
openerp._modules = <t t-raw="get_modules_order()"/>;
odoo._modules = <t t-raw="get_modules_order()"/>;
</script>
<link rel="stylesheet" href="/web/static/src/css/base.css"/>
<link rel="stylesheet" href="/web/static/src/css/data_export.css"/>
<link rel="stylesheet" href="/base/static/src/css/modules.css"/>
<script type="text/javascript" src="/web/static/lib/py.js/lib/py.js"></script>
<script type="text/javascript" src="/web/static/src/js/boot.js"></script>
<script type="text/javascript" src="/web/static/src/js/testing.js"></script>
<script type="text/javascript" src="/web/static/src/js/pyeval.js"></script>
<script type="text/javascript" src="/web/static/src/js/core.js"></script>
<!-- Special case: core.js declares $.browser needed by ba-bbq -->
<script type="text/javascript" src="/web/static/lib/jquery.ba-bbq/jquery.ba-bbq.js"></script>
<script type="text/javascript" src="/web/static/src/js/formats.js"></script>
<script type="text/javascript" src="/web/static/src/js/chrome.js"></script>
<script type="text/javascript" src="/web/static/src/js/views.js"></script>
<script type="text/javascript" src="/web/static/src/js/data.js"></script>
<script type="text/javascript" src="/web/static/src/js/data_export.js"></script>
<script type="text/javascript" src="/web/static/src/js/search.js"></script>
<script type="text/javascript" src="/web/static/src/js/view_list.js"></script>
<script type="text/javascript" src="/web/static/src/js/view_form.js"></script>
<script type="text/javascript" src="/web/static/src/js/view_list_editable.js"></script>
<script type="text/javascript" src="/web/static/src/js/view_pivot.js"></script>
<script type="text/javascript" src="/web/static/src/js/view_graph.js"></script>
<script type="text/javascript" src="/web/static/src/js/view_tree.js"></script>
<script type="text/javascript" src="/web/static/src/js/framework/formats.js"></script>
<script type="text/javascript" src="/web/static/src/js/framework/view.js"></script>
<script type="text/javascript" src="/web/static/src/js/framework/pyeval.js"></script>
<script type="text/javascript" src="/web/static/src/js/action_manager.js"></script>
<script type="text/javascript" src="/web/static/src/js/view_manager.js"></script>
<script type="text/javascript" src="/web/static/src/js/web_client.js"></script>
<script type="text/javascript" src="/web/static/src/js/framework/data.js"></script>
<script type="text/javascript" src="/web/static/src/js/compatibility.js"></script>
<script type="text/javascript" src="/web/static/src/js/framework/misc.js"></script>
<script type="text/javascript" src="/web/static/src/js/framework/crash_manager.js"></script>
<script type="text/javascript" src="/web/static/src/js/widgets/change_password.js"></script>
<script type="text/javascript" src="/web/static/src/js/widgets/database_manager.js"></script>
<script type="text/javascript" src="/web/static/src/js/widgets/data_export.js"></script>
<script type="text/javascript" src="/web/static/src/js/widgets/date_picker.js"></script>
<script type="text/javascript" src="/web/static/src/js/widgets/loading.js"></script>
<script type="text/javascript" src="/web/static/src/js/widgets/notification.js"></script>
<script type="text/javascript" src="/web/static/src/js/widgets/sidebar.js"></script>
<script type="text/javascript" src="/web/static/src/js/widgets/systray_menu.js"></script>
<script type="text/javascript" src="/web/static/src/js/widgets/user_menu.js"></script>
<script type="text/javascript" src="/web/static/src/js/framework/dialog.js"></script>
<script type="text/javascript" src="/web/static/src/js/menu.js"></script>
<script type="text/javascript" src="/web/static/src/js/views/list_common.js"></script>
<script type="text/javascript" src="/web/static/src/js/views/list_view.js"></script>
<script type="text/javascript" src="/web/static/src/js/views/form_view.js"></script>
<script type="text/javascript" src="/web/static/src/js/views/form_common.js"></script>
<script type="text/javascript" src="/web/static/src/js/views/form_widgets.js"></script>
<script type="text/javascript" src="/web/static/src/js/views/form_relational_widgets.js"></script>
<script type="text/javascript" src="/web/static/src/js/views/list_view_editable.js"></script>
<script type="text/javascript" src="/web/static/src/js/views/pivot_view.js"></script>
<script type="text/javascript" src="/web/static/src/js/views/graph_view.js"></script>
<script type="text/javascript" src="/web/static/src/js/views/graph_widget.js"></script>
<script type="text/javascript" src="/web/static/src/js/views/search_view.js"></script>
<script type="text/javascript" src="/web/static/src/js/views/search_filters.js"></script>
<script type="text/javascript" src="/web/static/src/js/views/search_inputs.js"></script>
<script type="text/javascript" src="/web/static/src/js/views/search_menus.js"></script>
<script type="text/javascript" src="/web/static/src/js/views/tree_view.js"></script>
<script type="text/javascript" src="/base/static/src/js/apps.js"></script>
</template>
@@ -204,14 +235,18 @@
<t t-call-assets="web.assets_backend"/>
<script type="text/javascript">
$(function() {
var s = new openerp.init();
<t t-if="init">
<t t-raw="init"/>
</t>
<t t-if="not init">
var wc = new s.web.WebClient();
wc.setElement($(document.body));
wc.start();
odoo.define('web.web_client', ['web.WebClient'], function (require) {
var WebClient = require('web.WebClient');
var web_client = new WebClient();
web_client.setElement($(document.body));
web_client.start();
return web_client;
});
odoo.init();
</t>
});
</script>
@@ -322,8 +357,8 @@
<title>Odoo Web Tests</title>
<link rel="shortcut icon" href="/web/static/src/img/favicon.ico" type="image/x-icon"/>
<link rel="stylesheet" href="/web/static/lib/qunit/qunit.css"/>
<script src="/web/static/lib/qunit/qunit.js"></script>
<link rel="stylesheet" href="/web/static/lib/qunit/qunit-1.17.1.css"/>
<script src="/web/static/lib/qunit/qunit-1.17.1.js"></script>
<t t-call="web.assets_common"/>
<t t-call="web.assets_backend"/>
@@ -331,35 +366,20 @@
<style>
body { overflow: auto !important; }
</style>
<script type="text/javascript" id="qunit_config">
localStorage.clear();
QUnit.config.testTimeout = 5 * 60 * 1000;
QUnit.moduleDone(function(result) {
console.log(result.name + " (" + result.passed + "/" + result.total + " passed tests)");
});
QUnit.done(function(result) {
if (result.failed === 0) {
console.log('ok');
} else {
console.log('error');
}
});
openerp.web.qweb.add_template("/web/webclient/qweb");
</script>
<script type="text/javascript" src="/web/static/test/testing.js"></script>
<script type="text/javascript" src="/web/static/test/framework.js"></script>
<script type="text/javascript" src="/web/static/test/registry.js"></script>
<script type="text/javascript" src="/web/static/test/form.js"></script>
<script type="text/javascript" src="/web/static/test/data.js"></script>
<script type="text/javascript" src="/web/static/test/list-utils.js"></script>
<script type="text/javascript" src="/web/static/test/formats.js"></script>
<script type="text/javascript" src="/web/static/test/rpc-misordered.js"></script>
<script type="text/javascript" src="/web/static/test/evals.js"></script>
<script type="text/javascript" src="/web/static/test/search.js"></script>
<script type="text/javascript" src="/web/static/test/list.js"></script>
<script type="text/javascript" src="/web/static/test/list-editable.js"></script>
<script type="text/javascript" src="/web/static/test/mutex.js"></script>
<script type="text/javascript" src="/web/static/test/data.js"></script>
<script type="text/javascript" src="/web/static/test/evals.js"></script>
<script type="text/javascript" src="/web/static/test/form.js"></script>
<script type="text/javascript" src="/web/static/test/formats.js"></script>
<script type="text/javascript" src="/web/static/test/framework.js"></script>
<script type="text/javascript" src="/web/static/test/list.js"></script>
<script type="text/javascript" src="/web/static/test/list-utils.js"></script>
<script type="text/javascript" src="/web/static/test/list-editable.js"></script>
<script type="text/javascript" src="/web/static/test/search.js"></script>
</head>
<body id="oe" class="openerp">